@fairblock/stabletrust 2.0.4 → 2.0.6

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,7 @@ 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).
242
253
 
243
254
  #### `ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options?)`
244
255
 
@@ -128,8 +128,9 @@ async function main() {
128
128
  // An auth signer can only ever be bound to a single anonymous account, so the
129
129
  // account ID must be stable per-wallet across runs (random UUIDs would cause
130
130
  // "auth signer already used" failures on every re-run with the same .env keys).
131
- const anon1Id = `anon-${anon1Wallet.address.toLowerCase()}`;
132
- 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()}`;
133
134
  log("anon1 id:", anon1Id);
134
135
  log("anon2 id:", anon2Id);
135
136
 
@@ -1,7 +1,6 @@
1
1
  import { ethers } from "ethers";
2
2
  import dotenv from "dotenv";
3
- import { ConfidentialTransferClient } from "@fairblock/stabletrust";
4
- import { ERC20_ABI } from "./constants.js";
3
+ import { ConfidentialTransferClient, ERC20_ABI } from "@fairblock/stabletrust";
5
4
  dotenv.config();
6
5
 
7
6
  /**
@@ -1,7 +1,6 @@
1
1
  import { ethers } from "ethers";
2
2
  import dotenv from "dotenv";
3
- import { ConfidentialTransferClient } from "@fairblock/stabletrust";
4
- import { ERC20_ABI } from "./constants.js";
3
+ import { ConfidentialTransferClient, ERC20_ABI } from "@fairblock/stabletrust";
5
4
  dotenv.config();
6
5
  const RPC_URL = process.env.RPC_URL || "https://rpc.testnet.stable.xyz";
7
6
  const EXPLORER_URL =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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",
@@ -21,8 +21,7 @@
21
21
  "src/",
22
22
  "pkg/",
23
23
  "examples/",
24
- "README.md",
25
- "CHANGELOG.md"
24
+ "README.md"
26
25
  ],
27
26
  "repository": {
28
27
  "type": "git",
@@ -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)
@@ -38,6 +42,16 @@ function _parseBigInt(v) {
38
42
  * Anonymous transfers are routed through the Fairycloak HTTP relay server instead of being
39
43
  * submitted directly on-chain. The relay pays gas for all managed transactions; the user only
40
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.
41
55
  */
42
56
  export class AnonymousTransferClient {
43
57
  /**
@@ -191,6 +205,7 @@ export class AnonymousTransferClient {
191
205
  * @returns {Promise<{exists:boolean, finalized:boolean, hasPendingAction:boolean, txId:bigint, elgamalPubkey:string, authNonce:bigint}>}
192
206
  */
193
207
  async getAnonymousAccountInfo(accountId) {
208
+ validateAnonymousAccountId(accountId, "accountId");
194
209
  try {
195
210
  const data = await this._fetch(
196
211
  "GET",
@@ -234,6 +249,7 @@ export class AnonymousTransferClient {
234
249
  * @returns {Promise<boolean>}
235
250
  */
236
251
  async isAuthorizedSigner(accountId, signerAddress) {
252
+ validateAnonymousAccountId(accountId, "accountId");
237
253
  const data = await this._fetch(
238
254
  "GET",
239
255
  `/v1/views/anonymous/accounts/${accountId}/authorized-signers/${signerAddress}`,
@@ -312,6 +328,7 @@ export class AnonymousTransferClient {
312
328
  * @returns {Promise<{available: {amount: number, ciphertext: string|null}, pending: {amount: number, ciphertext: string|null}}>}
313
329
  */
314
330
  async getAnonymousBalance(accountId, tokenAddress, elGamalPrivateKey) {
331
+ validateAnonymousAccountId(accountId, "accountId");
315
332
  if (!ethers.isAddress(tokenAddress))
316
333
  throw new Error(`Invalid token address: ${tokenAddress}`);
317
334
  if (!elGamalPrivateKey) throw new Error("elGamalPrivateKey is required");
@@ -347,6 +364,7 @@ export class AnonymousTransferClient {
347
364
  * @returns {Promise<{publicKey:string, privateKey:string}>} Base-64 encoded keypair
348
365
  */
349
366
  async deriveAnonymousKeys(authWallet, accountId) {
367
+ validateAnonymousAccountId(accountId, "accountId");
350
368
  try {
351
369
  const wasm = await this._getWasm();
352
370
  const address = await authWallet.getAddress();
@@ -508,13 +526,16 @@ export class AnonymousTransferClient {
508
526
  * The relay submits the `createAnonymousAccount` transaction and pays gas.
509
527
  *
510
528
  * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
511
- * @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.
512
531
  * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
513
532
  * @param {Object} [options]
514
533
  * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
515
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).
516
536
  */
517
537
  async createAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
538
+ validateAnonymousAccountId(accountId, "accountId");
518
539
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
519
540
  try {
520
541
  const authAddress = await authWallet.getAddress();
@@ -581,7 +602,8 @@ export class AnonymousTransferClient {
581
602
  * so reusing a wallet across different `accountId`s will still fail at creation time.
582
603
  *
583
604
  * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
584
- * @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.
585
607
  * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
586
608
  * @param {Object} [options]
587
609
  * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
@@ -589,8 +611,10 @@ export class AnonymousTransferClient {
589
611
  * @param {number} [options.timeoutMs=180000] - Max time to wait for creation/readiness
590
612
  * @param {number} [options.pollIntervalMs=2000]
591
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).
592
615
  */
593
616
  async ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
617
+ validateAnonymousAccountId(accountId, "accountId");
594
618
  const {
595
619
  deadlineOffset = DEFAULT_DEADLINE_OFFSET,
596
620
  waitUntilReady = true,
@@ -681,6 +705,7 @@ export class AnonymousTransferClient {
681
705
  { add = [], remove = [] } = {},
682
706
  options = {},
683
707
  ) {
708
+ validateAnonymousAccountId(accountId, "accountId");
684
709
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
685
710
  try {
686
711
  const authNonce = await this.getAuthNonce(accountId);
@@ -760,6 +785,7 @@ export class AnonymousTransferClient {
760
785
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
761
786
  */
762
787
  async deposit(authWallet, accountId, tokenAddress, amount, options = {}) {
788
+ validateAnonymousAccountId(accountId, "accountId");
763
789
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 600000n } =
764
790
  options;
765
791
  try {
@@ -933,6 +959,7 @@ export class AnonymousTransferClient {
933
959
  },
934
960
  options = {},
935
961
  ) {
962
+ validateAnonymousAccountId(accountId, "accountId");
936
963
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
937
964
  try {
938
965
  if (!ethers.isAddress(recipient))
@@ -1069,6 +1096,8 @@ export class AnonymousTransferClient {
1069
1096
  },
1070
1097
  options = {},
1071
1098
  ) {
1099
+ validateAnonymousAccountId(senderAccountId, "senderAccountId");
1100
+ validateAnonymousAccountId(recipientId, "recipientId");
1072
1101
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1073
1102
  try {
1074
1103
  if (!ethers.isAddress(token))
@@ -1181,6 +1210,7 @@ export class AnonymousTransferClient {
1181
1210
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1182
1211
  */
1183
1212
  async applyPending(authWallet, accountId, options = {}) {
1213
+ validateAnonymousAccountId(accountId, "accountId");
1184
1214
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1185
1215
  try {
1186
1216
  const authNonce = await this.getAuthNonce(accountId);
@@ -1248,6 +1278,7 @@ export class AnonymousTransferClient {
1248
1278
  },
1249
1279
  options = {},
1250
1280
  ) {
1281
+ validateAnonymousAccountId(accountId, "accountId");
1251
1282
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1252
1283
  try {
1253
1284
  if (!ethers.isAddress(destination))
@@ -1357,6 +1388,7 @@ export class AnonymousTransferClient {
1357
1388
  * @returns {Promise<{ amount: number, available: number, pending: number }>}
1358
1389
  */
1359
1390
  async getBalance(accountId, tokenAddress, elGamalPrivateKey) {
1391
+ validateAnonymousAccountId(accountId, "accountId");
1360
1392
  try {
1361
1393
  if (!ethers.isAddress(tokenAddress)) {
1362
1394
  throw new Error(`Invalid token address: ${tokenAddress}`);
@@ -1430,6 +1462,7 @@ export class AnonymousTransferClient {
1430
1462
  * @returns {Promise<bigint>}
1431
1463
  */
1432
1464
  async getPrepaidFeeBalance(accountId, token) {
1465
+ validateAnonymousAccountId(accountId, "accountId");
1433
1466
  try {
1434
1467
  if (!ethers.isAddress(token))
1435
1468
  throw new Error(`Invalid token address: ${token}`);
@@ -1493,6 +1526,7 @@ export class AnonymousTransferClient {
1493
1526
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1494
1527
  */
1495
1528
  async depositFees(authWallet, accountId, amount, options = {}) {
1529
+ validateAnonymousAccountId(accountId, "accountId");
1496
1530
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
1497
1531
  options;
1498
1532
  try {
@@ -1643,6 +1677,7 @@ export class AnonymousTransferClient {
1643
1677
  { token, destination, amount },
1644
1678
  options = {},
1645
1679
  ) {
1680
+ validateAnonymousAccountId(accountId, "accountId");
1646
1681
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1647
1682
  try {
1648
1683
  if (!ethers.isAddress(token))
@@ -1706,6 +1741,7 @@ export class AnonymousTransferClient {
1706
1741
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1707
1742
  */
1708
1743
  async withdrawAllFees(authWallet, accountId, { destination }, options = {}) {
1744
+ validateAnonymousAccountId(accountId, "accountId");
1709
1745
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1710
1746
  try {
1711
1747
  if (!ethers.isAddress(destination))
@@ -840,52 +840,6 @@ export class ConfidentialTransferClient {
840
840
  );
841
841
  }
842
842
 
843
- /**
844
- * Wait for pending balance to appear
845
- *
846
- * @param {string} address - Account address
847
- * @param {string} privateKey - Private key for decryption
848
- * @param {string} tokenAddress - Token address
849
- * @param {Object} [options] - Options
850
- * @param {number} [options.maxAttempts=225] - Maximum polling attempts
851
- * @param {number} [options.intervalMs=400] - Polling interval in milliseconds
852
- * @returns {Promise<{amount: number, ciphertext: string}>}
853
- */
854
- async _waitForPendingBalance(
855
- address,
856
- privateKey,
857
- tokenAddress,
858
- options = {},
859
- ) {
860
- const { maxAttempts = 225, intervalMs = 400 } = options;
861
- await sleep(1000);
862
-
863
- try {
864
- for (let i = 0; i < maxAttempts; i++) {
865
- const pending = await this._getPendingBalance(
866
- address,
867
- privateKey,
868
- tokenAddress,
869
- );
870
-
871
- if (pending.amount > 0n) {
872
- return pending;
873
- }
874
-
875
- await sleep(intervalMs);
876
- }
877
-
878
- throw new Error(
879
- `Timeout waiting for pending balance after ${maxAttempts} attempts. The transfer may still be processing.`,
880
- );
881
- } catch (error) {
882
- if (error.message.includes("Timeout waiting for pending balance")) {
883
- throw error;
884
- }
885
- throw new Error(`Failed to wait for pending balance: ${error.message}`);
886
- }
887
- }
888
-
889
843
  /**
890
844
  * Get the current fee amount for confidential transfers
891
845
  *
package/src/constants.js CHANGED
@@ -24,11 +24,11 @@ export const ERC20_ABI = [
24
24
  export const TEMPO_FEE_TOKEN_ADDRESS =
25
25
  "0x20c0000000000000000000000000000000000000";
26
26
  export const STABLETRUST_CONTRACTS_BY_CHAIN_ID = Object.freeze({
27
- 2201: "0xA98e5ba75Bb44459916D52Abf1604caB3d98CC4B", //Stable
28
- 5042002: "0x18c8aEc4Ce4fCdB6687Bb99a402277e82394AFF7", //Arc
29
- 84532: "0xF66A0a1670F14AE5D1852B4E7d1e4C693b2Accfd", //Base
30
- 11155111: "0xa066b5C30382110d19925108BBa1Eef613a3A041", //Ethereum
31
- 421614: "0xbda65d65A7833D28F9391FF01d0b212B75538Cf2", //Arbitrum
27
+ 2201: "0xe1c1456CAb802312759a8cFc3976f88bf87082cf", //Stable
28
+ 5042002: "0x1B4f05f67CC33788Da4C89a7cd0b2f8E0055E605", //Arc
29
+ 84532: "0x4a251C9D79faCa20b193630A4ee313af7cBCDD93", //Base
30
+ 11155111: "0x7507a13352AFAa79D33E994f86f2f62463ba8DE4", //Ethereum
31
+ 421614: "0x5acECCdeb5CbD3C727eCB49F8706Eb80EF2f977F", //Arbitrum
32
32
  42431: "0xE559fB936C69c46E216bf61B07C16bF1a6d444aa", //Tempo
33
33
  });
34
34
 
package/src/index.d.ts CHANGED
@@ -169,6 +169,16 @@ declare module "@fairblock/stabletrust" {
169
169
  *
170
170
  * All operations (except `deposit`) are gas-free for the user — Fairycloak pays gas.
171
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`.
172
182
  */
