@fairblock/stabletrust 2.0.1 → 2.0.3

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
@@ -236,19 +236,9 @@ const keys = await client.deriveAnonymousKeys(authWallet, accountId);
236
236
 
237
237
  ### Key Methods
238
238
 
239
- #### `getNextAccountId()`
239
+ #### `createAccount(authWallet, accountId, elgamalPublicKey, options?)`
240
240
 
241
- Returns the account count. The ID that will be assigned to the **next** new account is `Number(count) + 1`.
242
-
243
- ```javascript
244
- const count = await client.getNextAccountId();
245
- const myNewId = Number(count) + 1;
246
- await client.createAccount(wallet, keys.publicKey);
247
- ```
248
-
249
- #### `createAccount(authWallet, elgamalPublicKey, options?)`
250
-
251
- Creates a new anonymous account via the relay. The relay pays gas.
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
242
 
253
243
  #### `updateAuthKeys(authWallet, accountId, { add, remove }, options?)`
254
244
 
@@ -429,11 +419,10 @@ const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
429
419
  const tokenAddress = "0xYourTokenAddress";
430
420
  const tokenDecimals = 6;
431
421
 
432
- // 1. Determine the ID your account will get, then create it
433
- const count = await client.getNextAccountId();
434
- const accountId = Number(count) + 1;
422
+ // 1. Choose a unique account ID, derive keys, then create the account
423
+ const accountId = "my-unique-account-id"; // any non-empty string you choose
435
424
  const keys = await client.deriveAnonymousKeys(wallet, accountId);
436
- await client.createAccount(wallet, keys.publicKey);
425
+ await client.createAccount(wallet, accountId, keys.publicKey);
437
426
 
438
427
  // 2. Deposit (user pays gas)
439
428
  const depositResult = await client.deposit(
@@ -14,7 +14,6 @@
14
14
  */
15
15
 
16
16
  import { ethers } from "ethers";
17
- import { randomUUID } from "crypto";
18
17
  import dotenv from "dotenv";
19
18
  import { AnonymousTransferClient } from "@fairblock/stabletrust";
20
19
 
@@ -30,9 +29,9 @@ const TOKEN_ADDRESS =
30
29
  const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? "6");
31
30
 
32
31
  // Amounts in raw ERC-20 units (6 decimals — matches complete-flow.js)
33
- const DEPOSIT_AMOUNT = 100_000n; // 0.1 USDC
34
- const TRANSFER_AMOUNT = 50_000n; // 0.05 USDC
35
- const WITHDRAW_AMOUNT = 25_000n; // 0.025 USDC
32
+ const DEPOSIT_AMOUNT = 100_000n; // 0.1 USDC
33
+ const TRANSFER_AMOUNT = 50_000n; // 0.05 USDC
34
+ const WITHDRAW_AMOUNT = 50_000n; // 0.025 USDC
36
35
 
37
36
  // Anvil test accounts — override via .env
38
37
  const ANON1_PK =
