Skip to main content

Client SDK

The Merces client runs entirely in your application. It generates proofs locally, holds the authentication secret, reads balances from the query nodes and submits transactions directly to the Merces contract. Amounts and balances never leave it in the clear.

This page covers the TypeScript client. A Rust client exists at parity and is used internally — see Rust client.

Requirements

Node.js 22 or later. An account with a funded ERC-20 balance on the chain you are targeting, and the deployment's contract addresses and query-node URLs — see Deployments & status.

The ERC-20 you wrap must support ERC-2612 permits. Deposits are signed as a permit rather than a separate approve() transaction.

RPC Stability

Note that - depending on the stability of your chosen RPC provider - you may have to add retries to various calls.

Install

npm install @taceo/merces-client viem

viem is also a direct dependency of the client, so keep your version in the same ^2.55.0 range to avoid two copies in the tree.

The package ships its own proving artifacts — the circuit wasm and proving keys are inside it, about 10 MB — so there is nothing to download before generating a proof. They are resolved relative to the module and cached in memory after first use, which works in Node, Vite, webpack and Next.js alike. Expect the first proof of a process to be slower than the rest.

The authentication secret

Every account is identified in the ID registry by a commitment to a BN254 field element called the authentication secret. Transfer and withdraw proofs prove ownership of the registered leaf using it.

Derive it from a wallet signature. The derivation is deterministic — the same wallet and ID registry always produce the same secret — so it does not need storing, only re-deriving:

import { deriveAuthSecret } from '@taceo/merces-client';

const authSecret = await deriveAuthSecret(walletClient, ID_REGISTRY_ADDRESS);

This prompts the wallet for an EIP-712 signature. Remote wallets will show it as a human-readable AuthSecretDerivation message rather than an opaque hash.

If you hold the secret elsewhere — a key vault, a CLI flag — parse it from its decimal form instead:

import { parseAuthSecret } from '@taceo/merces-client';

const authSecret = parseAuthSecret(process.env.MERCES_AUTH_SECRET!);
warning

The authentication secret is a spending credential. Anyone who holds it can prove ownership of the account's balance. Treat it like a private key: never log it, never send it to a server, and never put it in client-side storage you do not control.

Register

An address must be registered at the ID registry before it can hold a balance. Registration joins a user group, which sets the account's per-transaction and per-epoch limits.

import { isRegistered, register } from '@taceo/merces-client';

if (!await isRegistered({ publicClient, idRegistryAddress: ID_REGISTRY_ADDRESS, address })) {
const index = await register({
publicClient,
walletClient,
idRegistryAddress: ID_REGISTRY_ADDRESS,
authSecret,
groupId: GROUP_ID,
maxFee: parseUnits('1', decimals),
});
}

register returns the account's leaf index in the tree. It is idempotent for a matching registration: if the address is already registered under the same groupId and authSecret, it returns the existing index rather than reverting, so the isRegistered check above is a convenience, not a requirement. If the address is already registered under a different group or secret, it throws AlreadyRegisteredInDifferentGroupError or AlreadyRegisteredWithDifferentAuthSecretError instead — see Errors.

ArgumentRequiredNotes
publicClient / walletClientYesStandard viem clients
idRegistryAddressYesThe IIdRegistry contract
authSecretYesFrom deriveAuthSecret or parseAuthSecret
groupIdYesThe user group to join
maxFeeYesThe highest join fee you are willing to pay. The call reverts rather than exceeding it
userGroupAuthorizationNoSigned authorization, for groups that are not open to join
deadlineNoUnix seconds. Defaults to one hour from now
complianceDataNoAttestation bytes, if the deployment gates registration on a policy check. See Compliance

Registration touches only the ID registry — not the Merces contract, the token, or the query nodes.

Note: Some limitations apply when using the SDK with mainnet deployments. Use GROUP_ID "4" when registering on mainnet -- limits regarding number of wallets, transactions and transaction amounts apply to this user group.

Create a client

Use viem's built-in chains rather than defineChain. The built-in chains include chain-specific settings that affect event watching:

import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import * as viemChains from 'viem/chains';
import { Client } from '@taceo/merces-client';

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

// Look up chain from viem's built-in chains
const chain = Object.values(viemChains).find(c => c.id === CHAIN_ID);

