@fairblock/stabletrust 2.0.11 → 2.0.13

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/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "SDK for confidential transfers using homomorphic encryption and zero-knowledge proofs",
5
5
  "main": "src/index.js",
6
- "types": "src/index.d.ts",
6
+ "types": "src/withdrawal-fees.d.ts",
7
7
  "type": "module",
8
8
  "sideEffects": [
9
9
  "**/*.wasm"
10
10
  ],
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./src/index.d.ts",
13
+ "types": "./src/withdrawal-fees.d.ts",
14
14
  "import": "./src/index.js",
15
15
  "default": "./src/index.js"
16
16
  },
package/src/backend.js CHANGED
@@ -9,6 +9,7 @@ export const CHAIN_KEY_BY_ID = {
9
9
  11155111: "ethereumSepolia",
10
10
  421614: "arbitrumSepolia",
11
11
  42431: "tempo",
12
+ 42161: "arbitrumOne",
12
13
  };
13
14
 
14
15
  /**
@@ -9,6 +9,7 @@ import {
9
9
  ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
10
10
  ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
11
11
  getStabletrustContractAddress,
12
+ STABLETRUST_CONTRACTS_BY_CHAIN_ID,
12
13
  } from "./constants.js";
13
14
  import { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
14
15
  import {
@@ -46,6 +47,7 @@ export class ConfidentialTransferClient {
46
47
  * @param {string} [options.ipfsUploadUrl] - StableTrust IPFS upload endpoint for off-chain proof uploads
47
48
  * @param {string} [options.ipfsApiKey] - Optional bearer token for the StableTrust IPFS upload endpoint
48
49
  * @param {string} [options.apiBaseUrl] - StableTrust backend API base URL for request persistence
50
+ * @param {string} [options.applicationName] - App name stored on request rows (default: "SDK")
49
51
  */
