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.

typescript
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

typescript
// 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.

typescript
// 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.

typescript
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.

typescript
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.

typescript
// 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.

typescript
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

ErrorResolution
Insufficient prepaid fee balanceCall depositFees() with at least getFeeAmount() of the active fee token
Insufficient token balanceTop up the wallet with the required ERC-20 token before depositing
Amount too smallAmount 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 accountEach wallet can only be registered to one anonymous account