@fairblock/stabletrust 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -212,6 +212,17 @@ The `AnonymousTransferClient` routes all operations through the **Fairycloak rel
212
212
 
213
213
  > **Access Required** — Anonymous transfers are available to teams building privacy-critical applications. To obtain a Fairycloak relay URL and API key, reach out to the Fairblock team at [hello@fairblock.network](mailto:hello@fairblock.network).
214
214
 
215
+ ### Anonymous Account ID Rules
216
+
217
+ Every `accountId` (and `senderAccountId` / `recipientId`) you pass to `AnonymousTransferClient` must follow these rules, enforced both on-chain and by the Fairycloak relay:
218
+
219
+ - **Non-empty** string
220
+ - **At most 20 characters** long
221
+ - **Alphanumeric characters only** (`A-Z`, `a-z`, `0-9`) — no spaces, punctuation, or Unicode
222
+ - **Case-sensitive** — `"MyAccount"` and `"myaccount"` are different accounts; no normalization is applied
223
+
224
+ The SDK validates `accountId` (and related fields) before making any request and throws a descriptive `Error` if it doesn't follow these rules.
225
+
215
226
  ### Initialization
216
227
 
217
228
  ```javascript
@@ -238,7 +249,24 @@ const keys = await client.deriveAnonymousKeys(authWallet, accountId);
238
249
 
239
250
  #### `createAccount(authWallet, accountId, elgamalPublicKey, options?)`
240
251
 
241
- Creates a new anonymous account via the relay. The relay pays gas. `accountId` is a **caller-chosen string** (non-empty, case-sensitive) — pick any unique identifier you like (e.g. a UUID or a human-readable name).
252
+ Creates a new anonymous account via the relay. The relay pays gas. `accountId` is a **caller-chosen string** — pick any unique identifier you like (e.g. a short UUID fragment or a human-readable name), as long as it follows the [Anonymous Account ID Rules](#anonymous-account-id-rules) above (non-empty, at most 20 characters, alphanumeric only, case-sensitive).
253
+
254
+ #### `ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options?)`
255
+
256
+ Idempotent helper that creates the anonymous account if it doesn't already exist, and (by default) waits until it has settled (no pending CW→EVM action) before returning. If an account with `accountId` already exists on-chain, account creation is skipped entirely — safe to call on every run with a stable `accountId`.
257
+
258
+ > Note: an auth signer can only ever be bound to a single anonymous account, so reusing a wallet across different `accountId`s will fail at creation time even via `ensureAnonymousAccount`.
259
+
260
+ ```javascript
261
+ const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
262
+ wallet,
263
+ accountId,
264
+ keys.publicKey,
265
+ );
266
+ ```
267
+
268
+ - **Options**: `deadlineOffset` (default `3600`), `waitUntilReady` (default `true`), `timeoutMs` (default `180000`), `pollIntervalMs` (default `2000`)
269
+ - **Returns**: `{ accountId, accountInfo, created }`
242
270
 
243
271
  #### `updateAuthKeys(authWallet, accountId, { add, remove }, options?)`
244
272
 
@@ -339,6 +367,49 @@ const result = await client.withdraw(wallet, accountId, {
339
367
  });
340
368
  ```
341
369
 
370
+ ### Prepaid Fees
371
+
372
+ Every `transferToPublic` and `transferToAnonymous` call deducts a protocol fee from the sender account's **prepaid fee reserve**. The SDK checks this balance before submitting a transfer and throws a descriptive error if it's insufficient — top up the reserve with `depositFees` before transferring.
373
+
374
+ #### `getFeeToken()`
375
+
376
+ Returns the currently configured ERC-20 fee token address (checksummed). `depositFees` deposits must use this token. The fee token can be rotated by the protocol over time, but existing balances in old fee tokens remain withdrawable.
377
+
378
+ #### `getFeeAmount()`
379
+
380
+ Returns the protocol fee amount (in raw ERC-20 units of the fee token) deducted from the prepaid reserve on each anonymous transfer. Returns `0n` if no fee is configured.
381
+
382
+ #### `getPrepaidFeeBalance(accountId, token)`
383
+
384
+ Returns the prepaid fee balance (in raw ERC-20 units) for an anonymous account and a given fee token.
385
+
386
+ #### `depositFees(authWallet, accountId, amount, options?)`
387
+
388
+ Deposits tokens into the prepaid fee reserve for an anonymous account. **The user pays gas** for this operation (mirrors `deposit()`). Handles ERC-20 approval against the active fee token automatically.
389
+
390
+ ```javascript
391
+ const feeToken = await client.getFeeToken();
392
+ const feeAmount = await client.getFeeAmount();
393
+ const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
394
+
395
+ if (feeBalance < feeAmount) {
396
+ const result = await client.depositFees(
397
+ wallet,
398
+ accountId,
399
+ ethers.parseUnits("0.5", tokenDecimals),
400
+ );
401
+ await client.waitForRequest(result.request_id);
402
+ }
403
+ ```
404
+
405
+ #### `withdrawFees(authWallet, accountId, { token, destination, amount }, options?)`
406
+
407
+ Withdraws a specific amount from the prepaid fee reserve for a given fee token. The relay pays gas. Use this to reclaim fees from a specific (possibly historical) fee token.
408
+
409
+ #### `withdrawAllFees(authWallet, accountId, { destination }, options?)`
410
+
411
+ Withdraws all prepaid fee balances across all (including historical) fee tokens to `destination`. The relay pays gas.
412
+
342
413
  #### Request Tracking
343
414
 
344
415
  All relay operations return a `{ request_id, tx_hash, status }` object. Use the following to track completion:
@@ -419,10 +490,10 @@ const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
419
490
  const tokenAddress = "0xYourTokenAddress";
420
491
  const tokenDecimals = 6;
421
492
 
422
- // 1. Choose a unique account ID, derive keys, then create the account
493
+ // 1. Choose a unique account ID, derive keys, then ensure the account exists
423
494
  const accountId = "my-unique-account-id"; // any non-empty string you choose
424
495
  const keys = await client.deriveAnonymousKeys(wallet, accountId);
425
- await client.createAccount(wallet, accountId, keys.publicKey);
496
+ await client.ensureAnonymousAccount(wallet, accountId, keys.publicKey);
426
497
 
427
498
  // 2. Deposit (user pays gas)
