@fairblock/stabletrust 2.0.2 → 2.0.4

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,26 @@ 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`.
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).
242
+
243
+ #### `ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options?)`
244
+
245
+ 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`.
246
+
247
+ > 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`.
242
248
 
243
249
  ```javascript
244
- const count = await client.getNextAccountId();
245
- const myNewId = Number(count) + 1;
246
- await client.createAccount(wallet, keys.publicKey);
250
+ const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
251
+ wallet,
252
+ accountId,
253
+ keys.publicKey,
254
+ );
247
255
  ```
248
256
 
249
- #### `createAccount(authWallet, elgamalPublicKey, options?)`
250
-
251
- Creates a new anonymous account via the relay. The relay pays gas.
257
+ - **Options**: `deadlineOffset` (default `3600`), `waitUntilReady` (default `true`), `timeoutMs` (default `180000`), `pollIntervalMs` (default `2000`)
258
+ - **Returns**: `{ accountId, accountInfo, created }`
252
259
 
253
260
  #### `updateAuthKeys(authWallet, accountId, { add, remove }, options?)`
254
261
 
@@ -349,6 +356,49 @@ const result = await client.withdraw(wallet, accountId, {
349
356
  });
350
357
  ```
351
358
 
359
+ ### Prepaid Fees
360
+
361
+ 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.
362
+
363
+ #### `getFeeToken()`
364
+
365
+ 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.
366
+
367
+ #### `getFeeAmount()`
368
+
369
+ 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.
370
+
371
+ #### `getPrepaidFeeBalance(accountId, token)`
372
+
373
+ Returns the prepaid fee balance (in raw ERC-20 units) for an anonymous account and a given fee token.
374
+
375
+ #### `depositFees(authWallet, accountId, amount, options?)`
376
+
377
+ 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.
378
+
379
+ ```javascript
380
+ const feeToken = await client.getFeeToken();
381
+ const feeAmount = await client.getFeeAmount();
382
+ const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
383
+
384
+ if (feeBalance < feeAmount) {
385
+ const result = await client.depositFees(
386
+ wallet,
387
+ accountId,
388
+ ethers.parseUnits("0.5", tokenDecimals),
389
+ );
390
+ await client.waitForRequest(result.request_id);
391
+ }
392
+ ```
393
+
394
+ #### `withdrawFees(authWallet, accountId, { token, destination, amount }, options?)`
395
+
396
+ 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.
397
+
398
+ #### `withdrawAllFees(authWallet, accountId, { destination }, options?)`
399
+
400
+ Withdraws all prepaid fee balances across all (including historical) fee tokens to `destination`. The relay pays gas.
401
+
352
402
  #### Request Tracking
353
403
 
354
404
  All relay operations return a `{ request_id, tx_hash, status }` object. Use the following to track completion:
@@ -429,11 +479,10 @@ const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
429
479
  const tokenAddress = "0xYourTokenAddress";
430
480
  const tokenDecimals = 6;
431
481
 
432
- // 1. Determine the ID your account will get, then create it
433
- const count = await client.getNextAccountId();
434
- const accountId = Number(count) + 1;
482
+ // 1. Choose a unique account ID, derive keys, then ensure the account exists
483
+ const accountId = "my-unique-account-id"; // any non-empty string you choose
435
484
  const keys = await client.deriveAnonymousKeys(wallet, accountId);
436
- await client.createAccount(wallet, keys.publicKey);
485
+ await client.ensureAnonymousAccount(wallet, accountId, keys.publicKey);
437
486
 
438
487
  // 2. Deposit (user pays gas)
