@fairblock/stabletrust 2.0.3 → 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
@@ -240,6 +240,23 @@ const keys = await client.deriveAnonymousKeys(authWallet, accountId);
240
240
 
241
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
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`.
248
+
249
+ ```javascript
250
+ const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
251
+ wallet,
252
+ accountId,
253
+ keys.publicKey,
254
+ );
255
+ ```
256
+
257
+ - **Options**: `deadlineOffset` (default `3600`), `waitUntilReady` (default `true`), `timeoutMs` (default `180000`), `pollIntervalMs` (default `2000`)
258
+ - **Returns**: `{ accountId, accountInfo, created }`
259
+
243
260
  #### `updateAuthKeys(authWallet, accountId, { add, remove }, options?)`
244
261
 
245
262
  Adds or removes authorised signers for an anonymous account. Pass `ethers.Wallet` instances or raw uncompressed hex pubkey strings.
@@ -339,6 +356,49 @@ const result = await client.withdraw(wallet, accountId, {
339
356
  });
340
357
  ```
341
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
+
342
402
  #### Request Tracking
343
403
 
344
404
  All relay operations return a `{ request_id, tx_hash, status }` object. Use the following to track completion:
@@ -419,10 +479,10 @@ const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
419
479
  const tokenAddress = "0xYourTokenAddress";
420
480
  const tokenDecimals = 6;
421
481
 
422
- // 1. Choose a unique account ID, derive keys, then create the account
482
+ // 1. Choose a unique account ID, derive keys, then ensure the account exists
423
483
  const accountId = "my-unique-account-id"; // any non-empty string you choose
424
484
  const keys = await client.deriveAnonymousKeys(wallet, accountId);
425
- await client.createAccount(wallet, accountId, keys.publicKey);
485
+ await client.ensureAnonymousAccount(wallet, accountId, keys.publicKey);
426
486
 
427
487
  // 2. Deposit (user pays gas)
