Skip to content
docs

Ship a wallet: end to end

The pages before this one cover each part of a wallet on its own. This one puts them in the order you build them, with the server in the middle: an app that holds the keys, and a small backend that holds every other secret. The server code is examples/server, which CI starts and tests on every change; the app snippets come from the other examples/.

phone (Expo, Release build) your server (examples/server)
───────────────────────────── ────────────────────────────────
runtime-hermes POST /rpc/:caip2 → keyed RPC upstreams
keyring + secure-storage-react-native ── RPC ───► POST /swap2/:action → swap providers + keys
wallet-* accounts, policies, tx-preview ─ swaps ─► GET /health
backup-cloud (iCloud) auth + rate limits (you add)

An Expo app with a development or Release build (not Expo Go: key storage is a native module). Install the SDK packages from npm and make Hermes complete before anything else loads:

Terminal window
npm install @anywalletkit/runtime-hermes @anywalletkit/kit @anywalletkit/keyring \
@anywalletkit/secure-storage-react-native @anywalletkit/wallet-evm @anywalletkit/wallet-solana \
@anywalletkit/protocol-swap2-core @anywalletkit/protocol-swap2-router @anywalletkit/tx-preview-evm \
buffer fastestsmallesttextencoderdecoder react-native-url-polyfill
// index.ts — the very first lines
import { installHermesRuntime } from '@anywalletkit/runtime-hermes'
installHermesRuntime()

Details and the Metro settings: Installation.

The seed lives in the Keychain (iOS) or the Keystore (Android), opened by Face ID / fingerprint with the device passcode as fallback, on every read. The keyring keeps names and addresses in plain storage and asks the secure storage only to sign.

import { createSecureStorage } from '@anywalletkit/secure-storage-react-native'
const secrets = createSecureStorage({ namespace: 'my-wallet' })
export function openKeyring(storage: KeyringStorage, secrets: SecretStorage): Promise<Keyring> {
return Keyring.load({
storage, // names, addresses, flags
secrets, // one entry per seed or key
chains: {
evm: { Manager: WalletManagerEvm, config: { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 } },
solana: { Manager: WalletManagerSolana, config: { provider: 'https://api.mainnet-beta.solana.com' } },
ton: { Manager: WalletManagerTon, config: { tonClient: { url: 'https://toncenter.com/api/v2/jsonRPC' } } },
tron: { Manager: WalletManagerTron, config: { provider: 'https://api.trongrid.io' } },
},
})
}
export async function createWallet(keyring: Keyring) {
const { account, mnemonic } = await keyring.createMnemonic({ words: 12 })
// `mnemonic` is for the backup screen only: show it, ask for a few words back, drop it.
return { account, mnemonic }
}

One keyring signs on one EVM network; for Base, Arbitrum and the rest open one per network over the same secrets (Keyring: more than one EVM network). Back the seed up: seed-vault seals it into an encrypted file and backup-cloud keeps it in the app’s iCloud Drive container (Keys and storage).

Everything that needs a key you pay for, or a setting you don’t want in an app bundle, runs here: RPC with your provider keys, and the swap providers with their API keys and affiliate settings.

