@fairblock/stabletrust 1.0.22 → 2.0.0

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