50
52
  constructor(rpcUrl, contractAddressOrChainId, chainId, options = {}) {
51
53
  // Validate required config
@@ -75,9 +77,9 @@ export class ConfidentialTransferClient {
75
77
  throw new Error("chainId is required");
76
78
  }
77
79
  if (!resolvedContractAddress) {
78
- const supportedChainIds = [2201, 5042002, 84532, 11155111, 421614, 42431]
79
- .map(String)
80
- .join(", ");
80
+ const supportedChainIds = Object.keys(
81
+ STABLETRUST_CONTRACTS_BY_CHAIN_ID,
82
+ ).join(", ");
81
83
  throw new Error(
82
84
  `contractAddress is required for chainId ${resolvedChainId}. No default Stabletrust contract is configured for this chain. Supported chainIds: ${supportedChainIds}`,
83
85
  );
@@ -94,6 +96,7 @@ export class ConfidentialTransferClient {
94
96
  ipfsUploadUrl: ipfsOptions.ipfsUploadUrl || null,
95
97
  ipfsApiKey: ipfsOptions.ipfsApiKey || null,
96
98
  apiBaseUrl: ipfsOptions.apiBaseUrl || null,
99
+ applicationName: ipfsOptions.applicationName || "SDK",
97
100
  };
98
101
 
99
102
  // WASM will be auto-initialized on first use
@@ -126,7 +129,7 @@ export class ConfidentialTransferClient {
126
129
  const payload = {
127
130
  chainKey: CHAIN_KEY_BY_ID[this.config.chainId] || String(this.config.chainId),
128
131
  chainId: this.config.chainId,
129
- applicationName: "SDK",
132
+ applicationName: this.config.applicationName,
130
133
  ...data,
131
134
  };
132
135
  createRequest(payload, apiBaseUrl).catch((err) => {
@@ -869,11 +872,9 @@ export class ConfidentialTransferClient {
869
872
  throw new Error("Transfer transaction failed");
870
873
  }
871
874
 
872
- if (waitForFinalization) {
873
- await this._waitForGlobalState(senderAddress, "transfer");
874
- }
875
-
876
- // IBE-encrypt randomness then persist both records (fire-and-forget, after finalization)
875
+ // IBE-encrypt randomness and start persisting both records immediately after
876
+ // the EVM request transaction is mined. This is intentionally independent of
877
+ // waitForFinalization so the backend record can exist while the action is pending.
877
878
  const txHash = receipt.hash;
878
879
  const requestId = await this._resolveRequestId(receipt, senderAddress, [
879
880
  "TransferRequested",
@@ -897,7 +898,12 @@ export class ConfidentialTransferClient {
897
898
  senderEnc.queuedEncrypted && receiverEnc.queuedEncrypted
898
899
  ? {
899
900
  queuedEncryptedSenderRandomness: senderEnc.queuedEncrypted,
900
- queuedEncryptedReceiverRandomness: receiverEnc.queuedEncrypted,
901
+ queuedEncryptedSenderRandomnessId:
902
+ senderEnc.queuedEncryptedId,
903
+ queuedEncryptedReceiverRandomness:
904
+ receiverEnc.queuedEncrypted,
905
+ queuedEncryptedReceiverRandomnessId:
906
+ receiverEnc.queuedEncryptedId,
901
907
  }
902
908
  : {};
903
909
 
@@ -910,7 +916,9 @@ export class ConfidentialTransferClient {
910
916
  status: "pending",
911
917
  details: transferDetails,
912
918
  encryptedSenderRandomness: senderEnc.encrypted,
919
+ encryptedSenderRandomnessId: senderEnc.encryptedId,
913
920
  encryptedReceiverRandomness: receiverEnc.encrypted,
921
+ encryptedReceiverRandomnessId: receiverEnc.encryptedId,
914
922
  ...queuedFields,
915
923
  });
916
924
 
@@ -923,16 +931,23 @@ export class ConfidentialTransferClient {
923
931
  status: "pending",
924
932
  details: transferDetails,
925
933
  encryptedReceiverRandomness: receiverEnc.encrypted,
934
+ encryptedReceiverRandomnessId: receiverEnc.encryptedId,
926
935
  ...(receiverEnc.queuedEncrypted
927
936
  ? {
928
937
  queuedEncryptedReceiverRandomness:
929
938
  receiverEnc.queuedEncrypted,
939
+ queuedEncryptedReceiverRandomnessId:
940
+ receiverEnc.queuedEncryptedId,
930
941
  }
931
942
  : {}),
932
943
  });
933
944
  })
934
945
  .catch(() => {});
935
946
 
947
+ if (waitForFinalization) {
948
+ await this._waitForGlobalState(senderAddress, "transfer");
949
+ }
950
+
936
951
  return receipt;
937
952
  } catch (error) {
938
953
  const message = error?.message ?? String(error);
package/src/constants.js CHANGED
@@ -12,9 +12,13 @@ const APPLY_PENDING_SIGNATURE = "applyPending()";
12
12
  export const TRANSFER_CONFIDENTIAL_SIGNATURE = "transferConfidential(address,address,bytes,bool)";
13
13
  export const WITHDRAW_CONFIDENTIAL_SIGNATURE = "withdraw(address,uint256,bytes,bool)";
14
14
  export const FEE_TOKEN_SIGNATURE = "feeToken()";
15
+ export const FEE_ACCOUNT_SIGNATURE = "feeAccount()";
15
16
  export const NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE = "nonAnonymousTransferFee()";
17
+ export const NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE = "nonAnonymousWithdrawFee()";
18
+ export const NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE = "nonAnonymousWithdrawFeePpm()";
16
19
  export const ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE = "anonymousIpfsTransferFee()";
17
20
  export const ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE = "anonymousInlineTransferFee()";
21
+ export const ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE = "anonymousWithdrawFeePpm()";
18
22
 
19
23
  export const CONTRACT_ABI = [
20
24
  `function ${CREATE_CONFIDENTIAL_ACCOUNT_SIGNATURE} external`,
@@ -23,21 +27,29 @@ export const CONTRACT_ABI = [
23
27
  `function ${GET_AVAILABLE_SIGNATURE} external view returns (bytes c1, bytes c2)`,
24
28
  `function ${GET_PENDING_SIGNATURE} external view returns (bytes c1, bytes c2)`,
25
29
  `function ${TRANSFER_CONFIDENTIAL_SIGNATURE} external payable`,
26
- `function ${WITHDRAW_CONFIDENTIAL_SIGNATURE} external`,
30
+ `function ${WITHDRAW_CONFIDENTIAL_SIGNATURE} external payable`,
27
31
  `function ${APPLY_PENDING_SIGNATURE} external`,
28
32
  `function ${FEE_TOKEN_SIGNATURE} external view returns (address)`,
33
+ `function ${FEE_ACCOUNT_SIGNATURE} external view returns (address)`,
29
34
  `function ${NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
35
+ `function ${NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE} external view returns (uint256)`,
36
+ `function ${NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE} external view returns (uint256)`,
30
37
  `function ${ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
31
38
  `function ${ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE} external view returns (uint256)`,
32
- // Events used to extract request/tx IDs from receipts for backend persistence
33
- "event DepositRequested(address indexed ownerAddr, uint256 indexed txId, address indexed token, uint256 plainAmount, bytes availableShare, bytes pendingShare)",
34
- "event DepositProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, string errorMsg, address token, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter, uint64 minimumPendingCreditCounter, bytes availableShare, bytes pendingShare)",
35
- "event TransferRequested(address indexed sender, address indexed recipient, uint256 indexed txId, address token, bytes proof, uint256 feePaid, bool useOffchainVerify, bytes senderPubkey, bytes recipientPubkey, bytes senderCurrC1, bytes senderCurrC2)",
36
- "event TransferProcessed(address indexed sender, address indexed recipient, uint256 indexed txId, bool ok, string errorMsg, address token, bytes senderNewAvailC1, bytes senderNewAvailC2, bytes recipientNewPendingC1, bytes recipientNewPendingC2, uint64 senderPendingCreditCounter, bytes senderAvailableShare, uint64 recipientPendingCreditCounter, uint64 recipientMinimumPendingCreditCounter, bytes recipientPendingShare)",
39
+ `function ${ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE} external view returns (uint256)`,
40
+
41
+ // Current ConfidentialMirror Diamond events. ResultCode is a Solidity enum
42
+ // and therefore appears as uint8 in the ABI.
43
+ "event CreateAccountRequested(address indexed ownerAddr, uint256 indexed txId, bytes elgamalPubkey)",
44
+ "event CreateAccountProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, bytes elgamalPubkey)",
45
+ "event DepositRequested(address indexed ownerAddr, uint256 indexed txId, address indexed token, uint256 plainAmount, bytes availableC1, bytes availableC2)",
46
+ "event DepositProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address token, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter)",
47
+ "event TransferRequested(address indexed sender, address indexed recipient, uint256 indexed txId, address token, bytes proof, bytes senderPubkey, bytes recipientPubkey, bytes senderCurrC1, bytes senderCurrC2, bool offchainZKP)",
48
+ "event TransferProcessed(address indexed sender, address indexed recipient, uint256 indexed txId, bool ok, uint8 code, address token, bytes senderNewAvailC1, bytes senderNewAvailC2, bytes recipientNewPendingC1, bytes recipientNewPendingC2, uint64 senderPendingCreditCounter, uint64 recipientPendingCreditCounter)",
37
49
  "event ApplyPendingRequested(address indexed ownerAddr, uint256 indexed txId)",
38
- "event ApplyPendingProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, string errorMsg, address[] tokens, bytes[] newAvailC1, bytes[] newAvailC2, uint64 pendingCreditCounter, uint64 minimumPendingCreditCounter, bytes availableShare)",
39
- "event WithdrawRequested(address indexed ownerAddr, uint256 indexed txId, address token, uint256 plainAmount, bytes proof, bool useOffchainVerify, bytes userPubkey, bytes userCurrC1, bytes userCurrC2)",
40
- "event WithdrawProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, string errorMsg, address token, uint256 plainAmount, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter, uint64 minimumPendingCreditCounter, bytes availableShare)",
50
+ "event ApplyPendingProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address[] tokens, bytes[] newAvailC1, bytes[] newAvailC2, uint64 pendingCreditCounter)",
51
+ "event WithdrawRequested(address indexed ownerAddr, uint256 indexed txId, address token, uint256 plainAmount, bytes proof, bytes userPubkey, bytes userCurrC1, bytes userCurrC2, bool offchainZKP)",
52
+ "event WithdrawProcessed(address indexed ownerAddr, uint256 indexed txId, bool ok, uint8 code, address token, uint256 plainAmount, bytes newAvailC1, bytes newAvailC2, uint64 pendingCreditCounter)",
41
53
  ];
42
54
 
43
55
  export const ERC20_ABI = [
@@ -57,6 +69,7 @@ export const STABLETRUST_CONTRACTS_BY_CHAIN_ID = Object.freeze({
57
69
  11155111: "0x7507a13352AFAa79D33E994f86f2f62463ba8DE4", //Ethereum
58
70
  421614: "0x5acECCdeb5CbD3C727eCB49F8706Eb80EF2f977F", //Arbitrum
59
71
  42431: "0xE559fB936C69c46E216bf61B07C16bF1a6d444aa", //Tempo
72
+ 42161: "0xCAA6384D5Ac8b9111D482dd676BB890f2b6e9513", //Arbitrum One (mainnet)
60
73
  });
61
74
 
62
75
  export function getStabletrustContractAddress(chainId) {
@@ -0,0 +1,265 @@
1
+ import { ethers } from "ethers";
2
+ import { ConfidentialTransferClient } from "./confidential-client.js";
3
+ import { encodeTransferProof, encodeWithdrawProof, toContractScale } from "./utils.js";
4
+
5
+ /**
6
+ * ConfidentialTransferClient variant for orchestrators that need explicit
7
+ * ownership of pending-balance application and transaction lifecycle.
8
+ *
9
+ * Backwards compatibility is preserved by keeping autoApplyPending=true by
10
+ * default. CTS sets it to false so applyPending remains an explicit workload
11
+ * action rather than being hidden inside deposit/transfer/withdraw helpers.
12
+ */
13
+ export class ControlledConfidentialTransferClient extends ConfidentialTransferClient {
14
+ constructor(rpcUrl, contractAddressOrChainId, chainId, options = {}) {
15
+ const resolvedOptions =
16
+ typeof chainId === "object" && chainId !== null ? chainId : options || {};
17
+
18
+ super(rpcUrl, contractAddressOrChainId, chainId, options);
19
+ this.autoApplyPending = resolvedOptions.autoApplyPending !== false;
20
+ const configuredSafetyBps = Number(resolvedOptions.gasLimitSafetyBps ?? 2500);
21
+ if (!Number.isFinite(configuredSafetyBps) || configuredSafetyBps < 0) {
22
+ throw new Error("gasLimitSafetyBps must be a non-negative finite number");
23
+ }
24
+ this.gasLimitSafetyBps = BigInt(Math.trunc(configuredSafetyBps));
25
+ }
26
+
27
+ async _applyPendingIfNeeded(wallet, privateKey, tokenAddress, actionLabel) {
28
+ if (!this.autoApplyPending) return false;
29
+ await super._applyPendingIfNeeded(wallet, privateKey, tokenAddress, actionLabel);
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * Estimate gas immediately before broadcast and add configurable headroom.
35
+ * Some public RPCs return estimates with too little margin for state that can
36
+ * change between estimation and execution. Passing an explicit padded limit
37
+ * prevents otherwise-valid CTS operations from failing with OutOfGas.
38
+ */
39
+ async _submitWithGasSafety(contract, methodName, args = [], overrides = {}) {
40
+ const method = contract[methodName];
41
+ if (typeof method !== "function" || typeof method.estimateGas !== "function") {
42
+ throw new Error(`Contract method ${methodName} is unavailable`);
43
+ }
44
+ if (overrides.gasLimit !== undefined) {
45
+ return await method(...args, overrides);
46
+ }
47
+ const estimate = await method.estimateGas(...args, overrides);
48
+ const gasLimit =
49
+ (estimate * (10000n + this.gasLimitSafetyBps) + 9999n) / 10000n;
50
+ return await method(...args, { ...overrides, gasLimit });
51
+ }
52
+
53
+ /**
54
+ * Submit account creation and return immediately after broadcast. The caller
55
+ * owns receipt waiting and contract-state reconciliation.
56
+ */
57
+ async submitCreateAccount(wallet) {
58
+ if (!wallet) throw new Error("Wallet is required");
59
+ const address = await wallet.getAddress();
60
+ const keys = await this._deriveKeys(wallet);
61
+ const account = await this.getAccountInfo(address);
62
+ if (account.exists) return { created: false, keys, tx: null, account };
63
+
64
+ const contract = this.contract.connect(wallet);
65
+ const tx = await this._submitWithGasSafety(
66
+ contract,
67
+ "createConfidentialAccount",
68
+ [Buffer.from(keys.publicKey, "base64")],
69
+ );
70
+ return { created: true, keys, tx, account };
71
+ }
72
+
73
+ /**
74
+ * Submit a deposit without performing an implicit ERC-20 approval. This is
75
+ * intentional for orchestrators: approval is a separate auditable operation.
76
+ */
77
+ async submitDeposit(wallet, tokenAddress, amount) {
78
+ if (!wallet) throw new Error("Wallet is required");
79
+ if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
80
+ const rawAmount = BigInt(amount);
81
+ if (rawAmount <= 0n) throw new Error("Amount must be greater than 0");
82
+
83
+ const address = await wallet.getAddress();
84
+ const token = this._getTokenContract(tokenAddress);
85
+ const [decimals, balance, allowance] = await Promise.all([
86
+ token.decimals(),
87
+ token.balanceOf(address),
88
+ token.allowance(address, this.config.contractAddress),
89
+ ]);
90
+ if (balance < rawAmount) {
91
+ throw new Error(`Insufficient token balance. Required: ${rawAmount}, Available: ${balance}`);
92
+ }
93
+ if (allowance < rawAmount) {
94
+ throw new Error(`Insufficient token allowance. Required: ${rawAmount}, Available: ${allowance}`);
95
+ }
96
+
97
+ const plainAmount = toContractScale(rawAmount, decimals);
98
+ return await this._submitWithGasSafety(
99
+ this.contract.connect(wallet),
100
+ "deposit",
101
+ [tokenAddress, plainAmount],
102
+ );
103
+ }
104
+
105
+ /** Submit a normal-to-normal confidential transfer after proof generation. */
106
+ async submitTransfer(wallet, recipientAddress, tokenAddress, amount, options = {}) {
107
+ if (!wallet) throw new Error("Sender wallet is required");
108
+ if (!ethers.isAddress(recipientAddress)) throw new Error(`Invalid recipient address: ${recipientAddress}`);
109
+ if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
110
+ const rawAmount = BigInt(amount);
111
+ if (rawAmount <= 0n) throw new Error("Transfer amount must be greater than 0");
112
+
113
+ const offchainZKP = this._resolveOffchainZKP(options);
114
+ const token = this._getTokenContract(tokenAddress);
115
+ const tokenDecimals = await token.decimals();
116
+ const transferAmount = toContractScale(rawAmount, tokenDecimals);
117
+ const senderAddress = await wallet.getAddress();
118
+
119
+ const [senderKeys, recipientAccount] = await Promise.all([
120
+ this._deriveKeys(wallet),
121
+ this.getAccountInfo(recipientAddress),
122
+ ]);
123
+ if (!senderKeys?.privateKey) throw new Error("Failed to derive sender keys");
124
+ if (!recipientAccount.exists || !recipientAccount.finalized) {
125
+ throw new Error(`Recipient account does not exist or is not finalized. Address: ${recipientAddress}`);
126
+ }
127
+
128
+ let recipientPublicKey = recipientAccount.elgamalPubkey;
129
+ if (!recipientPublicKey) throw new Error("Recipient public key is required");
130
+ if (typeof recipientPublicKey === "string" && recipientPublicKey.startsWith("0x")) {
131
+ recipientPublicKey = Buffer.from(recipientPublicKey.slice(2), "hex").toString("base64");
132
+ }
133
+
134
+ const balance = await this.getConfidentialBalance(senderAddress, senderKeys.privateKey, tokenAddress);
135
+ const available = balance.available.amount;
136
+ if (!balance.available.ciphertext) throw new Error("Current balance ciphertext is required");
137
+ if (available < rawAmount) {
138
+ throw new Error(`Insufficient balance. Required: ${rawAmount}, Available: ${available}`);
139
+ }
140
+
141
+ const currentBalanceContractScale =
142
+ (BigInt(available) * 100n) / 10n ** BigInt(tokenDecimals);
143
+ const wasm = await this._getWasm();
144
+ const proof = JSON.parse(
145
+ wasm.generate_transfer_proof(
146
+ JSON.stringify({
147
+ current_balance_ciphertext: balance.available.ciphertext,
148
+ current_balance: Number(currentBalanceContractScale),
149
+ transfer_amount: Number(transferAmount),
150
+ source_keypair: senderKeys.privateKey,
151
+ destination_pubkey: recipientPublicKey,
152
+ }),
153
+ ),
154
+ );
155
+ if (!proof.success) {
156
+ throw new Error(`Proof generation failed: ${proof.error || "Unknown error"}`);
157
+ }
158
+
159
+ const encodedProof = ethers.getBytes(encodeTransferProof(proof.data));
160
+ const proofArg = await this._resolveContractProofArg(
161
+ encodedProof,
162
+ "transfer-proof.bin",
163
+ offchainZKP,
164
+ );
165
+ const overrides = await this._prepareNonAnonymousTransferFee(wallet);
166
+ return await this._submitWithGasSafety(
167
+ this.contract.connect(wallet),
168
+ "transferConfidential",
169
+ [recipientAddress, tokenAddress, proofArg, offchainZKP],
170
+ overrides,
171
+ );
172
+ }
173
+
174
+ /** Submit an explicit applyPending transaction and return the tx response. */
175
+ async submitApplyPending(wallet) {
176
+ if (!wallet) throw new Error("Wallet is required");
177
+ return await this._submitWithGasSafety(
178
+ this.contract.connect(wallet),
179
+ "applyPending",
180
+ );
181
+ }
182
+
183
+ /** Submit a withdrawal after proof generation and return immediately. */
184
+ async submitWithdraw(wallet, tokenAddress, amount, options = {}) {
185
+ if (!wallet) throw new Error("Wallet is required");
186
+ if (!ethers.isAddress(tokenAddress)) throw new Error(`Invalid token address: ${tokenAddress}`);
187
+ const rawAmount = BigInt(amount);
188
+ if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
189
+
190
+ const offchainZKP = this._resolveOffchainZKP(options);
191
+ const token = this._getTokenContract(tokenAddress);
192
+ const tokenDecimals = await token.decimals();
193
+ const withdrawAmount = toContractScale(rawAmount, tokenDecimals);
194
+ const keys = await this._deriveKeys(wallet);
195
+ if (!keys?.privateKey) throw new Error("Failed to derive keys");
196
+
197
+ const address = await wallet.getAddress();
198
+ const balance = await this.getConfidentialBalance(address, keys.privateKey, tokenAddress);
199
+ if (!balance.available.ciphertext) throw new Error("Current balance ciphertext is required");
200
+ if (balance.available.amount < rawAmount) {
201
+ throw new Error(`Insufficient balance. Required: ${rawAmount}, Available: ${balance.available.amount}`);
202
+ }
203
+
204
+ const currentBalanceContractScale =
205
+ (BigInt(balance.available.amount) * 100n) / 10n ** BigInt(tokenDecimals);
206
+ const account = await this.getAccountInfo(address);
207
+ const txID = BigInt(account.txId ?? 0n);
208
+ const wasm = await this._getWasm();
209
+ const proof = JSON.parse(
210
+ wasm.generate_withdraw_proof(
211
+ JSON.stringify({
212
+ current_balance_ciphertext: balance.available.ciphertext,
213
+ current_balance: Number(currentBalanceContractScale),
214
+ withdraw_amount: Number(withdrawAmount),
215
+ keypair: keys.privateKey,
216
+ nonce: Number(txID + 1n),
217
+ }),
218
+ ),
219
+ );
220
+ if (!proof.success) {
221
+ throw new Error(`Withdrawal proof generation failed: ${proof.error || "Unknown error"}`);
222
+ }
223
+
224
+ const encodedProof = ethers.getBytes(encodeWithdrawProof(proof.data));
225
+ const proofArg = await this._resolveContractProofArg(
226
+ encodedProof,
227
+ "withdraw-proof.bin",
228
+ offchainZKP,
229
+ );
230
+ return await this._submitWithGasSafety(
231
+ this.contract.connect(wallet),
232
+ "withdraw",
233
+ [tokenAddress, withdrawAmount, proofArg, offchainZKP],
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Compatibility helper that keeps the older wait-for-receipt behavior.
239
+ */
240
+ async createAccount(wallet, options = {}) {
241
+ const { waitForFinalization = true } = options;
242
+ const submitted = await this.submitCreateAccount(wallet);
243
+ if (!submitted.created) {
244
+ let account = submitted.account;
245
+ if (waitForFinalization && !account.finalized) {
246
+ await this._waitForGlobalState(await wallet.getAddress(), "account creation");
247
+ account = await this.getAccountInfo(await wallet.getAddress());
248
+ }
249
+ return { created: false, keys: submitted.keys, receipt: null, account };
250
+ }
251
+
252
+ const receipt = await submitted.tx.wait();
253
+ if (!receipt || receipt.status === 0) throw new Error("Account creation transaction failed");
254
+ if (waitForFinalization) {
255
+ await this._waitForGlobalState(await wallet.getAddress(), "account creation");
256
+ }
257
+ const account = await this.getAccountInfo(await wallet.getAddress());
258
+ return { created: true, keys: submitted.keys, receipt, account };
259
+ }
260
+
261
+ /** Public explicit applyPending entry point preserving receipt-wait behavior. */
262
+ async applyPending(wallet, options = {}) {
263
+ return await this._applyPending(wallet, options);
264
+ }
265
+ }
@@ -0,0 +1,430 @@
1
+ import { ethers } from "ethers";
2
+ import { ConfidentialTransferClient as BaseConfidentialTransferClient } from "./confidential-client.js";
3
+ import { ControlledConfidentialTransferClient as BaseControlledConfidentialTransferClient } from "./controlled-confidential-client.js";
4
+ import { AnonymousTransferClient as BaseAnonymousTransferClient } from "./anonymous-client.js";
5
+ import { encodeWithdrawProof, toContractScale, validateAnonymousAccountId } from "./utils.js";
6
+ import {
7
+ TRANSFER_CONFIDENTIAL_SIGNATURE,
8
+ WITHDRAW_CONFIDENTIAL_SIGNATURE,
9
+ FEE_TOKEN_SIGNATURE,
10
+ FEE_ACCOUNT_SIGNATURE,
11
+ NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
12
+ NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
13
+ NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
14
+ ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
15
+ ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
16
+ ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
17
+ } from "./constants.js";
18
+
19
+ export const WITHDRAW_FEE_PPM_DENOMINATOR = 1_000_000n;
20
+
21
+ function calculatePercentageFee(grossAmount, ppm) {
22
+ const gross = BigInt(grossAmount);
23
+ const rate = BigInt(ppm);
24
+ if (rate < 0n || rate >= WITHDRAW_FEE_PPM_DENOMINATOR) {
25
+ throw new Error(
26
+ `Withdrawal fee PPM must be between 0 and ${WITHDRAW_FEE_PPM_DENOMINATOR - 1n}`,
27
+ );
28
+ }
29
+ return (gross * rate) / WITHDRAW_FEE_PPM_DENOMINATOR;
30
+ }
31
+
32
+ function grossAfterContractScale(rawAmount, tokenDecimals) {
33
+ const scaled = toContractScale(rawAmount, tokenDecimals);
34
+ const divisor = 10n ** BigInt(tokenDecimals);
35
+ return (scaled * divisor) / 100n;
36
+ }
37
+
38
+ const nonAnonymousFeeMethods = {
39
+ _assertLatestContractAbi() {
40
+ const requiredSignatures = [
41
+ TRANSFER_CONFIDENTIAL_SIGNATURE,
42
+ WITHDRAW_CONFIDENTIAL_SIGNATURE,
43
+ FEE_TOKEN_SIGNATURE,
44
+ FEE_ACCOUNT_SIGNATURE,
45
+ NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
46
+ NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
47
+ NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
48
+ ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
49
+ ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
50
+ ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
51
+ ];
52
+
53
+ const missingSignatures = requiredSignatures.filter(
54
+ (signature) => !this.contract.interface.getFunction(signature),
55
+ );
56
+ if (missingSignatures.length > 0) {
57
+ throw new Error(
58
+ `Installed @fairblock/stabletrust ABI is stale or incomplete. Missing ${missingSignatures.join(", ")}. Reinstall/update the SDK.`,
59
+ );
60
+ }
61
+ },
62
+
63
+ async getFeeAccount() {
64
+ try {
65
+ return ethers.getAddress(await this.contract.feeAccount());
66
+ } catch (error) {
67
+ throw new Error(`Failed to get fee account: ${error.message}`);
68
+ }
69
+ },
70
+
71
+ async getNonAnonymousWithdrawFee() {
72
+ try {
73
+ return await this.contract.nonAnonymousWithdrawFee();
74
+ } catch (error) {
75
+ throw new Error(`Failed to get non-anonymous withdrawal fixed fee: ${error.message}`);
76
+ }
77
+ },
78
+
79
+ async getNonAnonymousWithdrawFeePpm() {
80
+ try {
81
+ return await this.contract.nonAnonymousWithdrawFeePpm();
82
+ } catch (error) {
83
+ throw new Error(`Failed to get non-anonymous withdrawal percentage fee: ${error.message}`);
84
+ }
85
+ },
86
+
87
+ async getAnonymousWithdrawFeePpm() {
88
+ try {
89
+ return await this.contract.anonymousWithdrawFeePpm();
90
+ } catch (error) {
91
+ throw new Error(`Failed to get anonymous withdrawal percentage fee: ${error.message}`);
92
+ }
93
+ },
94
+
95
+ async _prepareNonAnonymousWithdrawFee(wallet) {
96
+ const [feeTokenRaw, feeAmountRaw] = await Promise.all([
97
+ this.contract.feeToken(),
98
+ this.contract.nonAnonymousWithdrawFee(),
99
+ ]);
100
+
101
+ const feeToken = ethers.getAddress(feeTokenRaw);
102
+ const feeAmount = BigInt(feeAmountRaw);
103
+ if (feeAmount === 0n) return { value: 0n };
104
+
105
+ if (feeToken === ethers.ZeroAddress) {
106
+ return { value: feeAmount };
107
+ }
108
+
109
+ const senderAddress = await wallet.getAddress();
110
+ const feeTokenContract = this._getTokenContract(feeToken);
111
+ const [balance, allowance] = await Promise.all([
112
+ feeTokenContract.balanceOf(senderAddress),
113
+ feeTokenContract.allowance(senderAddress, this.config.contractAddress),
114
+ ]);
115
+
116
+ if (balance < feeAmount) {
117
+ throw new Error(
118
+ `Insufficient withdrawal fee token balance. Required: ${feeAmount}, available: ${balance}, fee token: ${feeToken}`,
119
+ );
120
+ }
121
+
122
+ if (allowance < feeAmount) {
123
+ const approveTx = await feeTokenContract
124
+ .connect(wallet)
125
+ .approve(this.config.contractAddress, ethers.MaxUint256);
126
+ const approveReceipt = await approveTx.wait();
127
+ if (!approveReceipt || approveReceipt.status === 0) {
128
+ throw new Error("Withdrawal fee token approval failed");
129
+ }
130
+ }
131
+
132
+ return { value: 0n };
133
+ },
134
+
135
+ async getWithdrawalFeeQuote(tokenAddress, amount) {
136
+ if (!ethers.isAddress(tokenAddress)) {
137
+ throw new Error(`Invalid token address: ${tokenAddress}`);
138
+ }
139
+ const rawAmount = BigInt(amount);
140
+ if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
141
+
142
+ const token = this._getTokenContract(tokenAddress);
143
+ const [tokenDecimals, fixedFee, fixedFeeToken, percentageFeePpm] = await Promise.all([
144
+ token.decimals(),
145
+ this.getNonAnonymousWithdrawFee(),
146
+ this.getFeeToken(),
147
+ this.getNonAnonymousWithdrawFeePpm(),
148
+ ]);
149
+ const grossAmount = grossAfterContractScale(rawAmount, tokenDecimals);
150
+ const estimatedPercentageFee = calculatePercentageFee(grossAmount, percentageFeePpm);
151
+
152
+ return {
153
+ grossAmount,
154
+ fixedFee: BigInt(fixedFee),
155
+ fixedFeeToken,
156
+ percentageFeePpm: BigInt(percentageFeePpm),
157
+ estimatedPercentageFee,
158
+ withdrawalToken: ethers.getAddress(tokenAddress),
159
+ estimatedNetAmount: grossAmount - estimatedPercentageFee,
160
+ };
161
+ },
162
+ };
163
+
164
+ export class ConfidentialTransferClient extends BaseConfidentialTransferClient {
165
+ _assertLatestContractAbi() {
166
+ return nonAnonymousFeeMethods._assertLatestContractAbi.call(this);
167
+ }
168
+ getFeeAccount() { return nonAnonymousFeeMethods.getFeeAccount.call(this); }
169
+ getNonAnonymousWithdrawFee() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFee.call(this); }
170
+ getNonAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFeePpm.call(this); }
171
+ getAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getAnonymousWithdrawFeePpm.call(this); }
172
+ _prepareNonAnonymousWithdrawFee(wallet) { return nonAnonymousFeeMethods._prepareNonAnonymousWithdrawFee.call(this, wallet); }
173
+ getWithdrawalFeeQuote(tokenAddress, amount) { return nonAnonymousFeeMethods.getWithdrawalFeeQuote.call(this, tokenAddress, amount); }
174
+
175
+ async withdraw(wallet, tokenAddress, amount, options = {}) {
176
+ const { waitForFinalization = true } = options;
177
+ const offchainZKP = this._resolveOffchainZKP(options);
178
+
179
+ try {
180
+ if (!wallet) throw new Error("Wallet is required");
181
+ if (!tokenAddress || !ethers.isAddress(tokenAddress)) {
182
+ throw new Error(`Invalid token address: ${tokenAddress}`);
183
+ }
184
+ if (!amount || BigInt(amount) <= 0n) {
185
+ throw new Error("Withdrawal amount must be greater than 0");
186
+ }
187
+
188
+ const tokenContract = this._getTokenContract(tokenAddress);
189
+ const tokenDecimals = await tokenContract.decimals();
190
+ const withdrawAmount = toContractScale(amount, tokenDecimals);
191
+
192
+ const derivedKeys = await this._deriveKeys(wallet);
193
+ if (!derivedKeys?.privateKey) throw new Error("Failed to derive keys");
194
+
195
+ const address = await wallet.getAddress();
196
+ let balanceSummary = await this.getConfidentialBalance(
197
+ address,
198
+ derivedKeys.privateKey,
199
+ tokenAddress,
200
+ );
201
+
202
+ if (balanceSummary.available.amount < BigInt(amount)) {
203
+ await this._applyPendingIfNeeded(
204
+ wallet,
205
+ derivedKeys.privateKey,
206
+ tokenAddress,
207
+ "withdraw",
208
+ );
209
+ balanceSummary = await this.getConfidentialBalance(
210
+ address,
211
+ derivedKeys.privateKey,
212
+ tokenAddress,
213
+ );
214
+ }
215
+
216
+ const currentBalanceCiphertext = balanceSummary.available.ciphertext;
217
+ const currentBalance = balanceSummary.available.amount;
218
+ if (!currentBalanceCiphertext) {
219
+ throw new Error("Current balance ciphertext is required. Did you call getConfidentialBalance()?");
220
+ }
221
+ if (balanceSummary.amount < BigInt(amount)) {
222
+ throw new Error(`Insufficient balance. Required: ${amount}, Total: ${balanceSummary.amount}`);
223
+ }
224
+ if (currentBalance === undefined || currentBalance < BigInt(amount)) {
225
+ throw new Error(`Insufficient balance. Required: ${amount}, Available: ${currentBalance}`);
226
+ }
227
+
228
+ const currentBalanceContractScale =
229
+ (BigInt(currentBalance) * 100n) / 10n ** BigInt(tokenDecimals);
230
+ const accountInfo = await this.getAccountInfo(address);
231
+ const txId = BigInt(accountInfo.txId ?? 0n);
232
+
233
+ const wasm = await this._getWasm();
234
+ const proof = JSON.parse(
235
+ wasm.generate_withdraw_proof(
236
+ JSON.stringify({
237
+ current_balance_ciphertext: currentBalanceCiphertext,
238
+ current_balance: Number(currentBalanceContractScale),
239
+ withdraw_amount: Number(withdrawAmount),
240
+ keypair: derivedKeys.privateKey,
241
+ nonce: Number(txId + 1n),
242
+ }),
243
+ ),
244
+ );
245
+ if (!proof.success) {
246
+ throw new Error(
247
+ `Withdrawal proof generation failed: ${proof.error || "Unknown error. Check your balance and amount."}`,
248
+ );
249
+ }
250
+
251
+ const encodedProof = ethers.getBytes(encodeWithdrawProof(proof.data));
252
+ const withdrawZkpArg = await this._resolveContractProofArg(
253
+ encodedProof,
254
+ "withdraw-proof.bin",
255
+ offchainZKP,
256
+ );
257
+ const txOverrides = await this._prepareNonAnonymousWithdrawFee(wallet);
258
+
259
+ const tx = await this.contract.connect(wallet).withdraw(
260
+ tokenAddress,
261
+ BigInt(withdrawAmount),
262
+ withdrawZkpArg,
263
+ offchainZKP,
264
+ txOverrides,
265
+ );
266
+ const receipt = await tx.wait();
267
+ if (!receipt || receipt.status === 0) throw new Error("Withdrawal transaction failed");
268
+
269
+ if (waitForFinalization) {
270
+ await this._waitForGlobalState(address, "withdraw");
271
+ }
272
+
273
+ const requestId = await this._resolveRequestId(receipt, address, [
274
+ "WithdrawRequested",
275
+ "WithdrawProcessed",
276
+ ]);
277
+ const tokenSymbol = await tokenContract.symbol();
278
+ this._postRequest({
279
+ requestId,
280
+ userAddress: address.toLowerCase(),
281
+ operationKind: 4,
282
+ type: "withdraw",
283
+ transactionHash: receipt.hash,
284
+ status: "pending",
285
+ details: {
286
+ token: tokenSymbol,
287
+ tokenAddress,
288
+ amount: withdrawAmount.toString(),
289
+ amountInWei: true,
290
+ },
291
+ });
292
+
293
+ return receipt;
294
+ } catch (error) {
295
+ const message = error?.message ?? String(error);
296
+ if (
297
+ message.includes("Insufficient balance") ||
298
+ message.includes("fee token balance") ||
299
+ message.includes("proof generation failed")
300
+ ) {
301
+ throw error;
302
+ }
303
+ throw new Error(`Failed to withdraw: ${message}`);
304
+ }
305
+ }
306
+ }
307
+
308
+ export class ControlledConfidentialTransferClient extends BaseControlledConfidentialTransferClient {
309
+ _assertLatestContractAbi() {
310
+ return nonAnonymousFeeMethods._assertLatestContractAbi.call(this);
311
+ }
312
+ getFeeAccount() { return nonAnonymousFeeMethods.getFeeAccount.call(this); }
313
+ getNonAnonymousWithdrawFee() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFee.call(this); }
314
+ getNonAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getNonAnonymousWithdrawFeePpm.call(this); }
315
+ getAnonymousWithdrawFeePpm() { return nonAnonymousFeeMethods.getAnonymousWithdrawFeePpm.call(this); }
316
+ _prepareNonAnonymousWithdrawFee(wallet) { return nonAnonymousFeeMethods._prepareNonAnonymousWithdrawFee.call(this, wallet); }
317
+ getWithdrawalFeeQuote(tokenAddress, amount) { return nonAnonymousFeeMethods.getWithdrawalFeeQuote.call(this, tokenAddress, amount); }
318
+
319
+ async _submitWithGasSafety(contract, methodName, args = [], overrides = {}) {
320
+ let resolvedOverrides = overrides;
321
+ if (methodName === "withdraw" && overrides.value === undefined) {
322
+ const feeOverrides = await this._prepareNonAnonymousWithdrawFee(contract.runner);
323
+ resolvedOverrides = { ...feeOverrides, ...overrides };
324
+ }
325
+ return await super._submitWithGasSafety(
326
+ contract,
327
+ methodName,
328
+ args,
329
+ resolvedOverrides,
330
+ );
331
+ }
332
+ }
333
+
334
+ export class AnonymousTransferClient extends BaseAnonymousTransferClient {
335
+ async getFeeAccount() {
336
+ try {
337
+ const data = await this._fetch("GET", "/v1/views/fee-account");
338
+ const raw = data.result ?? data;
339
+ const addr = typeof raw === "string" ? raw : raw.feeAccount ?? raw;
340
+ return ethers.getAddress(addr);
341
+ } catch (error) {
342
+ throw new Error(`Failed to get fee account: ${error.message}`);
343
+ }
344
+ }
345
+
346
+ async getNonAnonymousWithdrawFee() {
347
+ try {
348
+ const data = await this._fetch("GET", "/v1/views/fees/non-anonymous-withdraw-fixed");
349
+ return BigInt(data.result ?? data);
350
+ } catch (error) {
351
+ throw new Error(`Failed to get non-anonymous withdrawal fixed fee: ${error.message}`);
352
+ }
353
+ }
354
+
355
+ async getNonAnonymousWithdrawFeePpm() {
356
+ try {
357
+ const data = await this._fetch("GET", "/v1/views/fees/non-anonymous-withdraw-ppm");
358
+ return BigInt(data.result ?? data);
359
+ } catch (error) {
360
+ throw new Error(`Failed to get non-anonymous withdrawal percentage fee: ${error.message}`);
361
+ }
362
+ }
363
+
364
+ async getAnonymousWithdrawFeePpm() {
365
+ try {
366
+ const data = await this._fetch("GET", "/v1/views/fees/anonymous-withdraw-ppm");
367
+ return BigInt(data.result ?? data);
368
+ } catch (error) {
369
+ throw new Error(`Failed to get anonymous withdrawal percentage fee: ${error.message}`);
370
+ }
371
+ }
372
+
373
+ async _assertSufficientWithdrawPrepaidFees(accountId, offchainZKP) {
374
+ validateAnonymousAccountId(accountId, "accountId");
375
+ const fixedFeePromise = offchainZKP
376
+ ? this.getAnonymousIpfsWithdrawFee()
377
+ : this.getAnonymousInlineWithdrawFee();
378
+ const [feeToken, fixedFee] = await Promise.all([
379
+ this.getFeeToken(),
380
+ fixedFeePromise,
381
+ ]);
382
+ if (fixedFee === 0n) return;
383
+
384
+ const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
385
+ if (balance < fixedFee) {
386
+ const path = offchainZKP ? "IPFS" : "inline";
387
+ throw new Error(
388
+ `Insufficient prepaid fee balance for anonymous account "${accountId}" (${path} withdrawal). ` +
389
+ `Required: ${fixedFee} (raw units of fee token ${feeToken}), available: ${balance}. ` +
390
+ `The success-only percentage withdrawal fee is not part of this prepaid requirement.`,
391
+ );
392
+ }
393
+ }
394
+
395
+ async getAnonymousWithdrawalFeeQuote(tokenAddress, amount, { offchainZKP = false } = {}) {
396
+ if (!ethers.isAddress(tokenAddress)) {
397
+ throw new Error(`Invalid token address: ${tokenAddress}`);
398
+ }
399
+ const rawAmount = BigInt(amount);
400
+ if (rawAmount <= 0n) throw new Error("Withdrawal amount must be greater than 0");
401
+
402
+ const fixedFeePromise = offchainZKP
403
+ ? this.getAnonymousIpfsWithdrawFee()
404
+ : this.getAnonymousInlineWithdrawFee();
405
+ const [tokenDecimals, fixedFee, fixedFeeToken, percentageFeePpm] = await Promise.all([
406
+ this._fetchTokenDecimals(tokenAddress),
407
+ fixedFeePromise,
408
+ this.getFeeToken(),
409
+ this.getAnonymousWithdrawFeePpm(),
410
+ ]);
411
+ const grossAmount = grossAfterContractScale(rawAmount, tokenDecimals);
412
+ const estimatedPercentageFee = calculatePercentageFee(grossAmount, percentageFeePpm);
413
+
414
+ return {
415
+ grossAmount,
416
+ fixedFee: BigInt(fixedFee),
417
+ fixedFeeToken,
418
+ percentageFeePpm: BigInt(percentageFeePpm),
419
+ estimatedPercentageFee,
420
+ withdrawalToken: ethers.getAddress(tokenAddress),
421
+ estimatedNetAmount: grossAmount - estimatedPercentageFee,
422
+ };
423
+ }
424
+
425
+ async withdraw(authWallet, accountId, params, options = {}) {
426
+ const offchainZKP = Boolean(params?.offchainZKP ?? false);
427
+ await this._assertSufficientWithdrawPrepaidFees(accountId, offchainZKP);
428
+ return await super.withdraw(authWallet, accountId, params, options);
429
+ }
430
+ }
package/src/ibe.js CHANGED
@@ -10,6 +10,16 @@ function extractPublicKey(pubkeyObj) {
10
10
  return pubkeyObj?.public_key || pubkeyObj?.pubkey || null;
11
11
  }
12
12
 
13
+ /**
14
+ * Build IBE identity: address (lowercase) + chain public key.
15
+ * @param {string} userAddress
16
+ * @param {string} publicKey
17
+ * @returns {string}
18
+ */
19
+ function buildIbeId(userAddress, publicKey) {
20
+ return userAddress.toLowerCase() + publicKey;
21
+ }
22
+
13
23
  async function getIbePublicKeys() {
14
24
  if (cachedIbePublicKeys) return cachedIbePublicKeys;
15
25
 
@@ -40,20 +50,37 @@ async function getIbePublicKeys() {
40
50
  * Always encrypts under the active pubkey; also encrypts under the queued
41
51
  * pubkey when the chain reports one (backup for key rotation).
42
52
  *
43
- * @param {string} userAddress - Wallet address used as the IBE identity
53
+ * Identity is address.toLowerCase() + chain pk (active or queued).
54
+ *
55
+ * @param {string} userAddress - Wallet address used as part of the IBE identity
44
56
  * @param {string} randomnessBase64 - Base64-encoded randomness
45
- * @returns {Promise<{ encrypted: string, queuedEncrypted: string | null }>}
46
- * Hex-encoded IBE ciphertexts
57
+ * @returns {Promise<{
58
+ * encrypted: string,
59
+ * encryptedId: string,
60
+ * queuedEncrypted: string | null,
61
+ * queuedEncryptedId: string | null
62
+ * }>}
63
+ * Hex-encoded IBE ciphertexts plus the identities used for each
47
64
  */
48
65
  export async function encryptRandomness(userAddress, randomnessBase64) {
49
66
  const { active, queued } = await getIbePublicKeys();
50
- const id = userAddress.toLowerCase();
51
67
  const randomnessBuffer = Buffer.from(randomnessBase64, "base64");
52
68
 
53
- const [encrypted, queuedEncrypted] = await Promise.all([
54
- timelockEncrypt(id, active, randomnessBuffer),
55
- queued ? timelockEncrypt(id, queued, randomnessBuffer) : Promise.resolve(null),
56
- ]);
69
+ // Active ciphertext → identity address.toLowerCase() + activePk
70
+ const encryptedId = buildIbeId(userAddress, active);
71
+ const encrypted = await timelockEncrypt(encryptedId, active, randomnessBuffer);
72
+
73
+ // Queued ciphertext → identity address.toLowerCase() + queuedPk
74
+ let queuedEncrypted = null;
75
+ let queuedEncryptedId = null;
76
+ if (queued) {
77
+ queuedEncryptedId = buildIbeId(userAddress, queued);
78
+ queuedEncrypted = await timelockEncrypt(
79
+ queuedEncryptedId,
80
+ queued,
81
+ randomnessBuffer,
82
+ );
83
+ }
57
84
 
58
- return { encrypted, queuedEncrypted };
85
+ return { encrypted, encryptedId, queuedEncrypted, queuedEncryptedId };
59
86
  }
package/src/index.d.ts CHANGED
@@ -554,6 +554,8 @@ declare module "@fairblock/stabletrust" {
554
554
  ipfsApiKey?: string;
555
555
  /** StableTrust backend API base URL for persisting request rows. Defaults to the production API. */
556
556
  apiBaseUrl?: string;
557
+ /** App name stored on request rows. Defaults to "SDK". */
558
+ applicationName?: string;
557
559
  }
558
560
 
559
561
  /**
package/src/index.js CHANGED
@@ -1,5 +1,9 @@
1
- export { ConfidentialTransferClient } from "./confidential-client.js";
2
- export { AnonymousTransferClient } from "./anonymous-client.js";
1
+ export {
2
+ ConfidentialTransferClient,
3
+ ControlledConfidentialTransferClient,
4
+ AnonymousTransferClient,
5
+ WITHDRAW_FEE_PPM_DENOMINATOR,
6
+ } from "./fee-aware-clients.js";
3
7
  export { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
4
8
  export {
5
9
  encodeTransferProof,
@@ -13,9 +17,13 @@ export {
13
17
  TRANSFER_CONFIDENTIAL_SIGNATURE,
14
18
  WITHDRAW_CONFIDENTIAL_SIGNATURE,
15
19
  FEE_TOKEN_SIGNATURE,
20
+ FEE_ACCOUNT_SIGNATURE,
16
21
  NON_ANONYMOUS_TRANSFER_FEE_SIGNATURE,
22
+ NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE,
23
+ NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
17
24
  ANONYMOUS_IPFS_TRANSFER_FEE_SIGNATURE,
18
25
  ANONYMOUS_INLINE_TRANSFER_FEE_SIGNATURE,
26
+ ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE,
19
27
  STABLETRUST_CONTRACTS_BY_CHAIN_ID,
20
28
  getStabletrustContractAddress,
21
29
  } from "./constants.js";
@@ -0,0 +1,83 @@
1
+ /// <reference path="./index.d.ts" />
2
+
3
+ declare module "@fairblock/stabletrust" {
4
+ export interface WithdrawalFeeQuote {
5
+ /** Gross withdrawn-token amount after the SDK's 2-decimal contract-scale normalization. */
6
+ grossAmount: bigint;
7
+ /** Fixed request fee, charged even if the later callback fails. */
8
+ fixedFee: bigint;
9
+ /** Token used for the fixed request fee. ethers.ZeroAddress means native currency. */
10
+ fixedFeeToken: string;
11
+ /** Success-only withdrawal percentage fee rate, in parts per million. */
12
+ percentageFeePpm: bigint;
13
+ /** Estimated success-only percentage fee, denominated in the withdrawn token. */
14
+ estimatedPercentageFee: bigint;
15
+ /** Token being withdrawn. */
16
+ withdrawalToken: string;
17
+ /** Estimated amount delivered on a successful callback. */
18
+ estimatedNetAmount: bigint;
19
+ }
20
+
21
+ export interface AnonymousWithdrawalQuoteOptions {
22
+ /** Selects the IPFS/offchainZKP fixed withdrawal fee when true; inline fee otherwise. */
23
+ offchainZKP?: boolean;
24
+ }
25
+
26
+ export interface ConfidentialTransferClient {
27
+ /** Get the global fee recipient. */
28
+ getFeeAccount(): Promise<string>;
29
+ /** Get the fixed non-anonymous withdrawal request fee. */
30
+ getNonAnonymousWithdrawFee(): Promise<bigint>;
31
+ /** Get the success-only non-anonymous withdrawal rate in PPM. */
32
+ getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
33
+ /** Get the shared anonymous success-only withdrawal rate in PPM. */
34
+ getAnonymousWithdrawFeePpm(): Promise<bigint>;
35
+ /** Quote fixed and percentage fees for a non-anonymous withdrawal. */
36
+ getWithdrawalFeeQuote(
37
+ tokenAddress: string,
38
+ amount: bigint | string | number,
39
+ ): Promise<WithdrawalFeeQuote>;
40
+ }
41
+
42
+ export interface ControlledConfidentialTransferClient {
43
+ /** Get the global fee recipient. */
44
+ getFeeAccount(): Promise<string>;
45
+ /** Get the fixed non-anonymous withdrawal request fee. */
46
+ getNonAnonymousWithdrawFee(): Promise<bigint>;
47
+ /** Get the success-only non-anonymous withdrawal rate in PPM. */
48
+ getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
49
+ /** Get the shared anonymous success-only withdrawal rate in PPM. */
50
+ getAnonymousWithdrawFeePpm(): Promise<bigint>;
51
+ /** Quote fixed and percentage fees for a non-anonymous withdrawal. */
52
+ getWithdrawalFeeQuote(
53
+ tokenAddress: string,
54
+ amount: bigint | string | number,
55
+ ): Promise<WithdrawalFeeQuote>;
56
+ }
57
+
58
+ export interface AnonymousTransferClient {
59
+ /** Get the global fee recipient through Fairycloak. */
60
+ getFeeAccount(): Promise<string>;
61
+ /** Get the fixed non-anonymous withdrawal request fee through Fairycloak. */
62
+ getNonAnonymousWithdrawFee(): Promise<bigint>;
63
+ /** Get the non-anonymous success-only withdrawal rate in PPM through Fairycloak. */
64
+ getNonAnonymousWithdrawFeePpm(): Promise<bigint>;
65
+ /** Get the shared anonymous success-only withdrawal rate in PPM through Fairycloak. */
66
+ getAnonymousWithdrawFeePpm(): Promise<bigint>;
67
+ /**
68
+ * Quote an anonymous withdrawal. The fixed fee is the path-specific prepaid
69
+ * request fee; the percentage fee is success-only and comes from the withdrawn token.
70
+ */
71
+ getAnonymousWithdrawalFeeQuote(
72
+ tokenAddress: string,
73
+ amount: bigint | string | number,
74
+ options?: AnonymousWithdrawalQuoteOptions,
75
+ ): Promise<WithdrawalFeeQuote>;
76
+ }
77
+
78
+ export const WITHDRAW_FEE_PPM_DENOMINATOR: bigint;
79
+ export const FEE_ACCOUNT_SIGNATURE: string;
80
+ export const NON_ANONYMOUS_WITHDRAW_FEE_SIGNATURE: string;
81
+ export const NON_ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE: string;
82
+ export const ANONYMOUS_WITHDRAW_FEE_PPM_SIGNATURE: string;
83
+ }