/** The real providers, configured with the keys that must never ship in the app. */
export function providersFromEnv(env: NodeJS.ProcessEnv) {
return [
lifiProvider({ ...(env.LIFI_API_KEY ? { apiKey: env.LIFI_API_KEY } : {}) }),
nearIntentsProvider({ ...(env.NEAR_INTENTS_JWT ? { jwt: env.NEAR_INTENTS_JWT } : {}) }),
thorchainProvider({}),
chainflipProvider(),
]
}
/** RPC_EIP155_1=https://a,https://b → { 'eip155:1': [a, b] }; RPC_SOLANA_MAINNET → 'solana:5eykt…'. */
export function upstreamsFromEnv(env: NodeJS.ProcessEnv): Record<string, string[]> {
const ALIASES: Record<string, string> = { 'solana:mainnet': 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }
const out: Record<string, string[]> = {}
for (const [key, value] of Object.entries(env)) {
const m = /^RPC_([A-Z0-9]+)_([A-Z0-9]+)$/.exec(key)
if (!m || !value) continue
const chain = `${m[1]!.toLowerCase()}:${m[2]!.toLowerCase()}`
out[ALIASES[chain] ?? chain] = value.split(',').map((u) => u.trim()).filter(Boolean)
}
return out
}
export function createServer(opts: ServerOptions): Server {
const guard = makeGuard(opts)
// With per-user sign-in (Sign-In with Ethereum), also pass `subject: (req) => <the user's address>`:
// quotes and swap ids are then bound to that user, and another user's are refused (403).
const swap2 = createSwap2Handler<IncomingMessage>({ providers: opts.providers, sealSecret: opts.sealSecret, guard: guard.swap2 })
const rpc = createRpcProxy({ upstreams: opts.upstreams, ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) })
// CCIP-Read for apps on React Native (offchain ENS names: cb.id, Basenames): https, public addresses only,
// the connection pinned to the checked address, no redirects. The app side is ensCcipViaProxy(`${api}/ccip`).
const ccip = createEnsCcipHandler(opts.ccip)
return createHttpServer(async (req, res) => {
if (!cors(req, res, opts.allowedOrigin)) return
const url = new URL(req.url ?? '/', 'http://local')
try {
if (req.method === 'GET' && url.pathname === '/health') return send(res, 200, { ok: true })
if (req.method !== 'POST') return send(res, 405, { error: 'method not allowed' })
const swap = /^\/swap2\/([a-z]+)$/.exec(url.pathname)
if (swap) {
guard.authenticate(req) // before reading (and parsing) up to 3 MB from anyone
const raw = await readBody(req, 3 * 1024 * 1024) // a Solana / TON payload can be ~2 MB
let body: unknown
try { body = JSON.parse(raw) } catch { return send(res, 400, { error: 'invalid JSON' }) }
const out = await swap2(swap[1]!, body, req)
return send(res, out.status, out.body)
}
if (url.pathname === '/ccip') {
guard.ccip(req) // the token and a per-caller limit, before the body is read
const raw = await readBody(req, 160 * 1024) // the gateway URL or calldata: up to 64 KiB of hex each
let body: unknown
try { body = JSON.parse(raw) } catch { return send(res, 400, { error: 'invalid JSON' }) }
const out = await ccip(body) // { url, method, body? } → { data } or { error }
return send(res, out.status, out.body)
}
const rpcPath = /^\/rpc\/([a-z0-9]+:[-_a-zA-Z0-9]+)$/.exec(url.pathname)
if (rpcPath) {
const charge = guard.rpc(req)
const out = await rpc(rpcPath[1]!, await readBody(req, 512 * 1024), { charge })
res.writeHead(out.status, { 'content-type': 'application/json' })
return res.end(out.body)
}
return send(res, 404, { error: 'not found' })
} catch (err) {
if (err instanceof Swap2HandlerError) return send(res, err.status, { error: err.message })
console.error('request failed', req.method, url.pathname) // never log bodies: they carry addresses and signed transactions
return send(res, 500, { error: 'internal error' })
}
})
}

The swap routes are createSwap2Handler from @anywalletkit/protocol-swap2-core/server: it validates every request, seals each quote (HMAC) and builds a deposit only for a quote it sealed itself, unchanged and not expired, so a client can’t make your server prepare a transaction on terms no provider offered. A provider’s refusal reaches the app with its reason, URLs stripped.

Every swap id the app gets carries an intent token from your server: tracking, claims and deposit notices need it, so your server won’t answer questions about swaps it didn’t start (deposit addresses are public on chain). With per-user sign-in, pass subject (the user’s Sign-In with Ethereum address, not an email): quotes and swap ids are then bound to that user, and another user’s are refused. Without it, the swap id works like a key to that one swap’s status, so ask users for a transaction hash, not a swap id, in support chats. Moving from another backend? acceptUntokenedTrackUntil lets swaps started there keep tracking for a while.

The RPC proxy forwards only what a wallet needs — reads, fee data, simulation and broadcasting an already signed transaction — and refuses anything that signs or administers a node. It also holds each call to a budget: eth_getLogs at most 5 000 blocks, no state or block overrides, answers up to 4 MiB, and a separate rate limit for expensive calls:

