@fairblock/stabletrust 2.0.6 → 2.0.10

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