@@ -121,8 +120,11 @@ async function main() {
121
120
 
122
121
  console.log("\n── [1] Generate anonymous account IDs ──────────────────");
123
122
 
124
- const anon1Id = randomUUID();
125
- const anon2Id = randomUUID();
123
+ // An auth signer can only ever be bound to a single anonymous account, so the
124
+ // account ID must be stable per-wallet across runs (random UUIDs would cause
125
+ // "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()}`;
126
128
  log("anon1 id:", anon1Id);
127
129
  log("anon2 id:", anon2Id);
128
130
 
@@ -142,26 +144,25 @@ async function main() {
142
144
 
143
145
  console.log("\n── [3] Create anonymous accounts ───────────────────────");
144
146
 
145
- log(` creating anon1 (id=${anon1Id}) …`);
146
- const cr1 = await client.createAccount(anon1Wallet, anon1Id, anon1Keys.publicKey);
147
- log(` request_id: ${cr1.request_id} status: ${cr1.status}`);
148
- if (cr1.error) log(` [!] Error: ${cr1.error}`);
149
-
150
- log(` creating anon2 (id=${anon2Id}) …`);
151
- const cr2 = await client.createAccount(anon2Wallet, anon2Id, anon2Keys.publicKey);
152
- log(` request_id: ${cr2.request_id} status: ${cr2.status}`);
153
- if (cr2.error) log(` [!] Error: ${cr2.error}`);
154
-
155
- log("Waiting for Fairycloak to confirm account creations …");
156
- const [r1, r2] = await Promise.all([
157
- client.waitForRequest(cr1.request_id, { timeoutMs: 180_000 }),
158
- client.waitForRequest(cr2.request_id, { timeoutMs: 180_000 }),
159
- ]);
160
- if (r1.error) log(" [!] create anon1 failed:", r1.error);
161
- if (r2.error) log(" [!] create anon2 failed:", r2.error);
147
+ log(` ensuring anon1 (id=${anon1Id}) …`);
148
+ const e1 = await client.ensureAnonymousAccount(
149
+ anon1Wallet,
150
+ anon1Id,
151
+ anon1Keys.publicKey,
152
+ );
153
+ log(
154
+ ` created=${e1.created} exists=${e1.accountInfo.exists} finalized=${e1.accountInfo.finalized}`,
155
+ );
162
156
 
163
- await waitForAccountReady(client, anon1Id, { label: "anon1" });
164
- await waitForAccountReady(client, anon2Id, { label: "anon2" });
157
+ log(` ensuring anon2 (id=${anon2Id}) …`);
158
+ const e2 = await client.ensureAnonymousAccount(
159
+ anon2Wallet,
160
+ anon2Id,
161
+ anon2Keys.publicKey,
162
+ );
163
+ log(
164
+ ` created=${e2.created} exists=${e2.accountInfo.exists} finalized=${e2.accountInfo.finalized}`,
165
+ );
165
166
 
166
167
  const [i1, i2] = await Promise.all([
167
168
  client.getAnonymousAccountInfo(anon1Id),
@@ -190,7 +191,9 @@ async function main() {
190
191
  // ── [4] Deposit ──────────────────────────────────────────────────────────────
191
192
 
192
193
  console.log("\n── [4] Deposit into anon1 ──────────────────────────────");
193
- log(`Depositing ${fmt(DEPOSIT_AMOUNT, TOKEN_DECIMALS)} tokens into anon1 (anon1 pays gas) …`);
194
+ log(
195
+ `Depositing ${fmt(DEPOSIT_AMOUNT, TOKEN_DECIMALS)} tokens into anon1 (anon1 pays gas) …`,
196
+ );
194
197
 
195
198
  const dep = await client.deposit(
196
199
  anon1Wallet,
@@ -292,7 +295,9 @@ async function main() {
292
295
  // ── [7] Withdraw anon2 → public address ──────────────────────────────────────
293
296
 
294
297
  console.log("\n── [7] Withdraw from anon2 ─────────────────────────────");
295
- log(`Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`);
298
+ log(
299
+ `Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`,
300
+ );
296
301
 
297
302
  const wd = await client.withdraw(anon2Wallet, anon2Id, {
298
303
  destination: withdrawDest,
@@ -327,9 +332,22 @@ async function main() {
327
332
  console.log("═══════════════════════════════════════════════════════");
328
333
  log("anon1 id :", anon1Id);
329
334
  log("anon2 id :", anon2Id);
330
- log("Deposited :", ethers.formatUnits(DEPOSIT_AMOUNT, TOKEN_DECIMALS), "tokens → anon1");
331
- log("Transferred:", ethers.formatUnits(TRANSFER_AMOUNT, TOKEN_DECIMALS), "tokens → anon2");
332
- log("Withdrawn :", ethers.formatUnits(WITHDRAW_AMOUNT, TOKEN_DECIMALS), "tokens →", withdrawDest);
335
+ log(
336
+ "Deposited :",
337
+ ethers.formatUnits(DEPOSIT_AMOUNT, TOKEN_DECIMALS),
338
+ "tokens → anon1",
339
+ );
340
+ log(
341
+ "Transferred:",
342
+ ethers.formatUnits(TRANSFER_AMOUNT, TOKEN_DECIMALS),
343
+ "tokens → anon2",
344
+ );
345
+ log(
346
+ "Withdrawn :",
347
+ ethers.formatUnits(WITHDRAW_AMOUNT, TOKEN_DECIMALS),
348
+ "tokens →",
349
+ withdrawDest,
350
+ );
333
351
  }
334
352
 
335
353
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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",
@@ -123,22 +123,21 @@ export class AnonymousTransferClient {
123
123
  }
124
124
 
125
125
  /**
126
- * Resolve a pubkey-or-wallet to an uncompressed 65-byte hex pubkey (0x04...).
127
- * Accepts: ethers.Wallet, ethers.SigningKey, or a hex pubkey string.
126
+ * Resolve a wallet/address/pubkey to a checksummed EVM address.
127
+ * Accepts: ethers.Wallet/Signer, an address string, or an uncompressed hex pubkey string.
128
128
  * @private
129
129
  */
130
- _resolvePubkey(walletOrPubkey) {
131
- if (typeof walletOrPubkey === "string") {
132
- return walletOrPubkey; // already a hex pubkey
130
+ async _resolveSignerAddress(walletOrAddress) {
131
+ if (typeof walletOrAddress === "string") {
132
+ if (ethers.isAddress(walletOrAddress))
133
+ return ethers.getAddress(walletOrAddress);
134
+ return ethers.computeAddress(walletOrAddress); // hex pubkey → address
133
135
  }
134
- if (walletOrPubkey.signingKey) {
135
- return walletOrPubkey.signingKey.publicKey; // ethers.Wallet → uncompressed pubkey
136
- }
137
- if (walletOrPubkey.publicKey) {
138
- return walletOrPubkey.publicKey; // ethers.SigningKey
136
+ if (typeof walletOrAddress.getAddress === "function") {
137
+ return await walletOrAddress.getAddress();
139
138
  }
140
139
  throw new Error(
141
- "Expected an ethers.Wallet or uncompressed hex public-key string",
140
+ "Expected an ethers.Wallet/Signer, an address string, or an uncompressed hex public-key string",
142
141
  );
143
142
  }
144
143
 
@@ -515,7 +514,6 @@ export class AnonymousTransferClient {
515
514
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
516
515
  try {
517
516
  const authAddress = await authWallet.getAddress();
518
- const authPubkey = this._resolvePubkey(authWallet); // uncompressed 65-byte pubkey
519
517
 
520
518
  // Normalise elgamal key to hex
521
519
  const elgamalHex = elgamalPublicKey.startsWith("0x")
@@ -557,7 +555,7 @@ export class AnonymousTransferClient {
557
555
  return await this._fetch("POST", "/v1/anonymous/accounts", {
558
556
  account_id: accountId,
559
557
  elgamal_pubkey: elgamalHex,
560
- auth_pubkeys: [authPubkey],
558
+ auth_signers: [authAddress],
561
559
  client_request_id: clientRequestId,
562
560
  deadline: String(deadline),
563
561
  signature,
@@ -569,14 +567,106 @@ export class AnonymousTransferClient {
569
567
  }
570
568
  }
571
569
 
570
+ /**
571
+ * Create an anonymous account if it doesn't already exist, and (optionally) wait
572
+ * until it has settled (no pending CW→EVM action) before returning.
573
+ *
574
+ * Looks the account up by `accountId` first — if it already exists on-chain this
575
+ * skips account creation entirely (idempotent re-runs with a stable `accountId`).
576
+ * Note that an auth signer can only ever be bound to a single anonymous account,
577
+ * so reusing a wallet across different `accountId`s will still fail at creation time.
578
+ *
579
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
580
+ * @param {string} accountId - Caller-chosen account ID (non-empty string, case-sensitive)
581
+ * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
582
+ * @param {Object} [options]
583
+ * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
584
+ * @param {boolean} [options.waitUntilReady=true] - Poll until `hasPendingAction` is false
585
+ * @param {number} [options.timeoutMs=180000] - Max time to wait for creation/readiness
586
+ * @param {number} [options.pollIntervalMs=2000]
587
+ * @returns {Promise<{accountId:string, accountInfo:Object, created:boolean}>}
588
+ */
589
+ async ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
590
+ const {
591
+ deadlineOffset = DEFAULT_DEADLINE_OFFSET,
592
+ waitUntilReady = true,
593
+ timeoutMs = 180_000,
594
+ pollIntervalMs = 2000,
595
+ } = options;
596
+
597
+ const cutoff = Date.now() + timeoutMs;
598
+ const waitForReady = async () => {
599
+ let info = await this.getAnonymousAccountInfo(accountId);
600
+ while (waitUntilReady && info.hasPendingAction) {
601
+ if (Date.now() > cutoff) {
602
+ throw new Error(
603
+ `Timeout waiting for anonymous account ${accountId} to become ready`,
604
+ );
605
+ }
606
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
607
+ info = await this.getAnonymousAccountInfo(accountId);
608
+ }
609
+ return info;
610
+ };
611
+
612
+ let accountInfo = await this.getAnonymousAccountInfo(accountId);
613
+ if (accountInfo.exists) {
614
+ accountInfo = await waitForReady();
615
+ return { accountId, accountInfo, created: false };
616
+ }
617
+
618
+ const failIfAlreadyUsed = async (err) => {
619
+ if (typeof err === "string" && err.includes("already used")) {
620
+ const address = await authWallet.getAddress();
621
+ throw new Error(
622
+ `Signer ${address} is already bound to a different anonymous account, ` +
623
+ `but no account exists with accountId "${accountId}". ` +
624
+ `The provided accountId is likely incorrect — each auth signer can only ` +
625
+ `ever be registered to a single anonymous account.`,
626
+ );
627
+ }
628
+ };
629
+
630
+ const created = await this.createAccount(authWallet, accountId, elgamalPublicKey, {
631
+ deadlineOffset,
632
+ });
633
+ if (created.status === "failed" || created.error) {
634
+ await failIfAlreadyUsed(created.error);
635
+ throw new Error(
636
+ `Failed to create anonymous account ${accountId}: ${created.error ?? "request failed"}`,
637
+ );
638
+ }
639
+
640
+ const finalRequest = await this.waitForRequest(created.request_id, { timeoutMs });
641
+ if (finalRequest.status === "failed" || finalRequest.error) {
642
+ await failIfAlreadyUsed(finalRequest.error);
643
+ throw new Error(
644
+ `Failed to create anonymous account ${accountId}: ${finalRequest.error ?? "request failed"}`,
645
+ );
646
+ }
647
+
648
+ while (!accountInfo.exists) {
649
+ if (Date.now() > cutoff) {
650
+ throw new Error(
651
+ `Timeout waiting for anonymous account ${accountId} to be created`,
652
+ );
653
+ }
654
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
655
+ accountInfo = await this.getAnonymousAccountInfo(accountId);
656
+ }
657
+
658
+ accountInfo = await waitForReady();
659
+ return { accountId, accountInfo, created: true };
660
+ }
661
+
572
662
  /**
573
663
  * Update the set of authorised signers for an anonymous account via Fairycloak.
574
664
  *
575
665
  * @param {ethers.Wallet|ethers.Signer} authWallet - A currently authorised signer
576
666
  * @param {string} accountId
577
667
  * @param {Object} keys
578
- * @param {Array<string|ethers.Wallet>} [keys.add=[]] - Pubkeys/wallets to add
579
- * @param {Array<string|ethers.Wallet>} [keys.remove=[]] - Pubkeys/wallets to remove
668
+ * @param {Array<string|ethers.Wallet|ethers.Signer>} [keys.add=[]] - Addresses/wallets/pubkeys to add
669
+ * @param {Array<string|ethers.Wallet|ethers.Signer>} [keys.remove=[]] - Addresses/wallets/pubkeys to remove
580
670
  * @param {Object} [options]
581
671
  * @param {number} [options.deadlineOffset=3600]
582
672
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
@@ -592,13 +682,11 @@ export class AnonymousTransferClient {
592
682
  const authNonce = await this.getAuthNonce(accountId);
593
683
  const deadline = this._makeDeadline(deadlineOffset);
594
684
 
595
- const addPubkeys = add.map((w) => this._resolvePubkey(w));
596
- const removePubkeys = remove.map((w) => this._resolvePubkey(w));
597
-
598
- // EIP-712 signs over address arrays, not raw pubkeys
599
- const addAddresses = addPubkeys.map((pk) => ethers.computeAddress(pk));
600
- const removeAddresses = removePubkeys.map((pk) =>
601
- ethers.computeAddress(pk),
685
+ const addAddresses = await Promise.all(
686
+ add.map((w) => this._resolveSignerAddress(w)),
687
+ );
688
+ const removeAddresses = await Promise.all(
689
+ remove.map((w) => this._resolveSignerAddress(w)),
602
690
  );
603
691
 
604
692
  const addSignersHash = ethers.keccak256(
@@ -633,8 +721,8 @@ export class AnonymousTransferClient {
633
721
 
634
722
  return await this._fetch("POST", "/v1/anonymous/keys/update", {
635
723
  account_id: accountId,
636
- add_pubkeys: addPubkeys,
637
- remove_pubkeys: removePubkeys,
724
+ add_signers: addAddresses,
725
+ remove_signers: removeAddresses,
638
726
  auth_nonce: String(authNonce),
639
727
  deadline: String(deadline),
640
728
  signature,
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: "0x0E56ade5A9d05D5fDa3ffe08Df64cFc0eD597D94", //Arc
28
+ 5042002: "0xdA4a410F600E1C245DC1188Ee559833879f62Fbb", //Arc
29
29
  84532: "0xF66A0a1670F14AE5D1852B4E7d1e4C693b2Accfd", //Base
30
30
  11155111: "0xa066b5C30382110d19925108BBa1Eef613a3A041", //Ethereum
31
31
  421614: "0xbda65d65A7833D28F9391FF01d0b212B75538Cf2", //Arbitrum