@fairblock/stabletrust 2.0.7 → 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.
@@ -4,8 +4,9 @@ import {
4
4
  encodeTransferProof,
5
5
  encodeWithdrawProof,
6
6
  validateAnonymousAccountId,
7
+ uploadBytesToIpfs,
7
8
  } from "./utils.js";
8
- import { getStabletrustContractAddress } from "./constants.js";
9
+ import { ERC20_ABI, getStabletrustContractAddress } from "./constants.js";
9
10
 
10
11
  // EIP-712 domain for anonymous operations (LibAnonAuth domain name)
11
12
  const ANON_DOMAIN_NAME = "ConfidentialMirrorAnonymous";
@@ -23,6 +24,18 @@ const ADD_FEES_INTERFACE = new ethers.Interface([
23
24
  "function addFees(string accountId, address token, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external payable",
24
25
  ]);
25
26
 
27
+ const WITHDRAW_FEES_INTERFACE = new ethers.Interface([
28
+ "function withdrawFees(string accountId, address token, address destination, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external",
29
+ ]);
30
+
31
+ const WITHDRAW_ALL_FEES_INTERFACE = new ethers.Interface([
32
+ "function withdrawAllFees(string accountId, address destination, uint256 authNonce, uint256 deadline, bytes signature) external",
33
+ ]);
34
+
35
+ const CREATE_ACCOUNT_INTERFACE = new ethers.Interface([
36
+ "function createAnonymousConfidentialAccount(string accountId, bytes elgamalPubkey, address[] authSigners, bytes32 clientRequestId, address feePayer, address feeToken, uint256 initialFeeDeposit, uint256 deadline, bytes signature) external payable",
37
+ ]);
38
+
26
39
  /**
27
40
  * Internal helper to parse values as BigInt, handling hex strings and null/undefined.
28
41
  * @private
@@ -61,8 +74,14 @@ export class AnonymousTransferClient {
61
74
  * @param {string} config.rpcUrl - EVM JSON-RPC endpoint (used for on-chain reads and raw-tx signing)
62
75
  * @param {string} [config.diamondAddress] - Optional override for the diamond contract address
63
76
  * @param {string} [config.apiKey] - Optional Fairycloak API key
77
+ * @param {string} [config.ipfsUploadUrl] - StableTrust IPFS upload endpoint used when `offchainZKP: true`
78
+ * @param {string} [config.ipfsApiKey] - Optional bearer token for the StableTrust IPFS upload endpoint
79
+ * @param {string} [config.ipfsGatewayUrl] - Optional read gateway base URL, e.g. "https://ipfs.example.com"
80
+ * @param {boolean} [config.enableNativeFeeToken=false] - Opt-in flag for native-ETH fee tokens. When the
81
+ * protocol fee token is native ETH (`address(0)`), account creation uses a user-funded raw tx. This is
82
+ * kept **disabled by default**
64
83
  */