173
183
  export class AnonymousTransferClient {
174
184
  constructor(config: AnonymousTransferClientConfig);
@@ -178,9 +188,7 @@ declare module "@fairblock/stabletrust" {
178
188
  /**
179
189
  * Get on-chain core state for an anonymous account.
180
190
  */
181
- getAnonymousAccountInfo(
182
- accountId: number | bigint,
183
- ): Promise<AnonymousAccountInfo>;
191
+ getAnonymousAccountInfo(accountId: string): Promise<AnonymousAccountInfo>;
184
192
 
185
193
  /**
186
194
  * Read the current authNonce for an anonymous account.
@@ -201,7 +209,7 @@ declare module "@fairblock/stabletrust" {
201
209
  * Use `.available` and `.pending` for individual balances, `.amount` for the total.
202
210
  */
203
211
  getBalance(
204
- accountId: number | bigint,
212
+ accountId: string,
205
213
  tokenAddress: string,
206
214
  elGamalPrivateKey: string,
207
215
  ): Promise<AnonymousAccountBalance>;
@@ -211,7 +219,7 @@ declare module "@fairblock/stabletrust" {
211
219
  * Use `getBalance` for a simpler numeric summary.
212
220
  */
213
221
  getAnonymousBalance(
214
- accountId: number | bigint,
222
+ accountId: string,
215
223
  tokenAddress: string,
216
224
  elGamalPrivateKey: string,
217
225
  ): Promise<AnonymousBalance>;
@@ -312,7 +320,8 @@ declare module "@fairblock/stabletrust" {
312
320
 
313
321
  /**
314
322
  * Create a new anonymous account. The relay pays gas.
315
- * @param accountId Caller-chosen account ID (non-empty string, case-sensitive)
323
+ * @param accountId Caller-chosen account ID. Must be a non-empty, case-sensitive,
324
+ * alphanumeric string of at most {@link MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} (20) characters.
316
325
  * @param elgamalPublicKey Base64 or "0x"-prefixed hex (32 bytes)
317
326
  */
318
327
  createAccount(
@@ -473,7 +482,7 @@ declare module "@fairblock/stabletrust" {
473
482
  /**
474
483
  * Deposit options
475
484
  */
476
- export interface DepositOptions {
485
+ export interface ConfidentialDepositOptions {
477
486
  waitForFinalization?: boolean;
478
487
  }
479
488
 
@@ -549,7 +558,7 @@ declare module "@fairblock/stabletrust" {
549
558
  wallet: ethers.Wallet | ethers.Signer,
550
559
  tokenAddress: string,
551
560
  amount: bigint | string | number,
552
- options?: DepositOptions,
561
+ options?: ConfidentialDepositOptions,
553
562
  ): Promise<ethers.ContractTransactionReceipt>;
554
563
 
555
564
  /**
@@ -637,6 +646,27 @@ declare module "@fairblock/stabletrust" {
637
646
  */
638
647
  export function encodeWithdrawProof(proof: any): string;
639
648
 
649
+ /**
650
+ * Maximum allowed length for an anonymous account ID (20 characters).
651
+ */
652
+ export const MAX_ANONYMOUS_ACCOUNT_ID_LENGTH: number;
653
+
654
+ /**
655
+ * Validate an anonymous account ID.
656
+ *
657
+ * Rules (must match the Fairycloak relay and on-chain contract):
658
+ * - Non-empty string
659
+ * - At most {@link MAX_ANONYMOUS_ACCOUNT_ID_LENGTH} (20) characters
660
+ * - Alphanumeric characters only (A-Z, a-z, 0-9)
661
+ * - Case-sensitive (no normalization is applied)
662
+ *
663
+ * @throws {Error} If `accountId` violates any of the above rules.
664
+ */
665
+ export function validateAnonymousAccountId(
666
+ accountId: string,
667
+ fieldName?: string,
668
+ ): void;
669
+
640
670
  /**
641
671
  * Constants
642
672
  */
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
  *
@@ -1,6 +0,0 @@
1
- export const ERC20_ABI = [
2
- "function approve(address spender, uint256 amount) external returns (bool)",
3
- "function allowance(address owner, address spender) external view returns (uint256)",
4
- "function balanceOf(address account) external view returns (uint256)",
5
- "function decimals() external view returns (uint8)",
6
- ];