/**
* A JSON-RPC proxy: the app talks to `POST /rpc/<caip2>`, the server adds the keyed upstream.
* Only the methods a wallet needs pass (reads, fee data, simulation, broadcasting a SIGNED
* transaction); anything that could sign or administer a node — eth_sign*, personal_*, admin_*,
* debug_*, eth_sendTransaction — is refused. Each call is also held to a budget: an
* `eth_getLogs` block range, no state or block overrides, a cap on the upstream's answer.
* Errors never carry the upstream URL or key.
*/
export interface RpcProxyOptions {
/** Upstream URLs per CAIP-2 chain, tried in order (failover on network errors and 5xx). */
upstreams: Readonly<Record<string, readonly string[]>>
/** Raw body cap; a signed transaction is the largest legitimate request. */
maxBodyBytes?: number
/** Batch size cap. */
maxBatch?: number
/** Widest `eth_getLogs` range, in blocks, between two block numbers (default 5 000). */
maxLogsRange?: number
/** Largest upstream answer relayed, in bytes (default 4 MiB); a larger one is a 502. */
maxResponseBytes?: number
timeoutMs?: number
fetchImpl?: typeof fetch
}
const EVM_METHODS = new Set([
'eth_chainId', 'net_version', 'eth_blockNumber', 'eth_getBalance', 'eth_getTransactionCount', 'eth_getCode',
'eth_getStorageAt', 'eth_call', 'eth_estimateGas', 'eth_createAccessList', 'eth_gasPrice', 'eth_maxPriorityFeePerGas',
'eth_feeHistory', 'eth_getBlockByNumber', 'eth_getBlockByHash', 'eth_getTransactionByHash', 'eth_getTransactionReceipt',
'eth_getLogs', 'eth_simulateV1', 'eth_sendRawTransaction',
])
const SOLANA_METHODS = new Set([
'getBalance', 'getAccountInfo', 'getMultipleAccounts', 'getLatestBlockhash', 'getBlockHeight', 'getSlot', 'getEpochInfo',
'getFeeForMessage', 'getRecentPrioritizationFees', 'getMinimumBalanceForRentExemption', 'getTokenAccountsByOwner',
'getTokenAccountBalance', 'getSignaturesForAddress', 'getSignatureStatuses', 'getTransaction', 'simulateTransaction',
'sendTransaction', 'isBlockhashValid',
])
const ALLOWED: Record<string, ReadonlySet<string>> = { eip155: EVM_METHODS, solana: SOLANA_METHODS }
/** Methods that cost the upstream (and your plan) far more than a balance read: give them their own rate limit. */
export const EXPENSIVE_METHODS: ReadonlySet<string> = new Set([
'eth_getLogs', 'eth_call', 'eth_estimateGas', 'eth_createAccessList', 'eth_simulateV1',
'getSignaturesForAddress', 'getTransaction', 'getTokenAccountsByOwner', 'getMultipleAccounts', 'simulateTransaction',
])
export interface RpcResult { status: number; body: string }
const rpcError = (status: number, id: unknown, message: string): RpcResult =>
({ status, body: JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error: { code: status === 400 ? -32600 : -32603, message } }) })
const BLOCK_TAGS = new Set(['latest', 'safe', 'finalized', 'pending'])
const BLOCK_NUMBER = /^0x[0-9a-fA-F]{1,16}$/
const present = (v: unknown) => v !== undefined && v !== null
/** Why one call is over budget, or null. Overrides would let a caller run arbitrary state on your node. */
function overBudget(method: string, params: unknown, maxLogsRange: number): string | null {
const p = Array.isArray(params) ? params : []
switch (method) {
case 'eth_getLogs': {
const f = p[0] as Record<string, unknown> | undefined
if (!f || typeof f !== 'object') return 'eth_getLogs expects a filter object'
if (present(f.blockHash)) return present(f.fromBlock) || present(f.toBlock) ? 'eth_getLogs: blockHash or a range, not both' : null
const from = f.fromBlock ?? 'latest'
const to = f.toBlock ?? 'latest'
if (typeof from === 'string' && typeof to === 'string' && BLOCK_NUMBER.test(from) && BLOCK_NUMBER.test(to)) {
const span = BigInt(to) - BigInt(from)
return span >= 0n && span <= BigInt(maxLogsRange) ? null : `eth_getLogs: at most ${maxLogsRange} blocks, from ≤ to`
}
if (typeof from === 'string' && typeof to === 'string' && BLOCK_TAGS.has(from) && BLOCK_TAGS.has(to)) return null
return `eth_getLogs: give two block numbers at most ${maxLogsRange} apart, or a blockHash`
}
case 'eth_call':
return present(p[2]) || present(p[3]) ? 'eth_call: state and block overrides are not allowed' : null
case 'eth_estimateGas':
case 'eth_createAccessList':
return present(p[2]) ? `${method}: state overrides are not allowed` : null
case 'eth_simulateV1': {
const blocks = (p[0] as { blockStateCalls?: unknown } | undefined)?.blockStateCalls
if (!Array.isArray(blocks) || blocks.length > 4) return 'eth_simulateV1: 1..4 blockStateCalls'
for (const b of blocks as Array<Record<string, unknown>>) {
if (present(b?.stateOverrides) || present(b?.blockOverrides)) return 'eth_simulateV1: state and block overrides are not allowed'
if (Array.isArray(b?.calls) && b.calls.length > 16) return 'eth_simulateV1: at most 16 calls per block'
}
return null
}
default:
return null
}
}
/** The upstream's body, or null when it is larger than `cap` (read as a stream, never buffered past the cap). */
async function readCapped(res: Response, cap: number): Promise<string | null> {
const declared = Number(res.headers.get('content-length') ?? Number.NaN)
if (Number.isFinite(declared) && declared > cap) { await res.body?.cancel(); return null }
if (!res.body) return ''
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > cap) { await reader.cancel(); return null }
chunks.push(value)
}
return Buffer.concat(chunks).toString('utf8')
}
interface RpcCall { jsonrpc: '2.0'; id?: string | number | null; method: string; params: unknown[] }
const CALL_KEYS = new Set(['jsonrpc', 'id', 'method', 'params'])
const idOf = (c: unknown): unknown => {
const id = c && typeof c === 'object' && !Array.isArray(c) ? (c as { id?: unknown }).id : null
return typeof id === 'string' || typeof id === 'number' ? id : null
}
/**
* One JSON-RPC call reduced to `{ jsonrpc, id, method, params }`, or why it is refused. Any other
* key is refused, and so is a case variant of the four (`Method`, `PARAMS`): Go's encoding/json,
* which geth-style nodes use, matches field names case-insensitively, last key wins, so an
* upstream could run a method this proxy never checked. `params` must be an array (absent = []).
*/
function toCall(c: unknown): RpcCall | string {
if (!c || typeof c !== 'object' || Array.isArray(c)) return 'a call must be a JSON object'
const o = c as Record<string, unknown>
for (const k of Object.keys(o)) if (!CALL_KEYS.has(k)) return `unexpected key: ${k.slice(0, 40)}`
if (o.jsonrpc !== '2.0') return 'jsonrpc must be "2.0"'
if (typeof o.method !== 'string') return 'method must be a string'
const id = o.id
if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') return 'id must be a string, number or null'
if (o.params !== undefined && !Array.isArray(o.params)) return 'params must be an array'
const params = (o.params as unknown[] | undefined) ?? []
return id === undefined ? { jsonrpc: '2.0', method: o.method, params } : { jsonrpc: '2.0', id, method: o.method, params }
}
export function createRpcProxy(opts: RpcProxyOptions) {
const maxBody = opts.maxBodyBytes ?? 256 * 1024
const maxBatch = opts.maxBatch ?? 20
const maxLogsRange = opts.maxLogsRange ?? 5_000
const maxResponse = opts.maxResponseBytes ?? 4 * 1024 * 1024
const timeoutMs = opts.timeoutMs ?? 15_000
const doFetch = opts.fetchImpl ?? fetch
/** `charge` sees the methods once the request is valid, before any upstream call: throw to refuse (rate limits). */
return async function proxy(chain: string, rawBody: string, hooks: { charge?: (methods: readonly string[]) => void | Promise<void> } = {}): Promise<RpcResult> {
const urls = opts.upstreams[chain]
if (!urls?.length) return rpcError(404, null, `no RPC configured for ${chain.slice(0, 64)}`)
if (Buffer.byteLength(rawBody) > maxBody) return rpcError(413, null, 'request too large')
let payload: unknown
try { payload = JSON.parse(rawBody) } catch { return rpcError(400, null, 'invalid JSON') }
const calls = Array.isArray(payload) ? payload : [payload]
if (calls.length === 0 || calls.length > maxBatch) return rpcError(400, null, `batch of 1..${maxBatch} calls`)
const allowed = ALLOWED[chain.split(':')[0]!]
const methods: string[] = []
const rebuilt: RpcCall[] = []
for (const c of calls) {
const call = toCall(c)
if (typeof call === 'string') return rpcError(400, idOf(c), call)
const { method, params, id } = call
if (!allowed?.has(method)) {
return rpcError(403, id, `method not allowed: ${method.slice(0, 60)}`)
}
const why = overBudget(method, params, maxLogsRange)
if (why) return rpcError(403, id, why)
methods.push(method)
rebuilt.push(call)
}
await hooks.charge?.(methods)
// Forward the request that was checked, not the bytes that came in: with a duplicated key
// ({"method":"eth_call", …, "method":"eth_sendRawTransaction"}) a parser that keeps the other
// occurrence would run a method the allowlist never saw. Each call is rebuilt from the four
// checked fields only, so nothing else (a "Method" or "PARAMS" that a case-insensitive Go
// decoder would pick up) reaches the upstream.
const checked = JSON.stringify(Array.isArray(payload) ? rebuilt : rebuilt[0])
for (const url of urls) {
try {
const res = await doFetch(url, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: checked,
signal: AbortSignal.timeout(timeoutMs),
})
if (res.status >= 500) { await res.body?.cancel(); continue } // the next upstream
const body = await readCapped(res, maxResponse)
if (body === null) return rpcError(502, null, 'upstream response too large')
return { status: res.status, body }
} catch { /* network error or timeout: the next upstream */ }
}
return rpcError(502, null, 'upstream unavailable') // no URL, no key
}
}

