SDK
Anonymous Transfer Client
Full sender anonymity for EVM token transfers. Your wallet address never appears on-chain. All operations except deposits are routed through the Fairycloak relay, which pays gas.
Access required
The anonymous client requires a Fairycloak relay URL and API key. Contact hello@fairblock.network to get access.
Initialize
Pass your relay URL, chain ID, and an RPC endpoint. The contract address resolves automatically from the chain ID. See Introduction for all supported chains.
import { AnonymousTransferClient } from "@fairblock/stabletrust";
import { ethers } from "ethers";
const client = new AnonymousTransferClient({
fairycloakUrl: "YOUR_FAIRYCLOAK_URL", // contact hello@fairblock.network
chainId: 84532,
rpcUrl: "https://sepolia.base.org",
// apiKey: "YOUR_API_KEY",
});
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, client.provider);Create an account
Account IDs are alphanumeric strings you choose freely. Your wallet is never linked to the account on-chain.
Account ID rules
·Non-empty string, at most 20 characters
·Alphanumeric only — A–Z, a–z, 0–9. No spaces, hyphens, or special characters
·Case-sensitive. "Alice" and "alice" are different accounts
·Validated client-side before any request. A descriptive error is thrown if the rules are not met
// Account IDs are alphanumeric strings you choose (max 20 chars, case-sensitive).
// Your wallet address never appears on-chain — only this ID does.
const accountId = "alice123";
// Derive a deterministic ElGamal keypair from wallet + accountId.
// Re-derive from the same inputs at any time to recover it.
const keys = await client.deriveAnonymousKeys(wallet, accountId);
// Create the account. Idempotent — safe to call on every startup.
// The relay submits the transaction and pays gas.
const { created } = await client.ensureAnonymousAccount(
wallet,
accountId,
keys.publicKey,
);Store the private key securely. It is required for balance decryption and proof generation. Re-derivable from the same wallet and accountId at any time.
Top up prepaid fees
Each transfer debits a small ERC-20 fee from a per-account reserve. Deposit enough to cover several transfers before starting. The SDK checks this balance before every transfer and throws a descriptive error if it is insufficient.
// Transfers deduct a small ERC-20 fee from a per-account prepaid reserve.
const feeToken = await client.getFeeToken();
const feeAmount = await client.getFeeAmount();
const feeBal = await client.getPrepaidFeeBalance(accountId, feeToken);
if (feeBal < feeAmount) {
const result = await client.depositFees(wallet, accountId, feeAmount * 10n);
await client.waitForRequest(result.request_id);
}Deposit
The only operation where your wallet pays gas. ERC-20 approval is handled automatically.
const tokenAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const tokenDecimals = 6;
// The only operation where your wallet pays gas.
// ERC-20 approval is handled automatically.
const result = await client.deposit(
wallet,
accountId,
tokenAddress,
ethers.parseUnits("10", tokenDecimals),
);
await client.waitForRequest(result.request_id);Check balance
Decryption happens entirely client-side. Your private key never leaves your device.
const balance = await client.getBalance(accountId, tokenAddress, keys.privateKey);
// Decryption happens client-side. Your private key never leaves your device.
// Amounts are in contract scale (rawAmount × 100 / 10^decimals).
// For a 6-decimal token: 1000 = 10 tokens.
console.log("Available:", balance.available);
console.log("Pending: ", balance.pending);Transfer
Send to a public address or to another anonymous account. The relay pays gas and your wallet is never revealed.
// To a public address
const result = await client.transferToPublic(wallet, accountId, {
recipient: "0xRecipientAddress",
token: tokenAddress,
elGamalPrivateKey: keys.privateKey,
amount: ethers.parseUnits("3", tokenDecimals),
});
await client.waitForRequest(result.request_id);
// To another anonymous account
const result2 = await client.transferToAnonymous(wallet, accountId, {
recipientId: "bob456",
token: tokenAddress,
elGamalPrivateKey: keys.privateKey,
amount: ethers.parseUnits("3", tokenDecimals),
});
await client.waitForRequest(result2.request_id);
// The recipient must call applyPending() before spending received funds.
await client.applyPending(recipientWallet, "bob456");After an anon-to-anon transfer, funds land in pending state. The recipient must call applyPending() before spending.
Withdraw
Send funds from your anonymous balance to any public EVM address. The relay pays gas and the ZK proof is generated client-side.
const result = await client.withdraw(wallet, accountId, {
destination: wallet.address,
token: tokenAddress,
plainAmount: ethers.parseUnits("2", tokenDecimals),
elGamalPrivateKey: keys.privateKey,
});
await client.waitForRequest(result.request_id);Method reference
deriveAnonymousKeys(wallet, accountId)Derives a deterministic ElGamal keypair from your wallet and account ID. Re-derive from the same inputs at any time to recover the keypair.
Store the privateKey securely. It is required for every balance check, transfer, and withdrawal.
ensureAnonymousAccount(wallet, accountId, publicKey, options?)Creates an anonymous account if it does not exist, then waits until it is ready. Idempotent — safe to call on every startup. The relay pays gas.
getFeeToken()Returns the checksummed ERC-20 address of the active fee token.
getFeeAmount()Returns the fee deducted per anonymous transfer in raw ERC-20 units (bigint).
getPrepaidFeeBalance(accountId, token)Returns the current prepaid fee reserve for an account and token in raw ERC-20 units (bigint).
depositFees(wallet, accountId, amount, options?)Tops up the prepaid fee reserve. ERC-20 approval is automatic. The user's wallet pays gas.
deposit(wallet, accountId, tokenAddress, amount, options?)Deposits ERC-20 tokens into the anonymous account. ERC-20 approval is automatic. The only operation where the user's wallet pays gas.
getBalance(accountId, tokenAddress, privateKey)Decrypts and returns available and pending balances in contract scale. Decryption is client-side.
transferToPublic(wallet, accountId, params, options?)Transfers from an anonymous account to a public EVM address. Relay pays gas. Requires sufficient prepaid fee balance.
transferToAnonymous(wallet, senderAccountId, params, options?)Transfers between two anonymous accounts. Relay pays gas. Funds land in pending state — the recipient must call applyPending() before spending.
applyPending(wallet, accountId, options?)Moves a pending incoming balance into the available balance. Must be called by the recipient after receiving an anon-to-anon transfer. Relay pays gas.
withdraw(wallet, accountId, params, options?)Withdraws to a public EVM address. ZK proof generated client-side. Relay pays gas.
updateAuthKeys(wallet, accountId, { add, remove }, options?)Adds or removes authorized signers for an anonymous account. Accepts ethers.Wallet instances, address strings, or uncompressed hex pubkeys.
waitForRequest(requestId, options?)Polls a relay request until it reaches a terminal state (completed, confirmed, or failed). Returns the final status object.
getRequestStatus(requestId)Returns the current status of a relay request without blocking.
getRequestEvents(requestId, options?)Returns the full event history for a relay request. Useful for reconnect and recovery in frontend applications.
Common errors
| Error | Resolution |
|---|---|
Insufficient prepaid fee balance | Call depositFees() with at least getFeeAmount() of the active fee token |
Insufficient token balance | Top up the wallet with the required ERC-20 token before depositing |
Amount too small | Amount rounds to 0 in contract scale. Use a larger value |
Fairycloak ... (HTTP 4xx) | Verify relay URL, API key, and that the signing wallet is authorized |
Signer already bound to a different account | Each wallet can only be registered to one anonymous account |