428
499
  const depositResult = await client.deposit(
@@ -441,7 +512,21 @@ const balance = await client.getBalance(
441
512
  );
442
513
  console.log("Available:", balance.available, "Pending:", balance.pending);
443
514
 
444
- // 4. Transfer to a public address (relay pays gas)
515
+ // 4. Top up the prepaid fee reserve if needed (user pays gas)
516
+ // transferToPublic/transferToAnonymous deduct a protocol fee from this reserve.
517
+ const feeToken = await client.getFeeToken();
518
+ const feeAmount = await client.getFeeAmount();
519
+ const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
520
+ if (feeBalance < feeAmount) {
521
+ const feeResult = await client.depositFees(
522
+ wallet,
523
+ accountId,
524
+ ethers.parseUnits("0.5", tokenDecimals),
525
+ );
526
+ await client.waitForRequest(feeResult.request_id);
527
+ }
528
+
529
+ // 5. Transfer to a public address (relay pays gas)
445
530
  const transferResult = await client.transferToPublic(wallet, accountId, {
446
531
  recipient: "0xRecipientAddress",
447
532
  token: tokenAddress,
@@ -450,7 +535,7 @@ const transferResult = await client.transferToPublic(wallet, accountId, {
450
535
  });
451
536
  await client.waitForRequest(transferResult.request_id);
452
537
 
453
- // 5. Withdraw (relay pays gas)
538
+ // 6. Withdraw (relay pays gas)
454
539
  const withdrawResult = await client.withdraw(wallet, accountId, {
455
540
  destination: wallet.address,
456
541
  token: tokenAddress,
@@ -494,6 +579,7 @@ try {
494
579
  | "Account finalization timeout" | Account creation is still processing | Wait a few minutes and retry |
495
580
  | "Proof generation failed" | Invalid inputs or cryptographic operation error | Verify all parameters and ensure sufficient balance |
496
581
  | "Amount too small" | Amount rounds to 0 in contract scale | Use a larger amount (minimum depends on token decimals) |
582
+ | "Insufficient prepaid fee balance" | Anonymous account's prepaid fee reserve is below the per-transfer fee | Call `depositFees(wallet, accountId, amount)` to top up; use `getFeeToken()`/`getFeeAmount()` to check requirements |
497
583
  | Fairycloak HTTP error | Relay unreachable or request rejected | Check the relay URL, API key, and account authorization |
498
584
 
499
585
  ---
@@ -5,9 +5,10 @@
5
5
  * [2] Derive ElGamal keypairs for both accounts
6
6
  * [3] Create both anonymous accounts via Fairycloak
7
7
  * [4] Deposit tokens into anon1
8
- * [5] Transfer anon1 → anon2
9
- * [6] Apply pending balance for anon2
10
- * [7] Withdraw anon2 → public address
8
+ * [5] Top up prepaid fees for anon1 (if balance < 0.1)
9
+ * [6] Transfer anon1 → anon2
10
+ * [7] Apply pending balance for anon2
11
+ * [8] Withdraw anon2 → public address
11
12
  *
12
13
  * Run:
13
14
  * node examples/anonymous-flow.js
@@ -31,7 +32,11 @@ const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? "6");
31
32
  // Amounts in raw ERC-20 units (6 decimals — matches complete-flow.js)
32
33
  const DEPOSIT_AMOUNT = 100_000n; // 0.1 USDC
33
34
  const TRANSFER_AMOUNT = 50_000n; // 0.05 USDC
34
- const WITHDRAW_AMOUNT = 50_000n; // 0.025 USDC
35
+ const WITHDRAW_AMOUNT = 20_000n; // 0.02 USDC
36
+
37
+ // Prepaid fee thresholds (raw units, assumes fee token uses TOKEN_DECIMALS)
38
+ const MIN_FEE_BALANCE = 1_000_000n; // 0.1 — deposit if balance falls below this
39
+ const FEE_DEPOSIT_AMOUNT = 2_000_000n; // 0.5 — how much to top up in one shot
35
40
 
36
41
  // Anvil test accounts — override via .env
37
42
  const ANON1_PK =
@@ -123,8 +128,9 @@ async function main() {
123
128
  // An auth signer can only ever be bound to a single anonymous account, so the
124
129
  // account ID must be stable per-wallet across runs (random UUIDs would cause
125
130
  // "auth signer already used" failures on every re-run with the same .env keys).
126
- const anon1Id = `anon-${anon1Wallet.address.toLowerCase()}`;
127
- const anon2Id = `anon-${anon2Wallet.address.toLowerCase()}`;
131
+ // Anonymous account IDs must be alphanumeric and at most 20 characters.
132
+ const anon1Id = `anon${anon1Wallet.address.slice(2, 6).toLowerCase()}`;
133
+ const anon2Id = `anon${anon2Wallet.address.slice(2, 6).toLowerCase()}`;
128
134
  log("anon1 id:", anon1Id);
129
135
  log("anon2 id:", anon2Id);
130
136
 
@@ -221,9 +227,48 @@ async function main() {
221
227
  "anon1:post-deposit",
222
228
  );
223
229
 
224
- // ── [5] Transfer anon1 → anon2 ───────────────────────────────────────────────
230
+ // ── [5] Top up prepaid fees for anon1 ───────────────────────────────────────
231
+
232
+ console.log("\n── [5] Top up prepaid fees for anon1 ───────────────────");
233
+
234
+ const feeToken = await client.getFeeToken();
235
+ const feeAmount = await client.getFeeAmount();
236
+ log(" fee token :", feeToken);
237
+ log(" fee/xfer :", fmt(feeAmount, TOKEN_DECIMALS), "tokens");
238
+
239
+ const feeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
240
+ log(" fee balance:", fmt(feeBalance, TOKEN_DECIMALS), "tokens");
241
+
242
+ if (feeBalance < MIN_FEE_BALANCE) {
243
+ log(
244
+ ` balance < ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}, depositing ${fmt(FEE_DEPOSIT_AMOUNT, TOKEN_DECIMALS)} fee tokens (anon1 pays gas) …`,
245
+ );
246
+ const fd = await client.depositFees(
247
+ anon1Wallet,
248
+ anon1Id,
249
+ FEE_DEPOSIT_AMOUNT,
250
+ );
251
+ log(" request_id:", fd.request_id, " status:", fd.status);
252
+ if (fd.error) log(" [!] Error:", fd.error);
253
+ if (fd.tx_hash) log(" tx_hash :", fd.tx_hash);
254
+
255
+ const fdFinal = await client.waitForRequest(fd.request_id, {
256
+ timeoutMs: 180_000,
257
+ });
258
+ log(" status :", fdFinal.status, " tx_hash:", fdFinal.tx_hash ?? "—");
259
+ if (fdFinal.error) log(" [!] Error:", fdFinal.error);
260
+
261
+ const newFeeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
262
+ log(" new fee balance:", fmt(newFeeBalance, TOKEN_DECIMALS), "tokens");
263
+ } else {
264
+ log(
265
+ ` fee balance sufficient (${fmt(feeBalance, TOKEN_DECIMALS)} ≥ ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}), skipping deposit.`,
266
+ );
267
+ }
268
+
269
+ // ── [6] Transfer anon1 → anon2 ───────────────────────────────────────────────
225
270
 
226
- console.log("\n── [5] Transfer anon1 → anon2 ──────────────────────────");
271
+ console.log("\n── [6] Transfer anon1 → anon2 ──────────────────────────");
227
272
  log(`Transferring ${fmt(TRANSFER_AMOUNT, TOKEN_DECIMALS)} tokens …`);
228
273
 
229
274
  const txAnon = await client.transferToAnonymous(anon1Wallet, anon1Id, {
@@ -266,9 +311,9 @@ async function main() {
266
311
  "anon2:post-transfer",
267
312
  );
268
313
 
269
- // ── [6] Apply pending for anon2 ──────────────────────────────────────────────
314
+ // ── [7] Apply pending for anon2 ──────────────────────────────────────────────
270
315
 
271
- console.log("\n── [6] Apply pending balance for anon2 ─────────────────");
316
+ console.log("\n── [7] Apply pending balance for anon2 ─────────────────");
272
317
  log("Moving incoming credit from pending → available …");
273
318
 
274
319
  const ap = await client.applyPending(anon2Wallet, anon2Id);
@@ -292,9 +337,9 @@ async function main() {
292
337
  "anon2:post-apply",
293
338
  );
294
339
 
295
- // ── [7] Withdraw anon2 → public address ──────────────────────────────────────
340
+ // ── [8] Withdraw anon2 → public address ──────────────────────────────────────
296
341
 
297
- console.log("\n── [7] Withdraw from anon2 ─────────────────────────────");
342
+ console.log("\n── [8] Withdraw from anon2 ─────────────────────────────");
298
343
  log(
299
344
  `Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`,
300
345
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "SDK for confidential transfers using homomorphic encryption and zero-knowledge proofs",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -1,6 +1,10 @@
1
1
  import { ethers } from "ethers";
2
2
  import { initializeWasm } from "./wasm-loader.js";
3
- import { encodeTransferProof, encodeWithdrawProof } from "./utils.js";
3
+ import {
4
+ encodeTransferProof,
5
+ encodeWithdrawProof,
6
+ validateAnonymousAccountId,
7
+ } from "./utils.js";
4
8
  import { getStabletrustContractAddress } from "./constants.js";
5
9
 
6
10
  // EIP-712 domain for anonymous operations (LibAnonAuth domain name)
@@ -10,11 +14,15 @@ const ANON_DOMAIN_VERSION = "1";
10
14
  // Default deadline offset: 1 hour
11
15
  const DEFAULT_DEADLINE_OFFSET = 3600;
12
16
 
13
- // Minimal ABI — only used for deposit calldata encoding (reads go through Fairycloak views)
17
+ // Minimal ABI — only used for deposit/fee calldata encoding (reads go through Fairycloak views)
14
18
  const DEPOSIT_INTERFACE = new ethers.Interface([
15
19
  "function depositAnonymous(string accountId, address token, uint256 plainAmount, uint256 authNonce, uint256 deadline, bytes authSig) external",
16
20
  ]);
17
21
 
22
+ const ADD_FEES_INTERFACE = new ethers.Interface([
23
+ "function addFees(string accountId, address token, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external payable",
24
+ ]);
25
+
18
26
  /**
19
27
  * Internal helper to parse values as BigInt, handling hex strings and null/undefined.
20
28
  * @private
@@ -34,6 +42,16 @@ function _parseBigInt(v) {
34
42
  * Anonymous transfers are routed through the Fairycloak HTTP relay server instead of being
35
43
  * submitted directly on-chain. The relay pays gas for all managed transactions; the user only
36
44
  * signs EIP-712 authorisation payloads (and a raw EVM transaction for deposits).
45
+ *
46
+ * ### Anonymous account ID rules
47
+ * Every `accountId` / `senderAccountId` / `recipientId` accepted by this client must:
48
+ * - be a non-empty string
49
+ * - be at most 20 characters long
50
+ * - contain only alphanumeric characters (A-Z, a-z, 0-9)
51
+ * - be treated as case-sensitive (no normalization is applied)
52
+ *
53
+ * IDs that don't follow these rules are rejected with a descriptive error before any
54
+ * network request is made.
37
55
  */
38
56
  export class AnonymousTransferClient {
39
57
  /**
@@ -187,6 +205,7 @@ export class AnonymousTransferClient {
187
205
  * @returns {Promise<{exists:boolean, finalized:boolean, hasPendingAction:boolean, txId:bigint, elgamalPubkey:string, authNonce:bigint}>}
188
206
  */
189
207
  async getAnonymousAccountInfo(accountId) {
208
+ validateAnonymousAccountId(accountId, "accountId");
190
209
  try {
191
210
  const data = await this._fetch(
192
211
  "GET",
@@ -230,6 +249,7 @@ export class AnonymousTransferClient {
230
249
  * @returns {Promise<boolean>}
231
250
  */
232
251
  async isAuthorizedSigner(accountId, signerAddress) {
252
+ validateAnonymousAccountId(accountId, "accountId");
233
253
  const data = await this._fetch(
234
254
  "GET",
235
255
  `/v1/views/anonymous/accounts/${accountId}/authorized-signers/${signerAddress}`,
@@ -308,6 +328,7 @@ export class AnonymousTransferClient {
308
328
  * @returns {Promise<{available: {amount: number, ciphertext: string|null}, pending: {amount: number, ciphertext: string|null}}>}
309
329
  */
310
330
  async getAnonymousBalance(accountId, tokenAddress, elGamalPrivateKey) {
331
+ validateAnonymousAccountId(accountId, "accountId");
311
332
  if (!ethers.isAddress(tokenAddress))
312
333
  throw new Error(`Invalid token address: ${tokenAddress}`);
313
334
  if (!elGamalPrivateKey) throw new Error("elGamalPrivateKey is required");
@@ -343,6 +364,7 @@ export class AnonymousTransferClient {
343
364
  * @returns {Promise<{publicKey:string, privateKey:string}>} Base-64 encoded keypair
344
365
  */
345
366
  async deriveAnonymousKeys(authWallet, accountId) {
367
+ validateAnonymousAccountId(accountId, "accountId");
346
368
  try {
347
369
  const wasm = await this._getWasm();
348
370
  const address = await authWallet.getAddress();
@@ -504,13 +526,16 @@ export class AnonymousTransferClient {
504
526
  * The relay submits the `createAnonymousAccount` transaction and pays gas.
505
527
  *
506
528
  * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
507
- * @param {string} accountId - Caller-chosen account ID (non-empty string, case-sensitive)
529
+ * @param {string} accountId - Caller-chosen account ID. Must be a non-empty, case-sensitive,
530
+ * alphanumeric string of at most 20 characters.
508
531
  * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
509
532
  * @param {Object} [options]
510
533
  * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
511
534
  * @returns {Promise<{request_id:string, tx_hash:string, status:string, action:string}>}
535
+ * @throws {Error} If `accountId` doesn't follow the anonymous account ID rules (see class docs).
512
536
  */
513
537
  async createAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
538
+ validateAnonymousAccountId(accountId, "accountId");
514
539
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
515
540
  try {
516
541
  const authAddress = await authWallet.getAddress();
@@ -577,7 +602,8 @@ export class AnonymousTransferClient {
577
602
  * so reusing a wallet across different `accountId`s will still fail at creation time.
578
603
  *
579
604
  * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
580
- * @param {string} accountId - Caller-chosen account ID (non-empty string, case-sensitive)
605
+ * @param {string} accountId - Caller-chosen account ID. Must be a non-empty, case-sensitive,
606
+ * alphanumeric string of at most 20 characters.
581
607
  * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
582
608
  * @param {Object} [options]
583
609
  * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
@@ -585,8 +611,10 @@ export class AnonymousTransferClient {
585
611
  * @param {number} [options.timeoutMs=180000] - Max time to wait for creation/readiness
586
612
  * @param {number} [options.pollIntervalMs=2000]
587
613
  * @returns {Promise<{accountId:string, accountInfo:Object, created:boolean}>}
614
+ * @throws {Error} If `accountId` doesn't follow the anonymous account ID rules (see class docs).
588
615
  */
589
616
  async ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
617
+ validateAnonymousAccountId(accountId, "accountId");
590
618
  const {
591
619
  deadlineOffset = DEFAULT_DEADLINE_OFFSET,
592
620
  waitUntilReady = true,
@@ -677,6 +705,7 @@ export class AnonymousTransferClient {
677
705
  { add = [], remove = [] } = {},
678
706
  options = {},
679
707
  ) {
708
+ validateAnonymousAccountId(accountId, "accountId");
680
709
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
681
710
  try {
682
711
  const authNonce = await this.getAuthNonce(accountId);
@@ -756,6 +785,7 @@ export class AnonymousTransferClient {
756
785
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
757
786
  */
758
787
  async deposit(authWallet, accountId, tokenAddress, amount, options = {}) {
788
+ validateAnonymousAccountId(accountId, "accountId");
759
789
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 600000n } =
760
790
  options;
761
791
  try {
@@ -929,6 +959,7 @@ export class AnonymousTransferClient {
929
959
  },
930
960
  options = {},
931
961
  ) {
962
+ validateAnonymousAccountId(accountId, "accountId");
932
963
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
933
964
  try {
934
965
  if (!ethers.isAddress(recipient))
@@ -986,6 +1017,8 @@ export class AnonymousTransferClient {
986
1017
  });
987
1018
  }
988
1019
 
1020
+ await this._assertSufficientPrepaidFees(accountId);
1021
+
989
1022
  const authNonce = await this.getAuthNonce(accountId);
990
1023
  const deadline = this._makeDeadline(deadlineOffset);
991
1024
  const proofHash = ethers.keccak256(proofHex);
@@ -1063,6 +1096,8 @@ export class AnonymousTransferClient {
1063
1096
  },
1064
1097
  options = {},
1065
1098
  ) {
1099
+ validateAnonymousAccountId(senderAccountId, "senderAccountId");
1100
+ validateAnonymousAccountId(recipientId, "recipientId");
1066
1101
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1067
1102
  try {
1068
1103
  if (!ethers.isAddress(token))
@@ -1118,6 +1153,8 @@ export class AnonymousTransferClient {
1118
1153
  });
1119
1154
  }
1120
1155
 
1156
+ await this._assertSufficientPrepaidFees(senderAccountId);
1157
+
1121
1158
  const authNonce = await this.getAuthNonce(senderAccountId);
1122
1159
  const deadline = this._makeDeadline(deadlineOffset);
1123
1160
  const proofHash = ethers.keccak256(proofHex);
@@ -1173,6 +1210,7 @@ export class AnonymousTransferClient {
1173
1210
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1174
1211
  */
1175
1212
  async applyPending(authWallet, accountId, options = {}) {
1213
+ validateAnonymousAccountId(accountId, "accountId");
1176
1214
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1177
1215
  try {
1178
1216
  const authNonce = await this.getAuthNonce(accountId);
@@ -1240,6 +1278,7 @@ export class AnonymousTransferClient {
1240
1278
  },
1241
1279
  options = {},
1242
1280
  ) {
1281
+ validateAnonymousAccountId(accountId, "accountId");
1243
1282
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1244
1283
  try {
1245
1284
  if (!ethers.isAddress(destination))
@@ -1349,6 +1388,7 @@ export class AnonymousTransferClient {
1349
1388
  * @returns {Promise<{ amount: number, available: number, pending: number }>}
1350
1389
  */
1351
1390
  async getBalance(accountId, tokenAddress, elGamalPrivateKey) {
1391
+ validateAnonymousAccountId(accountId, "accountId");
1352
1392
  try {
1353
1393
  if (!ethers.isAddress(tokenAddress)) {
1354
1394
  throw new Error(`Invalid token address: ${tokenAddress}`);
@@ -1376,6 +1416,372 @@ export class AnonymousTransferClient {
1376
1416
  }
1377
1417
  }
1378
1418
 
1419
+ // ─────────────────── prepaid fee reads ─────────────────────────
1420
+
1421
+ /**
1422
+ * Get the currently configured fee token address from the contract.
1423
+ *
1424
+ * Deposits via `depositFees` must use this token. Rotate-aware: the fee token
1425
+ * can change over time, but existing balances for old tokens remain withdrawable.
1426
+ *
1427
+ * @returns {Promise<string>} Checksummed ERC-20 token address (or address(0) if none set)
1428
+ */
1429
+ async getFeeToken() {
1430
+ try {
1431
+ const data = await this._fetch("GET", "/v1/views/fee-token");
1432
+ const raw = data.result ?? data;
1433
+ const addr = typeof raw === "string" ? raw : raw.feeToken ?? raw;
1434
+ return ethers.getAddress(addr);
1435
+ } catch (e) {
1436
+ throw new Error(`Failed to get fee token: ${e.message}`);
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * Get the currently configured protocol fee amount (in raw ERC-20 units of the fee token).
1442
+ *
1443
+ * This is the amount deducted from the prepaid reserve on each anonymous transfer.
1444
+ * Returns 0n if no fee is configured.
1445
+ *
1446
+ * @returns {Promise<bigint>}
1447
+ */
1448
+ async getFeeAmount() {
1449
+ try {
1450
+ const data = await this._fetch("GET", "/v1/views/fee-amount");
1451
+ return _parseBigInt(data.result ?? data);
1452
+ } catch (e) {
1453
+ throw new Error(`Failed to get fee amount: ${e.message}`);
1454
+ }
1455
+ }
1456
+
1457
+ /**
1458
+ * Get the prepaid fee balance for an anonymous account and token (in raw ERC-20 units).
1459
+ *
1460
+ * @param {string} accountId
1461
+ * @param {string} token - ERC-20 token address (use `getFeeToken()` to get the active fee token)
1462
+ * @returns {Promise<bigint>}
1463
+ */
1464
+ async getPrepaidFeeBalance(accountId, token) {
1465
+ validateAnonymousAccountId(accountId, "accountId");
1466
+ try {
1467
+ if (!ethers.isAddress(token))
1468
+ throw new Error(`Invalid token address: ${token}`);
1469
+ const data = await this._fetch(
1470
+ "GET",
1471
+ `/v1/views/anonymous/accounts/${accountId}/prepaid-fees/${token}`,
1472
+ );
1473
+ return _parseBigInt(data.result ?? data);
1474
+ } catch (e) {
1475
+ throw new Error(`Failed to get prepaid fee balance: ${e.message}`);
1476
+ }
1477
+ }
1478
+
1479
+ /**
1480
+ * Check that the anonymous account has sufficient prepaid fees to cover one transfer.
1481
+ * Throws a descriptive error if the balance is insufficient.
1482
+ * @private
1483
+ */
1484
+ async _assertSufficientPrepaidFees(accountId) {
1485
+ const [feeToken, feeAmount] = await Promise.all([
1486
+ this.getFeeToken(),
1487
+ this.getFeeAmount(),
1488
+ ]);
1489
+ if (feeAmount === 0n) return; // no fee configured
1490
+
1491
+ if (feeToken === ethers.ZeroAddress) return; // fee token not set
1492
+
1493
+ const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
1494
+ if (balance < feeAmount) {
1495
+ throw new Error(
1496
+ `Insufficient prepaid fee balance for anonymous account "${accountId}". ` +
1497
+ `Required: ${feeAmount} (raw units of fee token ${feeToken}), ` +
1498
+ `available: ${balance}. ` +
1499
+ `Call depositFees(wallet, accountId, amount) to top up the prepaid fee reserve. ` +
1500
+ `Use getFeeToken() to get the active fee token address and getFeeAmount() to see the required deposit amount per transfer.`,
1501
+ );
1502
+ }
1503
+ }
1504
+
1505
+ // ─────────────────── prepaid fee actions ────────────────────────
1506
+
1507
+ /**
1508
+ * Deposit tokens into the prepaid fee reserve for an anonymous account.
1509
+ *
1510
+ * Mirrors `deposit()` from a transaction-signing perspective: the user wallet pays
1511
+ * gas and signs the raw EVM transaction. Fairycloak validates and broadcasts it.
1512
+ *
1513
+ * Steps:
1514
+ * 1. Fetch the active fee token from the contract
1515
+ * 2. ERC-20 approve if allowance is insufficient
1516
+ * 3. Sign EIP-712 AnonymousAddFees authorisation payload
1517
+ * 4. Build and sign the raw `addFees(...)` EVM transaction
1518
+ * 5. Submit to Fairycloak `/v1/anonymous/fees/add/raw-tx`
1519
+ *
1520
+ * @param {ethers.Wallet} authWallet - Wallet that owns the tokens and pays gas
1521
+ * @param {string} accountId - Target anonymous account ID
1522
+ * @param {bigint|string|number} amount - Amount in raw ERC-20 units of the active fee token
1523
+ * @param {Object} [options]
1524
+ * @param {number} [options.deadlineOffset=3600]
1525
+ * @param {bigint} [options.gasLimit=300000n]
1526
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1527
+ */
1528
+ async depositFees(authWallet, accountId, amount, options = {}) {
1529
+ validateAnonymousAccountId(accountId, "accountId");
1530
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
1531
+ options;
1532
+ try {
1533
+ const amountBig = BigInt(amount);
1534
+ if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
1535
+
1536
+ const feeToken = await this.getFeeToken();
1537
+ if (feeToken === ethers.ZeroAddress)
1538
+ throw new Error(
1539
+ "No fee token is currently configured on the contract. Contact the protocol operator.",
1540
+ );
1541
+
1542
+ const depositorAddress = await authWallet.getAddress();
1543
+ const walletWithProvider = authWallet.connect
1544
+ ? authWallet.connect(this.provider)
1545
+ : authWallet;
1546
+
1547
+ // 1. Approve if needed
1548
+ const tokenContract = new ethers.Contract(
1549
+ feeToken,
1550
+ [
1551
+ "function balanceOf(address) view returns (uint256)",
1552
+ "function allowance(address,address) view returns (uint256)",
1553
+ "function approve(address,uint256) returns (bool)",
1554
+ ],
1555
+ walletWithProvider,
1556
+ );
1557
+
1558
+ const [balance, allowance] = await Promise.all([
1559
+ tokenContract.balanceOf(depositorAddress),
1560
+ tokenContract.allowance(depositorAddress, this.diamondAddress),
1561
+ ]);
1562
+
1563
+ if (balance < amountBig)
1564
+ throw new Error(
1565
+ `Insufficient fee token balance. Required: ${amountBig}, available: ${balance}`,
1566
+ );
1567
+
1568
+ if (allowance < amountBig) {
1569
+ const approveTx = await tokenContract.approve(
1570
+ this.diamondAddress,
1571
+ ethers.MaxUint256,
1572
+ );
1573
+ const receipt = await approveTx.wait();
1574
+ if (!receipt || receipt.status === 0)
1575
+ throw new Error("Fee token approval failed");
1576
+ }
1577
+
1578
+ // 2. EIP-712 AnonymousAddFees signature
1579
+ const authNonce = await this.getAuthNonce(accountId);
1580
+ const deadline = this._makeDeadline(deadlineOffset);
1581
+
1582
+ const domain = this._buildDomain();
1583
+ const types = {
1584
+ AnonymousAddFees: [
1585
+ { name: "accountId", type: "string" },
1586
+ { name: "token", type: "address" },
1587
+ { name: "amount", type: "uint256" },
1588
+ { name: "authNonce", type: "uint256" },
1589
+ { name: "deadline", type: "uint256" },
1590
+ ],
1591
+ };
1592
+ const value = {
1593
+ accountId: String(accountId),
1594
+ token: feeToken,
1595
+ amount: amountBig,
1596
+ authNonce,
1597
+ deadline: BigInt(deadline),
1598
+ };
1599
+
1600
+ const signature = await authWallet.signTypedData(domain, types, value);
1601
+
1602
+ // 3. Build and sign the raw addFees transaction
1603
+ const calldata = ADD_FEES_INTERFACE.encodeFunctionData("addFees", [
1604
+ accountId,
1605
+ feeToken,
1606
+ amountBig,
1607
+ authNonce,
1608
+ BigInt(deadline),
1609
+ signature,
1610
+ ]);
1611
+
1612
+ const [nonce, feeData] = await Promise.all([
1613
+ this.provider.getTransactionCount(depositorAddress),
1614
+ this.provider.getFeeData(),
1615
+ ]);
1616
+
1617
+ let txObj;
1618
+ if (feeData.maxFeePerGas) {
1619
+ txObj = {
1620
+ to: this.diamondAddress,
1621
+ data: calldata,
1622
+ nonce,
1623
+ chainId: BigInt(this.chainId),
1624
+ gasLimit,
1625
+ maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
1626
+ maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
1627
+ value: 0n,
1628
+ };
1629
+ } else {
1630
+ txObj = {
1631
+ to: this.diamondAddress,
1632
+ data: calldata,
1633
+ nonce,
1634
+ chainId: BigInt(this.chainId),
1635
+ gasLimit,
1636
+ gasPrice: (feeData.gasPrice * 11n) / 10n,
1637
+ value: 0n,
1638
+ };
1639
+ }
1640
+
1641
+ const signedTx = await authWallet.signTransaction(txObj);
1642
+
1643
+ // 4. Submit to Fairycloak
1644
+ return await this._fetch("POST", "/v1/anonymous/fees/add/raw-tx", {
1645
+ raw_tx: signedTx,
1646
+ });
1647
+ } catch (e) {
1648
+ const msg = e?.message ?? String(e);
1649
+ if (
1650
+ msg.startsWith("Fairycloak") ||
1651
+ msg.startsWith("Insufficient") ||
1652
+ msg.startsWith("No fee token")
1653
+ )
1654
+ throw e;
1655
+ throw new Error(`Failed to deposit fees: ${msg}`);
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * Withdraw a specific amount from the prepaid fee reserve via Fairycloak. The relay pays gas.
1661
+ *
1662
+ * Use this to reclaim fees for a specific (possibly historical) fee token.
1663
+ *
1664
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
1665
+ * @param {string} accountId
1666
+ * @param {Object} params
1667
+ * @param {string} params.token - Fee token address to withdraw
1668
+ * @param {string} params.destination - Destination EVM address
1669
+ * @param {bigint|string|number} params.amount - Amount in raw ERC-20 units
1670
+ * @param {Object} [options]
1671
+ * @param {number} [options.deadlineOffset=3600]
1672
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1673
+ */
1674
+ async withdrawFees(
1675
+ authWallet,
1676
+ accountId,
1677
+ { token, destination, amount },
1678
+ options = {},
1679
+ ) {
1680
+ validateAnonymousAccountId(accountId, "accountId");
1681
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1682
+ try {
1683
+ if (!ethers.isAddress(token))
1684
+ throw new Error(`Invalid token address: ${token}`);
1685
+ if (!ethers.isAddress(destination))
1686
+ throw new Error(`Invalid destination address: ${destination}`);
1687
+ const amountBig = BigInt(amount);
1688
+ if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
1689
+
1690
+ const authNonce = await this.getAuthNonce(accountId);
1691
+ const deadline = this._makeDeadline(deadlineOffset);
1692
+
1693
+ const domain = this._buildDomain();
1694
+ const types = {
1695
+ AnonymousWithdrawFees: [
1696
+ { name: "accountId", type: "string" },
1697
+ { name: "token", type: "address" },
1698
+ { name: "destination", type: "address" },
1699
+ { name: "amount", type: "uint256" },
1700
+ { name: "authNonce", type: "uint256" },
1701
+ { name: "deadline", type: "uint256" },
1702
+ ],
1703
+ };
1704
+ const value = {
1705
+ accountId: String(accountId),
1706
+ token,
1707
+ destination,
1708
+ amount: amountBig,
1709
+ authNonce,
1710
+ deadline: BigInt(deadline),
1711
+ };
1712
+
1713
+ const signature = await authWallet.signTypedData(domain, types, value);
1714
+
1715
+ return await this._fetch("POST", "/v1/anonymous/fees/withdraw", {
1716
+ account_id: accountId,
1717
+ token,
1718
+ destination,
1719
+ amount: String(amountBig),
1720
+ auth_nonce: String(authNonce),
1721
+ deadline: String(deadline),
1722
+ signature,
1723
+ });
1724
+ } catch (e) {
1725
+ const msg = e?.message ?? String(e);
1726
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1727
+ throw new Error(`Failed to withdraw fees: ${msg}`);
1728
+ }
1729
+ }
1730
+
1731
+ /**
1732
+ * Withdraw all prepaid fee balances across all (including historical) fee tokens via Fairycloak.
1733
+ * The relay pays gas.
1734
+ *
1735
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
1736
+ * @param {string} accountId
1737
+ * @param {Object} params
1738
+ * @param {string} params.destination - Destination EVM address
1739
+ * @param {Object} [options]
1740
+ * @param {number} [options.deadlineOffset=3600]
1741
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1742
+ */
1743
+ async withdrawAllFees(authWallet, accountId, { destination }, options = {}) {
1744
+ validateAnonymousAccountId(accountId, "accountId");
1745
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1746
+ try {
1747
+ if (!ethers.isAddress(destination))
1748
+ throw new Error(`Invalid destination address: ${destination}`);
1749
+
1750
+ const authNonce = await this.getAuthNonce(accountId);
1751
+ const deadline = this._makeDeadline(deadlineOffset);
1752
+
1753
+ const domain = this._buildDomain();
1754
+ const types = {
1755
+ AnonymousWithdrawAllFees: [
1756
+ { name: "accountId", type: "string" },
1757
+ { name: "destination", type: "address" },
1758
+ { name: "authNonce", type: "uint256" },
1759
+ { name: "deadline", type: "uint256" },
1760
+ ],
1761
+ };
1762
+ const value = {
1763
+ accountId: String(accountId),
1764
+ destination,
1765
+ authNonce,
1766
+ deadline: BigInt(deadline),
1767
+ };
1768
+
1769
+ const signature = await authWallet.signTypedData(domain, types, value);
1770
+
1771
+ return await this._fetch("POST", "/v1/anonymous/fees/withdraw-all", {
1772
+ account_id: accountId,
1773
+ destination,
1774
+ auth_nonce: String(authNonce),
1775
+ deadline: String(deadline),
1776
+ signature,
1777
+ });
1778
+ } catch (e) {
1779
+ const msg = e?.message ?? String(e);
1780
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1781
+ throw new Error(`Failed to withdraw all fees: ${msg}`);
1782
+ }
1783
+ }
1784
+
1379
1785
  // ──────────────────── request tracking ─────────────────────────
1380
1786
 
1381
1787
  /**
package/src/constants.js CHANGED
@@ -25,7 +25,7 @@ export const TEMPO_FEE_TOKEN_ADDRESS =
25
25
  "0x20c0000000000000000000000000000000000000";
26
26
  export const STABLETRUST_CONTRACTS_BY_CHAIN_ID = Object.freeze({
27
27
  2201: "0xA98e5ba75Bb44459916D52Abf1604caB3d98CC4B", //Stable
28
- 5042002: "0xdA4a410F600E1C245DC1188Ee559833879f62Fbb", //Arc
28
+ 5042002: "0xfc29476B8e53765Ca16ea32882370d850b245336", //Arc
29
29
  84532: "0xF66A0a1670F14AE5D1852B4E7d1e4C693b2Accfd", //Base
30
30
  11155111: "0xa066b5C30382110d19925108BBa1Eef613a3A041", //Ethereum
31
31
  421614: "0xbda65d65A7833D28F9391FF01d0b212B75538Cf2", //Arbitrum
package/src/index.d.ts CHANGED
@@ -146,11 +146,39 @@ declare module "@fairblock/stabletrust" {
146
146
  gasLimit?: bigint;
147
147
  }
148
148
 
149
+ export interface DepositFeesOptions extends DeadlineOptions {
150
+ gasLimit?: bigint;
151
+ }
152
+
153
+ export interface WithdrawFeesParams {
154
+ /** Fee token address (use `getFeeToken()` to get the active token). */
155
+ token: string;
156
+ /** Destination EVM address to receive the withdrawn tokens. */
157
+ destination: string;
158
+ /** Amount in raw ERC-20 units of the fee token. */
159
+ amount: bigint | string | number;
160
+ }
161
+
162
+ export interface WithdrawAllFeesParams {
163
+ /** Destination EVM address to receive all withdrawn fee tokens. */
164
+ destination: string;
165
+ }
166
+
149
167
  /**
150
168
  * AnonymousTransferClient — SDK class for anonymous confidential transfers via the Fairycloak relay.
151
169
  *
152
170
  * All operations (except `deposit`) are gas-free for the user — Fairycloak pays gas.
153
171
  * `deposit` requires the user to sign and pay for a raw EVM transaction.
172
+ *
173
+ * ### Anonymous account ID rules
174
+ * Every `accountId` / `senderAccountId` / `recipientId` passed to this client must:
175
+ * - be a non-empty string
176
+ * - be at most {@link MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} (20) characters
177
+ * - contain only alphanumeric characters (A-Z, a-z, 0-9)
178
+ * - be treated as case-sensitive (no normalization is applied)
179
+ *
180
+ * IDs that violate these rules are rejected with a descriptive error before any
181
+ * network request is made. See `validateAnonymousAccountId`.
154
182
  */
155
183
  export class AnonymousTransferClient {
156
184
  constructor(config: AnonymousTransferClientConfig);
@@ -229,11 +257,73 @@ declare module "@fairblock/stabletrust" {
229
257
  params: WithdrawProofParams,
230
258
  ): Promise<string>;
231
259
 
260
+ // ── prepaid fee reads ───────────────────────────────────────────
261
+
262
+ /**
263
+ * Get the currently configured fee token address from the contract.
264
+ * Deposits via `depositFees` must use this token.
265
+ */
266
+ getFeeToken(): Promise<string>;
267
+
268
+ /**
269
+ * Get the protocol fee amount in raw ERC-20 units of the fee token.
270
+ * This is the amount deducted per anonymous transfer. Returns 0n if no fee is set.
271
+ */
272
+ getFeeAmount(): Promise<bigint>;
273
+
274
+ /**
275
+ * Get the prepaid fee balance for an anonymous account and token (raw ERC-20 units).
276
+ * Use `getFeeToken()` to get the active fee token address.
277
+ */
278
+ getPrepaidFeeBalance(accountId: string, token: string): Promise<bigint>;
279
+
280
+ // ── prepaid fee actions ─────────────────────────────────────────
281
+
282
+ /**
283
+ * Deposit tokens into the prepaid fee reserve for an anonymous account.
284
+ * The user wallet pays gas for this raw EVM transaction.
285
+ * Handles ERC-20 approval automatically.
286
+ *
287
+ * The fee token is read from the contract — use `getFeeToken()` to see it.
288
+ * Use `getFeeAmount()` to see how much is deducted per transfer.
289
+ *
290
+ * @param amount Amount in raw ERC-20 units of the active fee token
291
+ */
292
+ depositFees(
293
+ authWallet: ethers.Wallet,
294
+ accountId: string,
295
+ amount: bigint | string | number,
296
+ options?: DepositFeesOptions,
297
+ ): Promise<FairycloakResponse>;
298
+
299
+ /**
300
+ * Withdraw a specific amount from the prepaid fee reserve. The relay pays gas.
301
+ * Supports historical fee tokens (after a fee-token rotation).
302
+ */
303
+ withdrawFees(
304
+ authWallet: ethers.Wallet | ethers.Signer,
305
+ accountId: string,
306
+ params: WithdrawFeesParams,
307
+ options?: DeadlineOptions,
308
+ ): Promise<FairycloakResponse>;
309
+
310
+ /**
311
+ * Withdraw all prepaid fee balances across all (including historical) fee tokens.
312
+ * The relay pays gas.
313
+ */
314
+ withdrawAllFees(
315
+ authWallet: ethers.Wallet | ethers.Signer,
316
+ accountId: string,
317
+ params: WithdrawAllFeesParams,
318
+ options?: DeadlineOptions,
319
+ ): Promise<FairycloakResponse>;
320
+
232
321
  // ── Fairycloak operations ───────────────────────────────────────
233
322
 
234
323
  /**
235
324
  * Create a new anonymous account. The relay pays gas.
236
- * @param accountId Caller-chosen account ID (non-empty string, case-sensitive)
325
+ * @param accountId Caller-chosen account ID. Must be a non-empty, case-sensitive,
326
+ * alphanumeric string of at most {@link MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} (20) characters.
237
327
  * @param elgamalPublicKey Base64 or "0x"-prefixed hex (32 bytes)
238
328
  */
239
329
  createAccount(
@@ -558,6 +648,27 @@ declare module "@fairblock/stabletrust" {
558
648
  */
559
649
  export function encodeWithdrawProof(proof: any): string;
560
650
 
651
+ /**
652
+ * Maximum allowed length for an anonymous account ID (20 characters).
653
+ */
654
+ export const MAX_ANONYMOUS_ACCOUNT_ID_LENGTH: number;
655
+
656
+ /**
657
+ * Validate an anonymous account ID.
658
+ *
659
+ * Rules (must match the Fairycloak relay and on-chain contract):
660
+ * - Non-empty string
661
+ * - At most {@link MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} (20) characters
662
+ * - Alphanumeric characters only (A-Z, a-z, 0-9)
663
+ * - Case-sensitive (no normalization is applied)
664
+ *
665
+ * @throws {Error} If `accountId` violates any of the above rules.
666
+ */
667
+ export function validateAnonymousAccountId(
668
+ accountId: string,
669
+ fieldName?: string,
670
+ ): void;
671
+
561
672
  /**
562
673
  * Constants
563
674
  */
package/src/index.js CHANGED
@@ -1,7 +1,12 @@
1
1
  export { ConfidentialTransferClient } from "./confidential-client.js";
2
2
  export { AnonymousTransferClient } from "./anonymous-client.js";
3
3
  export { deriveKeys, decryptCiphertext, combineCiphertext } from "./crypto.js";
4
- export { encodeTransferProof, encodeWithdrawProof } from "./utils.js";
4
+ export {
5
+ encodeTransferProof,
6
+ encodeWithdrawProof,
7
+ validateAnonymousAccountId,
8
+ MAX_ANONYMOUS_ACCOUNT_ID_LENGTH,
9
+ } from "./utils.js";
5
10
  export {
6
11
  CONTRACT_ABI,
7
12
  ERC20_ABI,
package/src/utils.js CHANGED
@@ -3,6 +3,40 @@ import { ethers } from "ethers";
3
3
  // Docs: https://docs.pinata.cloud
4
4
  const PINATA_UPLOAD_URL = "https://uploads.pinata.cloud/v3/files";
5
5
  const PINATA_JWT = process.env.PINATA_JWT;
6
+
7
+ // Anonymous account ID constraints (must match the Fairycloak relay and on-chain contract rules)
8
+ export const MAX_ANONYMOUS_ACCOUNT_ID_LENGTH = 20;
9
+ const ANONYMOUS_ACCOUNT_ID_REGEX = /^[A-Za-z0-9]+$/;
10
+
11
+ /**
12
+ * Validate an anonymous account ID against the rules enforced by the Fairycloak
13
+ * relay and the on-chain contract:
14
+ * - non-empty
15
+ * - at most 20 characters
16
+ * - alphanumeric characters only (A-Z, a-z, 0-9)
17
+ * - case-sensitive (no normalization is applied)
18
+ *
19
+ * @param {string} accountId - The anonymous account ID to validate.
20
+ * @param {string} [fieldName="accountId"] - Name of the field, used in error messages.
21
+ * @throws {Error} If the account ID violates any of the above rules.
22
+ */
23
+ export function validateAnonymousAccountId(accountId, fieldName = "accountId") {
24
+ const RULES =
25
+ `${fieldName} must be a non-empty string of at most ${MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} characters, ` +
26
+ `containing only alphanumeric characters (A-Z, a-z, 0-9). IDs are case-sensitive.`;
27
+
28
+ if (typeof accountId !== "string" || accountId.length === 0) {
29
+ throw new Error(`Invalid ${fieldName}: ${RULES}`);
30
+ }
31
+ if (accountId.length > MAX_ANONYMOUS_ACCOUNT_ID_LENGTH) {
32
+ throw new Error(
33
+ `Invalid ${fieldName} "${accountId}": exceeds ${MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} characters (got ${accountId.length}). ${RULES}`,
34
+ );
35
+ }
36
+ if (!ANONYMOUS_ACCOUNT_ID_REGEX.test(accountId)) {
37
+ throw new Error(`Invalid ${fieldName} "${accountId}": contains non-alphanumeric characters. ${RULES}`);
38
+ }
39
+ }
6
40
  /**
7
41
  * Encodes the ZK-Proof data for a transfer into a format the Solidity contract expects.
8
42
  *