428
488
  const depositResult = await client.deposit(
@@ -441,7 +501,21 @@ const balance = await client.getBalance(
441
501
  );
442
502
  console.log("Available:", balance.available, "Pending:", balance.pending);
443
503
 
444
- // 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)
445
519
  const transferResult = await client.transferToPublic(wallet, accountId, {
446
520
  recipient: "0xRecipientAddress",
447
521
  token: tokenAddress,
@@ -450,7 +524,7 @@ const transferResult = await client.transferToPublic(wallet, accountId, {
450
524
  });
451
525
  await client.waitForRequest(transferResult.request_id);
452
526
 
453
- // 5. Withdraw (relay pays gas)
527
+ // 6. Withdraw (relay pays gas)
454
528
  const withdrawResult = await client.withdraw(wallet, accountId, {
455
529
  destination: wallet.address,
456
530
  token: tokenAddress,
@@ -494,6 +568,7 @@ try {
494
568
  | "Account finalization timeout" | Account creation is still processing | Wait a few minutes and retry |
495
569
  | "Proof generation failed" | Invalid inputs or cryptographic operation error | Verify all parameters and ensure sufficient balance |
496
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 |
497
572
  | Fairycloak HTTP error | Relay unreachable or request rejected | Check the relay URL, API key, and account authorization |
498
573
 
499
574
  ---
@@ -5,9 +5,10 @@
5
5
  * [2] Derive ElGamal keypairs for both accounts
6
6
  * [3] Create both anonymous accounts via Fairycloak
7
7
  * [4] Deposit tokens into anon1
8
- * [5] Transfer anon1 → anon2
9
- * [6] Apply pending balance for anon2
10
- * [7] Withdraw anon2 → public address
8
+ * [5] Top up prepaid fees for anon1 (if balance < 0.1)
9
+ * [6] Transfer anon1 → anon2
10
+ * [7] Apply pending balance for anon2
11
+ * [8] Withdraw anon2 → public address
11
12
  *
12
13
  * Run:
13
14
  * node examples/anonymous-flow.js
@@ -31,7 +32,11 @@ const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? "6");
31
32
  // Amounts in raw ERC-20 units (6 decimals — matches complete-flow.js)
32
33
  const DEPOSIT_AMOUNT = 100_000n; // 0.1 USDC
33
34
  const TRANSFER_AMOUNT = 50_000n; // 0.05 USDC
34
- const WITHDRAW_AMOUNT = 50_000n; // 0.025 USDC
35
+ const WITHDRAW_AMOUNT = 20_000n; // 0.02 USDC
36
+
37
+ // Prepaid fee thresholds (raw units, assumes fee token uses TOKEN_DECIMALS)
38
+ const MIN_FEE_BALANCE = 1_000_000n; // 0.1 — deposit if balance falls below this
39
+ const FEE_DEPOSIT_AMOUNT = 2_000_000n; // 0.5 — how much to top up in one shot
35
40
 
36
41
  // Anvil test accounts — override via .env
37
42
  const ANON1_PK =
@@ -221,9 +226,48 @@ async function main() {
221
226
  "anon1:post-deposit",
222
227
  );
223
228
 
224
- // ── [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 ───────────────────────────────────────────────
225
269
 
226
- console.log("\n── [5] Transfer anon1 → anon2 ──────────────────────────");
270
+ console.log("\n── [6] Transfer anon1 → anon2 ──────────────────────────");
227
271
  log(`Transferring ${fmt(TRANSFER_AMOUNT, TOKEN_DECIMALS)} tokens …`);
228
272
 
229
273
  const txAnon = await client.transferToAnonymous(anon1Wallet, anon1Id, {
@@ -266,9 +310,9 @@ async function main() {
266
310
  "anon2:post-transfer",
267
311
  );
268
312
 
269
- // ── [6] Apply pending for anon2 ──────────────────────────────────────────────
313
+ // ── [7] Apply pending for anon2 ──────────────────────────────────────────────
270
314
 
271
- console.log("\n── [6] Apply pending balance for anon2 ─────────────────");
315
+ console.log("\n── [7] Apply pending balance for anon2 ─────────────────");
272
316
  log("Moving incoming credit from pending → available …");
273
317
 
274
318
  const ap = await client.applyPending(anon2Wallet, anon2Id);
@@ -292,9 +336,9 @@ async function main() {
292
336
  "anon2:post-apply",
293
337
  );
294
338
 
295
- // ── [7] Withdraw anon2 → public address ──────────────────────────────────────
339
+ // ── [8] Withdraw anon2 → public address ──────────────────────────────────────
296
340
 
297
- console.log("\n── [7] Withdraw from anon2 ─────────────────────────────");
341
+ console.log("\n── [8] Withdraw from anon2 ─────────────────────────────");
298
342
  log(
299
343
  `Withdrawing ${fmt(WITHDRAW_AMOUNT, TOKEN_DECIMALS)} tokens → ${withdrawDest} …`,
300
344
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.3",
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
@@ -986,6 +990,8 @@ export class AnonymousTransferClient {
986
990
  });
987
991
  }
988
992
 
993
+ await this._assertSufficientPrepaidFees(accountId);
994
+
989
995
  const authNonce = await this.getAuthNonce(accountId);
990
996
  const deadline = this._makeDeadline(deadlineOffset);
991
997
  const proofHash = ethers.keccak256(proofHex);
@@ -1118,6 +1124,8 @@ export class AnonymousTransferClient {
1118
1124
  });
1119
1125
  }
1120
1126
 
1127
+ await this._assertSufficientPrepaidFees(senderAccountId);
1128
+
1121
1129
  const authNonce = await this.getAuthNonce(senderAccountId);
1122
1130
  const deadline = this._makeDeadline(deadlineOffset);
1123
1131
  const proofHash = ethers.keccak256(proofHex);
@@ -1376,6 +1384,368 @@ export class AnonymousTransferClient {
1376
1384
  }
1377
1385
  }
1378
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
+
1379
1749
  // ──────────────────── request tracking ─────────────────────────
1380
1750
 
1381
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: "0xdA4a410F600E1C245DC1188Ee559833879f62Fbb", //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
  /**