65
- constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey } = {}) {
84
+ constructor({ fairycloakUrl, diamondAddress, chainId, rpcUrl, apiKey, ipfsUploadUrl, ipfsApiKey, ipfsGatewayUrl, enableNativeFeeToken = false } = {}) {
66
85
  if (!fairycloakUrl) throw new Error("fairycloakUrl is required");
67
86
  if (!chainId) throw new Error("chainId is required");
68
87
  if (!rpcUrl) throw new Error("rpcUrl is required");
@@ -80,6 +99,10 @@ export class AnonymousTransferClient {
80
99
  this.chainId = Number(chainId);
81
100
  this.rpcUrl = rpcUrl;
82
101
  this.apiKey = apiKey || null;
102
+ this.ipfsUploadUrl = ipfsUploadUrl || null;
103
+ this.ipfsApiKey = ipfsApiKey || null;
104
+ this.ipfsGatewayUrl = ipfsGatewayUrl ? ipfsGatewayUrl.replace(/\/$/, "") : null;
105
+ this.enableNativeFeeToken = Boolean(enableNativeFeeToken);
83
106
 
84
107
  this.provider = new ethers.JsonRpcProvider(rpcUrl);
85
108
  this._wasmModule = null;
@@ -169,11 +192,7 @@ export class AnonymousTransferClient {
169
192
  if (!this._tokenDecimals) this._tokenDecimals = {};
170
193
  const key = tokenAddress.toLowerCase();
171
194
  if (this._tokenDecimals[key] !== undefined) return this._tokenDecimals[key];
172
- const contract = new ethers.Contract(
173
- tokenAddress,
174
- ["function decimals() view returns (uint8)"],
175
- this.provider,
176
- );
195
+ const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider);
177
196
  const decimals = Number(await contract.decimals());
178
197
  this._tokenDecimals[key] = decimals;
179
198
  return decimals;
@@ -193,7 +212,13 @@ export class AnonymousTransferClient {
193
212
  */
194
213
  async _toContractScale(tokenAddress, rawAmount) {
195
214
  const decimals = await this._fetchTokenDecimals(tokenAddress);
196
- return (rawAmount * 100n) / 10n ** BigInt(decimals);
215
+ const divisor = 10n ** BigInt(decimals);
216
+ if ((rawAmount * 100n) % divisor !== 0n) {
217
+ console.warn(
218
+ `Amount ${rawAmount} is not fully representable in contract scale (2 decimals); the sub-cent remainder will be dropped.`,
219
+ );
220
+ }
221
+ return (rawAmount * 100n) / divisor;
197
222
  }
198
223
 
199
224
  // ─────────────────────── on-chain reads ────────────────────────
@@ -310,9 +335,14 @@ export class AnonymousTransferClient {
310
335
  );
311
336
  if (!ciphertext) return { amount: 0, ciphertext: null };
312
337
  const wasm = await this._getWasm();
313
- const result = JSON.parse(
314
- wasm.decrypt_ciphertext(ciphertext, elGamalPrivateKey),
315
- );
338
+ let result;
339
+ try {
340
+ result = JSON.parse(wasm.decrypt_ciphertext(ciphertext, elGamalPrivateKey));
341
+ } catch (e) {
342
+ throw new Error(
343
+ `Decryption failed. Is the elGamalPrivateKey correct for this accountId? (${e?.message ?? e})`,
344
+ );
345
+ }
316
346
  return { amount: result.decrypted_amount ?? 0, ciphertext };
317
347
  }
318
348
 
@@ -518,25 +548,95 @@ export class AnonymousTransferClient {
518
548
  );
519
549
  }
520
550
 
551
+ // ───────────────── off-chain ZKP (IPFS) helpers ─────────────────
552
+
553
+ /**
554
+ * Upload ABI-encoded proof bytes to the configured StableTrust IPFS upload endpoint for an
555
+ * off-chain ZKP transfer and return the bare CID. With off-chain ZKP, only this CID
556
+ * (<=128 bytes) is placed on-chain;
557
+ * Fairyport later fetches the full proof from IPFS and verifies it off-chain.
558
+ *
559
+ * @param {string} proofHex - "0x…" ABI-encoded proof hex (from generateTransferProof)
560
+ * @returns {Promise<string>} bare CID (no "ipfs://" prefix)
561
+ * @private
562
+ */
563
+ async _uploadProofToIpfs(proofHex) {
564
+ if (!proofHex) throw new Error("cannot upload an empty proof to IPFS");
565
+ const bytes = ethers.getBytes(proofHex);
566
+ const cid = await uploadBytesToIpfs(bytes, "anon-transfer-proof.bin", {
567
+ uploadUrl: this.ipfsUploadUrl,
568
+ apiKey: this.ipfsApiKey,
569
+ });
570
+ if (!cid) {
571
+ throw new Error(
572
+ "IPFS upload returned no CID (check STABLETRUST_IPFS_UPLOAD_URL / STABLETRUST_IPFS_API_KEY)",
573
+ );
574
+ }
575
+ return String(cid).trim();
576
+ }
577
+
578
+ /**
579
+ * Resolve a transfer/withdraw `proof` payload and its EIP-712 `proofHash`, for `offchainZKP`.
580
+ *
581
+ * - `offchainZKP=false` (inline proof, default): the payload is the proof hex and
582
+ * `proofHash = keccak256(proof bytes)` - the original architecture, unchanged.
583
+ * - `offchainZKP=true` (off-chain ZKP): the payload is a bare IPFS CID and
584
+ * `proofHash = keccak256(utf8 bytes of the CID)`. This matches the Fairycloak relay and the
585
+ * on-chain contract, which store the CID and verify the proof off-chain.
586
+ *
587
+ * @param {Object} p
588
+ * @param {boolean} p.offchainZKP
589
+ * @param {string} [p.proofHex] - "0x…" proof hex (auto mode, or inline manual mode)
590
+ * @param {string} [p.cid] - pre-uploaded bare CID (manual mode + offchainZKP)
591
+ * @returns {Promise<{proofPayload: string, proofHash: string}>}
592
+ * @private
593
+ */
594
+ async _resolveProofPayload({ offchainZKP, proofHex, cid }) {
595
+ if (!offchainZKP) {
596
+ const hex = proofHex.startsWith("0x") ? proofHex : `0x${proofHex}`;
597
+ return { proofPayload: hex, proofHash: ethers.keccak256(hex) };
598
+ }
599
+ const resolvedCid = (cid ?? (await this._uploadProofToIpfs(proofHex)))
600
+ .toString()
601
+ .trim()
602
+ .replace(/^ipfs:\/\//i, "");
603
+ if (!resolvedCid) {
604
+ throw new Error("off-chain ZKP transfer requires a non-empty IPFS CID");
605
+ }
606
+ return {
607
+ proofPayload: resolvedCid,
608
+ proofHash: ethers.keccak256(ethers.toUtf8Bytes(resolvedCid)),
609
+ };
610
+ }
611
+
521
612
  // ─────────────────── Fairycloak operations ─────────────────────
522
613
 
523
614
  /**
524
615
  * Create a new anonymous account via Fairycloak.
525
616
  *
526
- * The relay submits the `createAnonymousAccount` transaction and pays gas.
617
+ * Two submission modes, chosen automatically by the active fee token:
618
+ * - **ERC-20 fee token** (default): the relay submits `createAnonymousConfidentialAccount`
619
+ * and pays gas; the fee payer approves the diamond so it can pull the ERC-20 deposit.
620
+ * - **Native ETH fee token** (`feeToken == address(0)`): ETH can't be pulled with
621
+ * `transferFrom` and the contract requires `msg.sender == feePayer`, so the fee payer
622
+ * signs and funds the payable tx itself (`msg.value == initialFeeDeposit`) and the raw
623
+ * bytes are sent to `/v1/anonymous/accounts/raw-tx`. `authWallet` pays the gas.
527
624
  *
528
- * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
625
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer (also funds the tx for native fees)
529
626
  * @param {string} accountId - Caller-chosen account ID. Must be a non-empty, case-sensitive,
530
627
  * alphanumeric string of at most 20 characters.
531
628
  * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
532
629
  * @param {Object} [options]
533
630
  * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
631
+ * @param {bigint|string|number} [options.initialFeeDeposit] - Override the initial deposit (defaults to the contract minimum)
632
+ * @param {bigint} [options.gasLimit=1000000n] - Gas limit for the native-fee raw tx (unused for ERC-20)
534
633
  * @returns {Promise<{request_id:string, tx_hash:string, status:string, action:string}>}
535
634
  * @throws {Error} If `accountId` doesn't follow the anonymous account ID rules (see class docs).
536
635
  */
537
636
  async createAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
538
637
  validateAnonymousAccountId(accountId, "accountId");
539
- const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
638
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 1000000n } =
639
+ options;
540
640
  try {
541
641
  const authAddress = await authWallet.getAddress();
542
642
 
@@ -548,12 +648,36 @@ export class AnonymousTransferClient {
548
648
  const clientRequestId = ethers.hexlify(ethers.randomBytes(32));
549
649
  const deadline = this._makeDeadline(deadlineOffset);
550
650
 
551
- // EIP-712 struct hashes for dynamic fields
651
+ // Prepaid-fee funding (fees-for-all-ops)
652
+ // The relayer submits create... the fee payer must fund an initial deposit
653
+ // in the active fee token and approve the diamond so it can pull it into
654
+ // escrow. The create fee is charged from it and the remainder becomes the
655
+ // account's prepaid balance once creation finalises.
656
+ const [feeToken, minInitialDeposit, createFee] = await Promise.all([
657
+ this.getFeeToken(),
658
+ this.getAnonymousMinimumInitialFeeDeposit(),
659
+ this.getAnonymousCreateAccountFee(),
660
+ ]);
661
+ const feePayer = authAddress;
662
+ const initialFeeDeposit =
663
+ options.initialFeeDeposit != null
664
+ ? BigInt(options.initialFeeDeposit)
665
+ : minInitialDeposit;
666
+ if (initialFeeDeposit < minInitialDeposit)
667
+ throw new Error(
668
+ `initialFeeDeposit (${initialFeeDeposit}) is below the contract minimum (${minInitialDeposit})`,
669
+ );
670
+ if (initialFeeDeposit < createFee)
671
+ throw new Error(
672
+ `initialFeeDeposit (${initialFeeDeposit}) is below the create-account fee (${createFee})`,
673
+ );
674
+
675
+ const isNative = feeToken === ethers.ZeroAddress;
676
+
677
+ // EIP-712 AnonymousCreate signature - identical for both fee-token modes
678
+ // (the digest already binds feePayer/feeToken/initialFeeDeposit).
552
679
  const initialSignersHash = ethers.keccak256(
553
- ethers.AbiCoder.defaultAbiCoder().encode(
554
- ["address[]"],
555
- [[authAddress]],
556
- ),
680
+ ethers.AbiCoder.defaultAbiCoder().encode(["address[]"], [[authAddress]]),
557
681
  );
558
682
  const elgamalPubkeyHash = ethers.keccak256(elgamalHex);
559
683
 
@@ -564,6 +688,9 @@ export class AnonymousTransferClient {
564
688
  { name: "initialSignersHash", type: "bytes32" },
565
689
  { name: "elgamalPubkeyHash", type: "bytes32" },
566
690
  { name: "clientRequestId", type: "bytes32" },
691
+ { name: "feePayer", type: "address" },
692
+ { name: "feeToken", type: "address" },
693
+ { name: "initialFeeDeposit", type: "uint256" },
567
694
  { name: "deadline", type: "uint256" },
568
695
  ],
569
696
  };
@@ -572,16 +699,97 @@ export class AnonymousTransferClient {
572
699
  initialSignersHash,
573
700
  elgamalPubkeyHash,
574
701
  clientRequestId,
702
+ feePayer,
703
+ feeToken,
704
+ initialFeeDeposit,
575
705
  deadline: BigInt(deadline),
576
706
  };
577
707
 
578
708
  const signature = await authWallet.signTypedData(domain, types, value);
579
709
 
710
+ if (isNative) {
711
+ // Native-ETH fee-token support is implemented but gated off by default.
712
+ if (!this.enableNativeFeeToken)
713
+ throw new Error(
714
+ "Active fee token is native ETH (address 0), but native fee-token support is disabled. " +
715
+ "Construct the client with { enableNativeFeeToken: true } to opt in, or configure an ERC-20 fee token.",
716
+ );
717
+ // Native ETH fee token: user-funded raw-tx submission
718
+ // ETH can't be pulled with transferFrom and the contract requires
719
+ // msg.sender == feePayer, so the fee payer signs and funds the payable
720
+ // createAnonymousConfidentialAccount tx (msg.value == initialFeeDeposit);
721
+ // Fairycloak validates + broadcasts the raw bytes unchanged.
722
+ if (initialFeeDeposit > 0n) {
723
+ const nativeBalance = await this.provider.getBalance(feePayer);
724
+ if (nativeBalance < initialFeeDeposit)
725
+ throw new Error(
726
+ `Insufficient native balance for initial deposit. Required: ${initialFeeDeposit}, available: ${nativeBalance}`,
727
+ );
728
+ }
729
+ const calldata = CREATE_ACCOUNT_INTERFACE.encodeFunctionData(
730
+ "createAnonymousConfidentialAccount",
731
+ [
732
+ accountId,
733
+ elgamalHex,
734
+ [authAddress],
735
+ clientRequestId,
736
+ feePayer,
737
+ feeToken,
738
+ initialFeeDeposit,
739
+ BigInt(deadline),
740
+ signature,
741
+ ],
742
+ );
743
+ const signedTx = await this._signRawDiamondTx(
744
+ authWallet,
745
+ calldata,
746
+ initialFeeDeposit,
747
+ gasLimit,
748
+ );
749
+ return await this._fetch("POST", "/v1/anonymous/accounts/raw-tx", {
750
+ raw_tx: signedTx,
751
+ });
752
+ }
753
+
754
+ // ERC-20 fee token: managed (relayer-submitted) creation
755
+ // The fee payer funds an initial deposit in the fee token and approves the
756
+ // diamond so it can pull it into escrow; the relay submits + pays gas.
757
+ if (initialFeeDeposit > 0n) {
758
+ const walletWithProvider = authWallet.connect
759
+ ? authWallet.connect(this.provider)
760
+ : authWallet;
761
+ const tokenContract = new ethers.Contract(
762
+ feeToken,
763
+ ERC20_ABI,
764
+ walletWithProvider,
765
+ );
766
+ const [balance, allowance] = await Promise.all([
767
+ tokenContract.balanceOf(feePayer),
768
+ tokenContract.allowance(feePayer, this.diamondAddress),
769
+ ]);
770
+ if (balance < initialFeeDeposit)
771
+ throw new Error(
772
+ `Insufficient fee-token balance for initial deposit. Required: ${initialFeeDeposit}, available: ${balance} (token ${feeToken})`,
773
+ );
774
+ if (allowance < initialFeeDeposit) {
775
+ const approveTx = await tokenContract.approve(
776
+ this.diamondAddress,
777
+ ethers.MaxUint256,
778
+ );
779
+ const receipt = await approveTx.wait();
780
+ if (!receipt || receipt.status === 0)
781
+ throw new Error("Fee token approval failed");
782
+ }
783
+ }
784
+
580
785
  return await this._fetch("POST", "/v1/anonymous/accounts", {
581
786
  account_id: accountId,
582
787
  elgamal_pubkey: elgamalHex,
583
788
  auth_signers: [authAddress],
584
789
  client_request_id: clientRequestId,
790
+ fee_payer: feePayer,
791
+ fee_token: feeToken,
792
+ initial_fee_deposit: initialFeeDeposit.toString(),
585
793
  deadline: String(deadline),
586
794
  signature,
587
795
  });
@@ -802,11 +1010,7 @@ export class AnonymousTransferClient {
802
1010
  // 1. Approve if needed
803
1011
  const tokenContract = new ethers.Contract(
804
1012
  tokenAddress,
805
- [
806
- "function balanceOf(address) view returns (uint256)",
807
- "function allowance(address,address) view returns (uint256)",
808
- "function approve(address,uint256) returns (bool)",
809
- ],
1013
+ ERC20_ABI,
810
1014
  walletWithProvider,
811
1015
  );
812
1016
 
@@ -956,6 +1160,7 @@ export class AnonymousTransferClient {
956
1160
  elGamalPrivateKey,
957
1161
  amount,
958
1162
  destinationPublicKey,
1163
+ offchainZKP = false,
959
1164
  },
960
1165
  options = {},
961
1166
  ) {
@@ -967,9 +1172,16 @@ export class AnonymousTransferClient {
967
1172
  if (!ethers.isAddress(token))
968
1173
  throw new Error(`Invalid token address: ${token}`);
969
1174
 
1175
+ // `proofHex` holds the "0x…" proof (supplied or auto-generated below).
1176
+ const manualProof = Boolean(proof);
970
1177
  let proofHex;
971
- if (proof) {
972
- proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1178
+ let cid;
1179
+ if (manualProof) {
1180
+ if (offchainZKP) {
1181
+ cid = proof;
1182
+ } else {
1183
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1184
+ }
973
1185
  } else {
974
1186
  if (!elGamalPrivateKey)
975
1187
  throw new Error("Either proof or elGamalPrivateKey must be provided");
@@ -1017,11 +1229,17 @@ export class AnonymousTransferClient {
1017
1229
  });
1018
1230
  }
1019
1231
 
1020
- await this._assertSufficientPrepaidFees(accountId);
1232
+ await this._assertSufficientPrepaidFees(accountId, offchainZKP);
1021
1233
 
1022
1234
  const authNonce = await this.getAuthNonce(accountId);
1023
1235
  const deadline = this._makeDeadline(deadlineOffset);
1024
- const proofHash = ethers.keccak256(proofHex);
1236
+
1237
+ // Resolve the proof payload (inline hex vs off-chain IPFS CID) and its EIP-712 hash.
1238
+ const { proofPayload, proofHash } = await this._resolveProofPayload({
1239
+ offchainZKP,
1240
+ proofHex,
1241
+ cid,
1242
+ });
1025
1243
 
1026
1244
  const domain = this._buildDomain();
1027
1245
  const types = {
@@ -1030,6 +1248,7 @@ export class AnonymousTransferClient {
1030
1248
  { name: "recipient", type: "address" },
1031
1249
  { name: "token", type: "address" },
1032
1250
  { name: "proofHash", type: "bytes32" },
1251
+ { name: "offchainZKP", type: "bool" },
1033
1252
  { name: "authNonce", type: "uint256" },
1034
1253
  { name: "deadline", type: "uint256" },
1035
1254
  ],
@@ -1039,6 +1258,7 @@ export class AnonymousTransferClient {
1039
1258
  recipient,
1040
1259
  token,
1041
1260
  proofHash,
1261
+ offchainZKP,
1042
1262
  authNonce,
1043
1263
  deadline: BigInt(deadline),
1044
1264
  };
@@ -1049,7 +1269,8 @@ export class AnonymousTransferClient {
1049
1269
  sender_id: accountId,
1050
1270
  recipient,
1051
1271
  token,
1052
- proof: proofHex,
1272
+ proof: proofPayload,
1273
+ offchain_zkp: offchainZKP,
1053
1274
  auth_nonce: String(authNonce),
1054
1275
  deadline: String(deadline),
1055
1276
  signature,
@@ -1093,6 +1314,7 @@ export class AnonymousTransferClient {
1093
1314
  elGamalPrivateKey,
1094
1315
  amount,
1095
1316
  destinationPublicKey,
1317
+ offchainZKP = false,
1096
1318
  },
1097
1319
  options = {},
1098
1320
  ) {
@@ -1103,9 +1325,17 @@ export class AnonymousTransferClient {
1103
1325
  if (!ethers.isAddress(token))
1104
1326
  throw new Error(`Invalid token address: ${token}`);
1105
1327
 
1328
+ // In manual mode + offchainZKP the caller passes a CID (not proof hex),, otherwise
1329
+ // `proofHex` holds the "0x…" proof
1330
+ const manualProof = Boolean(proof);
1106
1331
  let proofHex;
1107
- if (proof) {
1108
- proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1332
+ let cid;
1333
+ if (manualProof) {
1334
+ if (offchainZKP) {
1335
+ cid = proof;
1336
+ } else {
1337
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1338
+ }
1109
1339
  } else {
1110
1340
  if (!elGamalPrivateKey)
1111
1341
  throw new Error("Either proof or elGamalPrivateKey must be provided");
@@ -1153,11 +1383,16 @@ export class AnonymousTransferClient {
1153
1383
  });
1154
1384
  }
1155
1385
 
1156
- await this._assertSufficientPrepaidFees(senderAccountId);
1386
+ await this._assertSufficientPrepaidFees(senderAccountId, offchainZKP);
1157
1387
 
1158
1388
  const authNonce = await this.getAuthNonce(senderAccountId);
1159
1389
  const deadline = this._makeDeadline(deadlineOffset);
1160
- const proofHash = ethers.keccak256(proofHex);
1390
+
1391
+ const { proofPayload, proofHash } = await this._resolveProofPayload({
1392
+ offchainZKP,
1393
+ proofHex,
1394
+ cid,
1395
+ });
1161
1396
 
1162
1397
  const domain = this._buildDomain();
1163
1398
  const types = {
@@ -1166,6 +1401,7 @@ export class AnonymousTransferClient {
1166
1401
  { name: "recipientId", type: "string" },
1167
1402
  { name: "token", type: "address" },
1168
1403
  { name: "proofHash", type: "bytes32" },
1404
+ { name: "offchainZKP", type: "bool" },
1169
1405
  { name: "authNonce", type: "uint256" },
1170
1406
  { name: "deadline", type: "uint256" },
1171
1407
  ],
@@ -1175,6 +1411,7 @@ export class AnonymousTransferClient {
1175
1411
  recipientId: String(recipientId),
1176
1412
  token,
1177
1413
  proofHash,
1414
+ offchainZKP,
1178
1415
  authNonce,
1179
1416
  deadline: BigInt(deadline),
1180
1417
  };
@@ -1185,7 +1422,8 @@ export class AnonymousTransferClient {
1185
1422
  sender_id: senderAccountId,
1186
1423
  recipient_id: recipientId,
1187
1424
  token,
1188
- proof: proofHex,
1425
+ proof: proofPayload,
1426
+ offchain_zkp: offchainZKP,
1189
1427
  auth_nonce: String(authNonce),
1190
1428
  deadline: String(deadline),
1191
1429
  signature,
@@ -1262,6 +1500,8 @@ export class AnonymousTransferClient {
1262
1500
  * @param {bigint|string|number} params.plainAmount - Withdrawal amount in token units
1263
1501
  * @param {string} [params.proof] - Pre-computed ZK proof ("0x..." hex)
1264
1502
  * @param {string} [params.elGamalPrivateKey] - ElGamal private key (base64) for auto-proof
1503
+ * @param {boolean} [params.offchainZKP=false] - When true, upload the proof to IPFS and put
1504
+ * only its CID on-chain (needs `ipfsUploadUrl`/`ipfsApiKey` config or the StableTrust IPFS env vars)
1265
1505
  * @param {Object} [options]
1266
1506
  * @param {number} [options.deadlineOffset=3600]
1267
1507
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
@@ -1275,6 +1515,7 @@ export class AnonymousTransferClient {
1275
1515
  plainAmount,
1276
1516
  proof,
1277
1517
  elGamalPrivateKey,
1518
+ offchainZKP = false,
1278
1519
  },
1279
1520
  options = {},
1280
1521
  ) {
@@ -1309,9 +1550,16 @@ export class AnonymousTransferClient {
1309
1550
  const authNonce = accountInfo.authNonce;
1310
1551
  const txId = accountInfo.txId;
1311
1552
 
1553
+ // In manual mode + offchainZKP the caller passes a CID (not proof hex)
1554
+ const manualProof = Boolean(proof);
1312
1555
  let proofHex;
1313
- if (proof) {
1314
- proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1556
+ let cid;
1557
+ if (manualProof) {
1558
+ if (offchainZKP) {
1559
+ cid = proof;
1560
+ } else {
1561
+ proofHex = proof.startsWith("0x") ? proof : `0x${proof}`;
1562
+ }
1315
1563
  } else {
1316
1564
  if (!elGamalPrivateKey)
1317
1565
  throw new Error("Either proof or elGamalPrivateKey must be provided");
@@ -1336,7 +1584,13 @@ export class AnonymousTransferClient {
1336
1584
  });
1337
1585
  }
1338
1586
  const deadline = this._makeDeadline(deadlineOffset);
1339
- const proofHash = ethers.keccak256(proofHex);
1587
+
1588
+ // Resolve the proof payload (inline hex vs off-chain IPFS CID) and its EIP-712 hash.
1589
+ const { proofPayload, proofHash } = await this._resolveProofPayload({
1590
+ offchainZKP,
1591
+ proofHex,
1592
+ cid,
1593
+ });
1340
1594
 
1341
1595
  const domain = this._buildDomain();
1342
1596
  const types = {
@@ -1346,6 +1600,7 @@ export class AnonymousTransferClient {
1346
1600
  { name: "token", type: "address" },
1347
1601
  { name: "plainAmount", type: "uint256" },
1348
1602
  { name: "proofHash", type: "bytes32" },
1603
+ { name: "offchainZKP", type: "bool" },
1349
1604
  { name: "authNonce", type: "uint256" },
1350
1605
  { name: "deadline", type: "uint256" },
1351
1606
  ],
@@ -1356,6 +1611,7 @@ export class AnonymousTransferClient {
1356
1611
  token,
1357
1612
  plainAmount: withdrawAmountContractScale,
1358
1613
  proofHash,
1614
+ offchainZKP,
1359
1615
  authNonce,
1360
1616
  deadline: BigInt(deadline),
1361
1617
  };
@@ -1367,7 +1623,8 @@ export class AnonymousTransferClient {
1367
1623
  destination,
1368
1624
  token,
1369
1625
  plain_amount: String(withdrawAmountContractScale),
1370
- proof: proofHex,
1626
+ proof: proofPayload,
1627
+ offchain_zkp: offchainZKP,
1371
1628
  auth_nonce: String(authNonce),
1372
1629
  deadline: String(deadline),
1373
1630
  signature,
@@ -1438,24 +1695,149 @@ export class AnonymousTransferClient {
1438
1695
  }
1439
1696
 
1440
1697
  /**
1441
- * Get the currently configured protocol fee amount (in raw ERC-20 units of the fee token).
1698
+ * Fixed fee charged when creating an anonymous account.
1442
1699
  *
1443
- * This is the amount deducted from the prepaid reserve on each anonymous transfer.
1444
- * Returns 0n if no fee is configured.
1700
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1701
+ */
1702
+ async getAnonymousCreateAccountFee() {
1703
+ try {
1704
+ const data = await this._fetch("GET", "/v1/views/fees/anonymous-create");
1705
+ return _parseBigInt(data.result ?? data);
1706
+ } catch (e) {
1707
+ throw new Error(`Failed to get anonymous create-account fee: ${e.message}`);
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * Minimum initial fee deposit required at account creation.
1445
1713
  *
1446
- * @returns {Promise<bigint>}
1714
+ * @returns {Promise<bigint>} Amount in raw fee-token units
1447
1715
  */
1448
- async getFeeAmount() {
1716
+ async getAnonymousMinimumInitialFeeDeposit() {
1449
1717
  try {
1450
- const data = await this._fetch("GET", "/v1/views/fee-amount");
1718
+ const data = await this._fetch(
1719
+ "GET",
1720
+ "/v1/views/fees/anonymous-minimum-initial-deposit",
1721
+ );
1451
1722
  return _parseBigInt(data.result ?? data);
1452
1723
  } catch (e) {
1453
- throw new Error(`Failed to get fee amount: ${e.message}`);
1724
+ throw new Error(
1725
+ `Failed to get anonymous minimum initial fee deposit: ${e.message}`,
1726
+ );
1454
1727
  }
1455
1728
  }
1456
1729
 
1457
1730
  /**
1458
- * Get the prepaid fee balance for an anonymous account and token (in raw ERC-20 units).
1731
+ * Fixed fee charged for an auth-key update.
1732
+ *
1733
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1734
+ */
1735
+ async getAnonymousUpdateKeysFee() {
1736
+ try {
1737
+ const data = await this._fetch(
1738
+ "GET",
1739
+ "/v1/views/fees/anonymous-update-keys",
1740
+ );
1741
+ return _parseBigInt(data.result ?? data);
1742
+ } catch (e) {
1743
+ throw new Error(`Failed to get anonymous update-keys fee: ${e.message}`);
1744
+ }
1745
+ }
1746
+
1747
+ /**
1748
+ * Fixed fee charged for an apply-pending operation.
1749
+ *
1750
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1751
+ */
1752
+ async getAnonymousApplyPendingFee() {
1753
+ try {
1754
+ const data = await this._fetch("GET", "/v1/views/fees/anonymous-apply");
1755
+ return _parseBigInt(data.result ?? data);
1756
+ } catch (e) {
1757
+ throw new Error(`Failed to get anonymous apply-pending fee: ${e.message}`);
1758
+ }
1759
+ }
1760
+
1761
+ /**
1762
+ * Fixed fee charged for an IPFS-proof withdraw.
1763
+ *
1764
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1765
+ */
1766
+ async getAnonymousIpfsWithdrawFee() {
1767
+ try {
1768
+ const data = await this._fetch(
1769
+ "GET",
1770
+ "/v1/views/fees/anonymous-withdraw-ipfs",
1771
+ );
1772
+ return _parseBigInt(data.result ?? data);
1773
+ } catch (e) {
1774
+ throw new Error(`Failed to get anonymous IPFS withdraw fee: ${e.message}`);
1775
+ }
1776
+ }
1777
+
1778
+ /**
1779
+ * Fixed fee charged for an inline-proof withdraw.
1780
+ *
1781
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1782
+ */
1783
+ async getAnonymousInlineWithdrawFee() {
1784
+ try {
1785
+ const data = await this._fetch(
1786
+ "GET",
1787
+ "/v1/views/fees/anonymous-withdraw-inline",
1788
+ );
1789
+ return _parseBigInt(data.result ?? data);
1790
+ } catch (e) {
1791
+ throw new Error(
1792
+ `Failed to get anonymous inline withdraw fee: ${e.message}`,
1793
+ );
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * Get the fixed fee used by all non-anonymous confidential transfers.
1799
+ *
1800
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1801
+ */
1802
+ async getNonAnonymousTransferFee() {
1803
+ try {
1804
+ const data = await this._fetch("GET", "/v1/views/fees/non-anonymous");
1805
+ return _parseBigInt(data.result ?? data);
1806
+ } catch (e) {
1807
+ throw new Error(`Failed to get non-anonymous transfer fee: ${e.message}`);
1808
+ }
1809
+ }
1810
+
1811
+ /**
1812
+ * Get the fixed fee used by anonymous transfers with proofs stored on IPFS.
1813
+ *
1814
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1815
+ */
1816
+ async getAnonymousIpfsTransferFee() {
1817
+ try {
1818
+ const data = await this._fetch("GET", "/v1/views/fees/anonymous-ipfs");
1819
+ return _parseBigInt(data.result ?? data);
1820
+ } catch (e) {
1821
+ throw new Error(`Failed to get anonymous IPFS transfer fee: ${e.message}`);
1822
+ }
1823
+ }
1824
+
1825
+ /**
1826
+ * Get the fixed fee used by anonymous transfers with proofs submitted inline.
1827
+ *
1828
+ * @returns {Promise<bigint>} Fee amount in raw fee-token units
1829
+ */
1830
+ async getAnonymousInlineTransferFee() {
1831
+ try {
1832
+ const data = await this._fetch("GET", "/v1/views/fees/anonymous-inline");
1833
+ return _parseBigInt(data.result ?? data);
1834
+ } catch (e) {
1835
+ throw new Error(`Failed to get anonymous inline transfer fee: ${e.message}`);
1836
+ }
1837
+ }
1838
+
1839
+ /**
1840
+ * Get the prepaid fee balance for an anonymous account and token (in raw fee-token units).
1459
1841
  *
1460
1842
  * @param {string} accountId
1461
1843
  * @param {string} token - ERC-20 token address (use `getFeeToken()` to get the active fee token)
@@ -1481,21 +1863,25 @@ export class AnonymousTransferClient {
1481
1863
  * Throws a descriptive error if the balance is insufficient.
1482
1864
  * @private
1483
1865
  */
1484
- async _assertSufficientPrepaidFees(accountId) {
1866
+ async _assertSufficientPrepaidFees(accountId, offchainZKP) {
1867
+ const feePromise = offchainZKP
1868
+ ? this.getAnonymousIpfsTransferFee()
1869
+ : this.getAnonymousInlineTransferFee();
1485
1870
  const [feeToken, feeAmount] = await Promise.all([
1486
1871
  this.getFeeToken(),
1487
- this.getFeeAmount(),
1872
+ feePromise,
1488
1873
  ]);
1489
- if (feeAmount === 0n) return; // no fee configured
1874
+ if (feeAmount === 0n) return; // no fee configured for this path
1490
1875
 
1491
1876
  const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
1492
1877
  if (balance < feeAmount) {
1878
+ const path = offchainZKP ? "IPFS" : "inline";
1493
1879
  throw new Error(
1494
- `Insufficient prepaid fee balance for anonymous account "${accountId}". ` +
1880
+ `Insufficient prepaid fee balance for anonymous account "${accountId}" (${path} transfer). ` +
1495
1881
  `Required: ${feeAmount} (raw units of fee token ${feeToken}), ` +
1496
1882
  `available: ${balance}. ` +
1497
1883
  `Call depositFees(wallet, accountId, amount) to top up the prepaid fee reserve. ` +
1498
- `Use getFeeToken() to get the active fee token address and getFeeAmount() to see the required deposit amount per transfer.`,
1884
+ `Use getFeeToken() and the matching anonymous fee getter to check the required amount.`,
1499
1885
  );
1500
1886
  }
1501
1887
  }
@@ -1517,7 +1903,7 @@ export class AnonymousTransferClient {
1517
1903
  *
1518
1904
  * @param {ethers.Wallet} authWallet - Wallet that owns the tokens and pays gas
1519
1905
  * @param {string} accountId - Target anonymous account ID
1520
- * @param {bigint|string|number} amount - Amount in raw ERC-20 units of the active fee token
1906
+ * @param {bigint|string|number} amount - Amount in raw fee-token units (wei for native currency)
1521
1907
  * @param {Object} [options]
1522
1908
  * @param {number} [options.deadlineOffset=3600]
1523
1909
  * @param {bigint} [options.gasLimit=300000n]
@@ -1550,11 +1936,7 @@ export class AnonymousTransferClient {
1550
1936
  // ERC-20: approve if needed
1551
1937
  const tokenContract = new ethers.Contract(
1552
1938
  feeToken,
1553
- [
1554
- "function balanceOf(address) view returns (uint256)",
1555
- "function allowance(address,address) view returns (uint256)",
1556
- "function approve(address,uint256) returns (bool)",
1557
- ],
1939
+ ERC20_ABI,
1558
1940
  walletWithProvider,
1559
1941
  );
1560
1942
 
@@ -1662,11 +2044,55 @@ export class AnonymousTransferClient {
1662
2044
  }
1663
2045
 
1664
2046
  /**
1665
- * Withdraw a specific amount from the prepaid fee reserve via Fairycloak. The relay pays gas.
2047
+ * Build and sign a raw EVM transaction targeting the diamond, for the user-funded
2048
+ * raw-tx flows (withdrawFees / withdrawAllFees). The wallet that signs pays the gas.
2049
+ * Supports both EIP-1559 and legacy fee markets. Nonce/chainId/fees are supplied
2050
+ * explicitly, so `authWallet` does not need to be provider-connected.
2051
+ * @private
2052
+ */
2053
+ async _signRawDiamondTx(authWallet, calldata, value, gasLimit) {
2054
+ const fromAddress = await authWallet.getAddress();
2055
+ const [nonce, feeData] = await Promise.all([
2056
+ this.provider.getTransactionCount(fromAddress),
2057
+ this.provider.getFeeData(),
2058
+ ]);
2059
+ let txObj;
2060
+ if (feeData.maxFeePerGas) {
2061
+ txObj = {
2062
+ to: this.diamondAddress,
2063
+ data: calldata,
2064
+ nonce,
2065
+ chainId: BigInt(this.chainId),
2066
+ gasLimit,
2067
+ maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
2068
+ maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
2069
+ value,
2070
+ };
2071
+ } else {
2072
+ txObj = {
2073
+ to: this.diamondAddress,
2074
+ data: calldata,
2075
+ nonce,
2076
+ chainId: BigInt(this.chainId),
2077
+ gasLimit,
2078
+ gasPrice: (feeData.gasPrice * 11n) / 10n,
2079
+ value,
2080
+ };
2081
+ }
2082
+ return authWallet.signTransaction(txObj);
2083
+ }
2084
+
2085
+ /**
2086
+ * Withdraw a specific amount from the prepaid fee reserve.
2087
+ *
2088
+ * User-funded raw-transaction model (like deposit/addFees): the anonymous signer
2089
+ * authorises the inner operation via EIP-712, and the same wallet signs and funds
2090
+ * the outer `withdrawFees(...)` EVM transaction (zero ETH value). Fairycloak validates
2091
+ * and broadcasts it unchanged. The wallet pays the gas; no prepaid fee is charged.
1666
2092
  *
1667
2093
  * Use this to reclaim fees for a specific (possibly historical) fee token.
1668
2094
  *
1669
- * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
2095
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer; also funds the outer tx
1670
2096
  * @param {string} accountId
1671
2097
  * @param {Object} params
1672
2098
  * @param {string} params.token - Fee token address to withdraw
@@ -1674,6 +2100,7 @@ export class AnonymousTransferClient {
1674
2100
  * @param {bigint|string|number} params.amount - Amount in raw ERC-20 units
1675
2101
  * @param {Object} [options]
1676
2102
  * @param {number} [options.deadlineOffset=3600]
2103
+ * @param {bigint} [options.gasLimit=300000n]
1677
2104
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1678
2105
  */
1679
2106
  async withdrawFees(
@@ -1683,7 +2110,8 @@ export class AnonymousTransferClient {
1683
2110
  options = {},
1684
2111
  ) {
1685
2112
  validateAnonymousAccountId(accountId, "accountId");
1686
- const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
2113
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
2114
+ options;
1687
2115
  try {
1688
2116
  if (!ethers.isAddress(token))
1689
2117
  throw new Error(`Invalid token address: ${token}`);
@@ -1717,14 +2145,28 @@ export class AnonymousTransferClient {
1717
2145
 
1718
2146
  const signature = await authWallet.signTypedData(domain, types, value);
1719
2147
 
2148
+ // Build + sign the user-funded raw withdrawFees tx (must carry zero ETH value).
2149
+ const calldata = WITHDRAW_FEES_INTERFACE.encodeFunctionData(
2150
+ "withdrawFees",
2151
+ [
2152
+ accountId,
2153
+ token,
2154
+ destination,
2155
+ amountBig,
2156
+ authNonce,
2157
+ BigInt(deadline),
2158
+ signature,
2159
+ ],
2160
+ );
2161
+ const signedTx = await this._signRawDiamondTx(
2162
+ authWallet,
2163
+ calldata,
2164
+ 0n,
2165
+ gasLimit,
2166
+ );
2167
+
1720
2168
  return await this._fetch("POST", "/v1/anonymous/fees/withdraw", {
1721
- account_id: accountId,
1722
- token,
1723
- destination,
1724
- amount: String(amountBig),
1725
- auth_nonce: String(authNonce),
1726
- deadline: String(deadline),
1727
- signature,
2169
+ raw_tx: signedTx,
1728
2170
  });
1729
2171
  } catch (e) {
1730
2172
  const msg = e?.message ?? String(e);
@@ -1734,20 +2176,25 @@ export class AnonymousTransferClient {
1734
2176
  }
1735
2177
 
1736
2178
  /**
1737
- * Withdraw all prepaid fee balances across all (including historical) fee tokens via Fairycloak.
1738
- * The relay pays gas.
2179
+ * Withdraw all prepaid fee balances across all (including historical) fee tokens.
1739
2180
  *
1740
- * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
2181
+ * User-funded raw-transaction model (like withdrawFees): the anonymous signer
2182
+ * authorises the drain via EIP-712 and the same wallet signs and funds the outer
2183
+ * `withdrawAllFees(...)` EVM transaction (zero ETH value). The wallet pays the gas.
2184
+ *
2185
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer; also funds the outer tx
1741
2186
  * @param {string} accountId
1742
2187
  * @param {Object} params
1743
2188
  * @param {string} params.destination - Destination EVM address
1744
2189
  * @param {Object} [options]
1745
2190
  * @param {number} [options.deadlineOffset=3600]
2191
+ * @param {bigint} [options.gasLimit=300000n]
1746
2192
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1747
2193
  */
1748
2194
  async withdrawAllFees(authWallet, accountId, { destination }, options = {}) {
1749
2195
  validateAnonymousAccountId(accountId, "accountId");
1750
- const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
2196
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
2197
+ options;
1751
2198
  try {
1752
2199
  if (!ethers.isAddress(destination))
1753
2200
  throw new Error(`Invalid destination address: ${destination}`);
@@ -1773,12 +2220,20 @@ export class AnonymousTransferClient {
1773
2220
 
1774
2221
  const signature = await authWallet.signTypedData(domain, types, value);
1775
2222
 
2223
+ // Build + sign the user-funded raw withdrawAllFees tx (must carry zero ETH value).
2224
+ const calldata = WITHDRAW_ALL_FEES_INTERFACE.encodeFunctionData(
2225
+ "withdrawAllFees",
2226
+ [accountId, destination, authNonce, BigInt(deadline), signature],
2227
+ );
2228
+ const signedTx = await this._signRawDiamondTx(
2229
+ authWallet,
2230
+ calldata,
2231
+ 0n,
2232
+ gasLimit,
2233
+ );
2234
+
1776
2235
  return await this._fetch("POST", "/v1/anonymous/fees/withdraw-all", {
1777
- account_id: accountId,
1778
- destination,
1779
- auth_nonce: String(authNonce),
1780
- deadline: String(deadline),
1781
- signature,
2236
+ raw_tx: signedTx,
1782
2237
  });
1783
2238
  } catch (e) {
1784
2239
  const msg = e?.message ?? String(e);