@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 +78 -14
- package/examples/anonymous-flow.js +101 -39
- package/package.json +1 -1
- package/src/anonymous-client.js +483 -25
- package/src/constants.js +1 -1
- package/src/index.d.ts +79 -0
package/README.md
CHANGED
|
@@ -236,19 +236,26 @@ const keys = await client.deriveAnonymousKeys(authWallet, accountId);
|
|
|
236
236
|
|
|
237
237
|
### Key Methods
|
|
238
238
|
|
|
239
|
-
#### `
|
|
239
|
+
#### `createAccount(authWallet, accountId, elgamalPublicKey, options?)`
|
|
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
|
+
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
250
|
+
const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
|
|
251
|
+
wallet,
|
|
252
|
+
accountId,
|
|
253
|
+
keys.publicKey,
|
|
254
|
+
);
|
|
247
255
|
```
|
|
248
256
|
|
|
249
|
-
|
|
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.
|
|
433
|
-
const
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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]
|
|
9
|
-
* [6]
|
|
10
|
-
* [7]
|
|
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
|
|
34
|
-
const TRANSFER_AMOUNT =
|
|
35
|
-
const WITHDRAW_AMOUNT =
|
|
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
|
-
|
|
125
|
-
|
|
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(`
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
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]
|
|
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── [
|
|
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
|
-
// ── [
|
|
313
|
+
// ── [7] Apply pending for anon2 ──────────────────────────────────────────────
|
|
267
314
|
|
|
268
|
-
console.log("\n── [
|
|
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
|
-
// ── [
|
|
339
|
+
// ── [8] Withdraw anon2 → public address ──────────────────────────────────────
|
|
293
340
|
|
|
294
|
-
console.log("\n── [
|
|
295
|
-
log(
|
|
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(
|
|
331
|
-
|
|
332
|
-
|
|
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
package/src/anonymous-client.js
CHANGED
|
@@ -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
|
|
127
|
-
* Accepts: ethers.Wallet,
|
|
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
|
-
|
|
131
|
-
if (typeof
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
138
|
-
return
|
|
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
|
-
|
|
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=[]] -
|
|
579
|
-
* @param {Array<string|ethers.Wallet>} [keys.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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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: "
|
|
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
|
/**
|