439
488
  const depositResult = await client.deposit(
@@ -452,7 +501,21 @@ const balance = await client.getBalance(
452
501
  );
453
502
  console.log("Available:", balance.available, "Pending:", balance.pending);
454
503
 
455
- // 4. Transfer to a public address (relay pays gas)
504
+ // 4. Top up the prepaid fee reserve if needed (user pays gas)
505
+ // transferToPublic/transferToAnonymous deduct a protocol fee from this reserve.
506
+ const feeToken = await client.getFeeToken();
507
+ const feeAmount = await client.getFeeAmount();
508
+ const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
509
+ if (feeBalance < feeAmount) {
510
+ const feeResult = await client.depositFees(
511
+ wallet,
512
+ accountId,
513
+ ethers.parseUnits("0.5", tokenDecimals),
514
+ );
515
+ await client.waitForRequest(feeResult.request_id);
516
+ }
517
+
518
+ // 5. Transfer to a public address (relay pays gas)
456
519
  const transferResult = await client.transferToPublic(wallet, accountId, {
457
520
  recipient: "0xRecipientAddress",
458
521
  token: tokenAddress,
@@ -461,7 +524,7 @@ const transferResult = await client.transferToPublic(wallet, accountId, {
461
524
  });
462
525
  await client.waitForRequest(transferResult.request_id);
463
526
 
464
- // 5. Withdraw (relay pays gas)
527
+ // 6. Withdraw (relay pays gas)
465
528
  const withdrawResult = await client.withdraw(wallet, accountId, {
466
529
  destination: wallet.address,
467
530
  token: tokenAddress,
@@ -505,6 +568,7 @@ try {
505
568
  | "Account finalization timeout" | Account creation is still processing | Wait a few minutes and retry |
506
569
  | "Proof generation failed" | Invalid inputs or cryptographic operation error | Verify all parameters and ensure sufficient balance |
507
570
  | "Amount too small" | Amount rounds to 0 in contract scale | Use a larger amount (minimum depends on token decimals) |
571
+ | "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 |
508
572
  | Fairycloak HTTP error | Relay unreachable or request rejected | Check the relay URL, API key, and account authorization |
509
573
 
510
574
  ---
@@ -5,16 +5,16 @@
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
14
15
  */
15
16
 
16
17
  import { ethers } from "ethers";
17
- import { randomUUID } from "crypto";
18
18
  import dotenv from "dotenv";
19
19
  import { AnonymousTransferClient } from "@fairblock/stabletrust";
20
20
 
@@ -30,9 +30,13 @@ const TOKEN_ADDRESS =
30
30
  const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? "6");
31
31
 
32
32
  // 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
33
+ const DEPOSIT_AMOUNT = 100_000n; // 0.1 USDC
34
+ const TRANSFER_AMOUNT = 50_000n; // 0.05 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
36
40
 
37
41
  // Anvil test accounts — override via .env
38
42
  const ANON1_PK =
@@ -121,8 +125,11 @@ async function main() {
121
125
 
122
126
  console.log("\n── [1] Generate anonymous account IDs ──────────────────");
123
127
 
124
- const anon1Id = randomUUID();
125
- const anon2Id = randomUUID();
128
+ // An auth signer can only ever be bound to a single anonymous account, so the
129
+ // account ID must be stable per-wallet across runs (random UUIDs would cause
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()}`;
126
133
  log("anon1 id:", anon1Id);
127
134
  log("anon2 id:", anon2Id);
128
135
 
@@ -142,26 +149,25 @@ async function main() {
142
149
 
143
150
  console.log("\n── [3] Create anonymous accounts ───────────────────────");
144
151
 
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);
152
+ log(` ensuring anon1 (id=${anon1Id}) …`);
153
+ const e1 = await client.ensureAnonymousAccount(
154
+ anon1Wallet,
155
+ anon1Id,
156
+ anon1Keys.publicKey,
157
+ );
158
+ log(
159
+ ` created=${e1.created} exists=${e1.accountInfo.exists} finalized=${e1.accountInfo.finalized}`,
160
+ );
162
161
 
163
- await waitForAccountReady(client, anon1Id, { label: "anon1" });
164
- await waitForAccountReady(client, anon2Id, { label: "anon2" });
162
+ log(` ensuring anon2 (id=${anon2Id}) …`);
163
+ const e2 = await client.ensureAnonymousAccount(
164
+ anon2Wallet,
165
+ anon2Id,
166
+ anon2Keys.publicKey,
167
+ );
168
+ log(
169
+ ` created=${e2.created} exists=${e2.accountInfo.exists} finalized=${e2.accountInfo.finalized}`,
170
+ );
165
171
 
166
172
  const [i1, i2] = await Promise.all([
167
173
  client.getAnonymousAccountInfo(anon1Id),
@@ -190,7 +196,9 @@ async function main() {
190
196
  // ── [4] Deposit ──────────────────────────────────────────────────────────────
191
197
 
192
198
  console.log("\n── [4] Deposit into anon1 ──────────────────────────────");
193
- log(`Depositing ${fmt(DEPOSIT_AMOUNT, TOKEN_DECIMALS)} tokens into anon1 (anon1 pays gas) …`);
199
+ log(
200
+ `Depositing ${fmt(DEPOSIT_AMOUNT, TOKEN_DECIMALS)} tokens into anon1 (anon1 pays gas) …`,
201
+ );
194
202
 
195
203
  const dep = await client.deposit(
196
204
  anon1Wallet,
@@ -218,9 +226,48 @@ async function main() {
218
226
  "anon1:post-deposit",
219
227
  );
220
228
 
221
- // ── [5] Transfer anon1 → anon2 ───────────────────────────────────────────────
229
+ // ── [5] Top up prepaid fees for anon1 ───────────────────────────────────────
230
+
231
+ console.log("\n── [5] Top up prepaid fees for anon1 ───────────────────");
232
+
233
+ const feeToken = await client.getFeeToken();
234
+ const feeAmount = await client.getFeeAmount();
235
+ log(" fee token :", feeToken);
236
+ log(" fee/xfer :", fmt(feeAmount, TOKEN_DECIMALS), "tokens");
237
+
238
+ const feeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
239
+ log(" fee balance:", fmt(feeBalance, TOKEN_DECIMALS), "tokens");
240
+
241
+ if (feeBalance < MIN_FEE_BALANCE) {
242
+ log(
243
+ ` balance < ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}, depositing ${fmt(FEE_DEPOSIT_AMOUNT, TOKEN_DECIMALS)} fee tokens (anon1 pays gas) …`,
244
+ );
245
+ const fd = await client.depositFees(
246
+ anon1Wallet,
247
+ anon1Id,
248
+ FEE_DEPOSIT_AMOUNT,
249
+ );
250
+ log(" request_id:", fd.request_id, " status:", fd.status);
251
+ if (fd.error) log(" [!] Error:", fd.error);
252
+ if (fd.tx_hash) log(" tx_hash :", fd.tx_hash);
253
+
254
+ const fdFinal = await client.waitForRequest(fd.request_id, {
255
+ timeoutMs: 180_000,
256
+ });
257
+ log(" status :", fdFinal.status, " tx_hash:", fdFinal.tx_hash ?? "—");
258
+ if (fdFinal.error) log(" [!] Error:", fdFinal.error);
259
+
260
+ const newFeeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
261
+ log(" new fee balance:", fmt(newFeeBalance, TOKEN_DECIMALS), "tokens");
262
+ } else {
263
+ log(
264
+ ` fee balance sufficient (${fmt(feeBalance, TOKEN_DECIMALS)} ≥ ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}), skipping deposit.`,
265
+ );
266
+ }
267
+
268
+ // ── [6] Transfer anon1 → anon2 ───────────────────────────────────────────────
222
269
 
223
- console.log("\n── [5] Transfer anon1 → anon2 ──────────────────────────");
270
+ console.log("\n── [6] Transfer anon1 → anon2 ──────────────────────────");
224
271
  log(`Transferring ${fmt(TRANSFER_AMOUNT, TOKEN_DECIMALS)} tokens …`);
225
272
 
226
273
  const txAnon = await client.transferToAnonymous(anon1Wallet, anon1Id, {
@@ -263,9 +310,9 @@ async function main() {
263
310
  "anon2:post-transfer",
264
311
  );
265
312
 
266
- // ── [6] Apply pending for anon2 ──────────────────────────────────────────────
313
+ // ── [7] Apply pending for anon2 ──────────────────────────────────────────────
267
314
 
268
- console.log("\n── [6] Apply pending balance for anon2 ─────────────────");
315
+ console.log("\n── [7] Apply pending balance for anon2 ─────────────────");
269
316
  log("Moving incoming credit from pending → available …");
270
317
 
271
318
  const ap = await client.applyPending(anon2Wallet, anon2Id);
@@ -289,10 +336,12 @@ async function main() {
289
336
  "anon2:post-apply",
290
337
  );
291
338
 
292
- // ── [7] Withdraw anon2 → public address ──────────────────────────────────────
339
+ // ── [8] Withdraw anon2 → public address ──────────────────────────────────────
293
340
 
294
- console.log("\n── [7] Withdraw from anon2 ─────────────────────────────");
295
- log(`Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`);
341
+ console.log("\n── [8] Withdraw from anon2 ─────────────────────────────");
342
+ log(
343
+ `Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`,
344
+ );
296
345
 
297
346
  const wd = await client.withdraw(anon2Wallet, anon2Id, {
298
347
  destination: withdrawDest,
@@ -327,9 +376,22 @@ async function main() {
327
376
  console.log("═══════════════════════════════════════════════════════");
328
377
  log("anon1 id :", anon1Id);
329
378
  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);
379
+ log(
380
+ "Deposited :",
381
+ ethers.formatUnits(DEPOSIT_AMOUNT, TOKEN_DECIMALS),
382
+ "tokens → anon1",
383
+ );
384
+ log(
385
+ "Transferred:",
386
+ ethers.formatUnits(TRANSFER_AMOUNT, TOKEN_DECIMALS),
387
+ "tokens → anon2",
388
+ );
389
+ log(
390
+ "Withdrawn :",
391
+ ethers.formatUnits(WITHDRAW_AMOUNT, TOKEN_DECIMALS),
392
+ "tokens →",
393
+ withdrawDest,
394
+ );
333
395
  }
334
396
 
335
397
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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",
@@ -10,11 +10,15 @@ const ANON_DOMAIN_VERSION = "1";
10
10
  // Default deadline offset: 1 hour
11
11
  const DEFAULT_DEADLINE_OFFSET = 3600;
12
12
 
13
- // Minimal ABI — only used for deposit calldata encoding (reads go through Fairycloak views)
13
+ // Minimal ABI — only used for deposit/fee calldata encoding (reads go through Fairycloak views)
14
14
  const DEPOSIT_INTERFACE = new ethers.Interface([
15
15
  "function depositAnonymous(string accountId, address token, uint256 plainAmount, uint256 authNonce, uint256 deadline, bytes authSig) external",
16
16
  ]);
17
17
 
18
+ const ADD_FEES_INTERFACE = new ethers.Interface([
19
+ "function addFees(string accountId, address token, uint256 amount, uint256 authNonce, uint256 deadline, bytes signature) external payable",
20
+ ]);
21
+
18
22
  /**
19
23
  * Internal helper to parse values as BigInt, handling hex strings and null/undefined.
20
24
  * @private
@@ -123,22 +127,21 @@ export class AnonymousTransferClient {
123
127
  }
124
128
 
125
129
  /**
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.
130
+ * Resolve a wallet/address/pubkey to a checksummed EVM address.
131
+ * Accepts: ethers.Wallet/Signer, an address string, or an uncompressed hex pubkey string.
128
132
  * @private
129
133
  */
130
- _resolvePubkey(walletOrPubkey) {
131
- if (typeof walletOrPubkey === "string") {
132
- return walletOrPubkey; // already a hex pubkey
133
- }
134
- if (walletOrPubkey.signingKey) {
135
- return walletOrPubkey.signingKey.publicKey; // ethers.Wallet → uncompressed pubkey
134
+ async _resolveSignerAddress(walletOrAddress) {
135
+ if (typeof walletOrAddress === "string") {
136
+ if (ethers.isAddress(walletOrAddress))
137
+ return ethers.getAddress(walletOrAddress);
138
+ return ethers.computeAddress(walletOrAddress); // hex pubkey → address
136
139
  }
137
- if (walletOrPubkey.publicKey) {
138
- return walletOrPubkey.publicKey; // ethers.SigningKey
140
+ if (typeof walletOrAddress.getAddress === "function") {
141
+ return await walletOrAddress.getAddress();
139
142
  }
140
143
  throw new Error(
141
- "Expected an ethers.Wallet or uncompressed hex public-key string",
144
+ "Expected an ethers.Wallet/Signer, an address string, or an uncompressed hex public-key string",
142
145
  );
143
146
  }
144
147
 
@@ -515,7 +518,6 @@ export class AnonymousTransferClient {
515
518
  const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
516
519
  try {
517
520
  const authAddress = await authWallet.getAddress();
518
- const authPubkey = this._resolvePubkey(authWallet); // uncompressed 65-byte pubkey
519
521
 
520
522
  // Normalise elgamal key to hex
521
523
  const elgamalHex = elgamalPublicKey.startsWith("0x")
@@ -557,7 +559,7 @@ export class AnonymousTransferClient {
557
559
  return await this._fetch("POST", "/v1/anonymous/accounts", {
558
560
  account_id: accountId,
559
561
  elgamal_pubkey: elgamalHex,
560
- auth_pubkeys: [authPubkey],
562
+ auth_signers: [authAddress],
561
563
  client_request_id: clientRequestId,
562
564
  deadline: String(deadline),
563
565
  signature,
@@ -569,14 +571,106 @@ export class AnonymousTransferClient {
569
571
  }
570
572
  }
571
573
 
574
+ /**
575
+ * Create an anonymous account if it doesn't already exist, and (optionally) wait
576
+ * until it has settled (no pending CW→EVM action) before returning.
577
+ *
578
+ * Looks the account up by `accountId` first — if it already exists on-chain this
579
+ * skips account creation entirely (idempotent re-runs with a stable `accountId`).
580
+ * Note that an auth signer can only ever be bound to a single anonymous account,
581
+ * so reusing a wallet across different `accountId`s will still fail at creation time.
582
+ *
583
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Initial auth signer
584
+ * @param {string} accountId - Caller-chosen account ID (non-empty string, case-sensitive)
585
+ * @param {string} elgamalPublicKey - ElGamal public key as base64 or "0x"-prefixed hex (32 bytes)
586
+ * @param {Object} [options]
587
+ * @param {number} [options.deadlineOffset=3600] - Deadline in seconds from now
588
+ * @param {boolean} [options.waitUntilReady=true] - Poll until `hasPendingAction` is false
589
+ * @param {number} [options.timeoutMs=180000] - Max time to wait for creation/readiness
590
+ * @param {number} [options.pollIntervalMs=2000]
591
+ * @returns {Promise<{accountId:string, accountInfo:Object, created:boolean}>}
592
+ */
593
+ async ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options = {}) {
594
+ const {
595
+ deadlineOffset = DEFAULT_DEADLINE_OFFSET,
596
+ waitUntilReady = true,
597
+ timeoutMs = 180_000,
598
+ pollIntervalMs = 2000,
599
+ } = options;
600
+
601
+ const cutoff = Date.now() + timeoutMs;
602
+ const waitForReady = async () => {
603
+ let info = await this.getAnonymousAccountInfo(accountId);
604
+ while (waitUntilReady && info.hasPendingAction) {
605
+ if (Date.now() > cutoff) {
606
+ throw new Error(
607
+ `Timeout waiting for anonymous account ${accountId} to become ready`,
608
+ );
609
+ }
610
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
611
+ info = await this.getAnonymousAccountInfo(accountId);
612
+ }
613
+ return info;
614
+ };
615
+
616
+ let accountInfo = await this.getAnonymousAccountInfo(accountId);
617
+ if (accountInfo.exists) {
618
+ accountInfo = await waitForReady();
619
+ return { accountId, accountInfo, created: false };
620
+ }
621
+
622
+ const failIfAlreadyUsed = async (err) => {
623
+ if (typeof err === "string" && err.includes("already used")) {
624
+ const address = await authWallet.getAddress();
625
+ throw new Error(
626
+ `Signer ${address} is already bound to a different anonymous account, ` +
627
+ `but no account exists with accountId "${accountId}". ` +
628
+ `The provided accountId is likely incorrect — each auth signer can only ` +
629
+ `ever be registered to a single anonymous account.`,
630
+ );
631
+ }
632
+ };
633
+
634
+ const created = await this.createAccount(authWallet, accountId, elgamalPublicKey, {
635
+ deadlineOffset,
636
+ });
637
+ if (created.status === "failed" || created.error) {
638
+ await failIfAlreadyUsed(created.error);
639
+ throw new Error(
640
+ `Failed to create anonymous account ${accountId}: ${created.error ?? "request failed"}`,
641
+ );
642
+ }
643
+
644
+ const finalRequest = await this.waitForRequest(created.request_id, { timeoutMs });
645
+ if (finalRequest.status === "failed" || finalRequest.error) {
646
+ await failIfAlreadyUsed(finalRequest.error);
647
+ throw new Error(
648
+ `Failed to create anonymous account ${accountId}: ${finalRequest.error ?? "request failed"}`,
649
+ );
650
+ }
651
+
652
+ while (!accountInfo.exists) {
653
+ if (Date.now() > cutoff) {
654
+ throw new Error(
655
+ `Timeout waiting for anonymous account ${accountId} to be created`,
656
+ );
657
+ }
658
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
659
+ accountInfo = await this.getAnonymousAccountInfo(accountId);
660
+ }
661
+
662
+ accountInfo = await waitForReady();
663
+ return { accountId, accountInfo, created: true };
664
+ }
665
+
572
666
  /**
573
667
  * Update the set of authorised signers for an anonymous account via Fairycloak.
574
668
  *
575
669
  * @param {ethers.Wallet|ethers.Signer} authWallet - A currently authorised signer
576
670
  * @param {string} accountId
577
671
  * @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
672
+ * @param {Array<string|ethers.Wallet|ethers.Signer>} [keys.add=[]] - Addresses/wallets/pubkeys to add
673
+ * @param {Array<string|ethers.Wallet|ethers.Signer>} [keys.remove=[]] - Addresses/wallets/pubkeys to remove
580
674
  * @param {Object} [options]
581
675
  * @param {number} [options.deadlineOffset=3600]
582
676
  * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
@@ -592,13 +686,11 @@ export class AnonymousTransferClient {
592
686
  const authNonce = await this.getAuthNonce(accountId);
593
687
  const deadline = this._makeDeadline(deadlineOffset);
594
688
 
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),
689
+ const addAddresses = await Promise.all(
690
+ add.map((w) => this._resolveSignerAddress(w)),
691
+ );
692
+ const removeAddresses = await Promise.all(
693
+ remove.map((w) => this._resolveSignerAddress(w)),
602
694
  );
603
695
 
604
696
  const addSignersHash = ethers.keccak256(
@@ -633,8 +725,8 @@ export class AnonymousTransferClient {
633
725
 
634
726
  return await this._fetch("POST", "/v1/anonymous/keys/update", {
635
727
  account_id: accountId,
636
- add_pubkeys: addPubkeys,
637
- remove_pubkeys: removePubkeys,
728
+ add_signers: addAddresses,
729
+ remove_signers: removeAddresses,
638
730
  auth_nonce: String(authNonce),
639
731
  deadline: String(deadline),
640
732
  signature,
@@ -898,6 +990,8 @@ export class AnonymousTransferClient {
898
990
  });
899
991
  }
900
992
 
993
+ await this._assertSufficientPrepaidFees(accountId);
994
+
901
995
  const authNonce = await this.getAuthNonce(accountId);
902
996
  const deadline = this._makeDeadline(deadlineOffset);
903
997
  const proofHash = ethers.keccak256(proofHex);
@@ -1030,6 +1124,8 @@ export class AnonymousTransferClient {
1030
1124
  });
1031
1125
  }
1032
1126
 
1127
+ await this._assertSufficientPrepaidFees(senderAccountId);
1128
+
1033
1129
  const authNonce = await this.getAuthNonce(senderAccountId);
1034
1130
  const deadline = this._makeDeadline(deadlineOffset);
1035
1131
  const proofHash = ethers.keccak256(proofHex);
@@ -1288,6 +1384,368 @@ export class AnonymousTransferClient {
1288
1384
  }
1289
1385
  }
1290
1386
 
1387
+ // ─────────────────── prepaid fee reads ─────────────────────────
1388
+
1389
+ /**
1390
+ * Get the currently configured fee token address from the contract.
1391
+ *
1392
+ * Deposits via `depositFees` must use this token. Rotate-aware: the fee token
1393
+ * can change over time, but existing balances for old tokens remain withdrawable.
1394
+ *
1395
+ * @returns {Promise<string>} Checksummed ERC-20 token address (or address(0) if none set)
1396
+ */
1397
+ async getFeeToken() {
1398
+ try {
1399
+ const data = await this._fetch("GET", "/v1/views/fee-token");
1400
+ const raw = data.result ?? data;
1401
+ const addr = typeof raw === "string" ? raw : raw.feeToken ?? raw;
1402
+ return ethers.getAddress(addr);
1403
+ } catch (e) {
1404
+ throw new Error(`Failed to get fee token: ${e.message}`);
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * Get the currently configured protocol fee amount (in raw ERC-20 units of the fee token).
1410
+ *
1411
+ * This is the amount deducted from the prepaid reserve on each anonymous transfer.
1412
+ * Returns 0n if no fee is configured.
1413
+ *
1414
+ * @returns {Promise<bigint>}
1415
+ */
1416
+ async getFeeAmount() {
1417
+ try {
1418
+ const data = await this._fetch("GET", "/v1/views/fee-amount");
1419
+ return _parseBigInt(data.result ?? data);
1420
+ } catch (e) {
1421
+ throw new Error(`Failed to get fee amount: ${e.message}`);
1422
+ }
1423
+ }
1424
+
1425
+ /**
1426
+ * Get the prepaid fee balance for an anonymous account and token (in raw ERC-20 units).
1427
+ *
1428
+ * @param {string} accountId
1429
+ * @param {string} token - ERC-20 token address (use `getFeeToken()` to get the active fee token)
1430
+ * @returns {Promise<bigint>}
1431
+ */
1432
+ async getPrepaidFeeBalance(accountId, token) {
1433
+ try {
1434
+ if (!ethers.isAddress(token))
1435
+ throw new Error(`Invalid token address: ${token}`);
1436
+ const data = await this._fetch(
1437
+ "GET",
1438
+ `/v1/views/anonymous/accounts/${accountId}/prepaid-fees/${token}`,
1439
+ );
1440
+ return _parseBigInt(data.result ?? data);
1441
+ } catch (e) {
1442
+ throw new Error(`Failed to get prepaid fee balance: ${e.message}`);
1443
+ }
1444
+ }
1445
+
1446
+ /**
1447
+ * Check that the anonymous account has sufficient prepaid fees to cover one transfer.
1448
+ * Throws a descriptive error if the balance is insufficient.
1449
+ * @private
1450
+ */
1451
+ async _assertSufficientPrepaidFees(accountId) {
1452
+ const [feeToken, feeAmount] = await Promise.all([
1453
+ this.getFeeToken(),
1454
+ this.getFeeAmount(),
1455
+ ]);
1456
+ if (feeAmount === 0n) return; // no fee configured
1457
+
1458
+ if (feeToken === ethers.ZeroAddress) return; // fee token not set
1459
+
1460
+ const balance = await this.getPrepaidFeeBalance(accountId, feeToken);
1461
+ if (balance < feeAmount) {
1462
+ throw new Error(
1463
+ `Insufficient prepaid fee balance for anonymous account "${accountId}". ` +
1464
+ `Required: ${feeAmount} (raw units of fee token ${feeToken}), ` +
1465
+ `available: ${balance}. ` +
1466
+ `Call depositFees(wallet, accountId, amount) to top up the prepaid fee reserve. ` +
1467
+ `Use getFeeToken() to get the active fee token address and getFeeAmount() to see the required deposit amount per transfer.`,
1468
+ );
1469
+ }
1470
+ }
1471
+
1472
+ // ─────────────────── prepaid fee actions ────────────────────────
1473
+
1474
+ /**
1475
+ * Deposit tokens into the prepaid fee reserve for an anonymous account.
1476
+ *
1477
+ * Mirrors `deposit()` from a transaction-signing perspective: the user wallet pays
1478
+ * gas and signs the raw EVM transaction. Fairycloak validates and broadcasts it.
1479
+ *
1480
+ * Steps:
1481
+ * 1. Fetch the active fee token from the contract
1482
+ * 2. ERC-20 approve if allowance is insufficient
1483
+ * 3. Sign EIP-712 AnonymousAddFees authorisation payload
1484
+ * 4. Build and sign the raw `addFees(...)` EVM transaction
1485
+ * 5. Submit to Fairycloak `/v1/anonymous/fees/add/raw-tx`
1486
+ *
1487
+ * @param {ethers.Wallet} authWallet - Wallet that owns the tokens and pays gas
1488
+ * @param {string} accountId - Target anonymous account ID
1489
+ * @param {bigint|string|number} amount - Amount in raw ERC-20 units of the active fee token
1490
+ * @param {Object} [options]
1491
+ * @param {number} [options.deadlineOffset=3600]
1492
+ * @param {bigint} [options.gasLimit=300000n]
1493
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1494
+ */
1495
+ async depositFees(authWallet, accountId, amount, options = {}) {
1496
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET, gasLimit = 300000n } =
1497
+ options;
1498
+ try {
1499
+ const amountBig = BigInt(amount);
1500
+ if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
1501
+
1502
+ const feeToken = await this.getFeeToken();
1503
+ if (feeToken === ethers.ZeroAddress)
1504
+ throw new Error(
1505
+ "No fee token is currently configured on the contract. Contact the protocol operator.",
1506
+ );
1507
+
1508
+ const depositorAddress = await authWallet.getAddress();
1509
+ const walletWithProvider = authWallet.connect
1510
+ ? authWallet.connect(this.provider)
1511
+ : authWallet;
1512
+
1513
+ // 1. Approve if needed
1514
+ const tokenContract = new ethers.Contract(
1515
+ feeToken,
1516
+ [
1517
+ "function balanceOf(address) view returns (uint256)",
1518
+ "function allowance(address,address) view returns (uint256)",
1519
+ "function approve(address,uint256) returns (bool)",
1520
+ ],
1521
+ walletWithProvider,
1522
+ );
1523
+
1524
+ const [balance, allowance] = await Promise.all([
1525
+ tokenContract.balanceOf(depositorAddress),
1526
+ tokenContract.allowance(depositorAddress, this.diamondAddress),
1527
+ ]);
1528
+
1529
+ if (balance < amountBig)
1530
+ throw new Error(
1531
+ `Insufficient fee token balance. Required: ${amountBig}, available: ${balance}`,
1532
+ );
1533
+
1534
+ if (allowance < amountBig) {
1535
+ const approveTx = await tokenContract.approve(
1536
+ this.diamondAddress,
1537
+ ethers.MaxUint256,
1538
+ );
1539
+ const receipt = await approveTx.wait();
1540
+ if (!receipt || receipt.status === 0)
1541
+ throw new Error("Fee token approval failed");
1542
+ }
1543
+
1544
+ // 2. EIP-712 AnonymousAddFees signature
1545
+ const authNonce = await this.getAuthNonce(accountId);
1546
+ const deadline = this._makeDeadline(deadlineOffset);
1547
+
1548
+ const domain = this._buildDomain();
1549
+ const types = {
1550
+ AnonymousAddFees: [
1551
+ { name: "accountId", type: "string" },
1552
+ { name: "token", type: "address" },
1553
+ { name: "amount", type: "uint256" },
1554
+ { name: "authNonce", type: "uint256" },
1555
+ { name: "deadline", type: "uint256" },
1556
+ ],
1557
+ };
1558
+ const value = {
1559
+ accountId: String(accountId),
1560
+ token: feeToken,
1561
+ amount: amountBig,
1562
+ authNonce,
1563
+ deadline: BigInt(deadline),
1564
+ };
1565
+
1566
+ const signature = await authWallet.signTypedData(domain, types, value);
1567
+
1568
+ // 3. Build and sign the raw addFees transaction
1569
+ const calldata = ADD_FEES_INTERFACE.encodeFunctionData("addFees", [
1570
+ accountId,
1571
+ feeToken,
1572
+ amountBig,
1573
+ authNonce,
1574
+ BigInt(deadline),
1575
+ signature,
1576
+ ]);
1577
+
1578
+ const [nonce, feeData] = await Promise.all([
1579
+ this.provider.getTransactionCount(depositorAddress),
1580
+ this.provider.getFeeData(),
1581
+ ]);
1582
+
1583
+ let txObj;
1584
+ if (feeData.maxFeePerGas) {
1585
+ txObj = {
1586
+ to: this.diamondAddress,
1587
+ data: calldata,
1588
+ nonce,
1589
+ chainId: BigInt(this.chainId),
1590
+ gasLimit,
1591
+ maxFeePerGas: (feeData.maxFeePerGas * 12n) / 10n,
1592
+ maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 0n,
1593
+ value: 0n,
1594
+ };
1595
+ } else {
1596
+ txObj = {
1597
+ to: this.diamondAddress,
1598
+ data: calldata,
1599
+ nonce,
1600
+ chainId: BigInt(this.chainId),
1601
+ gasLimit,
1602
+ gasPrice: (feeData.gasPrice * 11n) / 10n,
1603
+ value: 0n,
1604
+ };
1605
+ }
1606
+
1607
+ const signedTx = await authWallet.signTransaction(txObj);
1608
+
1609
+ // 4. Submit to Fairycloak
1610
+ return await this._fetch("POST", "/v1/anonymous/fees/add/raw-tx", {
1611
+ raw_tx: signedTx,
1612
+ });
1613
+ } catch (e) {
1614
+ const msg = e?.message ?? String(e);
1615
+ if (
1616
+ msg.startsWith("Fairycloak") ||
1617
+ msg.startsWith("Insufficient") ||
1618
+ msg.startsWith("No fee token")
1619
+ )
1620
+ throw e;
1621
+ throw new Error(`Failed to deposit fees: ${msg}`);
1622
+ }
1623
+ }
1624
+
1625
+ /**
1626
+ * Withdraw a specific amount from the prepaid fee reserve via Fairycloak. The relay pays gas.
1627
+ *
1628
+ * Use this to reclaim fees for a specific (possibly historical) fee token.
1629
+ *
1630
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
1631
+ * @param {string} accountId
1632
+ * @param {Object} params
1633
+ * @param {string} params.token - Fee token address to withdraw
1634
+ * @param {string} params.destination - Destination EVM address
1635
+ * @param {bigint|string|number} params.amount - Amount in raw ERC-20 units
1636
+ * @param {Object} [options]
1637
+ * @param {number} [options.deadlineOffset=3600]
1638
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1639
+ */
1640
+ async withdrawFees(
1641
+ authWallet,
1642
+ accountId,
1643
+ { token, destination, amount },
1644
+ options = {},
1645
+ ) {
1646
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1647
+ try {
1648
+ if (!ethers.isAddress(token))
1649
+ throw new Error(`Invalid token address: ${token}`);
1650
+ if (!ethers.isAddress(destination))
1651
+ throw new Error(`Invalid destination address: ${destination}`);
1652
+ const amountBig = BigInt(amount);
1653
+ if (amountBig <= 0n) throw new Error("Amount must be greater than 0");
1654
+
1655
+ const authNonce = await this.getAuthNonce(accountId);
1656
+ const deadline = this._makeDeadline(deadlineOffset);
1657
+
1658
+ const domain = this._buildDomain();
1659
+ const types = {
1660
+ AnonymousWithdrawFees: [
1661
+ { name: "accountId", type: "string" },
1662
+ { name: "token", type: "address" },
1663
+ { name: "destination", type: "address" },
1664
+ { name: "amount", type: "uint256" },
1665
+ { name: "authNonce", type: "uint256" },
1666
+ { name: "deadline", type: "uint256" },
1667
+ ],
1668
+ };
1669
+ const value = {
1670
+ accountId: String(accountId),
1671
+ token,
1672
+ destination,
1673
+ amount: amountBig,
1674
+ authNonce,
1675
+ deadline: BigInt(deadline),
1676
+ };
1677
+
1678
+ const signature = await authWallet.signTypedData(domain, types, value);
1679
+
1680
+ return await this._fetch("POST", "/v1/anonymous/fees/withdraw", {
1681
+ account_id: accountId,
1682
+ token,
1683
+ destination,
1684
+ amount: String(amountBig),
1685
+ auth_nonce: String(authNonce),
1686
+ deadline: String(deadline),
1687
+ signature,
1688
+ });
1689
+ } catch (e) {
1690
+ const msg = e?.message ?? String(e);
1691
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1692
+ throw new Error(`Failed to withdraw fees: ${msg}`);
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * Withdraw all prepaid fee balances across all (including historical) fee tokens via Fairycloak.
1698
+ * The relay pays gas.
1699
+ *
1700
+ * @param {ethers.Wallet|ethers.Signer} authWallet - Authorised signer for the anonymous account
1701
+ * @param {string} accountId
1702
+ * @param {Object} params
1703
+ * @param {string} params.destination - Destination EVM address
1704
+ * @param {Object} [options]
1705
+ * @param {number} [options.deadlineOffset=3600]
1706
+ * @returns {Promise<{request_id:string, tx_hash:string, status:string}>}
1707
+ */
1708
+ async withdrawAllFees(authWallet, accountId, { destination }, options = {}) {
1709
+ const { deadlineOffset = DEFAULT_DEADLINE_OFFSET } = options;
1710
+ try {
1711
+ if (!ethers.isAddress(destination))
1712
+ throw new Error(`Invalid destination address: ${destination}`);
1713
+
1714
+ const authNonce = await this.getAuthNonce(accountId);
1715
+ const deadline = this._makeDeadline(deadlineOffset);
1716
+
1717
+ const domain = this._buildDomain();
1718
+ const types = {
1719
+ AnonymousWithdrawAllFees: [
1720
+ { name: "accountId", type: "string" },
1721
+ { name: "destination", type: "address" },
1722
+ { name: "authNonce", type: "uint256" },
1723
+ { name: "deadline", type: "uint256" },
1724
+ ],
1725
+ };
1726
+ const value = {
1727
+ accountId: String(accountId),
1728
+ destination,
1729
+ authNonce,
1730
+ deadline: BigInt(deadline),
1731
+ };
1732
+
1733
+ const signature = await authWallet.signTypedData(domain, types, value);
1734
+
1735
+ return await this._fetch("POST", "/v1/anonymous/fees/withdraw-all", {
1736
+ account_id: accountId,
1737
+ destination,
1738
+ auth_nonce: String(authNonce),
1739
+ deadline: String(deadline),
1740
+ signature,
1741
+ });
1742
+ } catch (e) {
1743
+ const msg = e?.message ?? String(e);
1744
+ if (msg.startsWith("Fairycloak") || msg.startsWith("Invalid")) throw e;
1745
+ throw new Error(`Failed to withdraw all fees: ${msg}`);
1746
+ }
1747
+ }
1748
+
1291
1749
  // ──────────────────── request tracking ─────────────────────────
1292
1750
 
1293
1751
  /**
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: "0x3dd15891e6a8C87Ac0eaB0FF9F76D2B8Bb390173", //Arc
28
+ 5042002: "0x18c8aEc4Ce4fCdB6687Bb99a402277e82394AFF7", //Arc
29
29
  84532: "0xF66A0a1670F14AE5D1852B4E7d1e4C693b2Accfd", //Base
30
30
  11155111: "0xa066b5C30382110d19925108BBa1Eef613a3A041", //Ethereum
31
31
  421614: "0xbda65d65A7833D28F9391FF01d0b212B75538Cf2", //Arbitrum
package/src/index.d.ts CHANGED
@@ -146,6 +146,24 @@ 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
  *
@@ -229,6 +247,67 @@ declare module "@fairblock/stabletrust" {
229
247
  params: WithdrawProofParams,
230
248
  ): Promise<string>;
231
249
 
250
+ // ── prepaid fee reads ───────────────────────────────────────────
251
+
252
+ /**
253
+ * Get the currently configured fee token address from the contract.
254
+ * Deposits via `depositFees` must use this token.
255
+ */
256
+ getFeeToken(): Promise<string>;
257
+
258
+ /**
259
+ * Get the protocol fee amount in raw ERC-20 units of the fee token.
260
+ * This is the amount deducted per anonymous transfer. Returns 0n if no fee is set.
261
+ */
262
+ getFeeAmount(): Promise<bigint>;
263
+
264
+ /**
265
+ * Get the prepaid fee balance for an anonymous account and token (raw ERC-20 units).
266
+ * Use `getFeeToken()` to get the active fee token address.
267
+ */
268
+ getPrepaidFeeBalance(accountId: string, token: string): Promise<bigint>;
269
+
270
+ // ── prepaid fee actions ─────────────────────────────────────────
271
+
272
+ /**
273
+ * Deposit tokens into the prepaid fee reserve for an anonymous account.
274
+ * The user wallet pays gas for this raw EVM transaction.
275
+ * Handles ERC-20 approval automatically.
276
+ *
277
+ * The fee token is read from the contract — use `getFeeToken()` to see it.
278
+ * Use `getFeeAmount()` to see how much is deducted per transfer.
279
+ *
280
+ * @param amount Amount in raw ERC-20 units of the active fee token
281
+ */
282
+ depositFees(
283
+ authWallet: ethers.Wallet,
284
+ accountId: string,
285
+ amount: bigint | string | number,
286
+ options?: DepositFeesOptions,
287
+ ): Promise<FairycloakResponse>;
288
+
289
+ /**
290
+ * Withdraw a specific amount from the prepaid fee reserve. The relay pays gas.
291
+ * Supports historical fee tokens (after a fee-token rotation).
292
+ */
293
+ withdrawFees(
294
+ authWallet: ethers.Wallet | ethers.Signer,
295
+ accountId: string,
296
+ params: WithdrawFeesParams,
297
+ options?: DeadlineOptions,
298
+ ): Promise<FairycloakResponse>;
299
+
300
+ /**
301
+ * Withdraw all prepaid fee balances across all (including historical) fee tokens.
302
+ * The relay pays gas.
303
+ */
304
+ withdrawAllFees(
305
+ authWallet: ethers.Wallet | ethers.Signer,
306
+ accountId: string,
307
+ params: WithdrawAllFeesParams,
308
+ options?: DeadlineOptions,
309
+ ): Promise<FairycloakResponse>;
310
+
232
311
  // ── Fairycloak operations ───────────────────────────────────────
233
312
 
234
313
  /**