@fairblock/stabletrust 1.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.
package/src/client.js ADDED
@@ -0,0 +1,885 @@
1
+ import { ethers } from "ethers";
2
+ import {
3
+ CONTRACT_ABI,
4
+ ERC20_ABI,
5
+ DEFAULT_CONFIG,
6
+ TEMPO_FEE_TOKEN_ADDRESS,
7
+ } from "./constants.js";
8
+ import { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
9
+ import { encodeTransferProof, encodeWithdrawProof, sleep } from "./utils.js";
10
+ import { initializeWasm } from "./wasm-loader.js";
11
+
12
+ // Auto-initialize WASM on first use
13
+ let wasmModulePromise = null;
14
+
15
+ function getWasmModule() {
16
+ if (!wasmModulePromise) {
17
+ wasmModulePromise = initializeWasm();
18
+ }
19
+ return wasmModulePromise;
20
+ }
21
+
22
+ /**
23
+ * ConfidentialTransferClient - Main SDK class for confidential transfers
24
+ */
25
+ export class ConfidentialTransferClient {
26
+ /**
27
+ * Create a new ConfidentialTransferClient instance
28
+ *
29
+ * @param {string} rpcUrl - RPC endpoint URL
30
+ * @param {string} contractAddress - Confidential transfer contract address
31
+ * @param {number} chainId - Chain ID
32
+ */
33
+ constructor(rpcUrl, contractAddress, chainId) {
34
+ // Validate required config
35
+ if (!rpcUrl) {
36
+ throw new Error("rpcUrl is required");
37
+ }
38
+ if (!contractAddress) {
39
+ throw new Error("contractAddress is required");
40
+ }
41
+
42
+ // Build config
43
+ this.config = {
44
+ rpcUrl,
45
+ contractAddress,
46
+ chainId,
47
+ };
48
+
49
+ // WASM will be auto-initialized on first use
50
+ this._wasmModule = null;
51
+
52
+ try {
53
+ this.provider = new ethers.JsonRpcProvider(this.config.rpcUrl);
54
+ this.contract = new ethers.Contract(
55
+ this.config.contractAddress,
56
+ CONTRACT_ABI,
57
+ this.provider,
58
+ );
59
+ } catch (error) {
60
+ throw new Error(
61
+ `Failed to initialize contracts: ${error.message}. Check your RPC URL and contract addresses.`,
62
+ );
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get WASM module (auto-initializes if needed)
68
+ * @private
69
+ */
70
+ async _getWasm() {
71
+ if (!this._wasmModule) {
72
+ this._wasmModule = await getWasmModule();
73
+ }
74
+ return this._wasmModule;
75
+ }
76
+
77
+ /**
78
+ * Get token contract for a specific token
79
+ * @private
80
+ */
81
+ _getTokenContract(tokenAddress) {
82
+ return new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
83
+ }
84
+
85
+ /**
86
+ * Derive encryption keys for a wallet
87
+ *
88
+ * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to derive keys for
89
+ * @returns {Promise<{publicKey: string, privateKey: string}>}
90
+ */
91
+ async deriveKeys(wallet) {
92
+ try {
93
+ if (!wallet) {
94
+ throw new Error("Wallet is required");
95
+ }
96
+ const wasm = await this._getWasm();
97
+ return await deriveKeys(
98
+ wallet,
99
+ {
100
+ chainId: this.config.chainId,
101
+ contractAddress: this.config.contractAddress,
102
+ },
103
+ wasm.generate_deterministic_keypair,
104
+ );
105
+ } catch (error) {
106
+ throw new Error(`Failed to derive keys: ${error.message}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get account information from the contract
112
+ *
113
+ * @param {string} address - Account address
114
+ * @returns {Promise<Object>} Account core information
115
+ */
116
+ async getAccountInfo(address) {
117
+ try {
118
+ if (!address || !ethers.isAddress(address)) {
119
+ throw new Error(`Invalid address: ${address}`);
120
+ }
121
+ return await this.contract.getAccountCore(address);
122
+ } catch (error) {
123
+ throw new Error(`Failed to get account info: ${error.message}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Create a confidential account if it doesn't exist and wait for finalization
129
+ *
130
+ * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to create account for
131
+ * @param {Object} [options] - Options
132
+ * @param {boolean} [options.waitForFinalization=true] - Wait for account finalization
133
+ * @param {number} [options.maxAttempts=30] - Max attempts to wait for finalization
134
+ * @returns {Promise<{publicKey: string, privateKey: string}>} The derived keys
135
+ */
136
+ async ensureAccount(wallet, options = {}) {
137
+ const { waitForFinalization = true, maxAttempts = 30 } = options;
138
+
139
+ try {
140
+ const address = await wallet.getAddress();
141
+ const keys = await this.deriveKeys(wallet);
142
+ let accountInfo = await this.getAccountInfo(address);
143
+
144
+ if (!accountInfo.exists) {
145
+ const tx = await this.contract
146
+ .connect(wallet)
147
+ .createConfidentialAccount(Buffer.from(keys.publicKey, "base64"));
148
+
149
+ const receipt = await tx.wait();
150
+ if (!receipt || receipt.status === 0) {
151
+ throw new Error("Account creation transaction failed");
152
+ }
153
+
154
+ // Refresh account info after creation
155
+ accountInfo = await this.getAccountInfo(address);
156
+ }
157
+
158
+ if (waitForFinalization) {
159
+ let attempts = 0;
160
+ while (!accountInfo.finalized && attempts < maxAttempts) {
161
+ await sleep(2000);
162
+ accountInfo = await this.getAccountInfo(address);
163
+ attempts++;
164
+ }
165
+
166
+ if (!accountInfo.finalized) {
167
+ throw new Error(
168
+ `Account finalization timeout after ${maxAttempts} attempts. The account was created but may not be ready yet.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ return keys;
174
+ } catch (error) {
175
+ if (error.message.includes("Account finalization timeout")) {
176
+ throw error;
177
+ }
178
+ throw new Error(`Failed to ensure account: ${error.message}`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get decrypted balance for an address
184
+ *
185
+ * @param {string} address - Account address
186
+ * @param {string} privateKey - Private key for decryption
187
+ * @param {string} tokenAddress - Token address
188
+ * @param {Object} [options] - Options
189
+ * @param {string} [options.type='available'] - Balance type: 'available' or 'pending'
190
+ * @returns {Promise<{amount: number, ciphertext: string|null}>}
191
+ */
192
+ async getBalance(address, privateKey, tokenAddress, options = {}) {
193
+ const { type = "available" } = options;
194
+
195
+ try {
196
+ if (!address || !ethers.isAddress(address)) {
197
+ throw new Error(`Invalid address: ${address}`);
198
+ }
199
+ if (!privateKey) {
200
+ throw new Error("Private key is required for decryption");
201
+ }
202
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
203
+ throw new Error(`Invalid token address: ${tokenAddress}`);
204
+ }
205
+
206
+ let c1, c2;
207
+ if (type.toLowerCase() === "pending") {
208
+ [c1, c2] = await this.contract.getPending(address, tokenAddress);
209
+ } else {
210
+ [c1, c2] = await this.contract.getAvailable(address, tokenAddress);
211
+ }
212
+
213
+ if ((!c1 || c1 === "0x") && (!c2 || c2 === "0x")) {
214
+ return { amount: 0, ciphertext: null };
215
+ }
216
+
217
+ const wasm = await this._getWasm();
218
+ const ciphertext = combineCiphertext(c1, c2);
219
+ const amount = decryptCiphertext(
220
+ ciphertext,
221
+ privateKey,
222
+ wasm.decrypt_ciphertext,
223
+ );
224
+
225
+ return { amount, ciphertext };
226
+ } catch (error) {
227
+ throw new Error(`Failed to get balance: ${error.message}`);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Deposit tokens into confidential account
233
+ *
234
+ * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to deposit from
235
+ * @param {string} tokenAddress - Token address to deposit
236
+ * @param {bigint|string|number} amount - Amount to deposit (in token units)
237
+ * @param {Object} [options] - Options
238
+ * @param {boolean} [options.waitForFinalization=true] - Wait for deposit finalization
239
+ * @returns {Promise<Object>} Transaction receipt
240
+ */
241
+ async deposit(wallet, tokenAddress, amount, options = {}) {
242
+ const { waitForFinalization = true } = options;
243
+
244
+ try {
245
+ if (!wallet) {
246
+ throw new Error("Wallet is required");
247
+ }
248
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
249
+ throw new Error(`Invalid token address: ${tokenAddress}`);
250
+ }
251
+ if (!amount || BigInt(amount) <= 0) {
252
+ throw new Error("Amount must be greater than 0");
253
+ }
254
+
255
+ const address = await wallet.getAddress();
256
+ const depositAmount = BigInt(amount);
257
+ const tokenContract = this._getTokenContract(tokenAddress);
258
+
259
+ // Check token balance
260
+ const tokenBalance = await tokenContract.balanceOf(address);
261
+ if (tokenBalance < depositAmount) {
262
+ throw new Error(
263
+ `Insufficient token balance. Required: ${depositAmount}, Available: ${tokenBalance}`,
264
+ );
265
+ }
266
+
267
+ // Check and approve if needed
268
+ const allowance = await tokenContract.allowance(
269
+ address,
270
+ this.config.contractAddress,
271
+ );
272
+
273
+ if (allowance < depositAmount) {
274
+ const approveTx = await tokenContract
275
+ .connect(wallet)
276
+ .approve(this.config.contractAddress, ethers.MaxUint256);
277
+
278
+ const approveReceipt = await approveTx.wait();
279
+ if (!approveReceipt || approveReceipt.status === 0) {
280
+ throw new Error("Token approval failed");
281
+ }
282
+ }
283
+
284
+ // Perform deposit
285
+ const depositTx = await this.contract
286
+ .connect(wallet)
287
+ .deposit(tokenAddress, depositAmount);
288
+
289
+ const receipt = await depositTx.wait();
290
+ if (!receipt || receipt.status === 0) {
291
+ throw new Error("Deposit transaction failed");
292
+ }
293
+
294
+ if (waitForFinalization) {
295
+ await this._waitForGlobalState(address, "deposit");
296
+ }
297
+
298
+ return receipt;
299
+ } catch (error) {
300
+ if (error.message.includes("Insufficient token balance")) {
301
+ throw error;
302
+ }
303
+ throw new Error(`Failed to deposit: ${error.message}`);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Transfer confidential tokens to another address
309
+ *
310
+ * @param {ethers.Wallet|ethers.Signer} senderWallet - Sender's wallet
311
+ * @param {string} recipientAddress - Recipient's address
312
+ * @param {string} tokenAddress - Token address to transfer
313
+ * @param {number} amount - Amount to transfer
314
+ * @param {Object} senderKeys - Sender's encryption keys
315
+ * @param {string} recipientPublicKey - Recipient's public key
316
+ * @param {string} currentBalanceCiphertext - Current balance ciphertext
317
+ * @param {number} currentBalance - Current balance (decrypted)
318
+ * @param {Object} [options] - Options
319
+ * @param {boolean} [options.useOffchainVerify=false] - Use offchain verification
320
+ * @param {boolean} [options.waitForFinalization=true] - Wait for transfer finalization
321
+ * @returns {Promise<Object>} Transaction receipt
322
+ */
323
+ async transfer(
324
+ senderWallet,
325
+ recipientAddress,
326
+ tokenAddress,
327
+ amount,
328
+ options = {},
329
+ ) {
330
+ const { useOffchainVerify = false, waitForFinalization = true } = options;
331
+
332
+ try {
333
+ // Validate inputs
334
+ if (!senderWallet) {
335
+ throw new Error("Sender wallet is required");
336
+ }
337
+ if (!recipientAddress || !ethers.isAddress(recipientAddress)) {
338
+ throw new Error(`Invalid recipient address: ${recipientAddress}`);
339
+ }
340
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
341
+ throw new Error(`Invalid token address: ${tokenAddress}`);
342
+ }
343
+ if (!amount || amount <= 0) {
344
+ throw new Error("Transfer amount must be greater than 0");
345
+ }
346
+
347
+ const senderAddress = await senderWallet.getAddress();
348
+
349
+ // Auto-derive sender keys
350
+ const derivedSenderKeys = await this.deriveKeys(senderWallet);
351
+ if (!derivedSenderKeys?.privateKey) {
352
+ throw new Error("Failed to derive sender keys");
353
+ }
354
+
355
+ // Auto-derive recipient public key
356
+ const recipientAccountInfo = await this.getAccountInfo(recipientAddress);
357
+ if (!recipientAccountInfo.exists) {
358
+ throw new Error(
359
+ `Recipient account does not exist. Address: ${recipientAddress}`,
360
+ );
361
+ }
362
+ let derivedRecipientPublicKey = recipientAccountInfo.pubkey;
363
+ if (!derivedRecipientPublicKey) {
364
+ throw new Error("Recipient public key is required");
365
+ }
366
+ // Convert hex bytes to base64 if needed
367
+ if (
368
+ typeof derivedRecipientPublicKey === "string" &&
369
+ derivedRecipientPublicKey.startsWith("0x")
370
+ ) {
371
+ derivedRecipientPublicKey = Buffer.from(
372
+ derivedRecipientPublicKey.slice(2),
373
+ "hex",
374
+ ).toString("base64");
375
+ }
376
+
377
+ // Auto-fetch current balance
378
+ const balanceInfo = await this.getBalance(
379
+ senderAddress,
380
+ derivedSenderKeys.privateKey,
381
+ tokenAddress,
382
+ );
383
+ const fee = await this.getFeeAmount();
384
+ if (!balanceInfo) {
385
+ throw new Error("Failed to fetch sender balance");
386
+ }
387
+ const derivedCurrentBalanceCiphertext = balanceInfo.ciphertext;
388
+ const derivedCurrentBalance = balanceInfo.amount;
389
+
390
+ if (!derivedCurrentBalanceCiphertext) {
391
+ throw new Error(
392
+ "Current balance ciphertext is required. Did you call getBalance()?",
393
+ );
394
+ }
395
+ if (
396
+ derivedCurrentBalance === undefined ||
397
+ derivedCurrentBalance < amount
398
+ ) {
399
+ throw new Error(
400
+ `Insufficient balance. Required: ${amount}, Available: ${derivedCurrentBalance}`,
401
+ );
402
+ }
403
+
404
+ // Generate proof
405
+ const proofInput = {
406
+ current_balance_ciphertext: derivedCurrentBalanceCiphertext,
407
+ current_balance:
408
+ typeof derivedCurrentBalance === "bigint"
409
+ ? derivedCurrentBalance.toString()
410
+ : derivedCurrentBalance,
411
+ transfer_amount: typeof amount === "bigint" ? Number(amount) : amount,
412
+ source_keypair: derivedSenderKeys.privateKey,
413
+ destination_pubkey: derivedRecipientPublicKey,
414
+ };
415
+
416
+ const wasm = await this._getWasm();
417
+ const proofResult = wasm.generate_transfer_proof(
418
+ JSON.stringify(proofInput),
419
+ );
420
+ const proof = JSON.parse(proofResult);
421
+
422
+ if (!proof.success) {
423
+ throw new Error(
424
+ `Proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
425
+ );
426
+ }
427
+
428
+ // Execute transfer based on chain type
429
+ // Tempo chain (42431) uses token-based(fee token for the current contract is pathUSD)fees instead of native currency
430
+ const receipt =
431
+ this.config.chainId === 42431
432
+ ? await this._executeTempoTransfer(
433
+ senderWallet,
434
+ senderAddress,
435
+ recipientAddress,
436
+ tokenAddress,
437
+ proof,
438
+ useOffchainVerify,
439
+ fee,
440
+ )
441
+ : await this._executeStandardTransfer(
442
+ senderWallet,
443
+ recipientAddress,
444
+ tokenAddress,
445
+ proof,
446
+ useOffchainVerify,
447
+ );
448
+
449
+ if (waitForFinalization) {
450
+ await this._waitForGlobalState(senderAddress, "transfer");
451
+ }
452
+
453
+ return receipt;
454
+ } catch (error) {
455
+ const message = error?.message ?? String(error);
456
+ if (
457
+ message.includes("Insufficient balance") ||
458
+ message.includes("Proof generation failed")
459
+ ) {
460
+ throw error;
461
+ }
462
+ throw new Error(`Failed to transfer: ${message}`);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Execute confidential transfer on Tempo chain (chainId 42431)
468
+ *
469
+ * Tempo is a stablecoin-focused chain without native currency for smart contracts.
470
+ * This method handles token-based fee payment using PathUSD, requiring:
471
+ * - Fee token approval before transfer
472
+ * - Fee token balance validation
473
+ * - Gas estimation with fallback for estimation failures
474
+ *
475
+ * @private
476
+ */
477
+ async _executeTempoTransfer(
478
+ senderWallet,
479
+ senderAddress,
480
+ recipientAddress,
481
+ tokenAddress,
482
+ proof,
483
+ useOffchainVerify,
484
+ fee,
485
+ ) {
486
+ const feeTokenAddress = TEMPO_FEE_TOKEN_ADDRESS;
487
+ let tx;
488
+
489
+ // Check if feeTokenAddress is configured - this indicates token-based fee payment
490
+ if (feeTokenAddress) {
491
+ // Approve fee token for the contract
492
+ const feeTokenContract = this._getTokenContract(feeTokenAddress);
493
+
494
+ // Check balance first
495
+ const feeTokenBalance = await feeTokenContract.balanceOf(senderAddress);
496
+ if (feeTokenBalance < fee) {
497
+ throw new Error(
498
+ `Insufficient fee token ${TEMPO_FEE_TOKEN_ADDRESS} balance. Required: ${fee}, Available: ${feeTokenBalance}`,
499
+ );
500
+ }
501
+
502
+ const allowance = await feeTokenContract.allowance(
503
+ senderAddress,
504
+ this.config.contractAddress,
505
+ );
506
+
507
+ if (allowance < fee) {
508
+ const approveTx = await feeTokenContract
509
+ .connect(senderWallet)
510
+ .approve(this.config.contractAddress, ethers.MaxUint256);
511
+
512
+ const approveReceipt = await approveTx.wait();
513
+ if (!approveReceipt || approveReceipt.status === 0) {
514
+ throw new Error("Fee token approval failed");
515
+ }
516
+ }
517
+
518
+ // Try to estimate gas first to catch any revert reasons early
519
+ let gasLimit = 2_000_000n;
520
+ try {
521
+ const estimatedGas = await this.contract
522
+ .connect(senderWallet)
523
+ .transferConfidential.estimateGas(
524
+ recipientAddress,
525
+ tokenAddress,
526
+ ethers.getBytes(encodeTransferProof(proof.data)),
527
+ useOffchainVerify,
528
+ { value: 0 },
529
+ );
530
+ // Add 20% buffer to estimated gas
531
+ gasLimit = (estimatedGas * 120n) / 100n;
532
+ } catch (gasEstError) {
533
+ // If gas estimation fails, use default gas limit
534
+ console.warn(
535
+ `Gas estimation failed, using default gas limit: ${gasEstError?.message || String(gasEstError)}`,
536
+ );
537
+ }
538
+
539
+ tx = await this.contract
540
+ .connect(senderWallet)
541
+ .transferConfidential(
542
+ recipientAddress,
543
+ tokenAddress,
544
+ ethers.getBytes(encodeTransferProof(proof.data)),
545
+ useOffchainVerify,
546
+ { value: 0, gasLimit },
547
+ );
548
+ } else {
549
+ try {
550
+ tx = await this.contract
551
+ .connect(senderWallet)
552
+ .transferConfidential(
553
+ recipientAddress,
554
+ tokenAddress,
555
+ ethers.getBytes(encodeTransferProof(proof.data)),
556
+ useOffchainVerify,
557
+ { value: fee },
558
+ );
559
+ } catch (gasError) {
560
+ if (
561
+ gasError?.code === "CALL_EXCEPTION" ||
562
+ gasError?.code === "UNKNOWN_ERROR" ||
563
+ gasError?.message?.includes("estimateGas") ||
564
+ gasError?.message?.includes("missing revert data")
565
+ ) {
566
+ const gasLimit = 2_000_000n;
567
+ tx = await this.contract
568
+ .connect(senderWallet)
569
+ .transferConfidential(
570
+ recipientAddress,
571
+ tokenAddress,
572
+ ethers.getBytes(encodeTransferProof(proof.data)),
573
+ useOffchainVerify,
574
+ { value: fee, gasLimit },
575
+ );
576
+ } else {
577
+ throw gasError;
578
+ }
579
+ }
580
+ }
581
+
582
+ const receipt = await tx.wait();
583
+ if (!receipt || receipt.status === 0) {
584
+ throw new Error("Transfer transaction failed");
585
+ }
586
+
587
+ return receipt;
588
+ }
589
+
590
+ /**
591
+ * Execute confidential transfer on standard chains
592
+ * @private
593
+ */
594
+ async _executeStandardTransfer(
595
+ senderWallet,
596
+ recipientAddress,
597
+ tokenAddress,
598
+ proof,
599
+ useOffchainVerify,
600
+ ) {
601
+ const fee = await this.contract.feeAmount();
602
+ const tx = await this.contract
603
+ .connect(senderWallet)
604
+ .transferConfidential(
605
+ recipientAddress,
606
+ tokenAddress,
607
+ ethers.getBytes(encodeTransferProof(proof.data)),
608
+ useOffchainVerify,
609
+ { value: fee },
610
+ );
611
+
612
+ const receipt = await tx.wait();
613
+ if (!receipt || receipt.status === 0) {
614
+ throw new Error("Transfer transaction failed");
615
+ }
616
+
617
+ return receipt;
618
+ }
619
+
620
+ /**
621
+ * Apply pending balance to available balance
622
+ *
623
+ * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to apply pending for
624
+ * @param {Object} [options] - Options
625
+ * @param {boolean} [options.waitForFinalization=true] - Wait for operation finalization
626
+ * @returns {Promise<Object>} Transaction receipt
627
+ */
628
+ async applyPending(wallet, options = {}) {
629
+ const { waitForFinalization = true } = options;
630
+
631
+ try {
632
+ if (!wallet) {
633
+ throw new Error("Wallet is required");
634
+ }
635
+
636
+ const address = await wallet.getAddress();
637
+
638
+ const tx = await this.contract.connect(wallet).applyPending();
639
+ const receipt = await tx.wait();
640
+
641
+ if (!receipt || receipt.status === 0) {
642
+ throw new Error("Apply pending transaction failed");
643
+ }
644
+
645
+ if (waitForFinalization) {
646
+ await this._waitForGlobalState(address, "apply pending");
647
+ }
648
+
649
+ return receipt;
650
+ } catch (error) {
651
+ throw new Error(`Failed to apply pending: ${error.message}`);
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Withdraw confidential tokens to public ERC20
657
+ *
658
+ * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to withdraw from
659
+ * @param {string} tokenAddress - Token address to withdraw
660
+ * @param {number} amount - Amount to withdraw
661
+ * @param {Object} keys - Encryption keys
662
+ * @param {string} currentBalanceCiphertext - Current balance ciphertext
663
+ * @param {number} currentBalance - Current balance (decrypted)
664
+ * @param {Object} [options] - Options
665
+ * @param {boolean} [options.useOffchainVerify=false] - Use offchain verification
666
+ * @param {boolean} [options.waitForFinalization=true] - Wait for withdrawal finalization
667
+ * @returns {Promise<Object>} Transaction receipt
668
+ */
669
+ async withdraw(wallet, tokenAddress, amount, options = {}) {
670
+ const { useOffchainVerify = false, waitForFinalization = true } = options;
671
+
672
+ try {
673
+ // Validate inputs
674
+ if (!wallet) {
675
+ throw new Error("Wallet is required");
676
+ }
677
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
678
+ throw new Error(`Invalid token address: ${tokenAddress}`);
679
+ }
680
+ if (!amount || amount <= 0) {
681
+ throw new Error("Withdrawal amount must be greater than 0");
682
+ }
683
+
684
+ // Auto-derive keys
685
+ const derivedKeys = await this.deriveKeys(wallet);
686
+ if (!derivedKeys?.privateKey) {
687
+ throw new Error("Failed to derive keys");
688
+ }
689
+
690
+ const balanceInfo = await this.getBalance(
691
+ wallet.address,
692
+ derivedKeys.privateKey,
693
+ tokenAddress,
694
+ {
695
+ type: "available",
696
+ },
697
+ );
698
+ if (!balanceInfo) {
699
+ throw new Error("Failed to fetch sender balance");
700
+ }
701
+ const currentBalanceCiphertext = balanceInfo.ciphertext;
702
+ const currentBalance = balanceInfo.amount;
703
+
704
+ if (!currentBalanceCiphertext) {
705
+ throw new Error(
706
+ "Current balance ciphertext is required. Did you call getBalance()?",
707
+ );
708
+ }
709
+ if (currentBalance === undefined || currentBalance < amount) {
710
+ throw new Error(
711
+ `Insufficient balance. Required: ${amount}, Available: ${currentBalance}`,
712
+ );
713
+ }
714
+
715
+ const address = await wallet.getAddress();
716
+
717
+ // Generate withdrawal proof
718
+ const withdrawInput = {
719
+ current_balance_ciphertext: currentBalanceCiphertext,
720
+ current_balance:
721
+ typeof currentBalance === "bigint"
722
+ ? Number(currentBalance)
723
+ : currentBalance,
724
+ withdraw_amount: typeof amount === "bigint" ? Number(amount) : amount,
725
+ keypair: derivedKeys.privateKey,
726
+ };
727
+
728
+ const wasm = await this._getWasm();
729
+ const proofResult = wasm.generate_withdraw_proof(
730
+ JSON.stringify(withdrawInput),
731
+ );
732
+ const proof = JSON.parse(proofResult);
733
+
734
+ if (!proof.success) {
735
+ throw new Error(
736
+ `Withdrawal proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
737
+ );
738
+ }
739
+
740
+ // Execute withdrawal
741
+ const tx = await this.contract
742
+ .connect(wallet)
743
+ .withdraw(
744
+ tokenAddress,
745
+ BigInt(amount),
746
+ ethers.getBytes(encodeWithdrawProof(proof.data)),
747
+ useOffchainVerify,
748
+ );
749
+
750
+ const receipt = await tx.wait();
751
+ if (!receipt || receipt.status === 0) {
752
+ throw new Error("Withdrawal transaction failed");
753
+ }
754
+
755
+ if (waitForFinalization) {
756
+ await this._waitForGlobalState(address, "withdraw");
757
+ }
758
+
759
+ return receipt;
760
+ } catch (error) {
761
+ const message = error?.message ?? String(error);
762
+ if (
763
+ message.includes("Insufficient balance") ||
764
+ message.includes("proof generation failed")
765
+ ) {
766
+ throw error;
767
+ }
768
+ throw new Error(`Failed to withdraw: ${message}`);
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Wait for pending action to complete (internal method)
774
+ *
775
+ * @param {string} address - Account address
776
+ * @param {string} actionLabel - Label for error messages
777
+ * @private
778
+ */
779
+ async _waitForGlobalState(address, actionLabel) {
780
+ // Initial cooldown to allow the relayer/indexer to pick up the transaction
781
+ await sleep(10000);
782
+
783
+ let attempts = 0;
784
+ const maxAttempts = 60;
785
+
786
+ while (attempts < maxAttempts) {
787
+ try {
788
+ const info = await this.contract.getAccountCore(address);
789
+ if (!info.hasPendingAction) {
790
+ return; // Success
791
+ }
792
+ } catch (error) {
793
+ // If we can't get account info, wait and retry
794
+ console.warn(
795
+ `Warning: Failed to check account state (attempt ${attempts + 1}): ${error.message}`,
796
+ );
797
+ }
798
+
799
+ await sleep(3000);
800
+ attempts++;
801
+ }
802
+
803
+ throw new Error(
804
+ `Timeout waiting for ${actionLabel} to complete. The transaction may still be processing. Please check your account later.`,
805
+ );
806
+ }
807
+
808
+ /**
809
+ * Wait for pending balance to appear
810
+ *
811
+ * @param {string} address - Account address
812
+ * @param {string} privateKey - Private key for decryption
813
+ * @param {string} tokenAddress - Token address
814
+ * @param {Object} [options] - Options
815
+ * @param {number} [options.maxAttempts=60] - Maximum polling attempts
816
+ * @param {number} [options.intervalMs=3000] - Polling interval in milliseconds
817
+ * @returns {Promise<{amount: number, ciphertext: string}>}
818
+ */
819
+ async waitForPendingBalance(address, privateKey, tokenAddress, options = {}) {
820
+ const { maxAttempts = 60, intervalMs = 3000 } = options;
821
+
822
+ try {
823
+ for (let i = 0; i < maxAttempts; i++) {
824
+ const pending = await this.getBalance(
825
+ address,
826
+ privateKey,
827
+ tokenAddress,
828
+ {
829
+ type: "pending",
830
+ },
831
+ );
832
+
833
+ if (pending.amount > 0) {
834
+ return pending;
835
+ }
836
+
837
+ await sleep(intervalMs);
838
+ }
839
+
840
+ throw new Error(
841
+ `Timeout waiting for pending balance after ${maxAttempts} attempts. The transfer may still be processing.`,
842
+ );
843
+ } catch (error) {
844
+ if (error.message.includes("Timeout waiting for pending balance")) {
845
+ throw error;
846
+ }
847
+ throw new Error(`Failed to wait for pending balance: ${error.message}`);
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Get the current fee amount for confidential transfers
853
+ *
854
+ * @returns {Promise<bigint>} Fee amount in wei
855
+ */
856
+ async getFeeAmount() {
857
+ try {
858
+ return await this.contract.feeAmount();
859
+ } catch (error) {
860
+ throw new Error(`Failed to get fee amount: ${error.message}`);
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Get ERC20 token balance
866
+ *
867
+ * @param {string} address - Account address
868
+ * @param {string} tokenAddress - Token address
869
+ * @returns {Promise<bigint>} Token balance
870
+ */
871
+ async getTokenBalance(address, tokenAddress) {
872
+ try {
873
+ if (!address || !ethers.isAddress(address)) {
874
+ throw new Error(`Invalid address: ${address}`);
875
+ }
876
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
877
+ throw new Error(`Invalid token address: ${tokenAddress}`);
878
+ }
879
+ const tokenContract = this._getTokenContract(tokenAddress);
880
+ return await tokenContract.balanceOf(address);
881
+ } catch (error) {
882
+ throw new Error(`Failed to get token balance: ${error.message}`);
883
+ }
884
+ }
885
+ }