@fairblock/stabletrust 1.0.22 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1353 @@
1
+ import { ethers } from "ethers";
2
+ import { initializeWasm } from "./wasm-loader.js";
3
+ import { encodeTransferProof, encodeWithdrawProof } from "./utils.js";
4
+ import { getStabletrustContractAddress } from "./constants.js";
5
+
6
+ // EIP-712 domain for anonymous operations (LibAnonAuth domain name)
7
+ const ANON_DOMAIN_NAME = "ConfidentialMirrorAnonymous";
8
+ const ANON_DOMAIN_VERSION = "1";
9
+
10
+ // Default deadline offset: 1 hour
11
+ const DEFAULT_DEADLINE_OFFSET = 3600;
12
+
13
+ // Minimal ABI — only used for deposit calldata encoding (reads go through Fairycloak views)
14
+ const DEPOSIT_INTERFACE = new ethers.Interface([
15
+ "function depositAnonymous(string accountId, address token, uint256 plainAmount, uint256 authNonce, uint256 deadline, bytes authSig) external",
16
+ ]);
17
+
18
+ /**
19
+ * Internal helper to parse values as BigInt, handling hex strings and null/undefined.
20
+ * @private
21
+ */
22
+ function _parseBigInt(v) {
23
+ if (v == null || v === "") return 0n;
24
+ try {
25
+ return BigInt(v);
26
+ } catch {
27
+ return 0n;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * AnonymousTransferClient — SDK class for anonymous confidential transfers via the Fairycloak relay.
33
+ *
34
+ * Anonymous transfers are routed through the Fairycloak HTTP relay server instead of being
35
+ * submitted directly on-chain. The relay pays gas for all managed transactions; the user only
36
+ * signs EIP-712 authorisation payloads (and a raw EVM transaction for deposits).
37
+ */
38
+ export class AnonymousTransferClient {
39
+ /**
40
+ * @param {Object} config
41
+ * @param {string} config.fairycloakUrl - Fairycloak relay base URL, e.g. "http://127.0.0.1:8080"
42
+ * @param {number|string} config.chainId - Chain ID (used to auto-resolve the contract address)
43
+ * @param {string} config.rpcUrl - EVM JSON-RPC endpoint (used for on-chain reads and raw-tx signing)
44
+ * @param {string} [config.diamondAddress] - Optional override for the diamond contract address
45
+ * @param {string} [config.apiKey] - Optional Fairycloak API key
46
+ */
47
+ constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey } = {}) {
48
+ if (!fairycloakUrl) throw new Error("fairycloakUrl is required");
49
+ if (!chainId) throw new Error("chainId is required");
50
+ if (!rpcUrl) throw new Error("rpcUrl is required");
51
+
52
+ const resolvedDiamondAddress =
53
+ diamondAddress || getStabletrustContractAddress(chainId);
54
+ if (!resolvedDiamondAddress || !ethers.isAddress(resolvedDiamondAddress)) {
55
+ throw new Error(
56
+ `No contract address found for chainId ${chainId}. Pass a diamondAddress explicitly or use a supported chainId.`,
57
+ );
58
+ }
59
+
60
+ this.fairycloakUrl = fairycloakUrl.replace(/\/$/, "");
61
+ this.diamondAddress = ethers.getAddress(resolvedDiamondAddress);
62
+ this.chainId = Number(chainId);
63
+ this.rpcUrl = rpcUrl;
64
+ this.apiKey = apiKey || null;
65
+
66
+ this.provider = new ethers.JsonRpcProvider(rpcUrl);
67
+ this._wasmModule = null;
68
+ }
69
+
70
+ // ─────────────────────── private helpers ───────────────────────
71
+
72
+ async _getWasm() {
73
+ if (!this._wasmModule) {
74
+ this._wasmModule = await initializeWasm();
75
+ }
76
+ return this._wasmModule;
77
+ }
78
+
79
+ _buildDomain() {
80
+ return {
81
+ name: ANON_DOMAIN_NAME,
82
+ version: ANON_DOMAIN_VERSION,
83
+ chainId: this.chainId,
84
+ verifyingContract: this.diamondAddress,
85
+ };
86
+ }
87
+
88
+ _makeDeadline(offsetSeconds = DEFAULT_DEADLINE_OFFSET) {
89
+ return Math.floor(Date.now() / 1000) + offsetSeconds;
90
+ }
91
+
92
+ /**
93
+ * Send an HTTP request to Fairycloak. Throws on non-2xx responses.
94
+ * @private
95
+ */
96
+ async _fetch(method, path, body) {
97
+ const headers = {
98
+ "Content-Type": "application/json",
99
+ Accept: "application/json",
100
+ };
101
+ if (this.apiKey) headers["X-API-Key"] = this.apiKey;
102
+
103
+ const res = await fetch(`${this.fairycloakUrl}${path}`, {
104
+ method,
105
+ headers,
106
+ body: body != null ? JSON.stringify(body) : undefined,
107
+ });
108
+
109
+ const text = await res.text();
110
+ let data;
111
+ try {
112
+ data = JSON.parse(text);
113
+ } catch {
114
+ data = { raw: text };
115
+ }
116
+
117
+ if (!res.ok) {
118
+ const msg =
119
+ data?.error?.message ?? data?.error ?? text ?? `HTTP ${res.status}`;
120
+ throw new Error(`Fairycloak ${method} ${path} (${res.status}): ${msg}`);
121
+ }
122
+ return data;
123
+ }
124
+
125
+ /**
126
+ * Resolve a pubkey-or-wallet to an uncompressed 65-byte hex pubkey (0x04...).
127
+ * Accepts: ethers.Wallet, ethers.SigningKey, or a hex pubkey string.
128
+ * @private
129
+ */
130
+ _resolvePubkey(walletOrPubkey) {
131
+ if (typeof walletOrPubkey === "string") {
132
+ return walletOrPubkey; // already a hex pubkey
133
+ }
134
+ if (walletOrPubkey.signingKey) {
135
+ return walletOrPubkey.signingKey.publicKey; // ethers.Wallet → uncompressed pubkey
136
+ }
137
+ if (walletOrPubkey.publicKey) {
138
+ return walletOrPubkey.publicKey; // ethers.SigningKey
139
+ }
140
+ throw new Error(
141
+ "Expected an ethers.Wallet or uncompressed hex public-key string",
142
+ );
143
+ }
144
+
145
+ // ─────────────── token decimal / scale helpers ─────────────────
146
+
147
+ /**
148
+ * Fetch and cache token decimals.
149
+ * @private
150
+ */
151
+ async _fetchTokenDecimals(tokenAddress) {
152
+ if (!this._tokenDecimals) this._tokenDecimals = {};
153
+ const key = tokenAddress.toLowerCase();
154
+ if (this._tokenDecimals[key] !== undefined) return this._tokenDecimals[key];
155
+ const contract = new ethers.Contract(
156
+ tokenAddress,
157
+ ["function decimals() view returns (uint8)"],
158
+ this.provider,
159
+ );
160
+ const decimals = Number(await contract.decimals());
161
+ this._tokenDecimals[key] = decimals;
162
+ return decimals;
163
+ }
164
+
165
+ /**
166
+ * Convert a raw ERC-20 token amount to contract scale.
167
+ *
168
+ * The anonymous contract stores balances and takes deposit/withdraw amounts in
169
+ * **contract scale**: `rawAmount * 100 / 10^decimals`. For a 6-decimal token
170
+ * (USDC) that means 0.1 USDC = 100_000 raw → 10 contract scale.
171
+ *
172
+ * @param {string} tokenAddress
173
+ * @param {bigint} rawAmount - Amount in raw ERC-20 units (wei-scaled by token decimals)
174
+ * @returns {Promise<bigint>} Amount in contract scale
175
+ * @private
176
+ */
177
+ async _toContractScale(tokenAddress, rawAmount) {
178
+ const decimals = await this._fetchTokenDecimals(tokenAddress);
179
+ return (rawAmount * 100n) / 10n ** BigInt(decimals);
180
+ }
181
+
182
+ // ─────────────────────── on-chain reads ────────────────────────
183
+
184
+ /**
185
+ * Get on-chain core state for an anonymous account.
186
+ *
187
+ * @param {string} accountId
188
+ * @returns {Promise<{exists:boolean, finalized:boolean, hasPendingAction:boolean, txId:bigint, elgamalPubkey:string, authNonce:bigint}>}
189
+ */
190
+ async getAnonymousAccountInfo(accountId) {
191
+ try {
192
+ const data = await this._fetch(
193
+ "GET",
194
+ `/v1/views/anonymous/accounts/${accountId}/core`,
195
+ );
196
+ // Support both { result: {...} } and flat response shapes
197
+ const r =
198
+ data.result != null && typeof data.result === "object"
199
+ ? data.result
200
+ : data;
201
+ return {
202
+ exists: r.exists ?? false,
203
+ finalized: r.finalized ?? false,
204
+ // Fairycloak may use snake_case or camelCase
205
+ hasPendingAction: r.pending_action ?? r.pendingAction ?? false,
206
+ txId: _parseBigInt(r.tx_id ?? r.txId),
207
+ elgamalPubkey: r.elgamal_pubkey ?? r.elgamalPubkey ?? "0x",
208
+ authNonce: _parseBigInt(r.auth_nonce ?? r.authNonce),
209
+ };
210
+ } catch (e) {
211
+ throw new Error(`Failed to get anonymous account info: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Read the current authNonce for an anonymous account from the chain.
217
+ *
218
+ * @param {string} accountId
219
+ * @returns {Promise<bigint>}
220
+ */
221
+ async getAuthNonce(accountId) {
222
+ const info = await this.getAnonymousAccountInfo(accountId);
223
+ return info.authNonce;
224
+ }
225
+
226
+ /**
227
+ * Check whether an address is an authorised signer for an anonymous account.
228
+ *
229
+ * @param {string} accountId
230
+ * @param {string} signerAddress
231
+ * @returns {Promise<boolean>}
232
+ */
233
+ async isAuthorizedSigner(accountId, signerAddress) {
234
+ const data = await this._fetch(
235
+ "GET",
236
+ `/v1/views/anonymous/accounts/${accountId}/authorized-signers/${signerAddress}`,
237
+ );
238
+ return data.result === true || data.result === "true";
239
+ }
240
+
241
+ // ─────────────── anonymous balance reads ───────────────────────
242
+
243
+ /**
244
+ * Fetch the raw combined ciphertext (base64) for an anonymous account's balance.
245
+ * Returns null when no balance has been set yet.
246
+ *
247
+ * @param {string} accountId
248
+ * @param {string} tokenAddress
249
+ * @param {"available"|"pending"} [type="available"]
250
+ * @returns {Promise<string|null>} Base64 ciphertext or null
251
+ * @private
252
+ */
253
+ async _getAnonymousCiphertext(accountId, tokenAddress, type = "available") {
254
+ const path =
255
+ type === "pending"
256
+ ? `/v1/views/anonymous/accounts/${accountId}/pending/${tokenAddress}`
257
+ : `/v1/views/anonymous/accounts/${accountId}/available/${tokenAddress}`;
258
+
259
+ const data = await this._fetch("GET", path);
260
+ const { c1, c2 } = data.result;
261
+
262
+ if ((!c1 || c1 === "0x") && (!c2 || c2 === "0x")) return null;
263
+
264
+ const combined = new Uint8Array(64);
265
+ combined.set(ethers.getBytes(c1), 0);
266
+ combined.set(ethers.getBytes(c2), 32);
267
+ return Buffer.from(combined).toString("base64");
268
+ }
269
+
270
+ /**
271
+ * Decrypt an anonymous account's balance from the chain.
272
+ * Returns amount in **contract scale** (the raw WASM-decrypted value).
273
+ *
274
+ * @param {string} accountId
275
+ * @param {string} tokenAddress
276
+ * @param {string} elGamalPrivateKey - Base64 private key
277
+ * @param {"available"|"pending"} [type="available"]
278
+ * @returns {Promise<{amount: number, ciphertext: string|null}>}
279
+ * @private
280
+ */
281
+ async _decryptAnonymousBalance(
282
+ accountId,
283
+ tokenAddress,
284
+ elGamalPrivateKey,
285
+ type = "available",
286
+ ) {
287
+ const ciphertext = await this._getAnonymousCiphertext(
288
+ accountId,
289
+ tokenAddress,
290
+ type,
291
+ );
292
+ if (!ciphertext) return { amount: 0, ciphertext: null };
293
+ const wasm = await this._getWasm();
294
+ const result = JSON.parse(
295
+ wasm.decrypt_ciphertext(ciphertext, elGamalPrivateKey),
296
+ );
297
+ return { amount: result.decrypted_amount ?? 0, ciphertext };
298
+ }
299
+
300
+ /**
301
+ * Get the decrypted available **and** pending balances for an anonymous account.
302
+ *
303
+ * Amounts are returned in **contract scale** (the encrypted integer stored on-chain,
304
+ * equivalent to `tokenAmount × 100 / 10^decimals`).
305
+ *
306
+ * @param {string} accountId
307
+ * @param {string} tokenAddress - ERC-20 token address
308
+ * @param {string} elGamalPrivateKey - ElGamal private key (base64) for decryption
309
+ * @returns {Promise<{available: {amount: number, ciphertext: string|null}, pending: {amount: number, ciphertext: string|null}}>}
310
+ */
311
+ async getAnonymousBalance(accountId, tokenAddress, elGamalPrivateKey) {
312
+ if (!ethers.isAddress(tokenAddress))
313
+ throw new Error(`Invalid token address: ${tokenAddress}`);
314
+ if (!elGamalPrivateKey) throw new Error("elGamalPrivateKey is required");
315
+
316
+ const [available, pending] = await Promise.all([
317
+ this._decryptAnonymousBalance(
318
+ accountId,
319
+ tokenAddress,
320
+ elGamalPrivateKey,
321
+ "available",
322
+ ),
323
+ this._decryptAnonymousBalance(
324
+ accountId,
325
+ tokenAddress,
326
+ elGamalPrivateKey,
327
+ "pending",
328
+ ),
329
+ ]);
330
+ return { available, pending };
331
+ }
332
+
333
+ // ────────────────── key derivation ─────────────────────────────
334
+
335
+ /**
336
+ * Derive a deterministic ElGamal keypair for an anonymous account from an auth wallet.
337
+ * Internally signs a typed-data message unique to (chainId, diamondAddress, accountId, authWallet)
338
+ * and feeds the signature into the WASM key derivation function.
339
+ *
340
+ * Store the returned `privateKey` securely — it cannot be recovered without the wallet.
341
+ *
342
+ * @param {ethers.Wallet|ethers.Signer} authWallet
343
+ * @param {string} accountId
344
+ * @returns {Promise<{publicKey:string, privateKey:string}>} Base-64 encoded keypair
345
+ */
346
+ async deriveAnonymousKeys(authWallet, accountId) {
347
+ try {
348
+ const wasm = await this._getWasm();
349
+ const address = await authWallet.getAddress();
350
+
351
+ const domain = {
352
+ name: "ConfidentialTokens",
353
+ version: "1",
354
+ chainId: this.chainId,
355
+ verifyingContract: this.diamondAddress,
356
+ };
357
+ const types = {
358
+ DeriveAnonymousElGamalKey: [
359
+ { name: "purpose", type: "string" },
360
+ { name: "accountId", type: "string" },
361
+ { name: "user", type: "address" },
362
+ { name: "context", type: "bytes32" },
363
+ ],
364
+ };
365
+ const contextHash = ethers.keccak256(
366
+ ethers.AbiCoder.defaultAbiCoder().encode(
367
+ ["uint256", "address", "string", "string"],
368
+ [
369
+ this.chainId,
370
+ this.diamondAddress,
371
+ String(accountId),
372
+ "anonymous-main",
373
+ ],
374
+ ),
375
+ );
376
+ const message = {
377
+ purpose: "anonymous-elgamal-key-derive-v1",
378
+ accountId: String(accountId),
379
+ user: address.toLowerCase(),
380
+ context: contextHash,
381
+ };
382
+
383
+ const signature = await authWallet.signTypedData(domain, types, message);
384
+ const domainContext = JSON.stringify({
385
+ chainId: this.chainId.toString(),
386
+ verifyingContract: this.diamondAddress,
387
+ user: address.toLowerCase(),
388
+ accountId: String(accountId),
389
+ purpose: "anonymous-elgamal-key-derive-v1",
390
+ version: "1",
391
+ });
392
+
393
+ const keypair = JSON.parse(
394
+ wasm.generate_deterministic_keypair(signature.slice(2), domainContext),
395
+ );
396
+ return {
397
+ publicKey: keypair.public_key,
398
+ privateKey: keypair.private_key,
399
+ };
400
+ } catch (e) {
401
+ throw new Error(`Failed to derive anonymous keys: ${e.message}`);
402
+ }
403
+ }
404
+
405
+ // ─────────────────── proof generation helpers ──────────────────
406
+
407
+ /**
408
+ * Generate a ZK transfer proof using the WASM module.
409
+ *
410
+ * Pass the result's `proofHex` directly to `transferToPublic` or `transferToAnonymous`.
411
+ *
412
+ * @param {string} elGamalPrivateKey - ElGamal private key (base64)
413
+ * @param {Object} params
414
+ * @param {string} params.currentBalanceCiphertext - Combined ciphertext (base64, 64 bytes)
415
+ * @param {number} params.currentBalanceContractScale - Balance in contract scale (token_amount × 100 / 10^decimals)
416
+ * @param {number} params.transferAmountContractScale - Transfer amount in the same scale
417
+ * @param {string} params.destinationPublicKey - Recipient ElGamal pubkey (base64)
418
+ * @returns {Promise<string>} ABI-encoded proof as "0x..." hex
419
+ */
420
+ async generateTransferProof(
421
+ elGamalPrivateKey,
422
+ {
423
+ currentBalanceCiphertext,
424
+ currentBalanceContractScale,
425
+ transferAmountContractScale,
426
+ destinationPublicKey,
427
+ },
428
+ ) {
429
+ const wasm = await this._getWasm();
430
+ const input = {
431
+ current_balance_ciphertext: currentBalanceCiphertext,
432
+ current_balance: currentBalanceContractScale,
433
+ transfer_amount: transferAmountContractScale,
434
+ source_keypair: elGamalPrivateKey,
435
+ destination_pubkey: destinationPublicKey,
436
+ };
437
+ const result = JSON.parse(
438
+ wasm.generate_transfer_proof(JSON.stringify(input)),
439
+ );
440
+ if (!result.success)
441
+ throw new Error(
442
+ `Transfer proof generation failed: ${result.error ?? "unknown error"}`,
443
+ );
444
+ return (
445
+ "0x" +
446
+ Buffer.from(ethers.getBytes(encodeTransferProof(result.data))).toString(
447
+ "hex",
448
+ )
449
+ );
450
+ }
451
+
452
+ /**
453
+ * Generate a ZK withdraw proof using the WASM module.
454
+ *
455
+ * Pass the result's `proofHex` directly to `withdraw`.
456
+ *
457
+ * @param {string} elGamalPrivateKey - ElGamal private key (base64)
458
+ * @param {Object} params
459
+ * @param {string} params.currentBalanceCiphertext - Combined ciphertext (base64, 64 bytes)
460
+ * @param {number} params.currentBalanceContractScale - Balance in contract scale
461
+ * @param {number} params.withdrawAmountContractScale - Withdraw amount in the same scale
462
+ * @param {number|bigint} params.nonce - Next user tx-id: pass `accountInfo.txId + 1n` (NOT the current txId)
463
+ * @returns {Promise<string>} ABI-encoded proof as "0x..." hex
464
+ */
465
+ async generateWithdrawProof(
466
+ elGamalPrivateKey,
467
+ {
468
+ currentBalanceCiphertext,
469
+ currentBalanceContractScale,
470
+ withdrawAmountContractScale,
471
+ nonce,
472
+ },
473
+ ) {
474
+ if (nonce === undefined || nonce === null)
475
+ throw new Error("nonce is required for withdraw proof generation");
476
+
477
+ const wasm = await this._getWasm();
478
+ const input = {
479
+ current_balance_ciphertext: currentBalanceCiphertext,
480
+ current_balance: currentBalanceContractScale,
481
+ withdraw_amount: withdrawAmountContractScale,
482
+ keypair: elGamalPrivateKey,
483
+ nonce: Number(nonce),
484
+ };
485
+ const result = JSON.parse(
486
+ wasm.generate_withdraw_proof(JSON.stringify(input)),
487
+ );
488
+ if (!result.success)
489
+ throw new Error(
490
+ `Withdraw proof generation failed: ${result.error ?? "unknown error"}`,
491
+ );
492
+ return (
493
+ "0x" +
494
+ Buffer.from(ethers.getBytes(encodeWithdrawProof(result.data))).toString(
495
+ "hex",
496
+ )
497
+ );
498
+ }
499
+
500
+ // ─────────────────── Fairycloak operations ─────────────────────
501
+
502
+ /**
503
+ * Create a new anonymous account via Fairycloak.
504
+ *
505
+ * The relay submits the `createAnonymousAccount` transaction and pays gas.
506
+ *
507
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
508
+ * @param {string} accountId - Caller-chosen account ID (non-empty string, case-sensitive)
509
+ * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
510
+ * @param {Object} [options]
511
+ * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
512
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string, action:string}>}
513
+ */
514
+ async createAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
515
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
516
+ try {
517
+ const authAddress = await authWallet.getAddress();
518
+ const authPubkey = this._resolvePubkey(authWallet); // uncompressed 65-byte pubkey
519
+
520
+ // Normalise elgamal key to hex
521
+ const elgamalHex = elgamalPublicKey.startsWith("0x")
522
+ ? elgamalPublicKey
523
+ : "0x" + Buffer.from(elgamalPublicKey, "base64").toString("hex");
524
+
525
+ const clientRequestId = ethers.hexlify(ethers.randomBytes(32));
526
+ const deadline = this._makeDeadline(deadlineOffset);
527
+
528
+ // EIP-712 struct hashes for dynamic fields
529
+ const initialSignersHash = ethers.keccak256(
530
+ ethers.AbiCoder.defaultAbiCoder().encode(
531
+ ["address[]"],
532
+ [[authAddress]],
533
+ ),
534
+ );
535
+ const elgamalPubkeyHash = ethers.keccak256(elgamalHex);
536
+
537
+ const domain = this._buildDomain();
538
+ const types = {
539
+ AnonymousCreate: [
540
+ { name: "accountId", type: "string" },
541
+ { name: "initialSignersHash", type: "bytes32" },
542
+ { name: "elgamalPubkeyHash", type: "bytes32" },
543
+ { name: "clientRequestId", type: "bytes32" },
544
+ { name: "deadline", type: "uint256" },
545
+ ],
546
+ };
547
+ const value = {
548
+ accountId,
549
+ initialSignersHash,
550
+ elgamalPubkeyHash,
551
+ clientRequestId,
552
+ deadline: BigInt(deadline),
553
+ };
554
+
555
+ const signature = await authWallet.signTypedData(domain, types, value);
556
+
557
+ return await this._fetch("POST", "/v1/anonymous/accounts", {
558
+ account_id: accountId,
559
+ elgamal_pubkey: elgamalHex,
560
+ auth_pubkeys: [authPubkey],
561
+ client_request_id: clientRequestId,
562
+ deadline: String(deadline),
563
+ signature,
564
+ });
565
+ } catch (e) {
566
+ const msg = e?.message ?? String(e);
567
+ if (msg.startsWith("Fairycloak")) throw e;
568
+ throw new Error(`Failed to create anonymous account: ${msg}`);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Update the set of authorised signers for an anonymous account via Fairycloak.
574
+ *
575
+ * @param {ethers.Wallet|ethers.Signer} authWallet - A currently authorised signer
576
+ * @param {string} accountId
577
+ * @param {Object} keys
578
+ * @param {Array<string|ethers.Wallet>} [keys.add=[]] - Pubkeys/wallets to add
579
+ * @param {Array<string|ethers.Wallet>} [keys.remove=[]] - Pubkeys/wallets to remove
580
+ * @param {Object} [options]
581
+ * @param {number} [options.deadlineOffset=3600]
582
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
583
+ */
584
+ async updateAuthKeys(
585
+ authWallet,
586
+ accountId,
587
+ { add = [], remove = [] } = {},
588
+ options = {},
589
+ ) {
590
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
591
+ try {
592
+ const authNonce = await this.getAuthNonce(accountId);
593
+ const deadline = this._makeDeadline(deadlineOffset);
594
+
595
+ const addPubkeys = add.map((w) => this._resolvePubkey(w));
596
+ const removePubkeys = remove.map((w) => this._resolvePubkey(w));
597
+
598
+ // EIP-712 signs over address arrays, not raw pubkeys
599
+ const addAddresses = addPubkeys.map((pk) => ethers.computeAddress(pk));
600
+ const removeAddresses = removePubkeys.map((pk) =>
601
+ ethers.computeAddress(pk),
602
+ );
603
+
604
+ const addSignersHash = ethers.keccak256(
605
+ ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [addAddresses]),
606
+ );
607
+ const removeSignersHash = ethers.keccak256(
608
+ ethers.AbiCoder.defaultAbiCoder().encode(
609
+ ["address[]"],
610
+ [removeAddresses],
611
+ ),
612
+ );
613
+
614
+ const domain = this._buildDomain();
615
+ const types = {
616
+ AnonymousUpdateKeys: [
617
+ { name: "accountId", type: "string" },
618
+ { name: "addSignersHash", type: "bytes32" },
619
+ { name: "removeSignersHash", type: "bytes32" },
620
+ { name: "authNonce", type: "uint256" },
621
+ { name: "deadline", type: "uint256" },
622
+ ],
623
+ };
624
+ const value = {
625
+ accountId: String(accountId),
626
+ addSignersHash,
627
+ removeSignersHash,
628
+ authNonce,
629
+ deadline: BigInt(deadline),
630
+ };
631
+
632
+ const signature = await authWallet.signTypedData(domain, types, value);
633
+
634
+ return await this._fetch("POST", "/v1/anonymous/keys/update", {
635
+ account_id: accountId,
636
+ add_pubkeys: addPubkeys,
637
+ remove_pubkeys: removePubkeys,
638
+ auth_nonce: String(authNonce),
639
+ deadline: String(deadline),
640
+ signature,
641
+ });
642
+ } catch (e) {
643
+ const msg = e?.message ?? String(e);
644
+ if (msg.startsWith("Fairycloak")) throw e;
645
+ throw new Error(`Failed to update auth keys: ${msg}`);
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Deposit tokens into an anonymous account by forwarding a user-signed raw EVM transaction.
651
+ *
652
+ * The user's wallet pays gas for this transaction (unlike other operations where Fairycloak
653
+ * pays gas). Fairycloak validates and broadcasts the raw tx unchanged.
654
+ *
655
+ * Steps performed internally:
656
+ * 1. ERC-20 approve (if allowance is insufficient)
657
+ * 2. Sign EIP-712 deposit auth payload
658
+ * 3. Build and sign the raw `depositAnonymous(...)` EVM transaction
659
+ * 4. Submit to Fairycloak `/v1/anonymous/deposit/raw-tx`
660
+ *
661
+ * @param {ethers.Wallet} authWallet - Wallet that owns the tokens and signs the tx
662
+ * @param {string} accountId - Target anonymous account ID
663
+ * @param {string} tokenAddress - ERC-20 token address
664
+ * @param {bigint|string|number} amount - Amount in token units (wei-scaled by token decimals)
665
+ * @param {Object} [options]
666
+ * @param {number} [options.deadlineOffset=3600]
667
+ * @param {bigint} [options.gasLimit=600000n]
668
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
669
+ */
670
+ async deposit(authWallet, accountId, tokenAddress, amount, options = {}) {
671
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 600000n } =
672
+ options;
673
+ try {
674
+ if (!ethers.isAddress(tokenAddress))
675
+ throw new Error(`Invalid token address: ${tokenAddress}`);
676
+ const amountBig = BigInt(amount);
677
+ if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
678
+
679
+ const depositorAddress = await authWallet.getAddress();
680
+ const walletWithProvider = authWallet.connect
681
+ ? authWallet.connect(this.provider)
682
+ : authWallet;
683
+
684
+ // 1. Approve if needed
685
+ const tokenContract = new ethers.Contract(
686
+ tokenAddress,
687
+ [
688
+ "function balanceOf(address) view returns (uint256)",
689
+ "function allowance(address,address) view returns (uint256)",
690
+ "function approve(address,uint256) returns (bool)",
691
+ ],
692
+ walletWithProvider,
693
+ );
694
+
695
+ const [balance, allowance] = await Promise.all([
696
+ tokenContract.balanceOf(depositorAddress),
697
+ tokenContract.allowance(depositorAddress, this.diamondAddress),
698
+ ]);
699
+
700
+ if (balance < amountBig)
701
+ throw new Error(
702
+ `Insufficient token balance. Required: ${amountBig}, Available: ${balance}`,
703
+ );
704
+
705
+ if (allowance < amountBig) {
706
+ const approveTx = await tokenContract.approve(
707
+ this.diamondAddress,
708
+ ethers.MaxUint256,
709
+ );
710
+ const receipt = await approveTx.wait();
711
+ if (!receipt || receipt.status === 0)
712
+ throw new Error("Token approval failed");
713
+ }
714
+
715
+ // Convert raw ERC-20 units → contract scale.
716
+ // The contract stores and accepts amounts as `rawAmount * 100 / 10^decimals`
717
+ // and internally calls `safeTransferFrom(user, contract, plainAmount * tokenMul)`
718
+ // where `tokenMul = 10^decimals / 100`. Passing raw units here would cause the
719
+ // contract to attempt a 10_000× larger transfer on a 6-decimal token, reverting.
720
+ const plainAmountContractScale = await this._toContractScale(
721
+ tokenAddress,
722
+ amountBig,
723
+ );
724
+ if (plainAmountContractScale <= 0n)
725
+ throw new Error(
726
+ `Amount too small: ${amountBig} raw units rounds to 0 in contract scale`,
727
+ );
728
+
729
+ // 2. EIP-712 deposit auth signature
730
+ const authNonce = await this.getAuthNonce(accountId);
731
+ const deadline = this._makeDeadline(deadlineOffset);
732
+
733
+ const domain = this._buildDomain();
734
+ const types = {
735
+ AnonymousDeposit: [
736
+ { name: "accountId", type: "string" },
737
+ { name: "token", type: "address" },
738
+ { name: "plainAmount", type: "uint256" },
739
+ { name: "authNonce", type: "uint256" },
740
+ { name: "deadline", type: "uint256" },
741
+ ],
742
+ };
743
+ const value = {
744
+ accountId: String(accountId),
745
+ token: tokenAddress,
746
+ plainAmount: plainAmountContractScale,
747
+ authNonce,
748
+ deadline: BigInt(deadline),
749
+ };
750
+
751
+ const authSig = await authWallet.signTypedData(domain, types, value);
752
+
753
+ // 3. Build and sign the raw depositAnonymous transaction
754
+ const calldata = DEPOSIT_INTERFACE.encodeFunctionData(
755
+ "depositAnonymous",
756
+ [
757
+ accountId,
758
+ tokenAddress,
759
+ plainAmountContractScale,
760
+ authNonce,
761
+ BigInt(deadline),
762
+ authSig,
763
+ ],
764
+ );
765
+
766
+ const [nonce, feeData] = await Promise.all([
767
+ this.provider.getTransactionCount(depositorAddress),
768
+ this.provider.getFeeData(),
769
+ ]);
770
+
771
+ let txObj;
772
+ if (feeData.maxFeePerGas) {
773
+ txObj = {
774
+ to: this.diamondAddress,
775
+ data: calldata,
776
+ nonce,
777
+ chainId: BigInt(this.chainId),
778
+ gasLimit,
779
+ maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
780
+ maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
781
+ value: 0n,
782
+ };
783
+ } else {
784
+ txObj = {
785
+ to: this.diamondAddress,
786
+ data: calldata,
787
+ nonce,
788
+ chainId: BigInt(this.chainId),
789
+ gasLimit,
790
+ gasPrice: (feeData.gasPrice * 11n) / 10n,
791
+ value: 0n,
792
+ };
793
+ }
794
+
795
+ const signedTx = await authWallet.signTransaction(txObj);
796
+
797
+ // 4. Submit to Fairycloak
798
+ return await this._fetch("POST", "/v1/anonymous/deposit/raw-tx", {
799
+ raw_tx: signedTx,
800
+ });
801
+ } catch (e) {
802
+ const msg = e?.message ?? String(e);
803
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Insufficient"))
804
+ throw e;
805
+ throw new Error(`Failed to deposit: ${msg}`);
806
+ }
807
+ }
808
+
809
+ /**
810
+ * Transfer from an anonymous account to a public EVM address via Fairycloak.
811
+ *
812
+ * **Auto-proof mode** (recommended): provide `elGamalPrivateKey`, `amount` (in token units),
813
+ * and `destinationPublicKey`. The SDK fetches the ciphertext from the chain, decrypts the
814
+ * current balance, and generates the ZK proof automatically.
815
+ *
816
+ * **Manual proof mode**: provide a pre-computed `proof` hex string from `generateTransferProof()`.
817
+ *
818
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
819
+ * @param {string} accountId - Sender's anonymous account ID
820
+ * @param {Object} params
821
+ * @param {string} params.recipient - Recipient EVM address (must have a confidential account)
822
+ * @param {string} params.token - Token address
823
+ * @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
824
+ * @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
825
+ * @param {bigint|string|number} [params.amount] - Transfer amount in token units for auto-proof
826
+ * @param {string} [params.destinationPublicKey] - Recipient ElGamal pubkey (base64) for auto-proof
827
+ * @param {Object} [options]
828
+ * @param {number} [options.deadlineOffset=3600]
829
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
830
+ */
831
+ async transferToPublic(
832
+ authWallet,
833
+ accountId,
834
+ {
835
+ recipient,
836
+ token,
837
+ proof,
838
+ elGamalPrivateKey,
839
+ amount,
840
+ destinationPublicKey,
841
+ },
842
+ options = {},
843
+ ) {
844
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
845
+ try {
846
+ if (!ethers.isAddress(recipient))
847
+ throw new Error(`Invalid recipient address: ${recipient}`);
848
+ if (!ethers.isAddress(token))
849
+ throw new Error(`Invalid token address: ${token}`);
850
+
851
+ let proofHex;
852
+ if (proof) {
853
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
854
+ } else {
855
+ if (!elGamalPrivateKey)
856
+ throw new Error("Either proof or elGamalPrivateKey must be provided");
857
+ if (amount === undefined || amount === null)
858
+ throw new Error("amount is required when auto-generating proof");
859
+
860
+ // Convert raw ERC-20 units → contract scale for proof generation
861
+ const transferAmountContractScale = Number(
862
+ await this._toContractScale(token, BigInt(amount)),
863
+ );
864
+
865
+ // Auto-resolve destinationPublicKey from the recipient's on-chain confidential account
866
+ let destPubkey = destinationPublicKey;
867
+ if (!destPubkey) {
868
+ const recipientData = await this._fetch(
869
+ "GET",
870
+ `/v1/views/accounts/${recipient}/core`,
871
+ );
872
+ if (!recipientData.result.exists)
873
+ throw new Error(
874
+ `Recipient ${recipient} has no confidential account on-chain`,
875
+ );
876
+ destPubkey = Buffer.from(
877
+ ethers.getBytes(recipientData.result.elgamal_pubkey),
878
+ ).toString("base64");
879
+ }
880
+
881
+ const { ciphertext, amount: currentBalance } =
882
+ await this._decryptAnonymousBalance(
883
+ accountId,
884
+ token,
885
+ elGamalPrivateKey,
886
+ "available",
887
+ );
888
+ if (!ciphertext)
889
+ throw new Error(
890
+ "No available balance found for this anonymous account and token",
891
+ );
892
+
893
+ proofHex = await this.generateTransferProof(elGamalPrivateKey, {
894
+ currentBalanceCiphertext: ciphertext,
895
+ currentBalanceContractScale: currentBalance,
896
+ transferAmountContractScale,
897
+ destinationPublicKey: destPubkey,
898
+ });
899
+ }
900
+
901
+ const authNonce = await this.getAuthNonce(accountId);
902
+ const deadline = this._makeDeadline(deadlineOffset);
903
+ const proofHash = ethers.keccak256(proofHex);
904
+
905
+ const domain = this._buildDomain();
906
+ const types = {
907
+ AnonymousTransferToPublic: [
908
+ { name: "senderId", type: "string" },
909
+ { name: "recipient", type: "address" },
910
+ { name: "token", type: "address" },
911
+ { name: "proofHash", type: "bytes32" },
912
+ { name: "authNonce", type: "uint256" },
913
+ { name: "deadline", type: "uint256" },
914
+ ],
915
+ };
916
+ const value = {
917
+ senderId: String(accountId),
918
+ recipient,
919
+ token,
920
+ proofHash,
921
+ authNonce,
922
+ deadline: BigInt(deadline),
923
+ };
924
+
925
+ const signature = await authWallet.signTypedData(domain, types, value);
926
+
927
+ return await this._fetch("POST", "/v1/anonymous/transfers/public", {
928
+ sender_id: accountId,
929
+ recipient,
930
+ token,
931
+ proof: proofHex,
932
+ auth_nonce: String(authNonce),
933
+ deadline: String(deadline),
934
+ signature,
935
+ });
936
+ } catch (e) {
937
+ const msg = e?.message ?? String(e);
938
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
939
+ throw new Error(`Failed to transfer to public: ${msg}`);
940
+ }
941
+ }
942
+
943
+ /**
944
+ * Transfer from one anonymous account to another via Fairycloak.
945
+ *
946
+ * **Auto-proof mode** (recommended): provide `elGamalPrivateKey`, `amount` (in token units),
947
+ * and `destinationPublicKey`. The SDK fetches the ciphertext, decrypts the balance, and generates
948
+ * the ZK proof automatically.
949
+ *
950
+ * **Manual proof mode**: provide a pre-computed `proof` hex string from `generateTransferProof()`.
951
+ *
952
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the sender account
953
+ * @param {string} senderAccountId
954
+ * @param {Object} params
955
+ * @param {string} params.recipientId - Recipient anonymous account ID
956
+ * @param {string} params.token - Token address
957
+ * @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
958
+ * @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
959
+ * @param {bigint|string|number} [params.amount] - Transfer amount in token units for auto-proof
960
+ * @param {string} [params.destinationPublicKey] - Recipient ElGamal pubkey (base64) for auto-proof
961
+ * @param {Object} [options]
962
+ * @param {number} [options.deadlineOffset=3600]
963
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
964
+ */
965
+ async transferToAnonymous(
966
+ authWallet,
967
+ senderAccountId,
968
+ {
969
+ recipientId,
970
+ token,
971
+ proof,
972
+ elGamalPrivateKey,
973
+ amount,
974
+ destinationPublicKey,
975
+ },
976
+ options = {},
977
+ ) {
978
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
979
+ try {
980
+ if (!ethers.isAddress(token))
981
+ throw new Error(`Invalid token address: ${token}`);
982
+
983
+ let proofHex;
984
+ if (proof) {
985
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
986
+ } else {
987
+ if (!elGamalPrivateKey)
988
+ throw new Error("Either proof or elGamalPrivateKey must be provided");
989
+ if (amount === undefined || amount === null)
990
+ throw new Error("amount is required when auto-generating proof");
991
+
992
+ // Convert raw ERC-20 units → contract scale for proof generation
993
+ const transferAmountContractScale = Number(
994
+ await this._toContractScale(token, BigInt(amount)),
995
+ );
996
+
997
+ // Auto-resolve destinationPublicKey from the recipient's anonymous account on-chain
998
+ let destPubkey = destinationPublicKey;
999
+ if (!destPubkey) {
1000
+ const recipientData = await this._fetch(
1001
+ "GET",
1002
+ `/v1/views/anonymous/accounts/${recipientId}/core`,
1003
+ );
1004
+ if (!recipientData.result.exists)
1005
+ throw new Error(
1006
+ `Recipient anonymous account ${recipientId} does not exist on-chain`,
1007
+ );
1008
+ destPubkey = Buffer.from(
1009
+ ethers.getBytes(recipientData.result.elgamal_pubkey),
1010
+ ).toString("base64");
1011
+ }
1012
+
1013
+ const { ciphertext, amount: currentBalance } =
1014
+ await this._decryptAnonymousBalance(
1015
+ senderAccountId,
1016
+ token,
1017
+ elGamalPrivateKey,
1018
+ "available",
1019
+ );
1020
+ if (!ciphertext)
1021
+ throw new Error(
1022
+ "No available balance found for this anonymous account and token",
1023
+ );
1024
+
1025
+ proofHex = await this.generateTransferProof(elGamalPrivateKey, {
1026
+ currentBalanceCiphertext: ciphertext,
1027
+ currentBalanceContractScale: currentBalance,
1028
+ transferAmountContractScale,
1029
+ destinationPublicKey: destPubkey,
1030
+ });
1031
+ }
1032
+
1033
+ const authNonce = await this.getAuthNonce(senderAccountId);
1034
+ const deadline = this._makeDeadline(deadlineOffset);
1035
+ const proofHash = ethers.keccak256(proofHex);
1036
+
1037
+ const domain = this._buildDomain();
1038
+ const types = {
1039
+ AnonymousTransferToAnonymous: [
1040
+ { name: "senderId", type: "string" },
1041
+ { name: "recipientId", type: "string" },
1042
+ { name: "token", type: "address" },
1043
+ { name: "proofHash", type: "bytes32" },
1044
+ { name: "authNonce", type: "uint256" },
1045
+ { name: "deadline", type: "uint256" },
1046
+ ],
1047
+ };
1048
+ const value = {
1049
+ senderId: String(senderAccountId),
1050
+ recipientId: String(recipientId),
1051
+ token,
1052
+ proofHash,
1053
+ authNonce,
1054
+ deadline: BigInt(deadline),
1055
+ };
1056
+
1057
+ const signature = await authWallet.signTypedData(domain, types, value);
1058
+
1059
+ return await this._fetch("POST", "/v1/anonymous/transfers/anonymous", {
1060
+ sender_id: senderAccountId,
1061
+ recipient_id: recipientId,
1062
+ token,
1063
+ proof: proofHex,
1064
+ auth_nonce: String(authNonce),
1065
+ deadline: String(deadline),
1066
+ signature,
1067
+ });
1068
+ } catch (e) {
1069
+ const msg = e?.message ?? String(e);
1070
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1071
+ throw new Error(`Failed to transfer to anonymous: ${msg}`);
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * Apply a pending balance for an anonymous account via Fairycloak.
1077
+ *
1078
+ * After receiving an anonymous-to-anonymous transfer, the recipient must call
1079
+ * this to move the pending credit into their available balance.
1080
+ *
1081
+ * @param {ethers.Wallet|ethers.Signer} authWallet
1082
+ * @param {string} accountId
1083
+ * @param {Object} [options]
1084
+ * @param {number} [options.deadlineOffset=3600]
1085
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1086
+ */
1087
+ async applyPending(authWallet, accountId, options = {}) {
1088
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1089
+ try {
1090
+ const authNonce = await this.getAuthNonce(accountId);
1091
+ const deadline = this._makeDeadline(deadlineOffset);
1092
+
1093
+ const domain = this._buildDomain();
1094
+ const types = {
1095
+ AnonymousApplyPending: [
1096
+ { name: "accountId", type: "string" },
1097
+ { name: "authNonce", type: "uint256" },
1098
+ { name: "deadline", type: "uint256" },
1099
+ ],
1100
+ };
1101
+ const value = {
1102
+ accountId: String(accountId),
1103
+ authNonce,
1104
+ deadline: BigInt(deadline),
1105
+ };
1106
+
1107
+ const signature = await authWallet.signTypedData(domain, types, value);
1108
+
1109
+ return await this._fetch("POST", "/v1/anonymous/apply", {
1110
+ account_id: accountId,
1111
+ auth_nonce: String(authNonce),
1112
+ deadline: String(deadline),
1113
+ signature,
1114
+ });
1115
+ } catch (e) {
1116
+ const msg = e?.message ?? String(e);
1117
+ if (msg.startsWith("Fairycloak")) throw e;
1118
+ throw new Error(`Failed to apply pending: ${msg}`);
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * Withdraw from an anonymous account to a public EVM address via Fairycloak.
1124
+ *
1125
+ * **Auto-proof mode** (recommended): provide `elGamalPrivateKey`. The SDK fetches the token
1126
+ * decimals, derives the contract-scale amount from `plainAmount`, and generates the ZK proof
1127
+ * automatically.
1128
+ *
1129
+ * **Manual proof mode**: provide a pre-computed `proof` hex string from `generateWithdrawProof()`.
1130
+ *
1131
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
1132
+ * @param {string} accountId
1133
+ * @param {Object} params
1134
+ * @param {string} params.destination - Destination EVM address
1135
+ * @param {string} params.token - Token address
1136
+ * @param {bigint|string|number} params.plainAmount - Withdrawal amount in token units
1137
+ * @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
1138
+ * @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
1139
+ * @param {Object} [options]
1140
+ * @param {number} [options.deadlineOffset=3600]
1141
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1142
+ */
1143
+ async withdraw(
1144
+ authWallet,
1145
+ accountId,
1146
+ {
1147
+ destination,
1148
+ token,
1149
+ plainAmount,
1150
+ proof,
1151
+ elGamalPrivateKey,
1152
+ },
1153
+ options = {},
1154
+ ) {
1155
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1156
+ try {
1157
+ if (!ethers.isAddress(destination))
1158
+ throw new Error(`Invalid destination address: ${destination}`);
1159
+ if (!ethers.isAddress(token))
1160
+ throw new Error(`Invalid token address: ${token}`);
1161
+
1162
+ const amountBig = BigInt(plainAmount);
1163
+
1164
+ // Convert raw ERC-20 units → contract scale.
1165
+ // The contract's withdrawAnonymous expects plainAmount in contract scale
1166
+ // and internally calls `safeTransfer(destination, plainAmount * tokenMul)`.
1167
+ const withdrawAmountContractScale = await this._toContractScale(
1168
+ token,
1169
+ amountBig,
1170
+ );
1171
+ if (withdrawAmountContractScale <= 0n)
1172
+ throw new Error(
1173
+ `Amount too small: ${amountBig} raw units rounds to 0 in contract scale`,
1174
+ );
1175
+
1176
+ // Single call — gives us both txId (proof nonce) and authNonce (EIP-712 sig).
1177
+ // txId is the contract's per-account transaction counter ("user tx-id").
1178
+ // Withdraw proofs bind to the *next* tx id (txId + 1) — the contract increments
1179
+ // on every action, and the proof must commit to what the counter will be after
1180
+ // submission. authNonce is the separate EIP-712 replay-protection nonce.
1181
+ const accountInfo = await this.getAnonymousAccountInfo(accountId);
1182
+ const authNonce = accountInfo.authNonce;
1183
+ const txId = accountInfo.txId;
1184
+
1185
+ let proofHex;
1186
+ if (proof) {
1187
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1188
+ } else {
1189
+ if (!elGamalPrivateKey)
1190
+ throw new Error("Either proof or elGamalPrivateKey must be provided");
1191
+
1192
+ const { ciphertext, amount: currentBalance } =
1193
+ await this._decryptAnonymousBalance(
1194
+ accountId,
1195
+ token,
1196
+ elGamalPrivateKey,
1197
+ "available",
1198
+ );
1199
+ if (!ciphertext)
1200
+ throw new Error(
1201
+ "No available balance found for this anonymous account and token",
1202
+ );
1203
+
1204
+ proofHex = await this.generateWithdrawProof(elGamalPrivateKey, {
1205
+ currentBalanceCiphertext: ciphertext,
1206
+ currentBalanceContractScale: currentBalance,
1207
+ withdrawAmountContractScale: Number(withdrawAmountContractScale),
1208
+ nonce: txId + 1n, // proof binds to *next* txId (current + 1)
1209
+ });
1210
+ }
1211
+ const deadline = this._makeDeadline(deadlineOffset);
1212
+ const proofHash = ethers.keccak256(proofHex);
1213
+
1214
+ const domain = this._buildDomain();
1215
+ const types = {
1216
+ AnonymousWithdraw: [
1217
+ { name: "accountId", type: "string" },
1218
+ { name: "destination", type: "address" },
1219
+ { name: "token", type: "address" },
1220
+ { name: "plainAmount", type: "uint256" },
1221
+ { name: "proofHash", type: "bytes32" },
1222
+ { name: "authNonce", type: "uint256" },
1223
+ { name: "deadline", type: "uint256" },
1224
+ ],
1225
+ };
1226
+ const value = {
1227
+ accountId: String(accountId),
1228
+ destination,
1229
+ token,
1230
+ plainAmount: withdrawAmountContractScale,
1231
+ proofHash,
1232
+ authNonce,
1233
+ deadline: BigInt(deadline),
1234
+ };
1235
+
1236
+ const signature = await authWallet.signTypedData(domain, types, value);
1237
+
1238
+ return await this._fetch("POST", "/v1/anonymous/withdraw", {
1239
+ account_id: accountId,
1240
+ destination,
1241
+ token,
1242
+ plain_amount: String(withdrawAmountContractScale),
1243
+ proof: proofHex,
1244
+ auth_nonce: String(authNonce),
1245
+ deadline: String(deadline),
1246
+ signature,
1247
+ });
1248
+ } catch (e) {
1249
+ const msg = e?.message ?? String(e);
1250
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1251
+ throw new Error(`Failed to withdraw: ${msg}`);
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Get total, available, and pending balance (contract scale).
1257
+ *
1258
+ * @param {string} accountId
1259
+ * @param {string} tokenAddress
1260
+ * @param {string} elGamalPrivateKey
1261
+ * @returns {Promise<{ amount: number, available: number, pending: number }>}
1262
+ */
1263
+ async getBalance(accountId, tokenAddress, elGamalPrivateKey) {
1264
+ try {
1265
+ if (!ethers.isAddress(tokenAddress)) {
1266
+ throw new Error(`Invalid token address: ${tokenAddress}`);
1267
+ }
1268
+ if (!elGamalPrivateKey) {
1269
+ throw new Error("elGamalPrivateKey is required");
1270
+ }
1271
+
1272
+ const { available, pending } = await this.getAnonymousBalance(
1273
+ accountId,
1274
+ tokenAddress,
1275
+ elGamalPrivateKey,
1276
+ );
1277
+
1278
+ const availableAmt = available?.amount ?? 0;
1279
+ const pendingAmt = pending?.amount ?? 0;
1280
+
1281
+ return {
1282
+ amount: availableAmt + pendingAmt,
1283
+ available: availableAmt,
1284
+ pending: pendingAmt,
1285
+ };
1286
+ } catch (e) {
1287
+ throw new Error(`Failed to get balance: ${e.message}`);
1288
+ }
1289
+ }
1290
+
1291
+ // ──────────────────── request tracking ─────────────────────────
1292
+
1293
+ /**
1294
+ * Get the current status of a Fairycloak request.
1295
+ *
1296
+ * Terminal statuses: `completed`, `failed`
1297
+ * Transaction statuses: `confirmed`, `mined`, `submitted`
1298
+ * Initial status: `accepted`
1299
+ *
1300
+ * @param {string} requestId
1301
+ * @returns {Promise<Object>}
1302
+ */
1303
+ async getRequestStatus(requestId) {
1304
+ return await this._fetch("GET", `/v1/requests/${requestId}`);
1305
+ }
1306
+
1307
+ /**
1308
+ * Fetch the durable event history for a Fairycloak request.
1309
+ *
1310
+ * Useful for reconnect/recovery in frontend applications.
1311
+ *
1312
+ * @param {string} requestId
1313
+ * @param {Object} [options]
1314
+ * @param {number} [options.afterSeq=0] - Only return events with sequence > afterSeq
1315
+ * @param {number} [options.limit=100]
1316
+ * @returns {Promise<{request_id:string, events:Array}>}
1317
+ */
1318
+ async getRequestEvents(requestId, { afterSeq = 0, limit = 100 } = {}) {
1319
+ return await this._fetch(
1320
+ "GET",
1321
+ `/v1/requests/${requestId}/events/history?after_seq=${afterSeq}&limit=${limit}`,
1322
+ );
1323
+ }
1324
+
1325
+ /**
1326
+ * Poll a Fairycloak request until it reaches a terminal state or times out.
1327
+ *
1328
+ * Terminal states: `completed`, `confirmed`, `failed`
1329
+ *
1330
+ * @param {string} requestId
1331
+ * @param {Object} [options]
1332
+ * @param {number} [options.timeoutMs=120000] - Total timeout in milliseconds
1333
+ * @param {number} [options.pollIntervalMs=2000]
1334
+ * @returns {Promise<Object>} Final request record
1335
+ */
1336
+ async waitForRequest(
1337
+ requestId,
1338
+ { timeoutMs = 120000, pollIntervalMs = 2000 } = {},
1339
+ ) {
1340
+ const terminal = new Set(["completed", "confirmed", "failed"]);
1341
+ const cutoff = Date.now() + timeoutMs;
1342
+
1343
+ while (Date.now() < cutoff) {
1344
+ const status = await this.getRequestStatus(requestId);
1345
+ if (terminal.has(status.status)) return status;
1346
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1347
+ }
1348
+
1349
+ throw new Error(
1350
+ `Timeout waiting for Fairycloak request ${requestId} after ${timeoutMs}ms`,
1351
+ );
1352
+ }
1353
+ }