@fairblock/stabletrust 2.0.7 → 2.0.11

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.
@@ -2,6 +2,12 @@ import { ethers } from "ethers";
2
2
  import {
3
3
  CONTRACT_ABI,
4
4
  ERC20_ABI,
5
+ TRANSFER_CONFIDENTIAL_SIGNATURE,
6
+ WITHDRAW_CONFIDENTIAL_SIGNATURE,
7
+ FEE_TOKEN_SIGNATURE,
8
+ NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
9
+ ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
10
+ ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
5
11
  getStabletrustContractAddress,
6
12
  } from "./constants.js";
7
13
  import { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
@@ -9,9 +15,12 @@ import {
9
15
  encodeTransferProof,
10
16
  encodeWithdrawProof,
11
17
  sleep,
18
+ toContractScale,
12
19
  uploadBytesToIpfs,
13
20
  } from "./utils.js";
14
21
  import { initializeWasm } from "./wasm-loader.js";
22
+ import { encryptRandomness } from "./ibe.js";
23
+ import { createRequest, CHAIN_KEY_BY_ID } from "./backend.js";
15
24
 
16
25
  // Auto-initialize WASM on first use
17
26
  let wasmModulePromise = null;
@@ -33,8 +42,12 @@ export class ConfidentialTransferClient {
33
42
  * @param {string} rpcUrl - RPC endpoint URL
34
43
  * @param {string|number} contractAddressOrChainId - Contract address or chain ID
35
44
  * @param {number} [chainId] - Chain ID when contract address is provided
45
+ * @param {Object} [options]
46
+ * @param {string} [options.ipfsUploadUrl] - StableTrust IPFS upload endpoint for off-chain proof uploads
47
+ * @param {string} [options.ipfsApiKey] - Optional bearer token for the StableTrust IPFS upload endpoint
48
+ * @param {string} [options.apiBaseUrl] - StableTrust backend API base URL for request persistence
36
49
  */
37
- constructor(rpcUrl, contractAddressOrChainId, chainId) {
50
+ constructor(rpcUrl, contractAddressOrChainId, chainId, options = {}) {
38
51
  // Validate required config
39
52
  if (!rpcUrl) {
40
53
  throw new Error("rpcUrl is required");
@@ -42,10 +55,15 @@ export class ConfidentialTransferClient {
42
55
 
43
56
  let resolvedChainId;
44
57
  let resolvedContractAddress;
58
+ let ipfsOptions = options || {};
45
59
 
46
- if (typeof contractAddressOrChainId === "number" && chainId === undefined) {
60
+ if (
61
+ typeof contractAddressOrChainId === "number" &&
62
+ (chainId === undefined || typeof chainId === "object")
63
+ ) {
47
64
  resolvedChainId = contractAddressOrChainId;
48
65
  resolvedContractAddress = getStabletrustContractAddress(resolvedChainId);
66
+ ipfsOptions = typeof chainId === "object" && chainId !== null ? chainId : ipfsOptions;
49
67
  } else {
50
68
  resolvedChainId = chainId;
51
69
  resolvedContractAddress =
@@ -73,6 +91,9 @@ export class ConfidentialTransferClient {
73
91
  rpcUrl,
74
92
  contractAddress: ethers.getAddress(resolvedContractAddress),
75
93
  chainId: Number(resolvedChainId),
94
+ ipfsUploadUrl: ipfsOptions.ipfsUploadUrl || null,
95
+ ipfsApiKey: ipfsOptions.ipfsApiKey || null,
96
+ apiBaseUrl: ipfsOptions.apiBaseUrl || null,
76
97
  };
77
98
 
78
99
  // WASM will be auto-initialized on first use
@@ -88,6 +109,7 @@ export class ConfidentialTransferClient {
88
109
  CONTRACT_ABI,
89
110
  this.provider,
90
111
  );
112
+ this._assertLatestContractAbi();
91
113
  } catch (error) {
92
114
  throw new Error(
93
115
  `Failed to initialize contracts: ${error.message}. Check your RPC URL and contract addresses.`,
@@ -95,6 +117,133 @@ export class ConfidentialTransferClient {
95
117
  }
96
118
  }
97
119
 
120
+ /**
121
+ * Fire-and-forget request row creation
122
+ * @private
123
+ */
124
+ _postRequest(data) {
125
+ const apiBaseUrl = this.config.apiBaseUrl || undefined;
126
+ const payload = {
127
+ chainKey: CHAIN_KEY_BY_ID[this.config.chainId] || String(this.config.chainId),
128
+ chainId: this.config.chainId,
129
+ applicationName: "SDK",
130
+ ...data,
131
+ };
132
+ createRequest(payload, apiBaseUrl).catch((err) => {
133
+ const status = err?.response?.status;
134
+ const body = err?.response?.data;
135
+ console.warn("[StableTrust] Failed to post request:", err.message);
136
+ if (status) console.warn("[StableTrust] Status:", status);
137
+ if (body) console.warn("[StableTrust] Response:", JSON.stringify(body, null, 2));
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Extract request/tx ID from receipt logs
143
+ * @private
144
+ */
145
+ _getRequestIdFromReceipt(receipt, eventNames) {
146
+ try {
147
+ const iface = this.contract.interface;
148
+ const contractAddr = this.config.contractAddress.toLowerCase();
149
+
150
+ for (const log of receipt.logs) {
151
+ if (log.address.toLowerCase() === contractAddr) {
152
+ const txId = this._parseLogItem(iface, log, eventNames);
153
+ if (txId) return txId;
154
+ }
155
+ }
156
+
157
+ for (const log of receipt.logs) {
158
+ const txId = this._parseLogItem(iface, log, eventNames);
159
+ if (txId) return txId;
160
+ }
161
+
162
+ for (const name of eventNames) {
163
+ try {
164
+ const topicHash = iface.getEvent(name).topicHash;
165
+ for (const log of receipt.logs) {
166
+ if (log.topics[0] === topicHash && log.topics.length >= 3) {
167
+ const txId = BigInt(log.topics[2]).toString();
168
+ if (txId !== "0") return txId;
169
+ }
170
+ }
171
+ } catch {
172
+ // Event not in ABI
173
+ }
174
+ }
175
+
176
+ return null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Parse a single log item to extract txId
184
+ * @private
185
+ */
186
+ _parseLogItem(iface, log, eventNames) {
187
+ try {
188
+ const parsed = iface.parseLog({
189
+ topics: [...log.topics],
190
+ data: log.data,
191
+ });
192
+
193
+ if (!parsed || !eventNames.includes(parsed.name)) return null;
194
+
195
+ if (parsed.args.txId !== undefined && parsed.args.txId !== null) {
196
+ return parsed.args.txId.toString();
197
+ }
198
+ if (parsed.args.requestId !== undefined && parsed.args.requestId !== null) {
199
+ return parsed.args.requestId.toString();
200
+ }
201
+
202
+ let txId;
203
+ if (
204
+ parsed.name === "TransferRequested" ||
205
+ parsed.name === "TransferProcessed"
206
+ ) {
207
+ txId = parsed.args[2];
208
+ } else {
209
+ txId = parsed.args[1];
210
+ }
211
+
212
+ return txId !== undefined && txId !== null ? txId.toString() : null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get requestId from account core state (txId)
220
+ * @private
221
+ */
222
+ async _getRequestIdFromAccountCore(address) {
223
+ try {
224
+ const account = await this.contract.getAccountCore(address);
225
+ const txId = account.txId?.toString?.() ?? String(account.txId);
226
+ return txId && txId !== "0" ? txId : null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Resolve a request ID from receipt logs, then account state, then tx hash
234
+ * @private
235
+ */
236
+ async _resolveRequestId(receipt, address, eventNames) {
237
+ let requestId = this._getRequestIdFromReceipt(receipt, eventNames);
238
+ if (!requestId) {
239
+ requestId = await this._getRequestIdFromAccountCore(address);
240
+ }
241
+ if (!requestId || requestId === "0") {
242
+ requestId = receipt.hash;
243
+ }
244
+ return requestId;
245
+ }
246
+
98
247
  /**
99
248
  * Get WASM module (auto-initializes if needed)
100
249
  * @private
@@ -114,6 +263,121 @@ export class ConfidentialTransferClient {
114
263
  return new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
115
264
  }
116
265
 
266
+ /**
267
+ * Fail fast when a stale SDK ABI is installed against the latest contract.
268
+ * This catches the common dependency-lock problem where the contract has
269
+ * `bytes,bool` proof methods but node_modules still contains the older ABI.
270
+ * @private
271
+ */
272
+ _assertLatestContractAbi() {
273
+ const requiredSignatures = [
274
+ TRANSFER_CONFIDENTIAL_SIGNATURE,
275
+ WITHDRAW_CONFIDENTIAL_SIGNATURE,
276
+ FEE_TOKEN_SIGNATURE,
277
+ NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
278
+ ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
279
+ ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
280
+ ];
281
+
282
+ const missingSignatures = requiredSignatures.filter(
283
+ (signature) => !this.contract.interface.getFunction(signature),
284
+ );
285
+ if (missingSignatures.length > 0) {
286
+ throw new Error(
287
+ `Installed @fairblock/stabletrust ABI is stale or incomplete. Missing ${missingSignatures.join(", ")}. Reinstall/update the SDK.`,
288
+ );
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Resolve whether non-anonymous proof bytes should be submitted inline or via
294
+ * StableTrust IPFS. `offchainZKP` is the latest contract flag;
295
+ * `useOffchainVerify` is kept as a deprecated SDK alias for older callers.
296
+ * Tempo keeps the old SDK behaviour of using IPFS by default.
297
+ * @private
298
+ */
299
+ _resolveOffchainZKP(options = {}) {
300
+ if (options.offchainZKP !== undefined) return Boolean(options.offchainZKP);
301
+ if (options.useOffchainVerify !== undefined) return Boolean(options.useOffchainVerify);
302
+ return this.config.chainId === 42431;
303
+ }
304
+
305
+ /**
306
+ * Prepare payment for a non-anonymous confidential transfer. Both inline and
307
+ * IPFS proof paths use the same live `nonAnonymousTransferFee`. Native fees
308
+ * are attached as msg.value; ERC-20 fees are approved for the diamond and
309
+ * collected by the contract with transferFrom.
310
+ * @private
311
+ */
312
+ async _prepareNonAnonymousTransferFee(senderWallet) {
313
+ const [feeTokenRaw, feeAmountRaw] = await Promise.all([
314
+ this.contract.feeToken(),
315
+ this.contract.nonAnonymousTransferFee(),
316
+ ]);
317
+
318
+ const feeToken = ethers.getAddress(feeTokenRaw);
319
+ const feeAmount = BigInt(feeAmountRaw);
320
+
321
+ if (feeAmount === 0n) {
322
+ return { value: 0n };
323
+ }
324
+
325
+ if (feeToken === ethers.ZeroAddress) {
326
+ return { value: feeAmount };
327
+ }
328
+
329
+ const senderAddress = await senderWallet.getAddress();
330
+ const feeTokenContract = this._getTokenContract(feeToken);
331
+ const [balance, allowance] = await Promise.all([
332
+ feeTokenContract.balanceOf(senderAddress),
333
+ feeTokenContract.allowance(senderAddress, this.config.contractAddress),
334
+ ]);
335
+
336
+ if (balance < feeAmount) {
337
+ throw new Error(
338
+ `Insufficient fee token balance. Required: ${feeAmount}, available: ${balance}, fee token: ${feeToken}`,
339
+ );
340
+ }
341
+
342
+ if (allowance < feeAmount) {
343
+ const approveTx = await feeTokenContract
344
+ .connect(senderWallet)
345
+ .approve(this.config.contractAddress, ethers.MaxUint256);
346
+ const approveReceipt = await approveTx.wait();
347
+ if (!approveReceipt || approveReceipt.status === 0) {
348
+ throw new Error("Fee token approval failed");
349
+ }
350
+ }
351
+
352
+ return { value: 0n };
353
+ }
354
+
355
+ /**
356
+ * For the latest contract, public transfer/withdraw always pass
357
+ * `(bytes proof, bool offchainZKP)`. When `offchainZKP=true`, the bytes arg is
358
+ * the bare CID encoded as UTF-8 bytes. When false, it is the full ABI proof.
359
+ * @private
360
+ */
361
+ async _resolveContractProofArg(encodedProof, proofName, offchainZKP) {
362
+ if (!offchainZKP) {
363
+ return encodedProof;
364
+ }
365
+
366
+ try {
367
+ const cid = await uploadBytesToIpfs(encodedProof, proofName, {
368
+ uploadUrl: this.config.ipfsUploadUrl,
369
+ apiKey: this.config.ipfsApiKey,
370
+ });
371
+ const bareCid = String(cid || "").trim().replace(/^ipfs:\/\//i, "");
372
+ if (!bareCid) {
373
+ throw new Error("IPFS upload returned no CID");
374
+ }
375
+ return ethers.toUtf8Bytes(bareCid);
376
+ } catch (ipfsError) {
377
+ throw new Error(`Failed to upload proof to IPFS: ${ipfsError.message}`);
378
+ }
379
+ }
380
+
117
381
  /**
118
382
  * Derive encryption keys for a wallet
119
383
  *
@@ -220,10 +484,13 @@ export class ConfidentialTransferClient {
220
484
  /**
221
485
  * Get total decrypted balance (available + pending) for an address
222
486
  *
487
+ * Amounts are in raw token units (bigint). Note that this differs from the anonymous client, which returns
488
+ * contract scale (amount * 100 / 10^decimals).
489
+ *
223
490
  * @param {string} address - Account address
224
491
  * @param {string} privateKey - Private key for decryption
225
492
  * @param {string} tokenAddress - Token address
226
- * @returns {Promise<{amount: number, available: {amount: number, ciphertext: string|null}, pending: {amount: number, ciphertext: string|null}}>}
493
+ * @returns {Promise<{amount: bigint, available: {amount: bigint, ciphertext: string|null}, pending: {amount: bigint, ciphertext: string|null}}>}
227
494
  */
228
495
  async getConfidentialBalance(address, privateKey, tokenAddress) {
229
496
  try {
@@ -329,7 +596,11 @@ export class ConfidentialTransferClient {
329
596
 
330
597
  if (pendingBalance.amount > 0n) {
331
598
  try {
332
- await this._applyPending(wallet, { waitForFinalization: true });
599
+ await this._applyPending(wallet, {
600
+ waitForFinalization: true,
601
+ tokenAddress,
602
+ pendingAmount: pendingBalance.amount,
603
+ });
333
604
  } catch (error) {
334
605
  console.warn(
335
606
  `Warning: Failed to apply pending balance before ${actionLabel}: ${error.message}. You may have pending balance that is not yet applied.`,
@@ -373,8 +644,7 @@ export class ConfidentialTransferClient {
373
644
 
374
645
  const tokenContract = this._getTokenContract(tokenAddress);
375
646
  const tokenDecimals = await tokenContract.decimals();
376
- const depositAmount =
377
- (BigInt(amount) * 100n) / 10n ** BigInt(tokenDecimals);
647
+ const depositAmount = toContractScale(amount, tokenDecimals);
378
648
 
379
649
  // Check token balance and allowance in parallel
380
650
  const [tokenBalance, allowance] = await Promise.all([
@@ -387,6 +657,7 @@ export class ConfidentialTransferClient {
387
657
  );
388
658
  }
389
659
 
660
+ const depositOverrides = {};
390
661
  if (allowance < BigInt(amount)) {
391
662
  const approveTx = await tokenContract
392
663
  .connect(wallet)
@@ -396,11 +667,17 @@ export class ConfidentialTransferClient {
396
667
  if (!approveReceipt || approveReceipt.status === 0) {
397
668
  throw new Error("Token approval failed");
398
669
  }
670
+ // Pin the deposit nonce to (approve nonce + 1). The approve was just
671
+ // broadcast from this same EOA; letting ethers resolve the deposit nonce
672
+ // via getTransactionCount can still return the pre-approve count on a fast
673
+ // node and assign the deposit the SAME nonce as the approve, which the node
674
+ // rejects as "nonce too low" (NONCE_EXPIRED).
675
+ depositOverrides.nonce = approveTx.nonce + 1;
399
676
  }
400
677
  // Perform deposit
401
678
  const depositTx = await this.contract
402
679
  .connect(wallet)
403
- .deposit(tokenAddress, depositAmount);
680
+ .deposit(tokenAddress, depositAmount, depositOverrides);
404
681
 
405
682
  const receipt = await depositTx.wait();
406
683
  if (!receipt || receipt.status === 0) {
@@ -411,6 +688,27 @@ export class ConfidentialTransferClient {
411
688
  await this._waitForGlobalState(address, "deposit");
412
689
  }
413
690
 
691
+ const tokenSymbol = await tokenContract.symbol();
692
+ const requestId = await this._resolveRequestId(receipt, address, [
693
+ "DepositRequested",
694
+ "DepositProcessed",
695
+ ]);
696
+
697
+ this._postRequest({
698
+ requestId,
699
+ userAddress: address.toLowerCase(),
700
+ operationKind: 1,
701
+ type: "deposit",
702
+ transactionHash: receipt.hash,
703
+ status: "pending",
704
+ details: {
705
+ token: tokenSymbol,
706
+ tokenAddress,
707
+ amount: depositAmount.toString(),
708
+ amountInWei: true,
709
+ },
710
+ });
711
+
414
712
  return receipt;
415
713
  } catch (error) {
416
714
  if (error.message.includes("Insufficient token balance")) {
@@ -426,7 +724,7 @@ export class ConfidentialTransferClient {
426
724
  * @param {ethers.Wallet|ethers.Signer} senderWallet - Sender's wallet
427
725
  * @param {string} recipientAddress - Recipient's address
428
726
  * @param {string} tokenAddress - Token address to transfer
429
- * @param {number} amount - Amount to transfer
727
+ * @param {bigint|string|number} amount - Amount to transfer (in token units)
430
728
  * @param {Object} [options] - Options
431
729
  * @param {boolean} [options.waitForFinalization=true] - Wait for transfer finalization
432
730
  * @returns {Promise<Object>} Transaction receipt
@@ -439,6 +737,7 @@ export class ConfidentialTransferClient {
439
737
  options = {},
440
738
  ) {
441
739
  const { waitForFinalization = true } = options;
740
+ const offchainZKP = this._resolveOffchainZKP(options);
442
741
 
443
742
  try {
444
743
  // Validate inputs
@@ -456,8 +755,7 @@ export class ConfidentialTransferClient {
456
755
  }
457
756
  const tokenContract = this._getTokenContract(tokenAddress);
458
757
  const tokenDecimals = await tokenContract.decimals();
459
- const transferAmount =
460
- (BigInt(amount) * 100n) / 10n ** BigInt(tokenDecimals);
758
+ const transferAmount = toContractScale(amount, tokenDecimals);
461
759
 
462
760
  const senderAddress = await senderWallet.getAddress();
463
761
 
@@ -497,14 +795,11 @@ export class ConfidentialTransferClient {
497
795
  "transfer",
498
796
  );
499
797
 
500
- const [balanceSummary, fee] = await Promise.all([
501
- this.getConfidentialBalance(
502
- senderAddress,
503
- derivedSenderKeys.privateKey,
504
- tokenAddress,
505
- ),
506
- this.getFeeAmount(),
507
- ]);
798
+ const balanceSummary = await this.getConfidentialBalance(
799
+ senderAddress,
800
+ derivedSenderKeys.privateKey,
801
+ tokenAddress,
802
+ );
508
803
  const derivedCurrentBalanceCiphertext =
509
804
  balanceSummary.available.ciphertext;
510
805
  const derivedCurrentBalance = balanceSummary.available.amount;
@@ -554,37 +849,20 @@ export class ConfidentialTransferClient {
554
849
  );
555
850
  }
556
851
  const encodedProof = ethers.getBytes(encodeTransferProof(proof.data));
557
- let transferZkpArg;
558
- let txOverrides;
559
-
560
- if (this.config.chainId === 42431) {
561
- // Tempo: upload proof to IPFS and pass an ipfs:// pointer; no native fee
562
- try {
563
- const cid = await uploadBytesToIpfs(
564
- encodedProof,
565
- "transfer-proof.bin",
566
- );
567
- transferZkpArg = ethers.toUtf8Bytes(`ipfs://${cid}`);
568
- } catch (ipfsError) {
569
- throw new Error(
570
- `Failed to upload proof to IPFS: ${ipfsError.message}`,
571
- );
572
- }
573
- txOverrides = { value: 0n };
574
- } else {
575
- // Standard chains: pass encoded proof bytes with native fee
576
- transferZkpArg = encodedProof;
577
- txOverrides = { value: await this.contract.feeAmount() };
578
- }
852
+ const transferZkpArg = await this._resolveContractProofArg(
853
+ encodedProof,
854
+ "transfer-proof.bin",
855
+ offchainZKP,
856
+ );
857
+ const txOverrides = await this._prepareNonAnonymousTransferFee(senderWallet);
579
858
 
580
- const tx = await this.contract
581
- .connect(senderWallet)
582
- .transferConfidential(
583
- recipientAddress,
584
- tokenAddress,
585
- transferZkpArg,
586
- txOverrides,
587
- );
859
+ const tx = await this.contract.connect(senderWallet).transferConfidential(
860
+ recipientAddress,
861
+ tokenAddress,
862
+ transferZkpArg,
863
+ offchainZKP,
864
+ txOverrides,
865
+ );
588
866
 
589
867
  const receipt = await tx.wait();
590
868
  if (!receipt || receipt.status === 0) {
@@ -595,6 +873,66 @@ export class ConfidentialTransferClient {
595
873
  await this._waitForGlobalState(senderAddress, "transfer");
596
874
  }
597
875
 
876
+ // IBE-encrypt randomness then persist both records (fire-and-forget, after finalization)
877
+ const txHash = receipt.hash;
878
+ const requestId = await this._resolveRequestId(receipt, senderAddress, [
879
+ "TransferRequested",
880
+ "TransferProcessed",
881
+ ]);
882
+ const tokenSymbol = await tokenContract.symbol();
883
+
884
+ Promise.all([
885
+ encryptRandomness(senderAddress, proof.data.sender_randomness),
886
+ encryptRandomness(recipientAddress, proof.data.receiver_randomness),
887
+ ])
888
+ .then(([senderEnc, receiverEnc]) => {
889
+ const transferDetails = {
890
+ sender: senderAddress.toLowerCase(),
891
+ recipient: recipientAddress.toLowerCase(),
892
+ token: tokenSymbol,
893
+ tokenAddress,
894
+ };
895
+
896
+ const queuedFields =
897
+ senderEnc.queuedEncrypted && receiverEnc.queuedEncrypted
898
+ ? {
899
+ queuedEncryptedSenderRandomness: senderEnc.queuedEncrypted,
900
+ queuedEncryptedReceiverRandomness: receiverEnc.queuedEncrypted,
901
+ }
902
+ : {};
903
+
904
+ this._postRequest({
905
+ requestId,
906
+ userAddress: senderAddress.toLowerCase(),
907
+ operationKind: 2,
908
+ type: "transfer",
909
+ transactionHash: txHash,
910
+ status: "pending",
911
+ details: transferDetails,
912
+ encryptedSenderRandomness: senderEnc.encrypted,
913
+ encryptedReceiverRandomness: receiverEnc.encrypted,
914
+ ...queuedFields,
915
+ });
916
+
917
+ this._postRequest({
918
+ requestId: `received-${requestId}-${recipientAddress.toLowerCase()}`,
919
+ userAddress: recipientAddress.toLowerCase(),
920
+ operationKind: 2,
921
+ type: "received",
922
+ transactionHash: txHash,
923
+ status: "pending",
924
+ details: transferDetails,
925
+ encryptedReceiverRandomness: receiverEnc.encrypted,
926
+ ...(receiverEnc.queuedEncrypted
927
+ ? {
928
+ queuedEncryptedReceiverRandomness:
929
+ receiverEnc.queuedEncrypted,
930
+ }
931
+ : {}),
932
+ });
933
+ })
934
+ .catch(() => {});
935
+
598
936
  return receipt;
599
937
  } catch (error) {
600
938
  const message = error?.message ?? String(error);
@@ -613,13 +951,14 @@ export class ConfidentialTransferClient {
613
951
  *
614
952
  * @param {ethers.Wallet|ethers.Signer} wallet - The wallet to withdraw from
615
953
  * @param {string} tokenAddress - Token address to withdraw
616
- * @param {number} amount - Amount to withdraw
954
+ * @param {bigint|string|number} amount - Amount to withdraw (in token units)
617
955
  * @param {Object} [options] - Options
618
956
  * @param {boolean} [options.waitForFinalization=true] - Wait for withdrawal finalization
619
957
  * @returns {Promise<Object>} Transaction receipt
620
958
  */
621
959
  async withdraw(wallet, tokenAddress, amount, options = {}) {
622
960
  const { waitForFinalization = true } = options;
961
+ const offchainZKP = this._resolveOffchainZKP(options);
623
962
 
624
963
  try {
625
964
  // Validate inputs
@@ -635,8 +974,7 @@ export class ConfidentialTransferClient {
635
974
 
636
975
  const tokenContract = this._getTokenContract(tokenAddress);
637
976
  const tokenDecimals = await tokenContract.decimals();
638
- const withdrawAmount =
639
- (BigInt(amount) * 100n) / 10n ** BigInt(tokenDecimals);
977
+ const withdrawAmount = toContractScale(amount, tokenDecimals);
640
978
 
641
979
  // Auto-derive keys
642
980
  const derivedKeys = await this._deriveKeys(wallet);
@@ -720,32 +1058,18 @@ export class ConfidentialTransferClient {
720
1058
 
721
1059
  // Execute withdrawal
722
1060
  const encodedProof = ethers.getBytes(encodeWithdrawProof(proof.data));
723
- let withdrawZkpArg;
724
-
725
- if (this.config.chainId === 42431) {
726
- // Tempo: upload proof to IPFS and pass an ipfs:// pointer
727
- try {
728
- const cid = await uploadBytesToIpfs(
729
- encodedProof,
730
- "withdraw-proof.bin",
731
- );
732
- withdrawZkpArg = ethers.toUtf8Bytes(`ipfs://${cid}`);
733
- } catch (ipfsError) {
734
- throw new Error(
735
- `Failed to upload proof to IPFS: ${ipfsError.message}`,
736
- );
737
- }
738
- } else {
739
- withdrawZkpArg = encodedProof;
740
- }
1061
+ const withdrawZkpArg = await this._resolveContractProofArg(
1062
+ encodedProof,
1063
+ "withdraw-proof.bin",
1064
+ offchainZKP,
1065
+ );
741
1066
 
742
- const tx = await this.contract
743
- .connect(wallet)
744
- .withdraw(
745
- tokenAddress,
746
- BigInt(withdrawAmount),
747
- withdrawZkpArg,
748
- );
1067
+ const tx = await this.contract.connect(wallet).withdraw(
1068
+ tokenAddress,
1069
+ BigInt(withdrawAmount),
1070
+ withdrawZkpArg,
1071
+ offchainZKP,
1072
+ );
749
1073
 
750
1074
  const receipt = await tx.wait();
751
1075
  if (!receipt || receipt.status === 0) {
@@ -756,6 +1080,27 @@ export class ConfidentialTransferClient {
756
1080
  await this._waitForGlobalState(address, "withdraw");
757
1081
  }
758
1082
 
1083
+ const requestId = await this._resolveRequestId(receipt, address, [
1084
+ "WithdrawRequested",
1085
+ "WithdrawProcessed",
1086
+ ]);
1087
+ const tokenSymbol = await tokenContract.symbol();
1088
+
1089
+ this._postRequest({
1090
+ requestId,
1091
+ userAddress: address.toLowerCase(),
1092
+ operationKind: 4,
1093
+ type: "withdraw",
1094
+ transactionHash: receipt.hash,
1095
+ status: "pending",
1096
+ details: {
1097
+ token: tokenSymbol,
1098
+ tokenAddress,
1099
+ amount: withdrawAmount.toString(),
1100
+ amountInWei: true,
1101
+ },
1102
+ });
1103
+
759
1104
  return receipt;
760
1105
  } catch (error) {
761
1106
  const message = error?.message ?? String(error);
@@ -778,7 +1123,7 @@ export class ConfidentialTransferClient {
778
1123
  * @returns {Promise<Object>} Transaction receipt
779
1124
  */
780
1125
  async _applyPending(wallet, options = {}) {
781
- const { waitForFinalization = true } = options;
1126
+ const { waitForFinalization = true, tokenAddress, pendingAmount } = options;
782
1127
 
783
1128
  try {
784
1129
  if (!wallet) {
@@ -798,6 +1143,37 @@ export class ConfidentialTransferClient {
798
1143
  await this._waitForGlobalState(address, "apply pending");
799
1144
  }
800
1145
 
1146
+ const requestId = await this._resolveRequestId(receipt, address, [
1147
+ "ApplyPendingRequested",
1148
+ "ApplyPendingProcessed",
1149
+ ]);
1150
+
1151
+ const tokenContract = tokenAddress
1152
+ ? this._getTokenContract(tokenAddress)
1153
+ : null;
1154
+ const tokenSymbol = tokenContract
1155
+ ? await tokenContract.symbol()
1156
+ : "USD₮0";
1157
+
1158
+ this._postRequest({
1159
+ requestId,
1160
+ userAddress: address.toLowerCase(),
1161
+ operationKind: 3,
1162
+ type: "secure claim",
1163
+ transactionHash: receipt.hash,
1164
+ status: "pending",
1165
+ details: {
1166
+ token: tokenSymbol,
1167
+ ...(tokenAddress ? { tokenAddress } : {}),
1168
+ ...(pendingAmount === undefined
1169
+ ? {}
1170
+ : {
1171
+ pendingAmount: pendingAmount.toString(),
1172
+ amountInWei: true,
1173
+ }),
1174
+ },
1175
+ });
1176
+
801
1177
  return receipt;
802
1178
  } catch (error) {
803
1179
  throw new Error(`Failed to apply pending: ${error.message}`);
@@ -841,15 +1217,55 @@ export class ConfidentialTransferClient {
841
1217
  }
842
1218
 
843
1219
  /**
844
- * Get the current fee amount for confidential transfers
1220
+ * Get the currently configured fee token. address(0) means native currency.
1221
+ *
1222
+ * @returns {Promise<string>} Checksummed token address
1223
+ */
1224
+ async getFeeToken() {
1225
+ try {
1226
+ return ethers.getAddress(await this.contract.feeToken());
1227
+ } catch (error) {
1228
+ throw new Error(`Failed to get fee token: ${error.message}`);
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * Get the fixed fee used by all non-anonymous confidential transfers,
1234
+ * regardless of whether the proof is inline or stored on IPFS.
1235
+ *
1236
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1237
+ */
1238
+ async getNonAnonymousTransferFee() {
1239
+ try {
1240
+ return await this.contract.nonAnonymousTransferFee();
1241
+ } catch (error) {
1242
+ throw new Error(`Failed to get non-anonymous transfer fee: ${error.message}`);
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Get the fixed fee used by anonymous transfers whose proof is stored on IPFS.
1248
+ *
1249
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1250
+ */
1251
+ async getAnonymousIpfsTransferFee() {
1252
+ try {
1253
+ return await this.contract.anonymousIpfsTransferFee();
1254
+ } catch (error) {
1255
+ throw new Error(`Failed to get anonymous IPFS transfer fee: ${error.message}`);
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * Get the fixed fee used by anonymous transfers whose proof is submitted inline.
845
1261
  *
846
- * @returns {Promise<bigint>} Fee amount in wei
1262
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
847
1263
  */
848
- async getFeeAmount() {
1264
+ async getAnonymousInlineTransferFee() {
849
1265
  try {
850
- return await this.contract.feeAmount();
1266
+ return await this.contract.anonymousInlineTransferFee();
851
1267
  } catch (error) {
852
- throw new Error(`Failed to get fee amount: ${error.message}`);
1268
+ throw new Error(`Failed to get anonymous inline transfer fee: ${error.message}`);
853
1269
  }
854
1270
  }
855
1271