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)1. The app shell
Section titled “1. The app shell”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:
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 linesimport { installHermesRuntime } from '@anywalletkit/runtime-hermes'installHermesRuntime()Details and the Metro settings: Installation.
2. Keys on the device
Section titled “2. Keys on the device”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).
3. Your server
Section titled “3. Your server”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).
4. RPC through the server
Section titled “4. RPC through the server”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).
5. Swaps through the server
Section titled “5. Swaps through the server”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.
6. Review before signing
Section titled “6. Review before signing”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.
7. Connecting to dApps
Section titled “7. Connecting to dApps”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.
8. Before you publish
Section titled “8. Before you publish”- 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_SECRETis ≥ 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.