const publicClient = createPublicClient({ chain, transport: http(RPC_URL) });
const walletClient = createWalletClient({ account, chain, transport: http(RPC_URL) });

const client = new Client(publicClient, walletClient, {
idRegistryAddress: ID_REGISTRY_ADDRESS,
contractAddress: MERCES_ADDRESS,
erc20Address: TOKEN_ADDRESS,
authSecret,
nodeUrls: [NODE_0_URL, NODE_1_URL, NODE_2_URL],
complianceUrl: COMPLIANCE_URL, // required for mainnet deployments
});

client.address returns the address the client is operating as.

warning

This requires you to pass your private key to the SDK. Use testing wallets or secure the key/environment variable in some manner.

Transport Configuration

Do not use aggressive timeouts on the HTTP transport. The SDK uses watchContractEvent to detect when actions are processed, which relies on polling. Aggressive timeouts can cause these requests to fail, resulting in ActionTimeoutError even though the action succeeded on-chain.

// WRONG - may break event watching
http(url, { timeout: 10_000, retryCount: 0 })

// CORRECT - use defaults
http(url)

Configuration

OptionRequiredNotes
idRegistryAddressYesThe IIdRegistry contract
contractAddressYesThe Merces contract
erc20AddressYesThe ERC-20 being wrapped
authSecretYesCommitted at the ID registry during registration
nodeUrlsYesThe three query nodes. Each serves one share
complianceUrlNoURL of the compliance service. Required for mainnet deployments
queryRetryMaxTimesNoMaximum consistency retry attempts. Default 10
queryRetryMinDelayMsNoMinimum delay before first retry. Default 100
queryRetryMaxDelayMsNoMaximum delay between retries (doubles each attempt). Default 2000
actionTimeoutMsNoWait for the ActionProcessed event. Default 30,000

Raising actionTimeoutMs is the usual fix under load — a timeout there means you stopped waiting, not that the action failed.

Deposit

Deposits move ERC-20 tokens from the public balance into the account's confidential balance. There is no separate approve() step: the client signs an ERC-2612 permit, valid for one hour, and the contract pulls the tokens with it.

import { parseUnits } from 'viem';

await client.deposit({ amount: parseUnits('100', decimals) });

A deposit always credits the depositing account. To move funds to someone else, deposit and then transfer.

Balances

const onchain = await client.erc20Balance(); // public, read from the token contract
const confidential = await client.privateBalance(); // secret-shared across the query nodes

privateBalance() asks all three query nodes for their share and reconstructs the value locally. Every node must answer consistently, so a node lagging behind causes a retry rather than a wrong answer. If they cannot agree within the retry limits, it throws.

Transfer

await client.transfer({
receiver: RECEIVER_ADDRESS,
amount: parseUnits('50', decimals),
});

Sender, receiver and amount are all hidden behind commitments. The receiver must already be registered.

Withdraw

await client.withdraw({ amount: parseUnits('50', decimals) });

// or pay out to a different address
await client.withdraw({ amount, receiver: OTHER_ADDRESS });

Tokens are paid out only once the MPC network has processed the action. A withdrawal ends in an ordinary token transfer, so its amount and destination are visible onchain — but nothing links it to any particular confidential transfer.

Waiting and settlement

deposit, withdraw and transfer do not return when the transaction is mined. Each waits for the receipt, reads the ActionQueued log, then waits for the matching ActionProcessed event that carries the MPC network's decision. They resolve with the hash of the transaction containing that event, and throw InvalidTransactionError if the network rejected the action.

An ActionTimeoutError means you stopped waiting, not that the action failed. It may still settle. Check the balance or history before retrying.

History

const { transactions, total } = await client.history({ page: 0, pageSize: 25 });

page counts back from the most recent action, so page 0 is the latest pageSize entries. total is the account's entry count across all pages.

Each entry is:

FieldTypeNotes
actionIndexbigintThe action's index in the contract
transactionType'deposit' | 'withdraw' | 'incoming_transfer' | 'outgoing_transfer'
senderAddressAddress | nullResolved from the registry. null for deposits
receiverAddressAddress | nullnull for withdrawals
amountbigint
txHashHash
timestampnumberUnix seconds

The nodes serve the balance left after each action, so amount is derived from the difference between consecutive balances rather than stored directly. Entries within a page are ordered oldest first.