Authentication and rate limits are yours to add, and the example says so loudly:

/**
* ⚠️ Replace this before production. A token baked into an app is NOT a secret: anyone can pull
* it out of the bundle. It only stops casual scraping. A real deployment authenticates the USER
* (Sign-In with Ethereum, your account system, App Attest / Play Integrity for the app itself)
* and rate-limits per user, in a shared store (Redis, Upstash), not in this process's memory.
*/
function makeGuard(opts: ServerOptions) {
// An empty token would equal an empty Authorization header: every request would pass.
if (typeof opts.apiToken !== 'string' || opts.apiToken.trim() === '') throw new Error('apiToken must be a non-empty string (an empty one lets every request through).')
const token = Buffer.from(opts.apiToken)
const limits = { rpcHeavy: 60, ccip: 60, ...(opts.limits ?? { live: 30, display: 240, status: 120, rpc: 600 }) }
const limiter = createRateLimiter()
const take = (key: string, perMinute: number) => limiter.take(key, perMinute)
const authenticate = (req: IncomingMessage): string => {
const got = Buffer.from(String(req.headers.authorization ?? '').replace(/^Bearer /, ''))
if (got.length !== token.length || !timingSafeEqual(got, token)) throw new Swap2HandlerError(401, 'unauthorized')
return opts.callerKey ? opts.callerKey(req) : (req.socket.remoteAddress ?? 'unknown') // the caller key; per USER in production
}
return {
/** The token check alone: run it before reading a body. */
authenticate,
swap2: (call: Swap2Call, req: IncomingMessage) => {
const caller = authenticate(req)
// Display quotes (route list, every keystroke) get their own, larger bucket so they never
// starve the one live quote the confirm step needs.
const bucket = call.action === 'track' || call.action === 'claim' ? 'status' : call.dry ? 'display' : 'live'
take(`${caller}:swap2:${bucket}`, limits[bucket])
},
/** One count per CCIP-Read gateway call: each makes this server fetch a URL a name's resolver chose. */
ccip: (req: IncomingMessage) => take(`${authenticate(req)}:ccip`, limits.ccip),
/** One count per request, then one per expensive call in it (a batch of 20 eth_getLogs is 20). */
rpc: (req: IncomingMessage) => {
const caller = authenticate(req)
take(`${caller}:rpc`, limits.rpc)
return (methods: readonly string[]) => {
for (const m of methods) if (EXPENSIVE_METHODS.has(m)) take(`${caller}:rpc-heavy`, limits.rpcHeavy)
}
},
}
}

