@fairblock/stabletrust 2.0.4 → 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,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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.4",
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)
@@ -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))
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: "0x18c8aEc4Ce4fCdB6687Bb99a402277e82394AFF7", //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
@@ -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);
@@ -312,7 +322,8 @@ declare module "@fairblock/stabletrust" {
312
322
 
313
323
  /**
314
324
  * Create a new anonymous account. The relay pays gas.
315
- * @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.
316
327
  * @param elgamalPublicKey Base64 or "0x"-prefixed hex (32 bytes)
317
328
  */
318
329
  createAccount(
@@ -637,6 +648,27 @@ declare module "@fairblock/stabletrust" {
637
648
  */
638
649
  export function encodeWithdrawProof(proof: any): string;
639
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
+
640
672
  /**
641
673
  * Constants
642
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
  *