User groups and limits

Each account's user group caps how much it can move per transaction and how many transactions it can make per epoch. Both are enforced client-side before a proof is built, so they fail fast and cost no gas:

import {
UserGroupAmountLimitError,
UserGroupTransactionLimitError,
} from '@taceo/merces-client';

try {
await client.transfer({ receiver, amount });
} catch (err) {
if (err instanceof UserGroupAmountLimitError) {
// err.amount, err.maxAmountPerTx
} else if (err instanceof UserGroupTransactionLimitError) {
// err.maxTxsPerEpoch — the account is out of transactions until the next epoch
}
}

Compliance

Mainnet deployments (Monad, Worldchain) require compliance attestation for register, deposit, and withdraw. Transfers do not require compliance — policy is enforced at the points where value enters and leaves the system.

For registration

Fetch compliance data and pass it to register:

import { fetchComplianceData, register } from '@taceo/merces-client';

const complianceData = await fetchComplianceData(COMPLIANCE_URL, address);

await register({
publicClient,
walletClient,
idRegistryAddress: ID_REGISTRY_ADDRESS,
authSecret,
groupId: GROUP_ID,
maxFee: parseUnits('1', decimals),
complianceData,
});

For deposit and withdraw

Pass complianceUrl when creating the client. The SDK fetches attestation automatically:

const client = new Client(publicClient, walletClient, {
// ... other config
complianceUrl: COMPLIANCE_URL,
});

// Compliance is handled internally
await client.deposit({ amount });
await client.withdraw({ amount });

If the deployment gates an action and the attestation is missing or rejected, the call throws NotCompliantError. See Pre-transaction screening for policy configuration.

Errors

Every error extends MercesClientError.

ErrorMeans
NotRegisteredErrorThe address has no leaf in the ID registry
AlreadyRegisteredInDifferentGroupErrorregister was called with a groupId that doesn't match the address's existing registration
AlreadyRegisteredWithDifferentAuthSecretErrorregister was called with an authSecret that doesn't match the address's existing registration
NotCompliantErrorA policy check rejected the action
InsufficientBalanceErrorCarries amount and balance
InvalidAmountErrorThe amount is zero or negative
CannotTransferToSelfErrorSender and receiver are the same account
UserGroupAmountLimitErrorCarries amount and maxAmountPerTx
UserGroupTransactionLimitErrorCarries maxTxsPerEpoch
InvalidTransactionErrorThe MPC network marked the action invalid
ActionTimeoutErroractionTimeoutMs elapsed before ActionProcessed
import {
MercesClientError,
NotCompliantError,
InsufficientBalanceError,
ActionTimeoutError,
} from '@taceo/merces-client';

try {
await client.transfer({ receiver, amount });
} catch (err) {
if (err instanceof NotCompliantError) {
// policy rejection — surface it, do not retry blindly
} else if (err instanceof InsufficientBalanceError) {
// err.amount vs err.balance
} else if (err instanceof ActionTimeoutError) {
// may still settle — check history before retrying
} else if (err instanceof MercesClientError) {
// anything else from the client
} else {
throw err;
}
}

CLI

The package installs a taceo-merces-client binary that exposes the same operations, useful for trying a deployment without writing code. Configuration comes from flags or the matching MERCES_CLIENT_* environment variables:

taceo-merces-client deposit --amount 1000 \
--id-registry-address 0x... \
--contract-address 0x... \
--erc20-address 0x... \
--rpc-url http://127.0.0.1:8545 \
--node-urls http://localhost:10010,http://localhost:10011,http://localhost:10012 \
--private-key 0x...

Without --auth-secret, it derives one by signature as above.

For mainnet deployments, use --chain to load preset configuration:

taceo-merces-client deposit --amount 1 \
--chain monad \
--private-key 0x...

The --chain flag (monad or world) supplies default addresses, node URLs, RPC URL, and compliance URL for that deployment. --rpc-url overrides the default RPC if both are given.

Available commands: deposit, withdraw, transfer, register, get-private-balance, get-erc20-balance, get-history.

Rust client

taceo-merces-client is the Rust client, at feature parity with this one. It is used internally and is not yet published to crates.io.

Reference

The package ships TypeScript definitions, so an editor gives you full signatures inline.