Run it anywhere Node runs; on a serverless platform call the two handlers from your route functions instead (examples/server README).

The app never sees an RPC key: each EVM network gets an EIP-1193 provider that posts to the proxy.

/** An EIP-1193 provider over the server's RPC proxy (it adds the upstream key). */
export function serverRpc(api: string, chain: string, token: string, fetchImpl: typeof fetch = fetch) {
let id = 0
return {
async request({ method, params }: { method: string; params?: unknown }) {
const res = await fetchImpl(`${api}/rpc/${chain}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params: params ?? [] }),
})
const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string } }
if (body.error) throw Object.assign(new Error(body.error.message), { code: body.error.code })
return body.result
},
}
}
export function walletOverServer(phrase: string, api: string, token: string, fetchImpl?: typeof fetch): Wallet {
const wallet = new Wallet(phrase)
wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: serverRpc(api, 'eip155:1', token, fetchImpl) as never, chainId: 1 })
wallet.registerWallet('eip155:8453', WalletManagerEvm, { provider: serverRpc(api, 'eip155:8453', token, fetchImpl) as never, chainId: 8453 })
return wallet
}

Pass chainId with every EVM registration (Chain ids). Balances, token lists and history work unchanged on top (Balances, History; explorer keys belong on the server too).

The app builds the same router it would build locally, but every provider is a remoteProvider pointing at your server. The engine still validates every quote and every transaction on the device before anything is signed: the server can refuse, it cannot make the app sign something else. That needs each provider’s pins (lifiPins, thorchainPins(), chainflipPins…): the contracts its calldata may call and approve come from the app, never from the server, and a remote provider without pins gets no calldata route at all. A route that pays an address the server names (a deposit address, a plain send) runs only on a stub opened with depositAddressRoutes — nearIntentsPins for NEAR Intents. The server’s address must be https.

/**
* What the app trusts for each provider on the server: the contracts its calldata may call and approve,
* THORChain's check of each build against two THORNodes and Chainflip's decode of each vault swap. A
* remote provider without pins gets no calldata route at all, and one without `depositAddressRoutes` no
* deposit-address route: nearIntentsPins opt NEAR Intents in, its deposit addresses are per quote.
*/
const PINS: Readonly<Record<string, RemotePins>> = { lifi: lifiPins, thorchain: thorchainPins(), chainflip: chainflipPins, near_intents: nearIntentsPins }
/** The same router the app would build locally, but every provider runs on the server. */
export function swapsOverServer(account: IWalletAccount, api: string, token: string, names = ['lifi', 'near_intents', 'thorchain', 'chainflip'], fetchImpl?: typeof fetch) {
const remote = (name: string) => remoteProvider({
name, baseUrl: `${api}/swap2`, headers: { authorization: `Bearer ${token}` }, ...PINS[name],
// https in production; plain http only for a server on this machine (the tests).
...(/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(api) ? { allowInsecureBaseUrl: true } : {}),
...(fetchImpl ? { fetchImpl } : {}),
})
return new RouterSwap2Protocol(account, {
fromChain: account.chain,
providers: names.map(remote), // the names the server's providers carry
prefer: 'best',
})
}

Then quote, show the routes, execute the one the user picked and follow it to the end:

export async function execute(swaps: RouterSwap2Protocol, options: Swap2Options, route: Swap2Route) {
const result = await swaps
.using(route.provider) // exactly the route the user picked
.swap2({ ...options, minAmountOut: route.quote.toTokenAmountMin }) // never accept less than you showed
return result.id // keep it: status, claims and support all start from this id
}
const DONE = new Set(['completed', 'failed', 'refunded', 'cancelled', 'expired', 'partial'])
export async function waitForDelivery(
swaps: RouterSwap2Protocol,
id: string,
destinationAccount: () => Promise<IWalletAccount>,
{ intervalMs = 10_000, timeoutMs = 2 * 60 * 60_000 }: { intervalMs?: number; timeoutMs?: number } = {},
) {
const deadline = Date.now() + timeoutMs
let claimed = false
while (Date.now() < deadline) {
const { status, transactions } = await swaps.getSwap2Status(id)
if (status === 'action-required' && !claimed) {
// Two-phase routes (CCTP): the mint is a transaction on the DESTINATION chain,
// signed by the user's account there and paid in that chain's gas. Once: the
// provider says 'action-required' until it sees the mint, so keep polling the
// id the claim returns (it carries the claim hash) instead of claiming again.
id = (await swaps.claimSwap2(id, await destinationAccount())).id
claimed = true
} else if (DONE.has(status)) {
return { status, transactions }
}
await new Promise((r) => setTimeout(r, intervalMs))
}
throw new Error(`Swap ${id} not settled within ${Math.ceil(timeoutMs / 1000)} s: keep its id and check again later.`)
}

Use the automatic slippage (suggestSlippage) and pass the minimum you showed: Swaps.

Every write goes through policies (fee caps, blocklists, a confirm step) and, for EVM, a simulation the user can read:

export const policies: Policies = {
// Refuse any Ethereum write whose quoted fee is above 0.005 ETH.
maxFee: { [ETHEREUM]: 5_000_000_000_000_000n },
// Never send to these addresses.
blocklist: { [ETHEREUM]: ['0x000000000000000000000000000000000000dEaD'] },
// The last word before anything is signed: show your confirmation sheet here.
confirm: async (intent) => {
console.log(`${intent.method} on ${intent.chain} → ${intent.to ?? 'contract'}`)
return true
},
}
export type Verdict = { level: 'ok' | 'warn' | 'danger'; lines: string[] }
/** Checks are codes, not prose: the wording (and the language) is yours. */
export function verdict(preview: TxPreview): Verdict {
const lines: string[] = []
let level: Verdict['level'] = 'ok'
const raise = (to: Verdict['level']) => { if (to === 'danger' || level === 'ok') level = to }
for (const check of preview.checks) {
const d = check.detail ?? {}
switch (check.code) {
case 'simulation-reverted':
raise('danger'); lines.push(`This transaction will fail: ${d.reason}`); break
case 'simulation-unavailable':
raise('warn'); lines.push('This network cannot simulate it. Check the details yourself.'); break
case 'approval-unlimited':
raise('danger'); lines.push(`${d.label ?? d.spender} could spend ALL of this token, now and later.`); break
case 'approval-excess':
raise('warn'); lines.push(`Approves ${d.amount}, but the swap needs only ${d.expected}.`); break
case 'approval-unchecked':
raise('warn'); lines.push(`Approves ${d.amount} to ${d.label ?? d.spender}; nothing says how much is needed.`); break
case 'approval-unknown-spender':
raise('warn'); lines.push(`Unknown spender ${d.spender}.`); break
case 'contract-unknown':
raise('warn'); lines.push(`Unknown contract ${d.address}.`); break
case 'recipient-foreign':
lines.push(`Sends to ${d.address}, which is not one of your accounts.`); break
default:
break // the "ok" codes need no line
}
}
return { level, lines }
}
/** Balance changes for the sheet: "-0.01 ETH", "+0.01 WETH". */
export function balanceLines(preview: TxPreview): string[] {
if (preview.simulation.status !== 'ok') return []
return preview.simulation.changes.map((c) => {
const sign = c.delta < 0n ? '-' : '+'
const abs = c.delta < 0n ? -c.delta : c.delta
const amount = c.decimals === null ? abs.toString() : formatUnits(abs, c.decimals)
return `${sign}${amount} ${c.symbol ?? c.token}`
})
}

More in Review and safety and Transaction preview.

WalletConnect for a mobile wallet is not in the SDK yet (a headless dApp-request handler is planned). Today you bring Reown WalletKit and route its session requests to kit accounts, so every request still passes your policies and review; see dApp connection. For browsers, extension-kit is the core of an extension wallet.

  • Testnet first. Run every flow on Sepolia / Base Sepolia (and Solana devnet) with the Networks testnets on: create, restore, send, swap, a failed swap.
  • A Release build on a real device. Dev builds hide Hermes and Metro problems.
  • No key in the app bundle. Grep the built bundle for your RPC and provider keys; they must only be on the server.
  • Server secrets in the host’s secret store; SEAL_SECRET is ≥ 32 random bytes.
  • Real user authentication and per-user rate limits on the server, in a shared store.
  • Logs without bodies or addresses next to IPs; alerts on upstream errors, provider refusal rates and 429s.
  • Key storage checks on a device: Face ID fails → passcode works; re-enrolled Face ID still opens the wallet; the backup restores on a second phone.
  • Small amounts first for every swap provider you enable.