@fairblock/stabletrust 2.0.6 → 2.0.10

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
@@ -2,12 +2,12 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The StableTrust SDK by Fairblock provides a robust interface for executing confidential and anonymous transfers using homomorphic encryption and zero-knowledge proofs. This package enables developers to integrate confidentiality features directly into their applications, allowing for secure token deposits, private transfers, and withdrawals while maintaining the integrity and auditability of the underlying blockchain transactions.
5
+ The stabletrust SDK by Fairblock provides a robust interface for executing confidential and unlinkable transfers using homomorphic encryption and zero-knowledge proofs. This package enables developers to integrate confidentiality features directly into their applications, allowing for secure token deposits, private transfers, and withdrawals while maintaining the integrity and auditability of the underlying blockchain transactions.
6
6
 
7
7
  For a comprehensive technical understanding of the architecture and cryptographic primitives, please refer to the following documentation:
8
8
 
9
9
  - **Technical Overview**: [Fairblock Confidential Transfers](https://docs.fairblock.network/docs/confidential_transfers/technical_overview)
10
- - **Stabletrust Protocol**: [Stabletrust Documentation](https://docs.fairblock.network/docs/confidential_transfers/stabletrust)
10
+ - **stabletrust Protocol**: [stabletrust Documentation](https://docs.fairblock.network/docs/confidential_transfers/stabletrust)
11
11
  - **Confidential Transactions**: [Transaction Mechanics](https://docs.fairblock.network/docs/confidential_transfers/confidential_transactions)
12
12
 
13
13
  ## Requirements
@@ -41,9 +41,9 @@ The SDK ships two clients that serve different privacy models:
41
41
  | Feature | `ConfidentialTransferClient` | `AnonymousTransferClient` |
42
42
  | :----------------- | :----------------------------------------------- | :----------------------------------------------------------- |
43
43
  | **Identity** | On-chain wallet address is visible | Account ID only — wallet address is hidden |
44
- | **Gas** | User pays gas for all transactions | Fairycloak relay pays gas (except deposit) |
44
+ | **Gas** | User pays gas for all transactions | Relay pays gas (except user-funded raw-tx ops: deposit, fee top-up/withdraw, native-ETH create) |
45
45
  | **Setup** | Direct RPC connection | Requires a running Fairycloak relay server |
46
- | **Recipient** | Must have a confidential account | Can receive to another anonymous account or a public address |
46
+ | **Recipient** | Must have a confidential account | Can receive to another unlinkable account or a public address |
47
47
  | **Key management** | Keys auto-derived from wallet signature | Keys derived per-account; store the private key yourself |
48
48
  | **Best for** | Privacy over balances/amounts with known senders | Full sender anonymity |
49
49
 
@@ -51,9 +51,9 @@ The SDK ships two clients that serve different privacy models:
51
51
 
52
52
  Transactions are submitted on-chain directly from your wallet. The **amount and balance are encrypted** (hidden), but the **sender's address is visible** on-chain. Ideal when you want confidential balances without hiding your identity.
53
53
 
54
- ### `AnonymousTransferClient` — Anonymous Transfers
54
+ ### `AnonymousTransferClient` — Unlinkable Transfers
55
55
 
56
- Transactions are routed through the **Fairycloak relay**, which submits them on-chain and pays gas. The sender's wallet address is **never revealed** on-chain — only a numeric account ID is associated with the transfer. Use this client when full sender anonymity is required.
56
+ Transactions are routed through the **Fairycloak relay**, which submits them on-chain and pays gas (recouped from the account's prepaid fee reserve). The sender's wallet address is **never revealed** on-chain - only the caller-chosen account ID is associated with the transfer. Use this client when full sender anonymity is required.
57
57
 
58
58
  ---
59
59
 
@@ -63,12 +63,12 @@ The following contract addresses are available for confidential transfers on tes
63
63
 
64
64
  | Network(Testnet) | Chain ID | Contract Address |
65
65
  | :--------------- | :------- | :------------------------------------------- |
66
- | Stable | 2201 | `0xB96aa42b246a956B170fE426A72fB610E4976f9E` |
67
- | Arc | 5042002 | `0xb20aB54e1c6AE55B0DD11FEB7FFf3fF1E9631f19` |
68
- | Base | 84532 | `0x962a8A7CD28BfFBb17C4F6Ec388782cca3ffd618` |
69
- | Ethereum | 11155111 | `0x2E48F3D9b8F4aCA9E6AF0630eaB2ceB7A3f5eEd1` |
70
- | Arbitrum | 421614 | `0x14Afd604971bee5b5fac52df2d56CaE421519Cc5` |
71
- | Tempo | 42431 | `0x08B6563C95dfe3a4F5533CAA6F7D55a74FCb4F6c` |
66
+ | Stable | 2201 | `0xe1c1456CAb802312759a8cFc3976f88bf87082cf` |
67
+ | Arc | 5042002 | `0x1B4f05f67CC33788Da4C89a7cd0b2f8E0055E605` |
68
+ | Base | 84532 | `0x4a251C9D79faCa20b193630A4ee313af7cBCDD93` |
69
+ | Ethereum | 11155111 | `0x7507a13352AFAa79D33E994f86f2f62463ba8DE4` |
70
+ | Arbitrum | 421614 | `0x5acECCdeb5CbD3C727eCB49F8706Eb80EF2f977F` |
71
+ | Tempo | 42431 | `0xE559fB936C69c46E216bf61B07C16bF1a6d444aa` |
72
72
 
73
73
  ---
74
74
 
@@ -84,7 +84,7 @@ Import and initialize the client with your network configuration.
84
84
  import { ConfidentialTransferClient } from "@fairblock/stabletrust";
85
85
  import { ethers } from "ethers";
86
86
 
87
- // Use SDK default StableTrust contract for a known chainId
87
+ // Use SDK default stabletrust contract for a known chainId
88
88
  const client = new ConfidentialTransferClient(
89
89
  "https://sepolia.base.org",
90
90
  84532,
@@ -96,7 +96,7 @@ If you are using a custom deployment, pass an explicit contract address:
96
96
  ```javascript
97
97
  const customClient = new ConfidentialTransferClient(
98
98
  "https://sepolia.base.org",
99
- "0xYourCustomStabletrustContract",
99
+ "0xYourCustomstabletrustContract",
100
100
  84532,
101
101
  );
102
102
  ```
@@ -116,14 +116,38 @@ const arcClient = new ConfidentialTransferClient(
116
116
  5042002,
117
117
  );
118
118
 
119
- // Tempo (special fee handling via IPFS proof upload)
119
+ // Tempo (defaults to offchainZKP/IPFS proof upload)
120
120
  const tempoClient = new ConfidentialTransferClient(
121
121
  "https://tempo-rpc.example.com",
122
122
  42431,
123
123
  );
124
124
  ```
125
125
 
126
- **Note on Tempo Chain**: The Tempo network (chainId 42431) uploads ZK proofs to IPFS instead of passing them as calldata. The SDK handles this automatically.
126
+ **Note on off-chain ZKP/IPFS proofs**: The latest confidential-transfer contract exposes `transferConfidential(address,address,bytes,bool)` and `withdraw(address,uint256,bytes,bool)`. By default the SDK submits inline ABI-encoded proof bytes with `offchainZKP=false`. Set `offchainZKP: true` on `confidentialTransfer` or `withdraw` to upload the ABI-encoded proof bytes to StableTrust IPFS and put only the bare CID bytes on-chain. Tempo (chainId 42431) keeps the old SDK behaviour and defaults to `offchainZKP=true`; pass `{ offchainZKP: false }` to force inline proofs.
127
+
128
+ ```javascript
129
+ const client = new ConfidentialTransferClient(rpcUrl, chainId, {
130
+ ipfsUploadUrl: process.env.STABLETRUST_IPFS_UPLOAD_URL,
131
+ ipfsApiKey: process.env.STABLETRUST_IPFS_API_KEY,
132
+ });
133
+
134
+ await client.confidentialTransfer(
135
+ signer,
136
+ recipientAddress,
137
+ tokenAddress,
138
+ amountToTransfer,
139
+ { offchainZKP: true },
140
+ );
141
+
142
+ await client.withdraw(
143
+ signer,
144
+ tokenAddress,
145
+ amountToWithdraw,
146
+ { offchainZKP: true },
147
+ );
148
+ ```
149
+
150
+ The deprecated option name `useOffchainVerify` is still accepted as an alias for `offchainZKP`.
127
151
 
128
152
  ### Token Denomination
129
153
 
@@ -186,7 +210,7 @@ Deposits ERC-20 tokens into the confidential contract. Handles ERC-20 approval a
186
210
 
187
211
  #### `confidentialTransfer(senderWallet, recipientAddress, tokenAddress, amount, options?)`
188
212
 
189
- Transfers a confidential amount to a recipient. The recipient must have an existing confidential account.
213
+ Transfers a confidential amount to a recipient. The recipient must have an existing confidential account. Both inline and IPFS proof paths use the same live non-anonymous transfer fee. The SDK reads the active fee configuration automatically: native fees are attached as `msg.value`, while ERC-20 fees are approved for the diamond when necessary.
190
214
 
191
215
  - **Returns**: Transaction receipt.
192
216
 
@@ -196,9 +220,14 @@ Withdraws from the confidential available balance back to public ERC-20.
196
220
 
197
221
  - **Returns**: Transaction receipt.
198
222
 
199
- #### `getFeeAmount()`
223
+ #### Fee configuration getters
224
+
225
+ - `getFeeToken()` returns the active fee token; `ethers.ZeroAddress` means native currency.
226
+ - `getNonAnonymousTransferFee()` returns the fixed fee used by both inline and IPFS non-anonymous transfers.
227
+ - `getAnonymousIpfsTransferFee()` returns the fixed anonymous IPFS-path fee.
228
+ - `getAnonymousInlineTransferFee()` returns the fixed anonymous inline-path fee.
200
229
 
201
- Returns the native fee (in wei) required for confidential transfers on the current chain.
230
+ All amounts are returned as raw units of the active fee token.
202
231
 
203
232
  #### `getPublicBalance(address, tokenAddress)`
204
233
 
@@ -208,11 +237,11 @@ Returns the public ERC-20 balance for an address.
208
237
 
209
238
  ## AnonymousTransferClient
210
239
 
211
- The `AnonymousTransferClient` routes all operations through the **Fairycloak relay server**. The relay submits transactions on-chain and pays gas on your behalf (except for deposits, which require the user to pay). Your wallet address is never revealed — only a numeric anonymous account ID appears on-chain.
240
+ The `AnonymousTransferClient` routes all operations through the **Fairycloak relay server**. The relay submits transactions on-chain and pays gas on your behalf, recovering the cost from the account's [prepaid fee reserve](#prepaid-fees). The exceptions are the **user-funded raw-tx operations** - `deposit`, `depositFees`, `withdrawFees`, `withdrawAllFees`, and native-ETH account creation - which you sign and pay gas for directly (and which are therefore not charged a prepaid fee). Your wallet address is never revealed - only your chosen unlinkable account ID appears on-chain.
212
241
 
213
- > **Access Required** — Anonymous transfers are available to teams building privacy-critical applications. To obtain a Fairycloak relay URL and API key, reach out to the Fairblock team at [hello@fairblock.network](mailto:hello@fairblock.network).
242
+ > **Access Required** — Unlinkable transfers are available to teams building privacy-critical applications. To obtain a Fairycloak relay URL and API key, reach out to the Fairblock team at [hello@fairblock.network](mailto:hello@fairblock.network).
214
243
 
215
- ### Anonymous Account ID Rules
244
+ ### Unlinkable Account ID Rules
216
245
 
217
246
  Every `accountId` (and `senderAccountId` / `recipientId`) you pass to `AnonymousTransferClient` must follow these rules, enforced both on-chain and by the Fairycloak relay:
218
247
 
@@ -235,9 +264,18 @@ const client = new AnonymousTransferClient({
235
264
  });
236
265
  ```
237
266
 
267
+ **Config options** — `fairycloakUrl`, `chainId`, and `rpcUrl` are required. Optional:
268
+
269
+ | option | default | purpose |
270
+ |---|---|---|
271
+ | `diamondAddress` | auto-resolved by `chainId` | override the ConfidentialMirror contract address |
272
+ | `apiKey` | - | Fairycloak API key (sent as `X-API-Key`) |
273
+ | `ipfsUploadUrl` / `ipfsApiKey` / `ipfsGatewayUrl` | env fallback | off-chain ZKP proof uploads (see [Off-chain ZKP proofs](#off-chain-zkp-proofs-ipfs)) |
274
+ | `enableNativeFeeToken` | `false` | opt in to **native-ETH fee tokens**. Off by default - the SDK refuses account creation when the protocol fee token is native ETH unless this is `true`. See [Prepaid Fees](#prepaid-fees). |
275
+
238
276
  ### Key Derivation
239
277
 
240
- Anonymous accounts use a per-account ElGamal keypair derived from a wallet signature. **Store the returned `privateKey` securely** — it cannot be recovered without the original wallet and account ID.
278
+ Unlinkable accounts use a per-account ElGamal keypair derived from a wallet signature. **Store the returned `privateKey` securely** — it cannot be recovered without the original wallet and account ID.
241
279
 
242
280
  ```javascript
243
281
  const keys = await client.deriveAnonymousKeys(authWallet, accountId);
@@ -249,13 +287,20 @@ const keys = await client.deriveAnonymousKeys(authWallet, accountId);
249
287
 
250
288
  #### `createAccount(authWallet, accountId, elgamalPublicKey, options?)`
251
289
 
252
- Creates a new anonymous account via the relay. The relay pays gas. `accountId` is a **caller-chosen string** — pick any unique identifier you like (e.g. a short UUID fragment or a human-readable name), as long as it follows the [Anonymous Account ID Rules](#anonymous-account-id-rules) above (non-empty, at most 20 characters, alphanumeric only, case-sensitive).
290
+ Creates a new unlinkable account. `accountId` is a **caller-chosen string** — pick any unique identifier you like (e.g. a short UUID fragment or a human-readable name), as long as it follows the [Unlinkable Account ID Rules](#unlinkable-account-id-rules) above (non-empty, at most 20 characters, alphanumeric only, case-sensitive).
291
+
292
+ Creation also seeds the account's **prepaid fee reserve** (see [Prepaid Fees](#prepaid-fees)). It pulls an `initialFeeDeposit` in the active fee token, immediately charges the one-time `anonymousCreateAccountFee`, and credits the remainder to the reserve once creation finalises. The deposit defaults to the contract's `anonymousMinimumInitialFeeDeposit`; override it with `options.initialFeeDeposit`. The submission model is chosen automatically by the active fee token:
293
+
294
+ - **ERC-20 fee token** (default): the SDK checks/approves the deposit and the **relay submits + pays gas**.
295
+ - **Native ETH fee token** (`feeToken == address(0)`): ETH can't be pulled with `transferFrom`, so the fee payer **signs and funds the payable tx itself** (`msg.value == initialFeeDeposit`, `authWallet` pays gas). This path is **disabled unless** the client is constructed with `enableNativeFeeToken: true` — otherwise `createAccount` throws.
296
+
297
+ - **Options**: `deadlineOffset` (default `3600`), `initialFeeDeposit` (default = contract minimum), `gasLimit` (default `1000000n`, only used for the native raw tx)
253
298
 
254
299
  #### `ensureAnonymousAccount(authWallet, accountId, elgamalPublicKey, options?)`
255
300
 
256
- 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`.
301
+ Idempotent helper that creates the unlinkable 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`.
257
302
 
258
- > 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`.
303
+ > Note: an auth signer can only ever be bound to a single unlinkable account, so reusing a wallet across different `accountId`s will fail at creation time even via `ensureAnonymousAccount`.
259
304
 
260
305
  ```javascript
261
306
  const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
@@ -270,7 +315,7 @@ const { accountId, accountInfo, created } = await client.ensureAnonymousAccount(
270
315
 
271
316
  #### `updateAuthKeys(authWallet, accountId, { add, remove }, options?)`
272
317
 
273
- Adds or removes authorised signers for an anonymous account. Pass `ethers.Wallet` instances or raw uncompressed hex pubkey strings.
318
+ Adds or removes authorised signers for an unlinkable account. Pass `ethers.Wallet` instances or raw uncompressed hex pubkey strings. The relay pays gas and charges the `anonymousUpdateKeysFee` from the account's [prepaid reserve](#prepaid-fees).
274
319
 
275
320
  #### `getAnonymousAccountInfo(accountId)`
276
321
 
@@ -294,7 +339,7 @@ Returns decrypted balances including raw ciphertexts — useful when you need th
294
339
 
295
340
  #### `deposit(authWallet, accountId, tokenAddress, amount, options?)`
296
341
 
297
- Deposits tokens into an anonymous account. The **user pays gas** for this operation. Handles ERC-20 approval automatically.
342
+ Deposits tokens into an unlinkable account. The **user pays gas** for this operation. Handles ERC-20 approval automatically.
298
343
 
299
344
  ```javascript
300
345
  const result = await client.deposit(
@@ -308,7 +353,7 @@ await client.waitForRequest(result.request_id);
308
353
 
309
354
  #### `transferToPublic(authWallet, accountId, params, options?)`
310
355
 
311
- Transfers from an anonymous account to a public EVM address. The relay pays gas.
356
+ Transfers from an unlinkable account to a public EVM address. The relay pays gas and charges the inline or IPFS transfer fee (depending on `offchainZKP`) from the sender's [prepaid reserve](#prepaid-fees).
312
357
 
313
358
  **Auto-proof mode** (recommended):
314
359
 
@@ -339,7 +384,7 @@ const result = await client.transferToPublic(wallet, accountId, {
339
384
 
340
385
  #### `transferToAnonymous(authWallet, senderAccountId, params, options?)`
341
386
 
342
- Transfers between two anonymous accounts. The relay pays gas.
387
+ Transfers between two unlinkable accounts. The relay pays gas and charges the inline or IPFS transfer fee (depending on `offchainZKP`) from the sender's [prepaid reserve](#prepaid-fees).
343
388
 
344
389
  ```javascript
345
390
  const result = await client.transferToAnonymous(wallet, senderAccountId, {
@@ -352,11 +397,11 @@ const result = await client.transferToAnonymous(wallet, senderAccountId, {
352
397
 
353
398
  #### `applyPending(authWallet, accountId, options?)`
354
399
 
355
- Moves a pending incoming balance into available. Must be called after receiving an anonymous-to-anonymous transfer. The relay pays gas.
400
+ Moves a pending incoming balance into available. Must be called after receiving an unlinkable-to-unlinkable transfer. The relay pays gas and charges the `anonymousApplyPendingFee` from the account's [prepaid reserve](#prepaid-fees).
356
401
 
357
402
  #### `withdraw(authWallet, accountId, params, options?)`
358
403
 
359
- Withdraws from an anonymous account to a public EVM address. The relay pays gas.
404
+ Withdraws from an unlinkable account to a public EVM address. The relay pays gas and charges the inline or IPFS withdraw fee (depending on `offchainZKP`) from the account's [prepaid reserve](#prepaid-fees).
360
405
 
361
406
  ```javascript
362
407
  const result = await client.withdraw(wallet, accountId, {
@@ -367,32 +412,110 @@ const result = await client.withdraw(wallet, accountId, {
367
412
  });
368
413
  ```
369
414
 
415
+ ### Off-chain ZKP proofs (IPFS)
416
+
417
+ By default an unlinkable proof is submitted **inline** through the Fairycloak relay. For `transferToPublic`, `transferToAnonymous`, and `withdraw` you can instead set **`offchainZKP: true`** to upload the proof to **StableTrust self-hosted IPFS** and put only the resulting **CID** on-chain. Fairycloak/Fairyport then fetches the full proof from IPFS and forwards the actual proof bytes to the CosmWasm contract. This keeps large (~2.5 KB) proofs out of calldata. The flag is optional and defaults to `false`, so the inline path is unchanged. These proof-carrying methods support it; `deposit` and `applyPending` do not (they carry no ZK proof).
418
+
419
+ **Providing StableTrust IPFS upload config.** When `offchainZKP` is enabled the SDK uploads proof bytes to the configured StableTrust IPFS upload endpoint. Provide it either via the client config (recommended, and required in browsers where `process.env` is unavailable):
420
+
421
+ ```javascript
422
+ const client = new AnonymousTransferClient({
423
+ fairycloakUrl: "http://127.0.0.1:8080",
424
+ chainId: 31337,
425
+ rpcUrl: "http://127.0.0.1:8545",
426
+ ipfsUploadUrl: process.env.STABLETRUST_IPFS_UPLOAD_URL,
427
+ ipfsApiKey: process.env.STABLETRUST_IPFS_API_KEY,
428
+ });
429
+ ```
430
+
431
+ …or, in Node.js, by setting `STABLETRUST_IPFS_UPLOAD_URL` and `STABLETRUST_IPFS_API_KEY` environment variables, used as fallback values when they are not passed in client config.
432
+
433
+ **Usage** - add the flag to a normal transfer:
434
+
435
+ ```javascript
436
+ // unlinkable -> unlinkable; proof stored on IPFS, CID on-chain
437
+ await client.transferToAnonymous(wallet, "alice", {
438
+ recipientId: "bob",
439
+ token: tokenAddress,
440
+ amount: ethers.parseUnits("5", 18),
441
+ elGamalPrivateKey: keys.privateKey,
442
+ offchainZKP: true,
443
+ });
444
+
445
+ // unlinkable -> public EVM address
446
+ await client.transferToPublic(wallet, "alice", {
447
+ recipient: "0xRecipientAddress",
448
+ token: tokenAddress,
449
+ amount: ethers.parseUnits("5", 18),
450
+ elGamalPrivateKey: keys.privateKey,
451
+ offchainZKP: true,
452
+ });
453
+
454
+ // withdraw to a public address; withdraw proof stored on IPFS, CID on-chain
455
+ await client.withdraw(wallet, "alice", {
456
+ destination: "0xDestinationAddress",
457
+ token: tokenAddress,
458
+ plainAmount: ethers.parseUnits("5", 18),
459
+ elGamalPrivateKey: keys.privateKey,
460
+ offchainZKP: true,
461
+ });
462
+ ```
463
+
464
+ In manual-proof mode (when you pass a pre-computed `proof`) with `offchainZKP: true`, the SDK treats `proof` as an already-uploaded CID rather than re-uploading it.
465
+
370
466
  ### Prepaid Fees
371
467
 
372
- 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.
468
+ The protocol uses a **prepaid-fee-for-all-operations** model. Each unlinkable account holds a **prepaid fee reserve** (a pot in the active fee token), and every relay-submitted operation debits a fixed fee from it:
469
+
470
+ | operation | fee |
471
+ |---|---|
472
+ | create account | `anonymousCreateAccountFee` (charged once, at creation) |
473
+ | update auth keys | `anonymousUpdateKeysFee` |
474
+ | transfer (→ public / → anonymous) | `anonymousInlineTransferFee` or `anonymousIpfsTransferFee` |
475
+ | apply pending | `anonymousApplyPendingFee` |
476
+ | withdraw | `anonymousInlineWithdrawFee` or `anonymousIpfsWithdrawFee` |
477
+
478
+ The relay fronts the gas for these operations and is reimbursed from the pot. **User-funded raw-tx operations do _not_ charge a prepaid fee** - `deposit`, `depositFees` (top-up), `withdrawFees`, and `withdrawAllFees` are signed and paid for by the user's own wallet, so there is nothing to reimburse.
479
+
480
+ The reserve is seeded at [account creation](#createaccountauthwallet-accountid-elgamalpublickey-options) (`initialFeeDeposit − anonymousCreateAccountFee`) and topped up with `depositFees`. Transfers additionally pre-check the reserve before submitting and throw a descriptive error if it can't cover the fee.
481
+
482
+ **Fee token.** There is one active fee token for the whole protocol, set by the admin and read via `getFeeToken()`. It can be an **ERC-20** (the default) or **native ETH** (`address(0)`) - fee amounts and the prepaid pot are denominated in whichever it is. Using a native-ETH fee token requires opting in with `enableNativeFeeToken: true` in the client config (see [createAccount](#createaccountauthwallet-accountid-elgamalpublickey-options)).
373
483
 
374
484
  #### `getFeeToken()`
375
485
 
376
- 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.
486
+ Returns the currently configured fee token address (checksummed). `ethers.ZeroAddress` represents native currency. `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.
377
487
 
378
- #### `getFeeAmount()`
488
+ #### Fee-config getters
379
489
 
380
- 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.
490
+ All getters read live from the protocol and return **raw units of the active fee token** (`0n` when a pathway has no configured fee).
491
+
492
+ Per-operation fees:
493
+
494
+ - `getAnonymousCreateAccountFee()` - one-time account-creation fee.
495
+ - `getAnonymousMinimumInitialFeeDeposit()` - minimum `initialFeeDeposit` accepted at creation.
496
+ - `getAnonymousUpdateKeysFee()` - auth-key update.
497
+ - `getAnonymousApplyPendingFee()` - apply-pending.
498
+ - `getAnonymousInlineTransferFee()` / `getAnonymousIpfsTransferFee()` - inline / IPFS transfer.
499
+ - `getAnonymousInlineWithdrawFee()` / `getAnonymousIpfsWithdrawFee()` - inline / IPFS withdraw.
500
+ - `getNonAnonymousTransferFee()` - the shared non-anonymous transfer fee.
381
501
 
382
502
  #### `getPrepaidFeeBalance(accountId, token)`
383
503
 
384
- Returns the prepaid fee balance (in raw ERC-20 units) for an anonymous account and a given fee token.
504
+ Returns the prepaid fee balance in raw units of the selected fee token. For native currency reserves, the amount is in wei.
385
505
 
386
506
  #### `depositFees(authWallet, accountId, amount, options?)`
387
507
 
388
- 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.
508
+ Deposits tokens into the prepaid fee reserve for an unlinkable account. **The user pays gas** for this operation (mirrors `deposit()`). Handles ERC-20 approval against the active fee token automatically.
389
509
 
390
510
  ```javascript
511
+ const offchainZKP = true;
391
512
  const feeToken = await client.getFeeToken();
392
- const feeAmount = await client.getFeeAmount();
513
+ const inlineFee = await client.getAnonymousInlineTransferFee();
514
+ const ipfsFee = await client.getAnonymousIpfsTransferFee();
515
+ const requiredFee = offchainZKP ? ipfsFee : inlineFee;
393
516
  const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
394
517
 
395
- if (feeBalance < feeAmount) {
518
+ if (feeBalance < requiredFee) {
396
519
  const result = await client.depositFees(
397
520
  wallet,
398
521
  accountId,
@@ -404,11 +527,11 @@ if (feeBalance < feeAmount) {
404
527
 
405
528
  #### `withdrawFees(authWallet, accountId, { token, destination, amount }, options?)`
406
529
 
407
- 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.
530
+ Reclaims a specific amount from the prepaid fee reserve for a given fee token. **User-funded raw-tx** model (like `deposit`/`depositFees`): the anonymous signer authorises the withdrawal via EIP-712 and the same wallet signs and funds the outer transaction, so **the user pays gas and no prepaid fee is charged** — the full amount is returned. Use this to reclaim a specific (possibly historical) fee token.
408
531
 
409
532
  #### `withdrawAllFees(authWallet, accountId, { destination }, options?)`
410
533
 
411
- Withdraws all prepaid fee balances across all (including historical) fee tokens to `destination`. The relay pays gas.
534
+ Drains all prepaid fee balances across all (including historical) fee tokens to `destination`. Same user-funded raw-tx model as `withdrawFees` — the user pays gas, no prepaid fee.
412
535
 
413
536
  #### Request Tracking
414
537
 
@@ -475,7 +598,7 @@ const withdrawAmount = ethers.parseUnits("2", tokenDecimals);
475
598
  await client.withdraw(wallet, tokenAddress, withdrawAmount);
476
599
  ```
477
600
 
478
- ### Anonymous Flow (AnonymousTransferClient)
601
+ ### Unlinkable Flow (AnonymousTransferClient)
479
602
 
480
603
  ```javascript
481
604
  import { AnonymousTransferClient } from "@fairblock/stabletrust";
@@ -512,12 +635,13 @@ const balance = await client.getBalance(
512
635
  );
513
636
  console.log("Available:", balance.available, "Pending:", balance.pending);
514
637
 
515
- // 4. Top up the prepaid fee reserve if needed (user pays gas)
516
- // transferToPublic/transferToAnonymous deduct a protocol fee from this reserve.
638
+ // 4. Top up the prepaid fee reserve if needed (user pays gas; no fee on the top-up itself)
639
+ // The reserve was seeded at account creation; every relay op (update-keys/transfer/apply/withdraw)
640
+ // draws a fixed fee from it. Here we make sure it can cover the next transfer.
517
641
  const feeToken = await client.getFeeToken();
518
- const feeAmount = await client.getFeeAmount();
642
+ const inlineFee = await client.getAnonymousInlineTransferFee();
519
643
  const feeBalance = await client.getPrepaidFeeBalance(accountId, feeToken);
520
- if (feeBalance < feeAmount) {
644
+ if (feeBalance < inlineFee) {
521
645
  const feeResult = await client.depositFees(
522
646
  wallet,
523
647
  accountId,
@@ -526,7 +650,7 @@ if (feeBalance < feeAmount) {
526
650
  await client.waitForRequest(feeResult.request_id);
527
651
  }
528
652
 
529
- // 5. Transfer to a public address (relay pays gas)
653
+ // 5. Transfer to a public address (relay pays gas; a transfer fee is drawn from the prepaid reserve)
530
654
  const transferResult = await client.transferToPublic(wallet, accountId, {
531
655
  recipient: "0xRecipientAddress",
532
656
  token: tokenAddress,
@@ -535,7 +659,7 @@ const transferResult = await client.transferToPublic(wallet, accountId, {
535
659
  });
536
660
  await client.waitForRequest(transferResult.request_id);
537
661
 
538
- // 6. Withdraw (relay pays gas)
662
+ // 6. Withdraw (relay pays gas; a withdraw fee is drawn from the prepaid reserve)
539
663
  const withdrawResult = await client.withdraw(wallet, accountId, {
540
664
  destination: wallet.address,
541
665
  token: tokenAddress,
@@ -572,39 +696,26 @@ try {
572
696
 
573
697
  ### Common Issues and Solutions
574
698
 
575
- | Issue | Cause | Solution |
576
- | :----------------------------- | :------------------------------------------------------ | :------------------------------------------------------ |
577
- | "Account does not exist" | Recipient hasn't initialized their confidential account | Recipient must call `ensureAccount()` first |
578
- | "Insufficient balance" | Transfer amount exceeds available confidential balance | Deposit more tokens or reduce transfer amount |
579
- | "Account finalization timeout" | Account creation is still processing | Wait a few minutes and retry |
580
- | "Proof generation failed" | Invalid inputs or cryptographic operation error | Verify all parameters and ensure sufficient balance |
581
- | "Amount too small" | Amount rounds to 0 in contract scale | Use a larger amount (minimum depends on token decimals) |
582
- | "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 |
583
- | Fairycloak HTTP error | Relay unreachable or request rejected | Check the relay URL, API key, and account authorization |
584
-
585
- ---
586
-
587
- ## Performance Metrics
588
-
589
- The following are estimated execution times for standard operations. Durations may vary based on network congestion and hardware.
590
-
591
- | Operation | Avg Duration |
592
- | :--------------- | :----------- |
593
- | Account Creation | 45s |
594
- | Deposit | 63s |
595
- | Transfer | 58s |
596
- | Withdraw | 58s |
699
+ | Issue | Cause | Solution |
700
+ | :--------------------------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------ |
701
+ | "Account does not exist" | Recipient hasn't initialized their confidential account | Recipient must call `ensureAccount()` first |
702
+ | "Insufficient balance" | Transfer amount exceeds available confidential balance | Deposit more tokens or reduce transfer amount |
703
+ | "Account finalization timeout" | Account creation is still processing | Wait a few minutes and retry |
704
+ | "Proof generation failed" | Invalid inputs or cryptographic operation error | Verify all parameters and ensure sufficient balance |
705
+ | "Amount too small" | Amount rounds to 0 in contract scale | Use a larger amount (minimum depends on token decimals) |
706
+ | "Insufficient prepaid fee balance" | Unlinkable account's prepaid fee reserve is below the selected inline/IPFS transfer fee | Call `depositFees(wallet, accountId, amount)` to top up; use `getFeeToken()` and the matching anonymous fee getter |
707
+ | Fairycloak HTTP error | Relay unreachable or request rejected | Check the relay URL, API key, and account authorization |
597
708
 
598
709
  ---
599
710
 
600
711
  ## Security Considerations
601
712
 
602
- When using the StableTrust SDK, follow these best practices to ensure the security of your confidential transactions:
713
+ When using the stabletrust SDK, follow these best practices to ensure the security of your confidential transactions:
603
714
 
604
715
  1. **Private Key Management**
605
716
  - Never expose or log private keys or seed phrases
606
717
  - Store private keys securely (e.g., hardware wallets, encrypted vaults)
607
- - Anonymous account `privateKey` values are sensitive — treat them the same as wallet private keys
718
+ - Unlinkable account `privateKey` values are sensitive — treat them the same as wallet private keys
608
719
 
609
720
  2. **Signer Security**
610
721
  - Use secure signer implementations (e.g., hardware wallets, encrypted key stores)
@@ -622,7 +733,7 @@ When using the StableTrust SDK, follow these best practices to ensure the securi
622
733
 
623
734
  5. **Balance Verification**
624
735
  - Check available balance before initiating transfers
625
- - Anonymous transfers between anonymous accounts require `applyPending()` before the recipient can spend
736
+ - Unlinkable transfers between unlinkable accounts require `applyPending()` before the recipient can spend
626
737
 
627
738
  6. **Error Handling**
628
739
  - Implement comprehensive error handling for all SDK operations
@@ -24,7 +24,7 @@ dotenv.config();
24
24
 
25
25
  const EVM_RPC = process.env.EVM_RPC ?? "http://127.0.0.1:8545";
26
26
  const FAIRYCLOAK_URL = process.env.FAIRYCLOAK_URL ?? "http://127.0.0.1:8080";
27
- const CHAIN_ID = Number(process.env.CHAIN_ID ?? "31337");
27
+ const CHAIN_ID = Number(process.env.CHAIN_ID ?? "5042002");
28
28
  const TOKEN_ADDRESS =
29
29
  process.env.TOKEN_ADDRESS ?? "0xE915164570b027C2A0FfadcB1B672192E35BF008";
30
30
  const TOKEN_DECIMALS = Number(process.env.TOKEN_DECIMALS ?? "6");
@@ -232,38 +232,49 @@ async function main() {
232
232
  console.log("\n── [5] Top up prepaid fees for anon1 ───────────────────");
233
233
 
234
234
  const feeToken = await client.getFeeToken();
235
- const feeAmount = await client.getFeeAmount();
235
+ const inlineFee = await client.getAnonymousInlineTransferFee();
236
236
  log(" fee token :", feeToken);
237
- log(" fee/xfer :", fmt(feeAmount, TOKEN_DECIMALS), "tokens");
238
-
239
- const feeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
240
- log(" fee balance:", fmt(feeBalance, TOKEN_DECIMALS), "tokens");
241
-
242
- if (feeBalance < MIN_FEE_BALANCE) {
243
- log(
244
- ` balance < ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}, depositing ${fmt(FEE_DEPOSIT_AMOUNT, TOKEN_DECIMALS)} fee tokens (anon1 pays gas) …`,
245
- );
246
- const fd = await client.depositFees(
247
- anon1Wallet,
248
- anon1Id,
249
- FEE_DEPOSIT_AMOUNT,
250
- );
251
- log(" request_id:", fd.request_id, " status:", fd.status);
252
- if (fd.error) log(" [!] Error:", fd.error);
253
- if (fd.tx_hash) log(" tx_hash :", fd.tx_hash);
254
-
255
- const fdFinal = await client.waitForRequest(fd.request_id, {
256
- timeoutMs: 180_000,
257
- });
258
- log(" status :", fdFinal.status, " tx_hash:", fdFinal.tx_hash ?? "—");
259
- if (fdFinal.error) log(" [!] Error:", fdFinal.error);
260
-
261
- const newFeeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
262
- log(" new fee balance:", fmt(newFeeBalance, TOKEN_DECIMALS), "tokens");
237
+ log(" inline fee :", fmt(inlineFee, TOKEN_DECIMALS), "tokens");
238
+
239
+ if (inlineFee === 0n) {
240
+ log(" no fee configured — transactions are free, skipping top-up.");
263
241
  } else {
264
- log(
265
- ` fee balance sufficient (${fmt(feeBalance, TOKEN_DECIMALS)} ≥ ${fmt(MIN_FEE_BALANCE, TOKEN_DECIMALS)}), skipping deposit.`,
266
- );
242
+ const feeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
243
+ log(" fee balance:", fmt(feeBalance, TOKEN_DECIMALS), "tokens");
244
+
245
+ const requiredFeeBalance =
246
+ inlineFee > MIN_FEE_BALANCE ? inlineFee : MIN_FEE_BALANCE;
247
+
248
+ if (feeBalance < requiredFeeBalance) {
249
+ const currency = feeToken === ethers.ZeroAddress ? "native" : "ERC-20";
250
+ const deficit = requiredFeeBalance - feeBalance;
251
+ const topUpAmount =
252
+ FEE_DEPOSIT_AMOUNT > deficit ? FEE_DEPOSIT_AMOUNT : deficit;
253
+ log(
254
+ ` balance < ${fmt(requiredFeeBalance, TOKEN_DECIMALS)}, depositing ${fmt(topUpAmount, TOKEN_DECIMALS)} ${currency} fee tokens (anon1 pays gas) …`,
255
+ );
256
+ const fd = await client.depositFees(
257
+ anon1Wallet,
258
+ anon1Id,
259
+ topUpAmount,
260
+ );
261
+ log(" request_id:", fd.request_id, " status:", fd.status);
262
+ if (fd.error) log(" [!] Error:", fd.error);
263
+ if (fd.tx_hash) log(" tx_hash :", fd.tx_hash);
264
+
265
+ const fdFinal = await client.waitForRequest(fd.request_id, {
266
+ timeoutMs: 180_000,
267
+ });
268
+ log(" status :", fdFinal.status, " tx_hash:", fdFinal.tx_hash ?? "—");
269
+ if (fdFinal.error) log(" [!] Error:", fdFinal.error);
270
+
271
+ const newFeeBalance = await client.getPrepaidFeeBalance(anon1Id, feeToken);
272
+ log(" new fee balance:", fmt(newFeeBalance, TOKEN_DECIMALS), "tokens");
273
+ } else {
274
+ log(
275
+ ` fee balance sufficient (${fmt(feeBalance, TOKEN_DECIMALS)} ≥ ${fmt(requiredFeeBalance, TOKEN_DECIMALS)}), skipping deposit.`,
276
+ );
277
+ }
267
278
  }
268
279
 
269
280
  // ── [6] Transfer anon1 → anon2 ───────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fairblock/stabletrust",
3
- "version": "2.0.6",
3
+ "version": "2.0.10",
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",
@@ -28,7 +28,10 @@
28
28
  "url": "https://github.com/Fairblock/stabletrust-sdk"
29
29
  },
30
30
  "scripts": {
31
- "test": "echo \"Error: no test specified\" && exit 1"
31
+ "test": "node --test test/*.test.mjs",
32
+ "test:live": "node --test test/ipfs-live.mjs",
33
+ "check:abi": "node scripts/check-contract-abi.mjs",
34
+ "check": "npm run check:abi && npm test"
32
35
  },
33
36
  "keywords": [
34
37
  "confidential",
@@ -41,10 +44,14 @@
41
44
  "author": "",
42
45
  "license": "Apache-2.0",
43
46
  "dependencies": {
47
+ "@noble/bls12-381": "^1.4.0",
48
+ "@noble/hashes": "^1.3.2",
49
+ "@stablelib/chacha20poly1305": "^1.0.1",
50
+ "axios": "^1.16.0",
44
51
  "dotenv": "^17.2.3",
45
52
  "ethers": "^6.16.0"
46
53
  },
47
54
  "engines": {
48
- "node": ">=16.0.0"
55
+ "node": ">=18.0.0"
49
56
  }
50
57
  }