This is the abridged developer documentation for Any Wallet Kit # Introduction > What Any Wallet Kit is, who it is for and the principles it is built on. Any Wallet Kit is an open-source SDK for building self-custodial multichain wallets. One seed phrase gives you accounts on EVM networks, Bitcoin, Solana, TON, Tron, NEAR and Zcash behind a single account interface. Protocol modules — swaps, cross-chain swaps, staking, lending, token approvals — are written against that interface, so a wallet picks the blocks it needs and nothing else. ## Who it is for [Section titled “Who it is for”](#who-it-is-for) * **Wallet developers** — ship a multichain wallet (mobile app, extension) without writing chain plumbing, derivation, fee models or swap integrations yourself. * **dApp and Mini App developers** — embed wallet features next to your product (an embedded, semi-custodial kit is planned). ## Principles [Section titled “Principles”](#principles) * **Signer is separate from the account.** The private key lives in a `Signer`. The local seed signer is one implementation; a Safe, a hardware wallet, MPC or a backend signer are others. The SDK never *requires* a seed. * **Built for mobile apps.** React Native / Hermes is the primary target and the release gate: every chain is tested in the real Hermes VM. Chain and protocol packages are pure TypeScript, with no native modules and no polyfill magic; one runtime package installs what Hermes is missing. Native code lives only in the optional storage packages (`secure-storage-react-native`, `backup-cloud`), which need a development build. * **Refusals are named, never silent.** When a quote, transaction or route cannot be used, you get an error that says why, not a `null`. * **Zeroize by design.** Everything that holds a key has `dispose()`. * **One bar for every module.** Each chain and protocol module passes the same conformance suite before it ships. Pull requests from outside the core team are not accepted yet; when contributions open, a module will get in the same way, with a named maintainer. ## How it fits together [Section titled “How it fits together”](#how-it-fits-together) | Layer | Packages | Role | | ------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Core | `core`, `conformance` | Signer and account interfaces, protocol base classes, errors, key derivation, test suites every module must pass | | Chains | `wallet-evm`, `wallet-btc`, `wallet-solana`, `wallet-ton`, `wallet-tron`, `wallet-near`, `wallet-zcash` | Accounts, balances, transfers, signing and fees per chain family | | Protocols | `protocol-swap2-*`, `protocol-staking-*`, `protocol-lending-*`, `protocol-approvals-evm` | Swaps and bridges, staking, lending, approvals | | Orchestration | `kit`, `keyring` | Register chains and modules, look accounts up by chain, enforce policies before every broadcast; many accounts and imported keys | | Data & safety | `tx-preview-evm`, `history-evm`, `names-ens`, `networks` | Pre-sign transaction review, activity history, ENS names, networks and custom RPCs | | Runtime | `runtime-hermes` | React Native / Hermes polyfills | See [Packages](/getting-started/packages/) for the full list. ## For AI agents [Section titled “For AI agents”](#for-ai-agents) The docs are also plain Markdown: [`/llms.txt`](/llms.txt) is the index, [`/llms-full.txt`](/llms-full.txt) is every page with its code samples in one file, and [`/llms-small.txt`](/llms-small.txt) is a shorter version. # Accounts > One object that answers "what is my address on Base" and hands out accounts that can sign. **The job:** one place that answers “what is my address on Base” and “give me something that can sign on Solana”, for every account the user has. **In the kit:** | Package | Role | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wallet-*` | One package per chain family. Its manager derives accounts and knows how to read, build, sign and send on that chain | | `kit` | The `Wallet` object: register the chains you support, then `getAccount('eip155:8453')`. [Policies](/concepts/policies/) wrap every account it hands out | | `keyring` | Several accounts from one seed, plus imported seeds and keys, in one list across chains ([guide](/guides/keyring/)) | Chains are named by [CAIP-2 ids](/concepts/chain-ids/): `eip155:1` for Ethereum, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` for Solana, and so on. Register every EVM network under its exact id with its own RPC (`eip155:1`, `eip155:8453`, …): an EVM manager talks to the network of its RPC only, so one `eip155` registration would send Base transactions to Ethereum ([Chain ids](/concepts/chain-ids/#one-rpc-per-evm-network)). Non-EVM chains have one network each and register by namespace (`solana`, `ton`, `tron`, `bip122`, `near`). Start with the [Quickstart](/getting-started/quickstart/): it creates accounts on seven chains in about twenty lines. **Decisions you make:** * **Which chains you register.** Each `wallet-*` package you install adds bundle size and needs its own data sources ([Networks and RPC](/build-a-wallet/networks-and-rpc/)). * **How many accounts you show.** Most wallets show account 0 and let the user add more. Recovering a wallet means scanning accounts 0, 1, 2… until one has no history. * **Read-only accounts.** A watched address (no key) is useful for portfolios and for a “watch wallet” feature. Protocol reads like balances and approvals work on it; writes do not. * **Dispose.** Call `wallet.dispose()` when the user locks the app: it wipes the seed from the wallet and from every manager, and the wallet refuses any later call. Build a new one on unlock. # Balances and prices > Reading balances, finding which tokens the user holds, and putting a fiat value next to them. ## Balances [Section titled “Balances”](#balances) **The job:** show what the user holds on every chain. **In the kit:** every account reads its native balance (`getBalance()`) and the token balances you ask for (`getTokenBalance(token)`, `getTokenBalances(tokens)`). The code is in the [Balances guide](/guides/balances/). ### Finding which tokens to show [Section titled “Finding which tokens to show”](#finding-which-tokens-to-show) The chain has no “tokens held by this address” call. Three ways to build the list: | Way | How | Trade-off | | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | | A token list | The asset registry in `swap2-assets` (about 400 tokens across the catalog chains), or your own list; read those balances | Cheap and predictable, but misses anything outside the list | | A holdings indexer | One call returns every token with a balance | Finds everything, spam included; one more provider | | The history | Tokens the user ever received ([History](/build-a-wallet/history/)) | Free with history; needs a balance read per token | Most wallets combine the first two: show the list, and add anything else found with a balance behind a “hidden / spam” filter. **Holdings indexers:** | Chain | Examples | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EVM | [Alchemy Token API](https://docs.alchemy.com/reference/alchemy-gettokenbalances), [Moralis](https://www.moralis.com), [GoldRush (Covalent)](https://www.goldrush.dev), [QuickNode](https://www.quicknode.com) add-ons | | Solana | [Helius](https://docs.helius.dev), or RPC `getTokenAccountsByOwner` | | TON | [tonapi](https://docs.tonconsole.com/tonapi) jettons by owner | | Tron | [TronGrid](https://developers.tron.network/docs/trongrid) account endpoint | ### Gotchas [Section titled “Gotchas”](#gotchas) * **Amounts are `bigint` in base units.** Format with the token’s decimals. Never convert a large value through `Number(…)`: on some Hermes builds it returns garbage above 2⁶³. For display, use `bigintToUnits(value, decimals)` from `core`. * **The gas coin differs per network.** POL on Polygon, MNT on Mantle, USDC on Arc. Take the symbol and decimals from the network, not from a hardcoded “ETH”. * **Pending sends.** A balance read right after a broadcast may not include it yet. Show the pending amount from your own record until the transaction lands. * **Spam tokens.** Airdropped tokens with names like “Claim reward at …” are phishing. Hide unknown tokens by default and never show their names as links. ## Prices [Section titled “Prices”](#prices) **The job:** a fiat value next to every amount, and a portfolio total. **In the kit:** nothing yet. **You bring:** a price API. | Provider | Notes | | --------------------------------------------------- | -------------------------------------------------------- | | [CoinGecko API](https://docs.coingecko.com) | Look up by contract address per platform; free demo tier | | [CoinMarketCap API](https://coinmarketcap.com/api/) | Free basic tier | | [DefiLlama API](https://api-docs.defillama.com) | Prices by `chain:address`, keyless coins endpoint | **Rules:** * **Look tokens up by contract address**, never by symbol: symbols collide, and fake tokens copy real ones. * **Cache** prices for a minute or more, and show when a price is stale. * **Proxy the key** through your server if the plan needs one. * **Show “no price” rather than $0** for a token the API doesn’t know. Check before you rely on a provider Plans, limits and coverage change. We checked these links on 2026-10-23. Read each provider’s current terms and test with your real token list. A listing here is not an endorsement. # dApp connection > Letting websites talk to the wallet — a browser extension, WalletConnect on mobile, and an in-app browser. **The job:** “Connect wallet” on a website ends in your wallet, and every request the site makes passes your review. ## Browser extension [Section titled “Browser extension”](#browser-extension) **In the kit:** `extension-kit` is the core of a Chromium (MV3) extension, without UI: * an encrypted vault (password, or a passkey through WebAuthn PRF), and a session that locks itself when idle; * the dApp request router, per-site permissions and the approval queue; * signing checks, including Sign-In with Ethereum (EIP-4361) messages checked against the asking site. TON Connect requests are parsed and answered by the kit too. **You bring:** the providers your page script injects (EVM with EIP-1193 and EIP-6963 discovery, Solana, TON Connect, Tron, Bitcoin, NEAR), the UI (popup, approval window), the store listing and its account. The code, and how to publish to the Chrome Web Store, are in the [Browser extension guide](/guides/extension-kit/). **Rules:** * **Show the requesting site’s origin** on every approval, from the browser, never from the page’s own words. * **Connect is not approve.** A connected site can ask; every signature still needs the user. * **Store listing hygiene:** the Chrome Web Store account that publishes your extension can push an update to every user. Protect it with a hardware key and publish manually. ## Mobile: WalletConnect [Section titled “Mobile: WalletConnect”](#mobile-walletconnect) On mobile, dApps reach a wallet through WalletConnect (now run by Reown). It is not in the SDK yet. **You bring:** [Reown WalletKit](https://docs.reown.com) and a project id from [reown.com](https://reown.com). Route its session requests (`eth_sendTransaction`, `personal_sign`, `eth_signTypedData_v4`, `solana_signTransaction`…) to `kit` accounts, so policies and [review](/build-a-wallet/review-and-safety/) apply to them like to any other write. ## Mobile: in-app browser [Section titled “Mobile: in-app browser”](#mobile-in-app-browser) A WebView that injects an EIP-1193 provider into the pages it opens. Requests go to the same handlers as WalletConnect. Show the page’s origin in the address bar and on every approval, and don’t inject into pages served over plain HTTP. Check before you rely on a provider WalletConnect / Reown changes its SDKs and project requirements over time. We checked these links on 2026-10-23. Follow their current docs. A listing here is not an endorsement. # Fiat and notifications > Buying and selling crypto with a card, and telling the user about incoming funds while the app is closed. ## Buy and sell [Section titled “Buy and sell”](#buy-and-sell) **The job:** a “Buy” button that ends with crypto in the user’s address, and a “Sell” button that ends with money in their bank. **In the kit:** `FiatProtocol` in `core` defines the shape: `quoteBuy`, `buy`, `quoteSell`, `sell`, `getTransactionDetail`, `getSupportedCryptoAssets`. No provider module ships yet; write one against that interface, or call the provider’s widget directly. **You bring:** an on-ramp provider. It runs KYC and payments; you pass the address and the asset, and receive webhooks about the order. | Provider | Docs | | ------------ | ---------------------------------------------- | | MoonPay | [dev.moonpay.com](https://dev.moonpay.com) | | Transak | [transak.com](https://transak.com) | | Ramp Network | [docs.ramp.network](https://docs.ramp.network) | | Banxa | [banxa.com](https://banxa.com) | **Things to settle with the provider:** * **Countries and assets.** Coverage differs per country, payment method and network. * **Who you are to them.** Most providers need a signed agreement and a business review before production keys. * **Fees.** The user sees the provider’s fee plus the network fee; some let you add your own. ## Notifications [Section titled “Notifications”](#notifications) **The job:** “You received 50 USDT” while the app is closed. **In the kit:** nothing yet. **You bring:** 1. **An address-watching service** that calls your server when something happens to an address: | Chain | Examples | | --------------------------------------------- | ------------------------------------------------------------------------------------- | | EVM, Solana and other chains QuickNode serves | [QuickNode Streams](https://www.quicknode.com/docs/streams) | | EVM | [Alchemy Notify (webhooks)](https://docs.alchemy.com/reference/notify-api-quickstart) | | Solana | [Helius webhooks](https://www.helius.dev/docs/webhooks) | | Any | Your own indexer polling the history sources | 2. **A server** that receives those events, decides what is worth a notification, and maps the address to a device token. 3. **Push delivery:** [Expo push](https://docs.expo.dev/push-notifications/overview/), or [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) and APNs directly. **Rules:** * **Only addresses leave the device**, never keys. Tell users their addresses are linked to a device on your server, and let them turn it off. * **Filter spam before you push.** A notification about a scam airdrop is phishing you delivered yourself. Check before you rely on a provider On-ramps and webhook services change coverage, pricing and requirements. We checked these links on 2026-10-23. Read each provider’s current terms. A listing here is not an endorsement. # History > An activity list with readable titles across every chain, and the explorer and indexer APIs behind it. **The job:** an activity list that reads “Swapped 100 USDC → 0.03 ETH” and “Staked 1 ETH”, not raw hashes and “Contract call”. **In the kit:** * **History sources**, one per chain: `history-evm` for EVM networks, and sources built into `wallet-btc`, `wallet-solana`, `wallet-ton`, `wallet-tron`, `wallet-near` and `wallet-zcash`. Register them with `wallet.registerHistorySource(…)`. * **The feed:** `kit` merges every chain into one list, newest first, with a cursor for paging. * **Labels from the modules:** every protocol module recognizes its own transactions (`describeActivity`), so a Lido stake, an Aave supply or a CCTP bridge reads as what it is. * **The intent journal:** record what the app meant when it broadcast, and a pending swap shows correctly before the explorer has indexed it. The code is in the [History guide](/guides/history/). ## Sources per chain [Section titled “Sources per chain”](#sources-per-chain) | Chain | Source the package reads | Key | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | EVM | [Etherscan API v2](https://docs.etherscan.io), [Blockscout PRO](https://docs.blockscout.com/devs/pro-api), [Alchemy](https://www.alchemy.com) Transfers; keyless Blockscout and Routescan as fallbacks | Etherscan v2 refuses keyless requests; a free Etherscan key plus a free Blockscout PRO key cover most networks | | Solana | JSON-RPC (`getSignaturesForAddress`, `getTransaction`), no indexer | Your RPC; public endpoints rate-limit quickly | | TON | [tonapi](https://tonapi.io) events, toncenter v3 as fallback | Works keyless at a low rate | | Tron | [TronGrid](https://www.trongrid.io) account endpoints | Key recommended | | Bitcoin | Blockbook (Trezor’s, [QuickNode’s add-on](https://www.quicknode.com/docs/bitcoin), or your own) | Depends on the instance | | NEAR | [nearblocks](https://nearblocks.io) API, archival RPC for single transactions | Keyless at a low rate | | Zcash | [Blockchair](https://blockchair.com/api) | Key for volume | Keys for explorers are secrets: put them behind your own proxy ([Your server](/build-a-wallet/your-server/)). `history-evm` takes a proxy URL instead of the Etherscan endpoint for that. ## Gotchas [Section titled “Gotchas”](#gotchas) * **Spam.** Airdropped fake tokens and “address poisoning”: tiny transfers from an address that looks like one the user sends to, so the user copies the wrong one from history. The feed hides rows the source flags as spam by default. Still, never offer “copy address” on an incoming row without a warning. * **Cross-chain operations are two rows on two chains.** A bridge is a send on one chain and a receive on another, minutes later. The intent journal and the swap modules tie them into one item. * **Explorers lag.** A transaction can be on-chain and missing from the explorer for a few seconds to minutes. Show your own pending row until the source has it. Check before you rely on a provider Explorer APIs change their free tiers and key rules often (Etherscan required keys in 2026). We checked these links on 2026-10-23. Read each provider’s current terms and keep a fallback source. A listing here is not an endorsement. # Keys and storage > Seed phrases, derivation, and keeping the seed safe on the device. ## Keys [Section titled “Keys”](#keys) **The job:** turn 12 or 24 words into keys for every chain, the same way other wallets do. Otherwise a user who imports their MetaMask or Phantom phrase sees an empty wallet. **In the kit:** `core`. * `getRandomSeedPhrase(12 | 24)` creates a phrase; `isValidSeedPhrase()` checks the wordlist and the checksum. * The phrase becomes a 64-byte seed, and keys are derived along the standard paths: BIP-32 for secp256k1 chains (EVM, Bitcoin, Tron, Zcash), SLIP-10 for ed25519 chains (Solana, TON, NEAR). * Paths are pinned by test vectors, so account `i` is the address MetaMask, Phantom, Tonkeeper, TronLink and MyNearWallet derive. See [Chain ids and derivation](/concepts/chain-ids/). **Decisions you make:** * **12 or 24 words.** Both are standard; 24 is the default. * **A BIP-39 passphrase or not.** The SDK uses an empty one, like every major wallet. A passphrase changes every address, so a user who forgets it loses the funds. * **Imports.** `keyring` imports a seed phrase, a single private key for one chain, and the 24-word TON format of Tonkeeper, Tonhub and MyTonWallet. See the [Keyring guide](/guides/keyring/). **Rules:** * **Don’t keep the phrase as a JS string longer than the onboarding screen.** A string cannot be wiped from memory. The SDK turns a phrase into the 64-byte seed the moment it gets it and keeps only the bytes; `dispose()` wipes them. Better still, keep the 64-byte seed in secure storage next to the phrase and unlock with it (`new Wallet(seedBytes)`): the words are then read only when the user opens “Show recovery phrase”. * **Logging an object never shows a key.** Seeds and private keys are not properties of any SDK object, so `console.log(account)`, `JSON.stringify(wallet)` or a crash report shows addresses and public keys only. The conformance suite checks this for every chain. * **Never send the phrase or the seed anywhere.** That includes analytics, crash reports, logs and clipboard history. * **Don’t let the OS screenshot the phrase screen.** Block screenshots and the app-switcher preview while it is shown. ## Key storage [Section titled “Key storage”](#key-storage) **The job:** keep the seed on the device, encrypted, unlocked only by the user. **In the kit:** * **Mobile:** `@anywalletkit/secure-storage-react-native`, an Expo module (Swift + Kotlin) that plugs into `keyring` as its `SecretStorage`. Each secret is a Keychain item / Keystore-wrapped file that only Face ID / Touch ID / a fingerprint, with the device passcode as the fallback, can open, on every read; it never leaves the device and is unreadable while the phone is locked. It needs a dev or Release build (not Expo Go) and `NSFaceIDUsageDescription` in `Info.plist`: ```ts import { createSecureStorage } from '@anywalletkit/secure-storage-react-native' const secrets = createSecureStorage({ namespace: 'my-wallet' }) const keyring = await Keyring.load({ storage, secrets, chains }) ``` * **Browser extension:** `extension-kit` has an encrypted vault (password or passkey) and a session that locks after 15 minutes idle. See [dApp connection](/build-a-wallet/dapp-connection/). **What to use on mobile:** | Platform | Store | Notes | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | iOS | [Keychain Services](https://developer.apple.com/documentation/security/keychain-services) | Use an access control that asks for Face ID **or** the device passcode, so a user whose Face ID fails is not locked out | | Android | [Android Keystore](https://developer.android.com/privacy-and-security/keystore) | Ask for biometrics with a device-credential fallback | | Expo / React Native | `@anywalletkit/secure-storage-react-native` (above); in Expo Go, [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/) with `requireAuthentication` | One API over both platforms | **Backup:** * Show the phrase once, then ask the user to confirm a few of the words. * Never store the phrase in plain files, app storage or a screenshot. * “Export private key” for one account goes behind the same authentication as the phrase. `keyring.exportPrivateKey` returns the key in the format the chain’s other wallets import, checked against the account’s address. See [Keyring](/guides/keyring/#back-up-and-remove). * Encrypted backups: `@anywalletkit/seed-vault` seals a secret into a password- or key-protected file, and `@anywalletkit/backup-cloud` keeps it in the app’s iCloud Drive container with the key in iCloud Keychain (Android is planned). ## Keys that never touch the phone [Section titled “Keys that never touch the phone”](#keys-that-never-touch-the-phone) Any key that signs somewhere else works through `externalSigner()`: a hardware wallet, an MPC service, a Safe, or a backend signer for bots. The account behaves the same; only the `sign` call goes out. See [Custom signer](/guides/custom-signer/). # Networks and RPC > Where every read and broadcast goes, how to choose RPC providers per chain, and how to keep keys off the client. **The job:** every balance, fee quote and broadcast goes through a node. This is where most wallet outages come from: a rate limit, a node that lags, a method the plan doesn’t serve. **In the kit:** * Every `wallet-*` package takes **a list** of endpoints and fails over in order, with a few retries across the list. * `networks` holds the catalog of mainnets, each with public RPCs to fail over through. It also stores the RPCs and custom networks the user adds, and `verifyNetwork()` checks that an endpoint really serves the chain it claims before you trust it. See the [Networks guide](/guides/networks/). ## What each chain package talks to [Section titled “What each chain package talks to”](#what-each-chain-package-talks-to) | Chain | Package expects | Public / free to start | Production options | | ------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EVM | JSON-RPC URL or EIP-1193 provider | [PublicNode](https://www.publicnode.com), endpoints on [Chainlist](https://chainlist.org) | [QuickNode](https://www.quicknode.com), [Alchemy](https://www.alchemy.com), [Infura](https://www.infura.io), [dRPC](https://drpc.org), [Ankr](https://www.ankr.com/rpc/), [Chainstack](https://chainstack.com) | | Solana | JSON-RPC URL | [Public clusters](https://solana.com/docs/references/clusters) (not for production) | [Helius](https://www.helius.dev), [QuickNode](https://www.quicknode.com), [Alchemy](https://www.alchemy.com) | | TON | toncenter v2 JSON-RPC; tonapi or toncenter v3 for emulation | [toncenter](https://toncenter.com) (low limit without a key), [tonapi](https://tonapi.io) | toncenter / tonapi with a key, [QuickNode](https://www.quicknode.com/chains) | | Tron | Full-node HTTP API | [TronGrid](https://www.trongrid.io) (low limit without a key) | TronGrid with a key, [QuickNode](https://www.quicknode.com/chains), [GetBlock](https://getblock.io) | | Bitcoin | Esplora or Blockbook HTTP | [mempool.space](https://mempool.space), [Blockstream](https://blockstream.info) | [QuickNode Blockbook](https://www.quicknode.com/docs/bitcoin), your own [Blockbook](https://github.com/trezor/blockbook) / [Esplora](https://github.com/Blockstream/esplora) / [mempool](https://github.com/mempool/mempool) | | NEAR | JSON-RPC | [FastNEAR](https://fastnear.com) | [NEAR RPC providers](https://docs.near.org/api/rpc/providers) | | Zcash | Blockchair or Blockbook | [Blockchair](https://blockchair.com/api) (low limit without a key) | Blockchair with a key, your own Blockbook | **How we run it.** Our reference wallet uses QuickNode for its EVM chains, with public endpoints as fallbacks. That is one setup that works, not the only one. ## Choosing a provider [Section titled “Choosing a provider”](#choosing-a-provider) | Option | Good for | Watch out | | ---------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Public endpoints | Prototypes, fallback | Rate limits. Many refuse `eth_getLogs` without an address filter, archive reads, or `eth_simulateV1` | | Paid providers | Production | Each plan has its own `eth_getLogs` block window (QuickNode: 10 000 blocks), per-method credit costs and per-second limits | | Your own node | Full control | You run it: disks, upgrades, monitoring | Before you pick, check the methods your features need on that provider’s plan: | Feature | Needs | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Review before signing](/build-a-wallet/review-and-safety/) | `eth_simulateV1` (EVM). Public endpoints on Ethereum, Base, Arbitrum, Optimism, Polygon and BNB Chain had it when we checked; Avalanche did not | | [Approvals](/guides/approvals/) | `eth_getLogs` **without** an address filter, over long block ranges. Most free endpoints refuse it | | [History](/build-a-wallet/history/) on Solana | `getSignaturesForAddress` and `getTransaction` with full history | | Old transactions | Archive data | ## Keep keys off the client [Section titled “Keep keys off the client”](#keep-keys-off-the-client) A paid RPC key shipped inside an app or an extension is public: anyone can extract it and spend your credits. Three ways out: 1. **A proxy** on your server that adds the key ([Your server](/build-a-wallet/your-server/)). 2. **Allow-listing** on the provider’s dashboard: restrict the key to your app’s bundle id or origin, and to the methods you use. This limits abuse; it does not hide the key. 3. **Client-safe keys**, where the provider designs them for that (toncenter, for one). ## Let users bring their own RPC [Section titled “Let users bring their own RPC”](#let-users-bring-their-own-rpc) Power users want their own node, and some regions block public providers. `networks` stores user RPCs per chain, can use **only** the user’s RPC when asked, verifies the endpoint before use, and parks endpoints that fail with the reason. Label screens that ran through a user RPC: a simulation from an unknown node is not a guarantee. Check before you rely on a provider Links, plans and limits change. We checked these links on 2026-10-23. Read the provider’s current docs and pricing, test with your real traffic, and keep a second provider for fallback. A listing here is not an endorsement. # Anatomy of a wallet > What a crypto wallet is made of, which Any Wallet Kit package covers each part, and what you bring yourself. A crypto wallet looks like one app, but it is about eighteen parts. Some are cryptography, some are network data, some are product decisions. This section goes through them in the order you’ll need them. For each part it says what the job is, which package does it, and what you have to bring yourself: an RPC, an API key, a storage layer. | # | Part | What the user sees | Any Wallet Kit | You bring | | -- | ---------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | | 1 | [Keys](/build-a-wallet/keys-and-storage/) | “Create wallet”, “Import seed phrase” | `core` | Nothing | | 2 | [Key storage](/build-a-wallet/keys-and-storage/#key-storage) | Face ID unlock, “Back up your phrase” | `secure-storage-react-native` (iOS Keychain, Android Keystore), `seed-vault` (encrypted backup file), `backup-cloud` (iCloud), `extension-kit` (browser) | Nothing on iOS; an Android cloud backup is planned | | 3 | [Accounts](/build-a-wallet/accounts/) | One address per chain, several accounts | `kit`, `keyring`, `wallet-*` | Nothing | | 4 | [Networks and RPC](/build-a-wallet/networks-and-rpc/) | Nothing, until it breaks | `networks` | RPC endpoints | | 5 | [Balances](/build-a-wallet/balances-and-prices/) | Total, list of assets | `wallet-*`, `swap2-assets` | RPC; an indexer to find tokens | | 6 | [Prices](/build-a-wallet/balances-and-prices/#prices) | $ next to every amount | Not in the SDK yet | A price API | | 7 | [Send](/build-a-wallet/send-and-receive/) | Recipient, amount, fee, confirm | `wallet-*` | RPC | | 8 | [Receive](/build-a-wallet/send-and-receive/#receive) | Address and QR code | `wallet-*` | A QR library | | 9 | [Names](/build-a-wallet/send-and-receive/#names) | `name.eth` in the recipient field | `names-ens` | An Ethereum RPC | | 10 | [History](/build-a-wallet/history/) | Activity list with readable titles | `history-evm`, `wallet-*`, `kit` | Explorer / indexer keys | | 11 | [Review before signing](/build-a-wallet/review-and-safety/) | “You send 100 USDC, you get 0.03 ETH” | `tx-preview-evm`, `kit` | An RPC with `eth_simulateV1` | | 12 | [Safety](/build-a-wallet/review-and-safety/#safety) | Approvals list, warnings | `protocol-approvals-evm`, `kit` | Blocklist sources | | 13 | [Swaps and bridges](/build-a-wallet/swaps-and-earn/) | Swap screen, route picker | `protocol-swap2-*` | Provider keys (some) | | 14 | [Earn](/build-a-wallet/swaps-and-earn/#earn) | Stake, lend, APY | `protocol-staking-*`, `protocol-lending-*` | RPC | | 15 | [dApp connection](/build-a-wallet/dapp-connection/) | “Connect wallet” on websites | `extension-kit` | WalletConnect on mobile | | 16 | [Buy and sell](/build-a-wallet/fiat-and-notifications/) | “Buy crypto” button | `FiatProtocol` interface only | An on-ramp provider | | 17 | [Notifications](/build-a-wallet/fiat-and-notifications/#notifications) | “You received 50 USDT” | Not in the SDK yet | A webhook service and push | | 18 | [Your server](/build-a-wallet/your-server/) | Nothing | `protocol-swap2-core/server` (swap backend), [`examples/server`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/server) (RPC proxy + swaps) | Hosting, user auth, rate limits | [Ship a wallet](/build-a-wallet/ship-a-wallet/) puts the parts together end to end, with the server. A minimal wallet is parts 1–8. A wallet people keep using adds 10, 11 and 13. Everything else is what sets one wallet apart from another. ## Build order [Section titled “Build order”](#build-order) 1. **Keys, storage, accounts.** The wallet can be created and restored. 2. **Networks, balances, prices, send, receive.** A usable wallet. 3. **History, review before signing.** A wallet people trust. 4. **Swaps, Earn, approvals.** A wallet people come back to. 5. **dApp connection, fiat, notifications, names.** A complete product. ## About the providers on these pages [Section titled “About the providers on these pages”](#about-the-providers-on-these-pages) These pages name third-party services (RPC nodes, indexers, price APIs, on-ramps) as examples of what fits each part. Always check the provider yourself * **Links, plans, limits and prices change.** We checked the links on 2026-10-23. Before you rely on a provider, open its current documentation and pricing page. * **Test with your real traffic.** Free tiers and “unlimited” plans have limits that show up only under load: rate limits, block-range caps, missing methods. * **A listing is not an endorsement.** We have no partnership with any provider named here. * **Keep two providers for anything critical**, so one outage does not take your wallet down. # Review and safety > Showing what a transaction will do before it is signed, and protecting users from approvals, drainers and fake tokens. ## Review before signing [Section titled “Review before signing”](#review-before-signing) **The job:** before the key is used, show what will happen in words the user understands: “You send 100 USDC, you get at least 0.03 ETH”, “This site asks to spend ALL your USDT”. **In the kit:** * `tx-preview-evm` decodes the calls and approvals, simulates the balance changes, and returns named checks (`approval-unlimited`, `simulation-reverted`, `recipient-foreign`…) that your app words its own way. See [Transaction preview](/guides/tx-preview/). * `kit` [policies](/concepts/policies/) call your `confirm` step before **every** signature and broadcast, and refuse by fee cap, allowlist or blocklist. Wire the preview into `confirm` and nothing reaches the key unseen. * Swap modules hand the exact plan to `reviewExecution` before the first approval. See [Swaps → Show a review screen](/guides/swaps/#show-a-review-screen). **You bring:** an RPC that serves `eth_simulateV1` (see [Networks and RPC](/build-a-wallet/networks-and-rpc/#choosing-a-provider)). Where a network lacks it, the preview says so instead of guessing; show that state honestly. **Design the sheet around three questions:** what leaves, what arrives, who gets permission. Put the fee and the raw details below that. ## Safety [Section titled “Safety”](#safety) **The job:** protect users from the most common losses. | Threat | What happens | In the kit | | ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Unlimited approvals | A contract the user approved once can drain the token later | [Approvals](/guides/approvals/): find and revoke; `tx-preview-evm` warns before granting one | | Permit / Permit2 phishing | A “free” signature authorizes a transfer | Policies treat Permit and Permit2 typed data as approvals: `confirm` sees the spender, the amount and `unlimited` | | Known drainers | The recipient or spender is a scam address | Policies `blocklist`, checked for every recipient and spender | | Address poisoning | The user copies a look-alike address from history | Spam flags in [History](/build-a-wallet/history/); warn on first sends | | Fake tokens | Airdropped tokens with phishing names | Hide unknown tokens ([Balances](/build-a-wallet/balances-and-prices/#gotchas)) | | Wrong network | Funds sent on a network the recipient doesn’t watch | Say the network on receive and review screens | **You bring:** blocklist data. Examples: [MetaMask eth-phishing-detect](https://github.com/MetaMask/eth-phishing-detect) (phishing domains for dApp connections), [ScamSniffer](https://scamsniffer.io) (scam addresses and domains), [Chainalysis sanctions screening](https://www.chainalysis.com/free-cryptocurrency-sanctions-screening-tools/). A risk-screening module for the SDK is planned. Check before you rely on a provider Blocklists differ in coverage, update speed and license. We checked these links on 2026-10-23. Read each source’s terms before you ship it in an app, and refresh the data often. A listing here is not an endorsement. # Send and receive > The send flow on every chain, what differs per chain, receiving with a QR code, and names instead of addresses. ## Send [Section titled “Send”](#send) **The job:** recipient, amount, fee, confirm, broadcast, wait. **In the kit:** every account has the same three calls. ```ts const { fee } = await account.quoteTransfer({ token, recipient, amount }) // nothing signed const { hash } = await account.transfer({ token, recipient, amount }) // signs and broadcasts const receipt = await account.getTransactionReceipt(hash) // undefined until it lands ``` `token` is omitted for the native coin. Amounts are base units (`bigint`). With [policies](/concepts/policies/) registered, the transfer passes the fee cap, the lists and your `confirm` step before anything is signed. **What differs per chain, and what your UI must show:** | Chain | What the package does | What can go wrong | | ------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | EVM | EIP-1559 fees; `autoResetAllowance` sends USDT’s `approve(0)` when needed | The user has no gas coin on that network | | Solana | Creates the recipient’s token account (ATA) when missing; sets the priority fee automatically | Creating an ATA costs rent, so a token send can cost more SOL than expected | | TON | Deploys the wallet contract on the first send; supports a text comment | Jetton sends need TON for the fee; exchanges require a comment (memo) | | Tron | `estimateFee` returns bandwidth, energy and the TRX that will be burned | Without staked energy, a USDT transfer burns several TRX | | Bitcoin | SegWit (`bc1q…`) or Taproot (`bc1p…`) accounts, fee rate in sat/vB | Dust outputs, a fee rate that is too low in a busy mempool | | NEAR | Adds `storage_deposit` before a token send to a new holder | Named vs implicit accounts in the recipient field | | Zcash | Transparent (`t1…`) addresses | Shielded addresses are not supported yet | **Rules for the send screen:** * **Validate the address before the amount screen.** Check the format for that chain. On EVM, warn when the address is a token contract: tokens sent to a token contract are usually lost. * **Warn on a first send** to an address the user has never sent to. * **Show the fee in the gas coin and in fiat**, and what the recipient will receive. * **Keep the user from sending the whole balance of the gas coin** without leaving the fee. * **After broadcast, show a pending row** right away and update it from the receipt. ## Receive [Section titled “Receive”](#receive) **The job:** show the address and a QR code. **In the kit:** `account.getAddress()`. **You bring:** a QR library. * **EVM:** the same address works on every EVM network. Say which network the sender should use, or tokens arrive on a network the user doesn’t watch. * **Bitcoin:** decide which address type to show (SegWit is the most widely accepted). * **TON and exchanges:** some exchanges need a comment to credit a deposit. The receive screen can’t help with that; the send screen of the exchange must. ## Names [Section titled “Names”](#names) **The job:** let users type `name.eth` instead of `0x…`. **In the kit:** `names-ens` resolves ENS names to the address for the chain you ask for, including L2 names and CCIP-Read (offchain) names. `.sol`, `.ton` and `.near` are planned. See the [Names guide](/guides/names/). **You bring:** an Ethereum mainnet RPC: resolution always starts on mainnet, whatever chain the user sends on. **Rule:** show the resolved address on the review screen, not only the name. A name can change owner; the address is what gets the funds. # Ship a wallet: end to end > The whole path from an empty Expo app to a release — keys on the device, a server for RPC and swaps, review before signing, and the checklist before you publish. 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`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/server), which CI starts and tests on every change; the app snippets come from the other `examples/`. ```plaintext 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”](#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: ```sh 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 ``` ```ts // index.ts — the very first lines import { installHermesRuntime } from '@anywalletkit/runtime-hermes' installHermesRuntime() ``` Details and the Metro settings: [Installation](/getting-started/installation/#react-native--expo). ## 2. Keys on the device [Section titled “2. Keys on the device”](#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. ```ts import { createSecureStorage } from '@anywalletkit/secure-storage-react-native' const secrets = createSecureStorage({ namespace: 'my-wallet' }) ``` ```ts export function openKeyring(storage: KeyringStorage, secrets: SecretStorage): Promise { 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' } }, }, }) } ``` ```ts 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](/guides/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](/build-a-wallet/keys-and-storage/)). ## 3. Your server [Section titled “3. Your server”](#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. ```ts /** 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 { const ALIASES: Record = { 'solana:mainnet': 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' } const out: Record = {} 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 } ``` ```ts export function createServer(opts: ServerOptions): Server { const guard = makeGuard(opts) // With per-user sign-in (Sign-In with Ethereum), also pass `subject: (req) => `: // quotes and swap ids are then bound to that user, and another user's are refused (403). const swap2 = createSwap2Handler({ 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: ```ts /** * A JSON-RPC proxy: the app talks to `POST /rpc/`, 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> /** 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> = { 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 = 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 | 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>) { 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 { 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 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 } = {}): Promise { 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: ```ts /** * ⚠️ 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](https://github.com/ultratestbro/anywalletkit/tree/main/examples/server)). ## 4. RPC through the server [Section titled “4. RPC through the server”](#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. ```ts /** 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](/concepts/chain-ids/#pass-chainid-with-every-evm-registration)). Balances, token lists and history work unchanged on top ([Balances](/guides/balances/), [History](/guides/history/); explorer keys belong on the server too). ## 5. Swaps through the server [Section titled “5. Swaps through the server”](#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. ```ts /** * 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> = { 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: ```ts 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 } ``` ```ts const DONE = new Set(['completed', 'failed', 'refunded', 'cancelled', 'expired', 'partial']) export async function waitForDelivery( swaps: RouterSwap2Protocol, id: string, destinationAccount: () => Promise, { 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](/guides/swaps/). ## 6. Review before signing [Section titled “6. Review before signing”](#6-review-before-signing) Every write goes through policies (fee caps, blocklists, a confirm step) and, for EVM, a simulation the user can read: ```ts 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 }, } ``` ```ts 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](/build-a-wallet/review-and-safety/) and [Transaction preview](/guides/tx-preview/). ## 7. Connecting to dApps [Section titled “7. Connecting to dApps”](#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](https://docs.reown.com) and route its session requests to `kit` accounts, so every request still passes your policies and review; see [dApp connection](/build-a-wallet/dapp-connection/#mobile-walletconnect). For browsers, `extension-kit` is the core of an extension wallet. ## 8. Before you publish [Section titled “8. Before you publish”](#8-before-you-publish) * **Testnet first.** Run every flow on Sepolia / Base Sepolia (and Solana devnet) with the [Networks](/guides/networks/#testnets) 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. # Swaps and Earn > Adding swaps, bridges, staking and lending — which modules, which keys, and what the UI must handle. ## Swaps and bridges [Section titled “Swaps and bridges”](#swaps-and-bridges) **The job:** “I have USDT on Tron, I want ETH on Base” in one screen. **In the kit:** twelve providers behind one `Swap2Protocol` interface, and a router that asks all of them in parallel and tags the best and the fastest route. Before it signs, the engine checks every quote: amounts, recipient, refund address, fee caps. The full flow is in [Swaps and bridges](/guides/swaps/). **You bring:** | Provider | Key | Get one | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | LI.FI | Optional; raises the rate limit, needed for an integrator fee | [li.fi](https://li.fi), [docs](https://docs.li.fi) | | NEAR Intents (1Click) | JWT for volume and fees | [docs.near-intents.org](https://docs.near-intents.org) | | Jupiter | Optional; keyless is 0.5 requests per second, a key is per organisation (keep it on your server) | [developers.jup.ag](https://developers.jup.ag/portal) | | Fynd | Hosted API key, or run your own `fynd serve`; the license asks for a revenue share if you charge fees on its swaps | [docs.fynd.xyz](https://docs.fynd.xyz) | | THORChain, Chainflip, CCTP, USDT0, Uniswap, Balancer, Wrap | None | — | | STON.fi Omniston | None; EVM orders keep an HTLC secret until delivery, so a serverless backend needs a durable store | See the module README | * **Secrets stay on a server.** The router can reach providers through your backend (the remote provider in `protocol-swap2-core`), so JWTs and affiliate settings never ship in the app. * **Track status.** Cross-chain swaps take minutes. Persist the swap id, poll `getSwap2Status`, and handle `action-required` (a CCTP claim on the destination chain). * **Pick routes in the app.** The router tags `best` and `fastest`; which one is the default, and how close two routes must be to count as a tie, is your product’s call. ## Earn [Section titled “Earn”](#earn) **The job:** staking and lending with a clear yield and a clear way out. **In the kit:** nine modules behind two interfaces: `StakingProtocol` (stake → request withdrawal → claim) and `LendingProtocol` (supply and withdraw; borrow and repay where the market allows). See [Earn](/guides/earn/). **You bring:** RPC on each chain. Some modules read rates from the protocol’s own public API (Lido’s APR), which you may want to cache on your server. **Show the user, before they deposit:** * **How long the exit takes.** Lido’s withdrawal queue takes days; a Jito exit can be instant at a fee, or wait for the epoch; native SOL stake cools down until the next epoch boundary (\~2 days), and native MON stake can be withdrawn one epoch after it deactivates (Monad epochs are about 4 hours). * **Whom they delegate to.** With native staking your app picks the validators; show each one’s commission and whether it is voting (Solana) or in the active set (Monad). * **What the yield is made of**, and that it changes. * **The health factor** before a borrow, and what happens when it drops below 1. # Your server > What a wallet backend is for, what never goes on it, and the minimal set of routes. A non-custodial wallet can run without a server. Most production wallets still have one, for the parts that can’t live in the app. **What it is for:** | Need | Why not in the app | | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | | RPC and explorer proxy | Paid keys in an app are public ([Networks and RPC](/build-a-wallet/networks-and-rpc/#keep-keys-off-the-client)) | | Swap provider proxy | Provider JWTs and affiliate settings ([Swaps](/build-a-wallet/swaps-and-earn/)) | | Swap status tracking | A cross-chain swap may need an action after the app is closed (Omniston disclosure, CCTP claim) | | Notifications | Watching addresses and pushing to devices ([Notifications](/build-a-wallet/fiat-and-notifications/#notifications)) | | Price cache | One price API call for all users instead of one per user | **In the kit:** * **Swap backend:** `createSwap2Handler` from `@anywalletkit/protocol-swap2-core/server` runs the providers on your server; the app reaches them with `remoteProvider`. It validates every request, seals quotes (HMAC) and builds deposits only for quotes it sealed, and requires a `guard` for authentication and rate limiting. * **A reference server:** [`examples/server`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/server) wires that handler and a JSON-RPC proxy (method allowlist, failover, no keys in errors) on `node:http`, tested in CI. [Ship a wallet](/build-a-wallet/ship-a-wallet/) walks through it with the app. * **Everything else is configuration:** every package takes the URL you choose, so pointing it at your proxy needs no SDK change. * **Not in the kit yet:** notifications (address watching → push), a shared price cache, and a durable store for Omniston’s EVM → TON orders. **Rules:** * **The server never holds user keys.** Not encrypted, not “for recovery”. If it signs, it signs for your own accounts (a relayer, a bot), with a backend signer. * **Authenticate the app.** A public proxy is a free RPC for everyone; limit it by app attestation, a signed session or per-address rate limits. * **Log nothing you’d regret.** Addresses and IPs together identify a user’s whole portfolio. * **Pin what you forward.** A proxy should allow the methods and hosts your app uses, not forward any URL it is given. # Build with AI > Copy-paste prompts that build a wallet, swaps, a review sheet, Earn, accounts, a payout bot or your own module with an AI coding agent, each tested on a clean project. Any Wallet Kit is small enough for a coding agent to use correctly when it has the right reference. This section gives you that reference and a set of prompts that build real things on the SDK. We run every prompt on a clean project before publishing it. ## Give your agent the SDK [Section titled “Give your agent the SDK”](#give-your-agent-the-sdk) * **[AGENTS.md](https://github.com/ultratestbro/anywalletkit/blob/main/AGENTS.md)**: one file with the packages, chain ids, the canonical patterns (wallet, balances, send, swaps, review, Earn, keyring, policies, modules), the React Native setup, the pitfalls and the security rules. Every API name in it is checked against the packages. * **[llms-small.txt](/llms-small.txt)**: these guides as plain text. * **[llms-full.txt](/llms-full.txt)**: the guides plus the generated API reference. It is large, so let the agent search it. Most agents read a URL when the prompt names it. The prompts below name both. ## The prompts [Section titled “The prompts”](#the-prompts) | Prompt | Builds | Starts from | | ------------------------------------------------ | -------------------------------------------------------------------------- | ------------ | | [Minimal wallet](/build-with-ai/minimal-wallet/) | Expo wallet: phrase in Face ID storage, balances on 6 chains, send | empty folder | | [Swaps and bridges](/build-with-ai/swaps/) | Swap screen on the router: streaming routes, auto slippage, review, status | the wallet | | [Transaction review](/build-with-ai/tx-review/) | Simulated review sheet for every EVM write | the wallet | | [Earn](/build-with-ai/earn/) | Lido, Aave v3 and Morpho with APY, positions, deposit / withdraw | the wallet | | [Accounts and imports](/build-with-ai/accounts/) | Account switcher, phrase / key / Tonkeeper imports, gated export | the wallet | | [Payout bot](/build-with-ai/backend-bot/) | Node CSV payouts: dry run, allowlist policy, idempotent journal | empty folder | | [Your own module](/build-with-ai/own-module/) | ERC-4626 `LendingProtocol` package with conformance tests | empty folder | Coming: a **Telegram Mini App wallet**, once the embedded-wallet kit ships. ## Eval results [Section titled “Eval results”](#eval-results) Each prompt ran once in a fresh Claude Code session (Opus 5.5, headless, no project settings or MCP servers) in a clean folder. The `@anywalletkit` packages were served from a local registry built from the repository; nothing else differed from a normal setup. Prompts 2–5 started from the app prompt 1 produced. We then checked every result ourselves: * **Expo prompts:** `npx tsc --noEmit` and `npx expo export --platform ios` (the Hermes bundle). * **Node prompts:** tsc, the tests, and a real run (the bot’s dry run against Base mainnet with the public test phrase; the module’s conformance suite). | Prompt | Result | Time | Turns | Cost | Gate | | -------------------- | --------------- | ------ | ----- | ----- | -------------------------------------------- | | Minimal wallet | pass, first run | 13 min | 68 | $3.63 | tsc ✓, iOS bundle ✓ (1 967 modules) | | Swaps and bridges | pass, first run | 9 min | 44 | $2.77 | tsc ✓, iOS bundle ✓ (2 023 modules) | | Transaction review | pass, first run | 8 min | 50 | $2.46 | tsc ✓, iOS bundle ✓ | | Earn | pass, first run | 9 min | 64 | $2.83 | tsc ✓, iOS bundle ✓, mainnet reads ✓ | | Accounts and imports | pass, first run | 9 min | 46 | $2.83 | tsc ✓, iOS bundle ✓ | | Payout bot | pass, first run | 7 min | 38 | $1.88 | tsc ✓, 17 tests ✓, dry run ✓ | | Your own module | pass, first run | 11 min | 57 | $2.95 | tsc ✓, 47 tests incl. conformance ✓, build ✓ | **What the eval changed:** * **Fixed in the SDK:** * `runtime-hermes/fast-pbkdf2`: `wallet-ton` no longer needs a native module or a stub on Metro. * `protocol-swap2-core`: Solana, Tron and Bitcoin accounts can swap with `fromChain: account.chain`. * Lido, SSV, Mellow and Approvals: these modules now accept real `wallet-evm` accounts in typed code. * **Added to AGENTS.md:** * the two forms of chain id; * balances without a biometric prompt (read-only accounts; TON needs its public key); * one EVM network per keyring; * Earn modules over keyring accounts; * the account errors (`BroadcastUnknownError`); * how modules read chain state and recognize their transactions. * **What the eval does not cover:** a bundle that builds is not an app that has been tapped through. None of the results ran on a device or sent a real transaction. Test on a Release build with small amounts. # Accounts and imports > Multiple accounts from one phrase, imported phrases, private keys and Tonkeeper wallets, and gated export. ## What you get [Section titled “What you get”](#what-you-get) An account switcher on `@anywalletkit/keyring`. * **Accounts:** add the next account of a phrase; rename, hide or switch accounts. * **Imports:** another phrase, with discovery of the accounts it has used; a private key, after previewing its address; a Tonkeeper 24-word phrase. * **Gated screens:** “Show recovery phrase” and “Export private key” need a fresh biometric check every time. ## Before you start [Section titled “Before you start”](#before-you-start) The wallet from [Minimal wallet](/build-with-ai/minimal-wallet/). Tonkeeper imports need the `fast-pbkdf2` Metro resolution from [Installation](/getting-started/installation/#ton-on-metro). ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder of the app the earlier prompts built. The raw file is [`prompts/05-accounts.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/05-accounts.md). ```text Add multiple accounts and imports to this Any Wallet Kit wallet with `@anywalletkit/keyring`. Read the "Keyring" part of https://docs.anywallet.tech/llms-small.txt (or AGENTS.md in github.com/ultratestbro/anywalletkit) first, and use only APIs that appear there. - Account switcher: list every account with its name and short addresses. Add account (the next index of the same phrase), rename, hide or unhide, set active. Every screen follows the active account. - Import another recovery phrase, with discovery of the accounts it has used (any balance counts as used). - Import a private key for one chain: show the address it gives before saving. - Import a Tonkeeper-style 24-word TON phrase. - "Show recovery phrase" and "Export private key" sit behind a fresh biometric / passcode check every time. Block screenshots while either is shown. - Remove a wallet, together with its accounts and its secret. - All secrets go through `@anywalletkit/secure-storage-react-native`. Listing accounts never prompts for biometrics. Signing uses one short-lived account per action. Done when `npx tsc --noEmit` passes and `npx expo export --platform ios` bundles. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/wallet/keyring.ts # Keyring.load, create / import / addAccount / rename / hide / setActive / remove src/app/accounts/ # switcher, add, rename, hide src/app/import/ # phrase (discover), private key (preview → confirm), Tonkeeper src/app/backup/ # re-authenticate → exportSecret / exportPrivateKey, screenshots blocked ``` ## Check it works [Section titled “Check it works”](#check-it-works) Check that it builds with `npx tsc --noEmit` and `npx expo export --platform ios`. On a phone build: 1. Add an account and confirm its addresses differ from account 1. 2. Import the same phrase into another wallet app and compare the addresses. 3. Open “Show recovery phrase”: it must ask for Face ID every time. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Tonkeeper import throws on the phone | Metro resolves `react-native-fast-pbkdf2` to a throwing stub; use `@anywalletkit/runtime-hermes/fast-pbkdf2` | | The account list asks for Face ID | Read `listAccounts()` / `AccountMeta.addresses`; only signing and export touch secrets | | Imported key shows an unexpected address | Always `previewPrivateKey` and ask the user to confirm before `importPrivateKey` | Security Never paste a real recovery phrase into an agent chat, a prompt or a test. Use the public test phrase (`abandon abandon … about`) while building, then small amounts on a phone build. Read the diff the agent wrote around keys and signing: nothing should log, store or send the phrase, and every write should pass through a review step. Export screens re-authenticate every time and block screenshots. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 9 min, 46 turns, $2.83, on top of the Minimal wallet result. Account listing never prompts. Each reveal relies on secure storage’s own Face ID / passcode prompt (one prompt, not two), with screenshots blocked. # Payout bot (Node) > A Node service that pays USDC on Base from a CSV: dry run by default, policies with an exact allowlist, an idempotent journal and strict key handling. ## What you get [Section titled “What you get”](#what-you-get) A TypeScript CLI on Node 22. * **Input:** a CSV of payouts; the seed phrase comes from an env var. * **Safety rails:** kit policies (an allowlist of exactly the CSV’s recipients, plus a fee cap), checksummed addresses, per-row limits and balance checks. * **Dry run:** by default it quotes every fee and prints the plan; `--send` pays. * **Journal:** written before and after each send, so a restart never pays twice and an unknown outcome waits for a human. ## Before you start [Section titled “Before you start”](#before-you-start) * Node 22. * For a real run, a funded hot wallet that holds only what the run pays out. Use a separate phrase, never your main one. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder. The raw file is [`prompts/06-backend-bot.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/06-backend-bot.md). ```text Create a Node.js (TypeScript, Node 22, ESM) payout bot on Any Wallet Kit, the `@anywalletkit/*` packages. Read https://docs.anywallet.tech/llms-small.txt (or AGENTS.md in github.com/ultratestbro/anywalletkit) first, and use only APIs that appear there. - Input: a CSV of `address,amount` rows, with amounts in USDC (6 decimals). Payouts go out in USDC on Base from account 0 of the seed phrase in the `WALLET_SEED_PHRASE` env var. - Safety: - kit policies with an allowlist of exactly the CSV's recipients and a max-fee cap; - checksum-validate every address; - refuse duplicates and amounts above a configurable per-row limit; - check the USDC and ETH balances before starting. - Dry run by default: quote each transfer's fee and print the plan and the total; nothing is signed. `--send` actually sends. - Idempotent: a JSON journal records each row as pending, sent (with its hash) or failed, written before and after every send. A restart never pays a row twice, and a row whose outcome is unknown stays pending for a human. No automatic retries. - Never print or write the seed phrase or keys. Log addresses, amounts and hashes only. Dispose the wallet on exit, including Ctrl-C. - Add a README that covers setup, the env vars, a dry run, and the key-handling rules. Done when `npx tsc --noEmit` passes, a unit test covers the CSV and journal logic, and the dry run works against a sample CSV. Use the public test phrase `test test test test test test test test test test test junk`, which has no funds, so the run shows the plan with zero balances. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/main.ts # args, env, dry run vs --send, Ctrl-C → dispose src/csv.ts # parse + checksum + limits + duplicates src/journal.ts # pending / sent / failed, written around every send src/wallet.ts # Wallet + WalletManagerEvm on Base, policies (allowlist, maxFee, confirm) test/ # CSV and journal unit tests README.md # setup, env vars, dry run, key handling ``` ## Check it works [Section titled “Check it works”](#check-it-works) ```sh npx tsc --noEmit && npm test WALLET_SEED_PHRASE="test test test test test test test test test test test junk" npm run payout -- examples/payouts.sample.csv ``` The test phrase has no funds, so the dry run shows zero balances and says why `--send` would refuse. Then run with a funded test wallet and one tiny row. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | -------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Every fee shows “unavailable” | An ERC-20 fee quote simulates the transfer: the wallet must hold the token | | A crash after sending, then a double payment | Write the journal row as `pending` **before** sending; `BroadcastUnknownError` keeps it pending | | `PolicyViolationError: allowlist` | Expected for any recipient outside the CSV: the allowlist is the point | Security The phrase comes from a secret manager or env var at start, never from the repo. Logs carry addresses, amounts and hashes only. Keep the hot wallet’s balance to what one run needs. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 7 min, 38 turns, $1.88, from an empty folder. 17 unit tests pass. The dry run with the test phrase printed the plan, showed zero balances, and listed why `--send` would refuse. The phrase never appeared in the output. # Earn > Add Lido staking, Aave v3 supply and the biggest Morpho USDC vault on Base, with APY, positions, deposits and withdrawals. ## What you get [Section titled “What you get”](#what-you-get) An Earn tab with three products: * Lido staking on Ethereum, with the two-phase withdrawal queue; * Aave v3 USDC on Ethereum; * the biggest Morpho USDC vault on Base. Each shows its APR / APY and the user’s position. Deposit and withdraw quote the fee first and go through the confirm flow. ## Before you start [Section titled “Before you start”](#before-you-start) The wallet from the earlier prompts. Earn writes cost mainnet gas, so test with small amounts. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder of the app the earlier prompts built. The raw file is [`prompts/04-earn.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/04-earn.md). ```text Add an Earn tab to this Any Wallet Kit wallet. Read the "Earn" part of https://docs.anywallet.tech/llms-small.txt (or AGENTS.md in github.com/ultratestbro/anywalletkit) first, and use only APIs that appear there. Three products: - Lido staking on Ethereum (ETH → stETH). - Aave v3 USDC supply on Ethereum. - The biggest Morpho USDC vault on Base, found with `MorphoApi`. For each product: - Show the APR / APY and the user's position, with a refresh. - Deposit and withdraw with an amount field, a fee quote, then confirmation through the existing confirm flow. - Lido withdrawals are two-phase: request first, then claim once the request is claimable. Show pending and claimable amounts. Put the protocol registrations next to the wallet setup. Amounts stay bigint end to end. Done when `npx tsc --noEmit` passes and `npx expo export --platform ios` bundles. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/earn/protocols.ts # registerProtocol('lido' | 'aave'), MorphoApi.listVaults → MorphoVaultProtocolEvm src/earn/useEarn.ts # APR/APY, positions, quote + deposit / withdraw, Lido request → claim src/app/earn/ # one card per product, amount sheet, pending / claimable for Lido ``` ## Check it works [Section titled “Check it works”](#check-it-works) Check that it builds with `npx tsc --noEmit` and `npx expo export --platform ios`. On a phone build, check the APYs against the protocols’ own sites. Then deposit and withdraw a few dollars in the Morpho vault on Base, where gas is cheap. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | | Lido “withdraw” returns no ETH | Unstaking is two-phase: `requestWithdrawal`, then `claimWithdrawals({ ids: claimableIds })` once finalized | | `withdraw` leaves dust | Use `amount: 'max'` for the whole position | | The Morpho vault list is empty | `MorphoApi` filters by chain and asset address; pass the asset on that chain | Security Never paste a real recovery phrase into an agent chat, a prompt or a test. Use the public test phrase (`abandon abandon … about`) while building, then small amounts on a phone build. Read the diff the agent wrote around keys and signing: nothing should log, store or send the phrase, and every write should pass through a review step. Show the APY source and the protocol’s risks; never imply a fixed return. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 9 min, 64 turns, $2.83, on top of the Minimal wallet result. A read-only check against mainnet returned the Lido APR and queue, Aave’s USDC APY (4.10 %), and the biggest Morpho USDC vault on Base. The run found a typing bug, now fixed: modules built directly over a keyring account, with no `registerProtocol`, could not take a real `wallet-evm` account in Lido / SSV / Mellow / Approvals. # Minimal wallet (Expo) > One prompt that builds an Expo wallet: recovery phrase in Face ID-protected storage, addresses and balances on six chains, send with a fee quote and confirmation. ## What you get [Section titled “What you get”](#what-you-get) An Expo (React Native, expo-router) app. It creates or imports a 12-word phrase and keeps it in `@anywalletkit/secure-storage-react-native` through `@anywalletkit/keyring`. The home screen shows account 0’s addresses and native balances on Ethereum, Base, Solana, TON, Tron and Bitcoin. A send screen quotes the fee, asks for confirmation through the kit’s policies, signs and shows the hash. ## Before you start [Section titled “Before you start”](#before-you-start) * Node 22+, and a Mac with Xcode (or an Android SDK / EAS) for the phone build. * An empty folder. The agent runs `create-expo-app` itself. * No keys: the public RPCs in the reference are enough to start. Put your own endpoints in the config file before you ship. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder. The raw file is [`prompts/01-minimal-wallet.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/01-minimal-wallet.md). ```text Build a minimal self-custodial multichain wallet app with Expo (React Native, TypeScript, expo-router) on Any Wallet Kit, the `@anywalletkit/*` packages. Before writing code, read the SDK reference for agents: AGENTS.md in github.com/ultratestbro/anywalletkit, then the guides at https://docs.anywallet.tech/llms-small.txt (the API reference is in llms-full.txt). Use only APIs that appear there. What the app does: - Onboarding: create a new 12-word recovery phrase (show it once, then ask for 3 of the words back) or import an existing phrase (validate it). - Keys: `@anywalletkit/keyring` with `@anywalletkit/secure-storage-react-native` as its secret storage (Face ID / Touch ID with passcode fallback); account names and addresses in AsyncStorage. The phrase never leaves secure storage after onboarding. - Home: account 0's addresses and native balances on Ethereum, Base, Solana, TON, Tron and Bitcoin, with pull to refresh. A failed read shows an error, never 0. - Send: pick a chain, enter recipient and amount, show the network fee quote, confirm, send, show the transaction hash. - Install the React Native / Hermes polyfills exactly as the reference says. Rules: amounts are bigint base units (format for display without Number); all RPC URLs in one config file (public endpoints are fine); never log or store the phrase or keys anywhere else; wrap every write in the kit's policies with a confirm step. Done when `npx tsc --noEmit` passes and `npx expo export --platform ios` bundles. Then tell me how to run it on a phone (a development or Release build; Expo Go cannot load the secure-storage native module). ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) Roughly this. Agents name files differently, and that is fine: ```text index.js # imports ./polyfills first, then expo-router/entry polyfills.js # getRandomValues, Buffer, TextDecoder, URL, installHermesRuntime() metro.config.js # react-native-fast-pbkdf2 → @anywalletkit/runtime-hermes/fast-pbkdf2 src/config.ts # every RPC URL src/wallet/ # keyring + secure storage, balances, send, bigint formatting src/app/onboarding/ # create (show once, confirm 3 words) / import (validate) src/app/(home) # addresses + balances, pull to refresh src/app/send # chain, recipient, amount → fee → confirm → hash ``` ## Check it works [Section titled “Check it works”](#check-it-works) ```sh npx tsc --noEmit npx expo export --platform ios # bundles for Hermes: catches polyfill and Metro problems without a device npx expo prebuild --platform ios && npx expo run:ios --device --configuration Release ``` On the phone: create a wallet, kill the app, reopen it, and Face ID should unlock the same addresses. Then send a tiny amount on a cheap chain. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Metro: `Unable to resolve module react-native-fast-pbkdf2` | Resolve it to `@anywalletkit/runtime-hermes/fast-pbkdf2` in `metro.config.js` and install `expo-crypto` ([Installation](/getting-started/installation/#ton-on-metro)) | | `crypto.getRandomValues must be defined`, `Buffer is not defined`, `TextDecoder is not defined` | The polyfills are not first: keep them in their own module and import it first from `index.js`; `package.json` `"main"` must point at `index.js` | | Secure storage throws `not-available` in Expo Go | It is a native module: use a development or Release build | | `no-passcode` | The device has no passcode; the module refuses to store a secret it cannot protect | | TON balance needs an unlock | A TON read-only account needs the public key; store it once (see [AGENTS.md](https://github.com/ultratestbro/anywalletkit/blob/main/AGENTS.md)) | | Signing on Base goes to Ethereum | One EVM network per keyring / registration; open a second keyring for Base ([Keyring: more than one EVM network](/guides/keyring/#more-than-one-evm-network)) | Security Never paste a real recovery phrase into an agent chat, a prompt or a test. Use the public test phrase (`abandon abandon … about`) while building, then small amounts on a phone build. Read the diff the agent wrote around keys and signing: nothing should log, store or send the phrase, and every write should pass through a review step. Block screenshots on the phrase screen. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 13 min, 68 agent turns, $3.63, Claude Code (Opus 5.5) starting from an empty folder. The app typechecked and bundled. The run found three gaps, now fixed in the SDK and the reference: * **TON on Metro.** `wallet-ton` needs `react-native-fast-pbkdf2` on Metro. The agent wrote a throwing stub; `runtime-hermes` now ships a working drop-in. * **Base on the keyring.** Signing on a second EVM network with the keyring needed a pattern; it is now in the Keyring guide and AGENTS.md. * **TON balances.** A TON balance needs the account’s public key; that pattern is in AGENTS.md too. # Your own protocol module > Build a LendingProtocol for any ERC-4626 vault as a standalone package, with the SDK's conformance suite, activity recognition and a registry manifest. ## What you get [Section titled “What you get”](#what-you-get) A standalone npm package, `@acme/protocol-lending-erc4626-evm`. * **Protocol:** a `LendingProtocol` with supply (exact approve + deposit), withdraw (redeem everything for `'max'`), quote twins, position and vault reads. * **Activity:** `describeActivity` recognizes the vault’s deposits and withdrawals. * **Tests:** Vitest, including `describeProtocolModule` from `@anywalletkit/conformance`. * **Packaging:** tsup builds and a `module.json`. ## Before you start [Section titled “Before you start”](#before-you-start) Node 22+ and an empty folder. The module lives in **your own repository and npm scope**: the SDK repository does not accept pull requests yet. External modules are listed through the registry’s `external.json`. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder. The raw file is [`prompts/07-own-module.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/07-own-module.md). ```text Create a new Any Wallet Kit protocol module as its own npm package in this directory: `@acme/protocol-lending-erc4626-evm`, a `LendingProtocol` for any ERC-4626 vault on an EVM chain. Read the "Writing a protocol module" part of AGENTS.md in github.com/ultratestbro/anywalletkit, plus the guides at https://docs.anywallet.tech/llms-small.txt, first. Use only APIs that appear there. Use viem for ABI encoding and reads. - Config: `{ vault, chainId }`, where `vault` is the vault's `0x…` address. - `supply({ token, amount })`: `token` must be the vault's asset (refuse others). It sends an exact approve, then `deposit(assets, me)`. - `withdraw({ token, amount })`: `amount` also accepts `'max'`. It calls `withdraw`, or `redeem` of every share for `'max'`. - Each write has a `quote*` twin that signs nothing. `borrow` / `repay` (and their quotes) are unsupported and say so. - Reads: `getPosition()` returns `{ shares, assets }`, and `getVault()` returns the asset address, its decimals, and the total assets. - Writes refuse a read-only account. Amounts are bigint. - `describeActivity` recognizes the vault's Deposit / Withdraw events ("Deposited 100 USDC · ") and returns null for anything else. Unit-test it with hand-built transactions. - Run the SDK's conformance suite (`describeProtocolModule` from `@anywalletkit/conformance`) under Vitest. - Package: ESM + CJS builds with types (tsup), a `module.json` registry manifest (tier `community`), and a README with usage. Done when `npm test` passes, `npm run build` produces dist, and `npx tsc --noEmit` passes. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/protocol.ts # class extends LendingProtocol, reads via viem, writes via the account src/activity.ts # describeActivity: Deposit / Withdraw → "Deposited 100 USDC · " test/conformance.test.ts # describeProtocolModule('…', { type: 'lending', … }) test/*.test.ts # supply / withdraw / quotes / activity with a mock account module.json # registry manifest, tier community tsup.config.ts, README.md ``` ## Check it works [Section titled “Check it works”](#check-it-works) ```sh npm test && npm run build && npx tsc --noEmit ``` Then supply and withdraw a few dollars in a real vault on Base from a script, with a funded test wallet. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Conformance: “a quote/read called sendTransaction” or “… called sign…” | `quote*` methods must estimate, never sign or send | | Conformance: writes do not refuse a read-only account | Call `this.requireSigningAccount('supply')` first in every write | | No way to read the vault from the account | Take an RPC in the config and read with your own viem client; `wallet-evm` accounts also have `simulateTransaction` | | `describeActivity` sees no events | `ChainTx` has no logs field: match `call.to` / `call.selector` and `transfers` | Security A module never holds keys: it builds transactions and the account signs them, behind the wallet’s policies. Approve exact amounts, check every address you call against the config, and refuse an account on another chain. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 11 min, 57 turns, $2.95, from an empty folder. 47 tests pass, including `describeProtocolModule`. ESM, CJS and type builds come out, and tsc is clean. # Swaps and bridges > Add a swap screen on the swap router: streaming routes from every provider, auto slippage, a review sheet, execution and status tracking. ## What you get [Section titled “What you get”](#what-you-get) A Swap screen on `@anywalletkit/protocol-swap2-router`, running the key-free providers (LI.FI, NEAR Intents, Chainflip, CCTP, USDT0, Uniswap, Wrap, Jupiter). * **Routes:** they stream in as each provider answers and carry best / fastest tags. * **Slippage:** Auto, with manual overrides. * **Review:** a sheet before the first approval or signature. * **Execution:** the exact route the user picked, with the guaranteed minimum enforced. * **Tracking:** status runs until a final state, including the claim step for CCTP. ## Before you start [Section titled “Before you start”](#before-you-start) * The wallet from [Minimal wallet](/build-with-ai/minimal-wallet/) (or your own app on the kit). * Optional keys: a LI.FI `apiKey` raises its rate limit, a 1Click `jwt` removes its fee. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder of the app the earlier prompts built. The raw file is [`prompts/02-swaps.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/02-swaps.md). ```text Add a Swap screen to this Any Wallet Kit wallet, using the swap router `@anywalletkit/protocol-swap2-router`. Read the "Swaps and bridges" part of https://docs.anywallet.tech/llms-small.txt (or AGENTS.md in github.com/ultratestbro/anywalletkit) first, and use only APIs that appear there. - Source: the active account on any chain the wallet supports; destination: any chain and token from `@anywalletkit/swap2-assets` (same-chain and cross-chain). - Providers that need no API key: LI.FI, NEAR Intents, Chainflip, CCTP, USDT0, Uniswap, Wrap, Jupiter. Put RPC URLs in the existing config. - Routes stream in as each provider answers (`quoteSwap2Routes` with `onUpdate`), tagged best / fastest; the user picks one. Show "you receive at least" (`toTokenAmountMin`), fees and ETA. - Slippage: Auto by default (`suggestSlippage`), with 0.1 / 0.5 / 1 / 3 % overrides; the same slippage for quoting and execution. - Execute exactly the picked route with `minAmountOut = route.quote.toTokenAmountMin`, behind a review sheet in `reviewExecution` (amounts, minimum, recipient, approvals) and the execution phases as progress. - Track the swap by its id until it reaches a final status; when the status is `action-required`, claim on the destination chain. - Map the swap errors to user messages. `Swap2MaybeSentError` means the swap may already be on chain: show it as pending and never retry. Done when `npx tsc --noEmit` passes and `npx expo export --platform ios` bundles. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/swap/providers.ts # provider list + RPCs from the config src/swap/useSwap.ts # quoteSwap2Routes(onUpdate), suggestSlippage, using(route.provider).swap2(...), getSwap2Status polling src/app/swap/ # token pickers, route list, slippage chips, review sheet, progress, status ``` ## Check it works [Section titled “Check it works”](#check-it-works) Check that it builds with `npx tsc --noEmit` and `npx expo export --platform ios`. Then on a phone build: 1. Quote 5 USDC from Base to Arbitrum. Routes should appear one by one. 2. Execute the cheapest one and watch the phases. 3. Wait for `completed`. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `swap2: no route available` from a Solana / Tron / Bitcoin account | Update `protocol-swap2-core`: older versions did not accept the genesis-hash chain id (`account.chain`) as `fromChain` | | Execution refuses with “requires caller-supplied minAmountOut” | Pass `minAmountOut: route.quote.toTokenAmountMin` | | `rejected by validation` at execution | Quote and execute with the same `slippage` | | A swap “failed” and the user retries, then pays twice | `Swap2MaybeSentError`: the swap may be on chain. Show it as pending and track `e.id` | Security Never paste a real recovery phrase into an agent chat, a prompt or a test. Use the public test phrase (`abandon abandon … about`) while building, then small amounts on a phone build. Read the diff the agent wrote around keys and signing: nothing should log, store or send the phrase, and every write should pass through a review step. Show the guaranteed minimum (`toTokenAmountMin`), never only the expected amount. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 9 min, 44 turns, $2.77, on top of the Minimal wallet result. The screen uses `quoteSwap2Routes` with streaming, `suggestSlippage` for both quote and execution, `using(route.provider).swap2({ …, minAmountOut: route.quote.toTokenAmountMin })`, the `reviewExecution` sheet, status polling with a claim, and a pending state for `Swap2MaybeSentError`. Building this cookbook also surfaced an SDK bug: `fromChain: account.chain` found no route from Solana, Tron or Bitcoin accounts. It is fixed in `protocol-swap2-core`. # Transaction review > Simulate every EVM write before it is signed: balance changes, approvals, recipient, a verdict from the preview's check codes. ## What you get [Section titled “What you get”](#what-you-get) A review sheet that runs `@anywalletkit/tx-preview-evm` before every EVM write. Plain sends go through `policies.confirm`, swaps through `reviewExecution`. The sheet shows: * the simulated balance changes; * every approval; * the recipient and the fee; * an ok / warn / danger verdict. Danger asks for a second, explicit confirmation. ## Before you start [Section titled “Before you start”](#before-you-start) The wallet from the earlier prompts, ideally with swaps. An RPC that supports `eth_simulateV1` gives full simulations; others fall back to `simulation-unavailable`. ## The prompt [Section titled “The prompt”](#the-prompt) Paste it into your agent as is, in the project folder of the app the earlier prompts built. The raw file is [`prompts/03-tx-review.md`](https://github.com/ultratestbro/anywalletkit/blob/main/prompts/03-tx-review.md). ```text Add a transaction review sheet to this Any Wallet Kit wallet with `@anywalletkit/tx-preview-evm`. Read the "Transaction preview" and "Policies" parts of https://docs.anywallet.tech/llms-small.txt (or AGENTS.md in github.com/ultratestbro/anywalletkit) first, and use only APIs that appear there. - Every EVM write is simulated before the user confirms: plain sends and contract calls through the kit's `policies.confirm`, swaps through the router's `reviewExecution` (preview the approvals and the source call as one sequence). - The sheet shows the simulated balance changes ("-0.01 ETH", "+25.1 USDC"), every approval with its spender and amount, the recipient, and the network fee. - A verdict from the preview's check codes: ok, warn or danger, each with one plain-language line. Danger (a revert, an unlimited approval) needs a second, explicit confirmation. "Simulation unavailable" is a warning, not a block. - Non-EVM writes and signed messages still get a confirmation sheet, without a simulation. - The user's own addresses count as "own" so moves between them aren't flagged as foreign. Done when `npx tsc --noEmit` passes and `npx expo export --platform ios` bundles. ``` ## What the result looks like [Section titled “What the result looks like”](#what-the-result-looks-like) ```text src/review/preview.ts # JSON-RPC transport, previewEvmTx, check codes → verdict + lines src/review/Sheet.tsx # changes, approvals, recipient, fee, verdict, second confirm on danger src/wallet/policies.ts # confirm → preview for eip155 writes, plain sheet otherwise ``` ## Check it works [Section titled “Check it works”](#check-it-works) Check that it builds with `npx tsc --noEmit` and `npx expo export --platform ios`. On a phone build: 1. Send to one of your own addresses. It must not be flagged as foreign. 2. Start a swap and confirm the approval amount equals the input. ## Common failures [Section titled “Common failures”](#common-failures) | Symptom | Fix | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Everything shows “simulation unavailable” | Your RPC has no `eth_simulateV1`; the module falls back to `eth_call` where it can, otherwise it is a warning by design | | The RPC transport loses error details | Throw `Object.assign(new Error(message), body.error)` so `code` and `data` reach the module | | Sends to your other accounts are flagged as foreign | Pass all of the user’s addresses in `own` | Security Never paste a real recovery phrase into an agent chat, a prompt or a test. Use the public test phrase (`abandon abandon … about`) while building, then small amounts on a phone build. Read the diff the agent wrote around keys and signing: nothing should log, store or send the phrase, and every write should pass through a review step. Treat `simulation-reverted` and `approval-unlimited` as danger. ## Eval [Section titled “Eval”](#eval) Passed on the first run: 8 min, 50 turns, $2.46, on top of the Minimal wallet result (without the swap screen, so the swap review was wired but had no caller yet). The agent tested its verdict logic in Node against a fake RPC: own-address sends, an unlimited approval, a revert, and no `eth_simulateV1`. # Accounts and signers > How Wallet, chain managers, accounts and signers fit together, and where the private key lives. ## Four pieces [Section titled “Four pieces”](#four-pieces) | Piece | Package | Job | | ---------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Wallet` | `kit` | The orchestrator. Holds one seed phrase or signer, knows which chain package serves which chain, hands out accounts wrapped in your [policies](/concepts/policies/). | | Chain manager (`WalletManagerEvm`, `WalletManagerSolana`…) | `wallet-*` | Derives accounts for one chain family and shares the RPC configuration between them. | | Account | `wallet-*` | One address on one chain: balances, fee quotes, transfers, signatures. | | `Signer` | `core` | The **only** place a private key exists. It turns bytes into a signature and does nothing else. | Accounts build transactions and talk to the network. The signer never touches the network and the account never touches the key. Because of that split, the key can live somewhere else entirely: a hardware wallet, an MPC node, a backend. The signer is not reachable from an account: it is a private field (`#signer`), so code the account is handed to cannot sign around the account’s checks or read a key. An account tells you only what holds its key, in `signerKind` (`seed`, `key` or `external`). A local signer’s key is not reachable from JavaScript at all, not even through a TypeScript-private method. ## The account interface [Section titled “The account interface”](#the-account-interface) Every account on every chain implements the same interface (`IWalletAccount` from `@anywalletkit/core`). Protocol modules are written against it, which is why one swap or staking module works with any wallet built on the SDK. | Method | What it does | | ----------------------------------------------------- | --------------------------------------------------------------------------------------- | | `chain` | The account’s [CAIP-2 chain id](/concepts/chain-ids/) | | `getAddress()` | The address in the chain’s usual format | | `getBalance()` / `getTokenBalance(token)` | Balance in base units, as `bigint` | | `quoteTransfer(options)` / `quoteSendTransaction(tx)` | The network fee, with nothing signed | | `transfer({ recipient, amount, token? })` | Build, check, sign and broadcast a native or token transfer | | `sendTransaction(tx)` | Sign and broadcast a transaction in the chain’s own format | | `signTransaction(tx)` | Sign without broadcasting | | `sign(message)` / `verify(message, signature)` | Message signatures the chain’s usual way (EIP-191, BIP-137, ed25519…) | | `getTransactionReceipt(hash)` | `{ status: 1 }` once confirmed, `{ status: 0 }` if it failed, `undefined` while pending | | `toReadOnlyAccount()` | The same address without signing ability | | `dispose()` | Overwrite this account’s key material with zeros | Chain packages add chain-specific methods on top (EIP-712 typed data on EVM, jetton transfers on TON, PSBTs on Bitcoin…). Those are listed in each package’s API reference. ## Read-only accounts [Section titled “Read-only accounts”](#read-only-accounts) A read-only account can read balances, quote fees and check receipts, but has no key. Use one for watch-only addresses, or to pass a protocol module an account that can only quote. Every chain manager has `getReadOnlyAccount(address)`, and every account has `toReadOnlyAccount()`. ## Signers [Section titled “Signers”](#signers) There are three kinds: | `kind` | Where the key is | Made with | | ---------- | ------------------------------------------------------------- | ------------------------------------------ | | `seed` | Derived on the device from the seed phrase | `new Wallet(seedPhrase)` does this for you | | `key` | One imported private key: a single account, nothing to derive | `privateKeySigner(curve, key)` from `core` | | `external` | Outside the app: hardware wallet, Safe, MPC, backend | `externalSigner()` from `core` | An external signer receives exactly what it must sign (a 32-byte digest for secp256k1 chains, message bytes for ed25519 chains) plus a `context` such as `evm-tx` or `solana-message`, so a device can show the right prompt. ```ts import { externalSigner, type SignOptions } from '@anywalletkit/core' import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' /** Your device / MPC node / backend: it holds the key, the SDK never sees it. */ export interface RemoteKey { publicKey: Uint8Array // 33-byte compressed secp256k1 sign(digest: Uint8Array, options: SignOptions): Promise // 65 bytes r‖s‖v } export function walletOnRemoteKey(remote: RemoteKey): Wallet { const signer = externalSigner({ curve: 'secp256k1', publicKey: remote.publicKey, // Throw UserRejectedError from here when the user declines on the device. sign: (payload, options) => remote.sign(payload, options), }) const wallet = new Wallet(signer) // no seed phrase anywhere wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) return wallet } ``` A signer works on one curve: `secp256k1` (EVM, Bitcoin, Tron, Zcash) or `ed25519` (Solana, TON, NEAR). A `Wallet` built on an external signer serves the chains of that curve. An external signer also holds a single key, so it gives you one account (index `0`). ## Disposal [Section titled “Disposal”](#disposal) Everything that holds a key has `dispose()`. It overwrites the key bytes in memory with zeros, and the object is unusable afterwards. `wallet.dispose()` disposes every manager, every account and the signer. Call it when the app locks. # Chain ids > CAIP-2 chain ids, registering chain packages by namespace or by exact id, and the EVM one-RPC-per-network rule. The SDK names chains with [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) ids: `:`. One format works for every ecosystem, so a swap from USDT on Tron to SOL on Solana is just two ids. | Chain | CAIP-2 id | | --------------- | ----------------------------------------- | | Ethereum | `eip155:1` | | Base | `eip155:8453` | | Arbitrum One | `eip155:42161` | | Any EVM network | `eip155:` | | Bitcoin | `bip122:000000000019d6689c085ae165831e93` | | Solana | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | | TON | `ton:-239` | | Tron | `tron:0x2b6653dc` | | NEAR | `near:mainnet` | | Zcash | `zcash:mainnet` | Every account reports its id as `account.chain`. ## Registering chain packages [Section titled “Registering chain packages”](#registering-chain-packages) `wallet.registerWallet(key, Manager, config)` accepts either form: * **Exact id** (`'eip155:8453'`): serves only that chain. * **Namespace** (`'solana'`, `'ton'`, `'bip122'`): serves every id that starts with it. `wallet.getAccount(chain)` uses the exact registration first and falls back to the namespace. ### One RPC per EVM network [Section titled “One RPC per EVM network”](#one-rpc-per-evm-network) An EVM chain manager talks to **one** network: the network of its RPC. Register each EVM network you support under its exact id, with its own RPC: ```ts wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: ETHEREUM_RPC, chainId: 1 }) wallet.registerWallet('eip155:8453', WalletManagerEvm, { provider: BASE_RPC, chainId: 8453 }) ``` Registering `'eip155'` once with an Ethereum RPC and then asking for `'eip155:8453'` gives you an account whose transactions go to **Ethereum**. Avoid this pattern. ### Pass `chainId` with every EVM registration [Section titled “Pass chainId with every EVM registration”](#pass-chainid-with-every-evm-registration) `chainId` in the EVM config is optional, but pass it, matching the id you register under: ```ts wallet.registerWallet('eip155:8453', WalletManagerEvm, { provider: 'https://base-rpc.publicnode.com', chainId: 8453 }) ``` Without it the account learns its network from the RPC (`eth_chainId`) on first use. Until then `account.chain` reads `eip155:unknown`, so code that keys on it (swap2’s `fromChain: account.chain`, policies per chain, history) has nothing to match, and offline signing of a transaction without its own `chainId` is impossible. With it, the chain is known at once and no extra RPC call is made. Non-EVM managers have one network and no `chainId`. ## Assets [Section titled “Assets”](#assets) Tokens use [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) ids in the swap packages, for example `eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7` for USDT on Ethereum. Account methods such as `getTokenBalance` and `transfer` take the chain’s own token identifier: a contract address, an SPL mint, a jetton master. # Errors > The error classes every package throws, and the rule that refusals are named. The SDK never answers “no” with a `null`. When a quote, a transaction or a route cannot be used, you get an error whose class and message say why. Check errors with `instanceof`, not by matching messages. ## From `@anywalletkit/core` [Section titled “From @anywalletkit/core”](#from-anywalletkitcore) | Error | When | Extra fields | | ------------------------ | ----------------------------------------------------------------- | ------------------------------------ | | `InsufficientFundsError` | The account cannot cover amount + fee | `required`, `available` (base units) | | `NetworkError` | An RPC or API call failed | `provider` (the endpoint) | | `UserRejectedError` | An external signer or your `confirm` policy declined | | | `SignerError` | The signer cannot sign: disposed, wrong curve, unsupported scheme | | | `ReadOnlyAccountError` | A write method was called on a read-only account | | | `NotImplementedError` | A protocol module does not implement an optional method | | ## From `@anywalletkit/kit` [Section titled “From @anywalletkit/kit”](#from-anywalletkitkit) | Error | When | Extra fields | | ---------------------- | ------------------------------------------------- | ---------------------------------------------------- | | `PolicyViolationError` | A [policy](/concepts/policies/) refused the write | `policy`: `'maxFee'`, `'allowlist'` or `'blocklist'` | ## From chain and protocol packages [Section titled “From chain and protocol packages”](#from-chain-and-protocol-packages) Each package adds errors for its own refusals: a simulation that would revert, a fee above the configured cap, an unsafe transaction shape, a swap quote that expired or whose route changed. They are listed in each package’s API reference. ```ts import { InsufficientFundsError, UserRejectedError } from '@anywalletkit/core' import { PolicyViolationError } from '@anywalletkit/kit' try { await account.transfer({ recipient, amount }) } catch (e) { if (e instanceof UserRejectedError) return // the user cancelled: say nothing if (e instanceof InsufficientFundsError) return showTopUp(e.required - e.available) if (e instanceof PolicyViolationError) return showBlocked(e.policy) throw e } ``` # Policies > Fee caps, allow and block lists, and a confirmation hook that run before every signature and broadcast. Policies are rules that run **before** anything is signed or broadcast. You pass them when you create the `Wallet`. Every account it hands out is wrapped in them, including the accounts protocol modules use for swaps, staking or lending, so a module cannot skip them. ```ts const wallet = new Wallet(seedPhrase, { policies: { maxFee: { 'eip155:1': 5_000_000_000_000_000n }, // 0.005 ETH allowlist: { 'tron:0x2b6653dc': [TREASURY_ADDRESS] }, blocklist: { 'eip155:1': [KNOWN_SCAM_ADDRESS] }, confirm: async (intent) => showConfirmationSheet(intent), // true = go, false = cancel }, }) ``` ## Rules [Section titled “Rules”](#rules) | Rule | Keyed by | Throws | | ----------- | -------------------------------------------------------------- | ---------------------------------------------- | | `maxFee` | chain id or namespace → cap in native base units | `PolicyViolationError` (`policy: 'maxFee'`) | | `allowlist` | chain id or namespace → destinations; anything else is refused | `PolicyViolationError` (`policy: 'allowlist'`) | | `blocklist` | chain id or namespace → destinations that are refused | `PolicyViolationError` (`policy: 'blocklist'`) | | `confirm` | one function for every write | `UserRejectedError` when it returns `false` | Address comparison ignores letter case. ## What is guarded [Section titled “What is guarded”](#what-is-guarded) Every method that produces a signature or a broadcast: `transfer`, `sendTransaction`, `signTransaction`, `sign`, the approval surfaces `approve`, `signPermit` and `signTypedData` (an EIP-2612 `Permit` or a Permit2 message is an approval: the lists check the **spender**, and `confirm` receives `kind: 'approval'` with `unlimited` and `deadline`; typed data that moves value by itself is a send judged by whom it pays: EIP-3009 `TransferWithAuthorization` / `ReceiveWithAuthorization` (`to`, with the verifying contract as the token), an ERC-2771 `ForwardRequest` and a `MetaTransaction` (the call they make, read like a transaction), a 1inch LOP `Order` (the settlement contract, and a `receiver` other than the maker). Any other typed data is `kind: 'typedData'` with `destinationUnknown: true`, because the policies cannot tell whether it moves value: an allowlist refuses it, and so does a blocklist without a `confirm` hook. A blocklisted `verifyingContract` is refused for all typed data), plus chain-specific write methods (Tron’s `signRaw`, NEAR’s `signBuilt` and `signNep413`, Solana’s `sendSerialized`, `signCompiled`, `signTransactionMessage`, swap2’s `sendSerializedTransaction`). The rule is deny-by-default: any other account method whose name starts with `sign`, `send`, `approve`, `revoke` or `transfer` is refused behind the wrapper (`PolicyViolationError`, `policy: 'method'`) unless it is listed in `UNGUARDED_WRITES` (only NEAR’s `transferActions`, which signs nothing), so a new signing method in a chain package cannot bypass the policies. Reads (balances, quotes, receipts) are never affected. ### Destinations [Section titled “Destinations”](#destinations) The lists check every destination a write names, not only the contract it calls: * **EVM**: the contract, plus the recipient or spender inside a standard token call: ERC-20, ERC-721 and ERC-1155 transfers, `approve`, `increaseAllowance`, `setApprovalForAll`, Permit2 `approve`, an on-chain EIP-2612 `permit`, ERC-677 `transferAndCall`. Any other call is judged by the contract it calls. * **Tron**: the same for TRC-20 calls, whether built from `parameters` or prebuilt (`raw_data_hex`). * **NEAR**: the receiver, plus who `ft_transfer(_call)`, `nft_transfer(_call)` and `storage_deposit` pay and the beneficiary of `deleteAccount`. * **TON**: each message’s target, plus a jetton or NFT transfer’s destination and excess receiver. * **Solana**: the recipient of a SOL transfer; for an SPL transfer, the **owner** of the token account it pays (read through the account when a list or `confirm` applies); for a Stake instruction, whom it pays or empowers (a `Withdraw` recipient, a new authority, the staker and withdrawer, the vote account, the destination stake account), never the Stake program. An allowlist must hold **all** of them. When part of a write cannot be read, the intent has `destinationUnknown: true`: an allowlist refuses it, and so does a blocklist without a `confirm` hook. `transfer` is judged by `recipient`, the field every chain pays. A TON jetton `transfer` also spends TON, so it is judged on the jetton master (it names the contract that receives `attachedValue`) and on a `responseDestination` other than the wallet itself (it gets the excess); a forward or custom payload cell makes it `destinationUnknown`, and `confirm` receives `attachedValue` and `forwardTonAmount`. wallet-ton itself refuses a foreign `responseDestination` unless `allowForeignResponseDestination` is set, and more than `maxJettonAttachedValue` (1 TON by default) attached. The checks and the signature work from one copy of the arguments, taken before the first check, and `confirm` gets a copy of its own: nothing can change what is signed after it was judged. ### Revokes [Section titled “Revokes”](#revokes) Taking an approval back moves nothing, so a revoke is its own kind: `kind: 'revoke'`. This covers an `approve`, `signPermit` or EIP-2612 `Permit` of 0, the revoke calls sent as raw calldata (`approve(spender, 0)`, `approve(0x0, tokenId)`, `setApprovalForAll(operator, false)`, Permit2 `lockdown`, or a batch made only of these), and `revokeDelegation()`. A revoke skips the allowlist and the spender’s blocklist entry, so the user can always take an allowance back from a known drainer. The blocklist still applies to the contract the revoke calls (the token, the NFT collection, Permit2). The fee cap and `confirm` apply as usual. `confirm` gets `revokes: [{ token, spender }]` to show what is being revoked. For each write the checks run in this order: 1. **Fee cap.** The account quotes the fee, and anything above the cap is refused (for `transfer`, `sendTransaction`, `signTransaction`). The cap is then handed to the account, which holds it on what it signs; `approve`, `sendSerialized`, `signRaw`, `revokeDelegation` and `sendSerializedTransaction` get it the same way. A write that pays a fee but cannot take a cap (`signBuilt`, `signCompiled`, `signTransactionMessage`) is refused under one. 2. **Allow and block lists.** These check the destination. They are skipped for message signatures, where nothing moves. 3. **`confirm`.** It receives a `WriteIntent` and has the last word. 4. Only then is the original call made. ## `WriteIntent` [Section titled “WriteIntent”](#writeintent) What `confirm` receives: | Field | Meaning | | -------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `chain` | CAIP-2 id | | `from` | The account’s address | | `method` | `transfer`, `sendTransaction`, `sign`… | | `kind` | `send`, `approval`, `revoke`, `typedData` or `message` | | `to` | Destination, when it can be read off the call (a `transfer`’s recipient, the contract called, an approval’s spender) | | `destinations` | Every destination the lists check (see above) | | `destinationUnknown` | Part of the write could not be read: `destinations` may be incomplete | | `token` | Token identifier for token transfers and approvals (a calldata transfer’s token too); `undefined` means the native coin | | `amount` | Amount in base units, when it can be read off the call | | `unlimited` | Approvals: type(uint128).max or above, a DAI-style `allowed`, or an amount that cannot be read | | `approvals` | Approvals: every token a signature grants (a Permit2 batch has several) | | `message` | `true` for message signatures | | `raw` | The original argument, for anything else you need | ## Which chain’s rules apply [Section titled “Which chain’s rules apply”](#which-chains-rules-apply) The rules of the chain id you asked for (`wallet.getAccount('eip155:1')`) **and** of the chain the account reports both apply. An EVM account that has not talked to its RPC yet reports `eip155:unknown`, and your `eip155:1` rules still cover it. A key can also be a namespace: `eip155` covers every EVM chain, `solana` every Solana cluster. Namespace rules and exact-id rules add up: * `maxFee`: every matching cap is checked, so the tightest one wins; * `allowlist`: a destination must be on **every** matching list; * `blocklist`: a destination on **any** matching list is refused. ```ts policies: { blocklist: { eip155: [KNOWN_SCAM_ADDRESS] }, // on every EVM chain maxFee: { eip155: 10n ** 16n, 'eip155:1': 5n * 10n ** 15n }, // tighter on mainnet } ``` When the account cannot learn its chain id, its namespace rules still apply; an exact-id rule of that family refuses the write (`policy: 'chain'`) instead of guessing. ## Policies and chain-level limits [Section titled “Policies and chain-level limits”](#policies-and-chain-level-limits) Chain packages have their own limits on top: `transferMaxFee` / `transactionMaxFee` in every chain config, a fee-rate ceiling on Bitcoin, refusal of burns to zero addresses, simulation before signing. Policies are the app-wide layer above them: one place, same shape on every chain. # API at a glance > The twenty operations most wallets need, each with a short snippet and links to its guide and its full API page. The rest of the API reference is lower-level. The [API reference](/api/) lists every exported class, function and type: about two thousand pages, most of them for module authors. These are the operations an app actually calls. The snippets come from [`examples/`](https://github.com/ultratestbro/anywalletkit/tree/main/examples), which CI type-checks and tests on every change; the storage and backup ones from the package READMEs. Chain ids are CAIP-2 (`eip155:1`, `solana:…`): see [Chain ids](/concepts/chain-ids/). ## Wallet and accounts [Section titled “Wallet and accounts”](#wallet-and-accounts) **1. Create or restore a seed phrase** — [`getRandomSeedPhrase`](/api/anywalletkit/core/functions/getrandomseedphrase/), [`isValidSeedPhrase`](/api/anywalletkit/core/functions/isvalidseedphrase/) ```ts import { getRandomSeedPhrase, isValidSeedPhrase } from '@anywalletkit/core' const phrase = getRandomSeedPhrase(12) // or 24 isValidSeedPhrase(words) // wordlist + checksum ``` **2. Open a wallet on the chains you support** — [`Wallet`](/api/anywalletkit/kit/classes/wallet/) · guide: [Quickstart](/getting-started/quickstart/) ```ts import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' const wallet = new Wallet(phrase, { policies }) // policies are optional (step 7) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) wallet.registerWallet('solana', WalletManagerSolana, { provider: 'https://api.mainnet-beta.solana.com' }) // … wallet-ton, wallet-tron, wallet-btc, wallet-near, wallet-zcash the same way ``` **3. Address and balances** — guide: [Balances](/guides/balances/) ```ts const account = await wallet.getAccount('eip155:1') // account #0; getAccount(chain, 1) = the next await account.getAddress() // derived locally, no network await account.getBalance() // native, bigint in base units await account.getTokenBalance('0xdAC17F958D2ee523a2206206994597C13D831ec7') // USDT ``` Read many balances with `Promise.allSettled` and show a failed read as an error, never as 0. **4. Several accounts, imported seeds and keys** — [`Keyring`](/api/anywalletkit/keyring/classes/keyring/) · guide: [Keyring](/guides/keyring/) ```ts import { Keyring } from '@anywalletkit/keyring' const keyring = await Keyring.load({ storage, secrets, chains }) const { account, mnemonic } = await keyring.createMnemonic({ words: 12 }) await keyring.importMnemonic(phrase, { discover: { isUsed, gapLimit: 1, maxIndex: 20 } }) await keyring.addAccount(sourceId, { name: 'Savings' }) // next index of the same seed await keyring.previewPrivateKey('evm', key) // address only, stores nothing ``` **5. Keep the seed on the phone behind Face ID** — [`createSecureStorage`](/api/anywalletkit/secure-storage-react-native/functions/createsecurestorage/) · guide: [Keys and storage](/build-a-wallet/keys-and-storage/#key-storage) ```ts import { createSecureStorage } from '@anywalletkit/secure-storage-react-native' const secrets = createSecureStorage({ namespace: 'my-wallet' }) // iOS Keychain / Android Keystore const keyring = await Keyring.load({ storage, secrets, chains }) ``` An Expo native module: a development or Release build, not Expo Go. **6. Encrypted backup to iCloud** — [`saveBackup`](/api/anywalletkit/backup-cloud/functions/savebackup/), [`restoreBackup`](/api/anywalletkit/backup-cloud/functions/restorebackup/) ```ts import { iCloudDrive, iCloudKeychain, saveBackup, restoreBackup } from '@anywalletkit/backup-cloud' const store = iCloudDrive({ container: 'iCloud.com.example.wallet' }) await saveBackup({ store, keyVault: iCloudKeychain(), password, secret, meta }) const opened = await restoreBackup({ store, name, keyVault: iCloudKeychain(), expect: source }) // or { password } ``` ## Sending and signing [Section titled “Sending and signing”](#sending-and-signing) **7. Guard every write: fee caps, blocklists, a confirm step** — [`Policies`](/api/anywalletkit/kit/interfaces/policies/), [`applyPolicies`](/api/anywalletkit/kit/functions/applypolicies/) · concept: [Policies](/concepts/policies/) ```ts const policies: Policies = { maxFee: { 'eip155:1': 5_000_000_000_000_000n }, blocklist: { 'eip155:1': ['0x000000000000000000000000000000000000dEaD'] }, confirm: async (intent) => showReviewSheet(intent), // false cancels; nothing is signed } ``` **8. Send** — guide: [Quickstart](/getting-started/quickstart/) ```ts const transfer = { token: USDT, recipient, amount: 1_000_000n } // omit token for the native coin const { fee } = await account.quoteTransfer(transfer) // nothing is signed const { hash } = await account.transfer(transfer) // simulate → policies → sign → broadcast await account.getTransactionReceipt(hash) // undefined until mined ``` **9. Sign and verify a message** ```ts const signature = await account.sign('Sign in to example.com') await account.verify('Sign in to example.com', signature) ``` **10. Show what a transaction will do before signing** — [`previewEvmTx`](/api/anywalletkit/tx-preview-evm/functions/previewevmtx/) · guide: [Transaction preview](/guides/tx-preview/) ```ts import { previewEvmTx } from '@anywalletkit/tx-preview-evm' const preview = await previewEvmTx({ chainId: 8453, from, rpc, own: [from], calls }) preview.checks // simulation-reverted, approval-unlimited, recipient-foreign, contract-unknown, … preview.simulation // { status: 'ok', changes } — simulated balance changes per address and token ``` **11. Send to `name.eth`** — [`ensResolver`](/api/anywalletkit/names-ens/functions/ensresolver/), [`nameResolvers`](/api/anywalletkit/core/functions/nameresolvers/) · guide: [Names](/guides/names/) ```ts import { nameResolvers, assertNameUnchanged } from '@anywalletkit/core' import { ensResolver } from '@anywalletkit/names-ens' const names = nameResolvers([ensResolver({ rpcUrl: 'https://ethereum-rpc.publicnode.com' })]) const resolved = await names.resolve('vitalik.eth', 'eip155:8453') await assertNameUnchanged(names, resolved.name, resolved.chain, resolved.address) // right before signing ``` ## Swaps and Earn [Section titled “Swaps and Earn”](#swaps-and-earn) **12. Quote a swap or bridge across every provider** — [`RouterSwap2Protocol`](/api/anywalletkit/protocol-swap2-router/classes/routerswap2protocol/) · guide: [Swaps](/guides/swaps/) ```ts import { RouterSwap2Protocol } from '@anywalletkit/protocol-swap2-router' const swaps = new RouterSwap2Protocol(account, { fromChain: account.chain, providers: [lifiProvider({}), nearIntentsProvider({}), /* … */] }) const routes = await swaps.quoteSwap2Routes(options, { onUpdate: (routes, pending) => render(routes) }) ``` **13. Execute the route the user picked** — guide: [Swaps](/guides/swaps/) ```ts const { id } = await swaps.using(route.provider).swap2({ ...options, minAmountOut: route.quote.toTokenAmountMin }) ``` Pass the minimum you showed; add `slippage` (a decimal) to bound the re-quote. `suggestSlippage(...)` from `@anywalletkit/protocol-swap2-core/slippage` gives an automatic one. **14. Track it to the end** — guide: [Swaps](/guides/swaps/) ```ts const { status, transactions } = await swaps.getSwap2Status(id) if (status === 'action-required') ({ id } = await swaps.claimSwap2(id, destinationAccount)) // CCTP mint; keep polling the returned id ``` **15. Stake, lend, withdraw** — [`AaveProtocolEvm`](/api/anywalletkit/protocol-lending-aave-evm/classes/aaveprotocolevm/), [Lido](/api/anywalletkit/protocol-staking-lido-evm/classes/default/) · guide: [Earn](/guides/earn/) ```ts wallet.registerProtocol('aave', AaveProtocolEvm, { chain: 'eip155:1', config: { market: 'core' } }) const aave = await wallet.getProtocol('aave') await aave.getSupplyApy(USDC) // 4.1 = 4.1 % await aave.supply({ token: USDC, amount }) await aave.withdraw({ token: USDC, amount: 'max' }) await aave.getPosition() // supplies, borrows, health factor ``` ## Safety, data and networks [Section titled “Safety, data and networks”](#safety-data-and-networks) **16. List and revoke token approvals** — [`ApprovalsProtocolEvm`](/api/anywalletkit/protocol-approvals-evm/classes/default/) · guide: [Approvals](/guides/approvals/) ```ts const approvals = await wallet.getProtocol('approvals') const { approvals: list, coverage } = await approvals.getApprovals() await approvals.revoke(list[0]) ``` **17. Activity history** — [`createEvmHistory`](/api/anywalletkit/history-evm/functions/createevmhistory/) · guide: [History](/guides/history/) ```ts wallet.registerHistorySource(createEvmHistory({ chainId: 1 })) const page = await wallet.history({ limit: 20 }) // every chain with a source, labelled by the modules await wallet.history({ limit: 20, cursor: page.cursor }) ``` **18. User RPCs and custom networks** — [`NetworksTable`](/api/anywalletkit/networks/classes/networkstable/) · guide: [Networks](/guides/networks/) ```ts const table = new NetworksTable(storage); await table.load() wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: table.rpcUrlsForSync('eip155:1'), chainId: 1 }) await table.addRpc('eip155:1', url) // after checkRpcUrl + verifyNetwork ``` **19. Run on React Native (Hermes)** — guide: [Installation](/getting-started/installation/#react-native--expo) ```ts import { installHermesRuntime } from '@anywalletkit/runtime-hermes' installHermesRuntime() // first line of your entry file, before any other @anywalletkit import ``` **20. Lock the app** — [`Wallet`](/api/anywalletkit/kit/classes/wallet/) ```ts wallet.dispose() // zeroizes the seed and keys; build a new Wallet on unlock ``` ## On your server [Section titled “On your server”](#on-your-server) **Run swap providers behind your backend** — `createSwap2Handler` from `@anywalletkit/protocol-swap2-core/server` · guide: [Ship a wallet](/build-a-wallet/ship-a-wallet/) ```ts import { createSwap2Handler } from '@anywalletkit/protocol-swap2-core/server' const handle = createSwap2Handler({ providers, sealSecret, guard: (call, req) => authAndRateLimit(call, req) }) const { status, body } = await handle(action, json, req) // POST /swap2/:action; the app uses remoteProvider ``` ## Everything else [Section titled “Everything else”](#everything-else) The API reference also documents the building blocks: provider classes for each swap venue, chain-specific account types, error classes, catalog data and the conformance kit for [writing your own module](/build-with-ai/own-module/). Errors you branch on are listed in [Errors](/concepts/errors/). # Installation > Add Any Wallet Kit to a React Native (Expo) app or a Node service. ## Try it from source [Section titled “Try it from source”](#try-it-from-source) The source is on [GitHub](https://github.com/ultratestbro/anywalletkit). Node 22+ and pnpm 10: ```sh git clone https://github.com/ultratestbro/anywalletkit.git cd anywalletkit pnpm install pnpm build pnpm --filter ./examples/quickstart test ``` Every snippet on this site comes from a package in [`examples/`](https://github.com/ultratestbro/anywalletkit/tree/main/examples), and each one runs the same way. ## Pick your packages [Section titled “Pick your packages”](#pick-your-packages) Every package is on npm under [`@anywalletkit`](https://www.npmjs.com/org/anywalletkit), published from CI with provenance (`npm audit signatures` checks it). npm, pnpm, yarn and `npx expo install` all work; the commands on this site use npm. Every wallet needs `core` and `kit`, plus one package per chain family it supports: ```sh npm install @anywalletkit/core @anywalletkit/kit \ @anywalletkit/wallet-evm @anywalletkit/wallet-solana @anywalletkit/wallet-ton \ @anywalletkit/wallet-btc @anywalletkit/wallet-tron @anywalletkit/wallet-near ``` Swaps, staking and lending are separate packages — add them when you need them (see [Packages](/getting-started/packages/)). ## React Native / Expo [Section titled “React Native / Expo”](#react-native--expo) React Native is the primary target: every chain package is tested inside the Hermes VM before a release. Hermes misses a few web APIs the cryptography and network code expect; you install them once, at the app entry, **before anything else is imported**. ```sh npx expo install react-native-get-random-values npm install @anywalletkit/runtime-hermes buffer fastestsmallesttextencoderdecoder react-native-url-polyfill ``` Put the polyfills in their own file so nothing can be evaluated before them: polyfills.js ```js // 1. Secure randomness — seed phrases and signatures depend on it. Must be first. import 'react-native-get-random-values' // 2. A global Buffer (TON and Bitcoin libraries read it). import { Buffer } from 'buffer' globalThis.Buffer ??= Buffer // 3. Hermes ships TextEncoder but no TextDecoder. import 'fastestsmallesttextencoderdecoder/EncoderDecoderTogether.min.js' // 4. React Native's URL has no working searchParams; providers parse endpoints with it. import 'react-native-url-polyfill/auto' // 5. Everything else the SDK relies on: AbortSignal.timeout / any, // crypto.subtle.digest, structuredClone, the Buffer.subarray fix. // Each piece is installed only when it is missing; it throws if 1–4 are absent. import { installHermesRuntime } from '@anywalletkit/runtime-hermes' installHermesRuntime() ``` index.js ```js import './polyfills' import 'expo-router/entry' // or your AppRegistry.registerComponent(...) ``` Point `"main"` in `package.json` at `index.js`. ### TON on Metro [Section titled “TON on Metro”](#ton-on-metro) `@anywalletkit/wallet-ton` depends on `@ton/crypto-primitives`, whose React Native build requires `expo-crypto` and the native `react-native-fast-pbkdf2` module. Install `expo-crypto` (`npx expo install expo-crypto`) and resolve the native module to the pure-JS drop-in that `runtime-hermes` ships: metro.config.js ```js const { getDefaultConfig } = require('expo/metro-config') const config = getDefaultConfig(__dirname) const resolveRequest = config.resolver.resolveRequest config.resolver.resolveRequest = (context, moduleName, platform) => moduleName === 'react-native-fast-pbkdf2' ? { type: 'sourceFile', filePath: require.resolve('@anywalletkit/runtime-hermes/fast-pbkdf2') } : (resolveRequest ?? context.resolveRequest)(context, moduleName, platform) module.exports = config ``` ## Node [Section titled “Node”](#node) Node 22 or newer needs no polyfills. Run a patched release: Node 20 no longer gets security fixes, and the October 2026 fixes are in 22.23.4, 24.22.1 and 26.12.1. ```sh npm install @anywalletkit/core @anywalletkit/kit @anywalletkit/wallet-evm ``` All packages ship ESM and CommonJS builds with TypeScript types. ## Next [Section titled “Next”](#next) Build your first multichain wallet in the [Quickstart](/getting-started/quickstart/). # Packages > Every Any Wallet Kit package, grouped by layer. Install only what your wallet uses. Every package is published under the `@anywalletkit/` scope. ## Core [Section titled “Core”](#core) | Package | What it does | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@anywalletkit/core` | Signer and account interfaces, protocol base classes, errors, BIP-39 / BIP-32 / SLIP-10 helpers | | `@anywalletkit/conformance` | Golden vectors and the test suites every chain and protocol module must pass | | `@anywalletkit/kit` | Orchestrator: one seed or signer, registered chain wallets and protocol modules, accounts by CAIP-2 chain id, policies (fee cap, allow / block lists, confirmation) before every broadcast | | `@anywalletkit/keyring` | Many accounts from one seed plus imported seeds and private keys, one account list across chains, per-execution accounts that dispose the key after use | | `@anywalletkit/runtime-hermes` | One-call React Native / Hermes polyfills the SDK relies on | | `@anywalletkit/secure-storage-react-native` | The keyring’s secret storage on iOS / Android: Face ID, Touch ID or fingerprint with a passcode fallback on every read (Expo module) | | `@anywalletkit/seed-vault` | Encrypted backup file for one seed or key, opened by a password (Argon2id) or a 32-byte key | | `@anywalletkit/backup-cloud` | iCloud Drive backups of seed-vault files, with the key in iCloud Keychain or a password | ## Chains [Section titled “Chains”](#chains) | Package | Chains | Derivation | | ----------------------------- | --------------------------------------- | --------------------------------------------- | | `@anywalletkit/wallet-evm` | Ethereum and 30+ EVM networks | `m/44'/60'/0'/0/i` | | `@anywalletkit/wallet-btc` | Bitcoin (BIP-84 SegWit, BIP-86 Taproot) | `m/84'/0'/0'/0/i` (Taproot `m/86'/0'/0'/0/i`) | | `@anywalletkit/wallet-solana` | Solana | `m/44'/501'/i'/0'` | | `@anywalletkit/wallet-ton` | TON (WalletContractV5R1) | `m/44'/607'/i'` | | `@anywalletkit/wallet-tron` | Tron | `m/44'/195'/0'/0/i` | | `@anywalletkit/wallet-near` | NEAR (implicit and named accounts) | `m/44'/397'/i'` | | `@anywalletkit/wallet-zcash` | Zcash (transparent addresses) | `m/44'/133'/0'/0/i` | ## Swaps and bridges [Section titled “Swaps and bridges”](#swaps-and-bridges) All swap modules implement one `Swap2Protocol` interface, so each works on its own or inside the router. | Package | What it does | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `@anywalletkit/protocol-swap2-core` | Swap engine, cross-chain router base, quote and execution safety rails | | `@anywalletkit/protocol-swap2-router` | Auctions every provider you install, tags the best and the fastest route, executes the winner, resumes status | | `@anywalletkit/swap2-assets` | CAIP-19 asset registry shared by the swap packages | | `@anywalletkit/protocol-swap2-near-intents` | NEAR Intents (1Click) — cross-chain | | `@anywalletkit/protocol-swap2-lifi` | LI.FI — same-chain and cross-chain | | `@anywalletkit/protocol-swap2-thorchain` | THORChain — native cross-chain swaps | | `@anywalletkit/protocol-swap2-chainflip` | Chainflip — native BTC, ETH, SOL, TRX and stablecoins between Ethereum, Arbitrum, Solana, Tron and Bitcoin; no key, 0 bps | | `@anywalletkit/protocol-swap2-omniston` | STON.fi Omniston — TON swaps, TON ↔ EVM and EVM ↔ EVM stablecoin orders | | `@anywalletkit/protocol-swap2-cctp` | Circle CCTP V2 — native USDC between EVM chains | | `@anywalletkit/protocol-swap2-usdt0` | USDT0 — Tether’s LayerZero OFT: USDT between 16 EVM chains in one transaction | | `@anywalletkit/protocol-swap2-uniswap` | Uniswap v3 straight from the contracts | | `@anywalletkit/protocol-swap2-balancer` | Balancer v2, priced and enforced on-chain by the Vault | | `@anywalletkit/protocol-swap2-fynd` | Fynd (Propeller Heads) same-chain swaps on 7 EVM chains, settled through TychoRouterV3 — Fynd outputs are under the Fynd License | | `@anywalletkit/protocol-swap2-wrap` | Native ↔ wrapped native (ETH ↔ WETH and others) | | `@anywalletkit/protocol-swap2-jupiter` | Jupiter — same-chain Solana swaps; the transaction is assembled and checked locally, no Jupiter fee | ## Earn [Section titled “Earn”](#earn) | Package | Protocol | Interface | | ------------------------------------------------------ | ------------------------------------------------------------------------ | ----------------- | | `@anywalletkit/protocol-staking-lido-evm` | Lido (ETH → stETH) | `StakingProtocol` | | `@anywalletkit/protocol-staking-ssv-evm` | SSV Network | `StakingProtocol` | | `@anywalletkit/protocol-staking-tonstakers-ton` | Tonstakers (TON → tsTON) | `StakingProtocol` | | `@anywalletkit/protocol-staking-spl-stake-pool-solana` | SPL stake pools (Jito by default) | `StakingProtocol` | | `@anywalletkit/protocol-staking-native-solana` | Native SOL staking to the validators your app lists (Stake program) | `StakingProtocol` | | `@anywalletkit/protocol-staking-native-monad` | Native MON staking to the validators your app lists (staking precompile) | `StakingProtocol` | | `@anywalletkit/protocol-lending-aave-evm` | Aave v3 | `LendingProtocol` | | `@anywalletkit/protocol-lending-morpho-evm` | Morpho Vaults and Blue markets | `LendingProtocol` | | `@anywalletkit/protocol-lending-mellow-evm` | Mellow Core Vaults (EarnETH / EarnUSD) | `LendingProtocol` | | `@anywalletkit/protocol-lending-kamino-solana` | Kamino Earn vaults and Lend markets | `LendingProtocol` | ## Data and safety [Section titled “Data and safety”](#data-and-safety) | Package | What it does | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@anywalletkit/tx-preview-evm` | What an EVM transaction will do before it is signed: decoded calls, approvals, simulated balance changes, a verdict | | `@anywalletkit/protocol-approvals-evm` | Discover token approvals (ERC-20 / 721 / 1155 / Permit2 / EIP-7702), flag risky ones, revoke | | `@anywalletkit/history-evm` | Activity history for every supported EVM chain | | `@anywalletkit/names-ens` | ENS names → the address for one chain | | `@anywalletkit/extension-kit` | The core of a Chromium (MV3) wallet extension: encrypted vault with passkey unlock, auto-locking session, dApp request router, per-origin permissions, approval queue, signing checks; no UI. See the [Browser extension guide](/guides/extension-kit/) | | `@anywalletkit/tokens` | Tokens users add by address: read from the chain (EVM, Solana incl. Token-2022), look-alike and issuer-power warnings, `TokensTable` over any JSON storage. See [Custom tokens](/guides/tokens/) | | `@anywalletkit/networks` | Networks: catalog mainnets with public RPC failover lists, default testnets, user RPCs and custom networks (`NetworksTable`), per-family endpoint verification, EIP-3085 parsing | # Quickstart > One seed phrase → accounts on seven chains, balances, a transfer, a signature and safety policies. This page builds a wallet on Ethereum, Base, Bitcoin, Solana, TON, Tron and NEAR from one seed phrase. Every snippet comes from [`examples/quickstart`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/quickstart) in the SDK repository, which our CI compiles and runs on every change. ## 1. Imports [Section titled “1. Imports”](#1-imports) ```ts import { Wallet, type Policies } from '@anywalletkit/kit' import { getRandomSeedPhrase, isValidSeedPhrase } from '@anywalletkit/core' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerBtc } from '@anywalletkit/wallet-btc' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' import { WalletManagerTon } from '@anywalletkit/wallet-ton' import { WalletManagerTron } from '@anywalletkit/wallet-tron' import { WalletManagerNear } from '@anywalletkit/wallet-near' ``` ## 2. Chain ids [Section titled “2. Chain ids”](#2-chain-ids) The SDK names chains with [CAIP-2](/concepts/chain-ids/) ids: one string format for every ecosystem. ```ts // CAIP-2 chain ids: one string names a chain on any ecosystem. export const ETHEREUM = 'eip155:1' export const BASE = 'eip155:8453' export const BITCOIN = 'bip122:000000000019d6689c085ae165831e93' export const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' export const TON = 'ton:-239' export const TRON = 'tron:0x2b6653dc' export const NEAR = 'near:mainnet' ``` ## 3. A seed phrase [Section titled “3. A seed phrase”](#3-a-seed-phrase) ```ts export function newSeedPhrase(): string { return getRandomSeedPhrase(12) // or 24 } export function checkSeedPhrase(words: string): boolean { return isValidSeedPhrase(words) // BIP-39 wordlist + checksum } ``` Store it safely The seed phrase is the user’s money. Keep it in the platform keystore (iOS Keychain / Android Keystore) behind biometrics, never in plain storage, logs or analytics. ## 4. Create the wallet [Section titled “4. Create the wallet”](#4-create-the-wallet) `Wallet` is the orchestrator from `@anywalletkit/kit`. Register one chain package per chain (or per chain family), each with its own RPC endpoint. Managers are built lazily, on first use. ```ts export function createWallet(seedPhrase: string, policies?: Policies): Wallet { const wallet = new Wallet(seedPhrase, policies ? { policies } : {}) // One EVM registration per network: each has its own RPC. wallet.registerWallet(ETHEREUM, WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) wallet.registerWallet(BASE, WalletManagerEvm, { provider: 'https://base-rpc.publicnode.com', chainId: 8453 }) // Other ecosystems register by namespace. wallet.registerWallet('bip122', WalletManagerBtc, { client: { type: 'esplora-http', clientConfig: { url: 'https://mempool.space/api' } }, }) wallet.registerWallet('solana', WalletManagerSolana, { provider: 'https://api.mainnet-beta.solana.com' }) wallet.registerWallet('ton', WalletManagerTon, { tonClient: { url: 'https://toncenter.com/api/v2/jsonRPC' } }) wallet.registerWallet('tron', WalletManagerTron, { provider: 'https://api.trongrid.io' }) wallet.registerWallet('near', WalletManagerNear, { provider: 'https://free.rpc.fastnear.com' }) return wallet } ``` ## 5. Addresses [Section titled “5. Addresses”](#5-addresses) ```ts export async function addresses(wallet: Wallet): Promise> { const out: Record = {} for (const chain of [ETHEREUM, BASE, BITCOIN, SOLANA, TON, TRON, NEAR]) { const account = await wallet.getAccount(chain) // account #0; getAccount(chain, 1) for the next one out[chain] = await account.getAddress() } return out } ``` Addresses are derived on the device, with no network calls. Every EVM network shares the same address. The default derivation paths are the common ones (MetaMask on EVM, Phantom on Solana and so on), so a user who imports a seed phrase from another wallet usually finds their funds. The paths are listed in [Packages](/getting-started/packages/#chains). ## 6. Balances [Section titled “6. Balances”](#6-balances) Amounts are always `bigint` in the chain’s base units (wei, satoshi, lamports, nanotons…). Format them for display yourself; never do money math with floating point numbers. ```ts export async function ethBalance(wallet: Wallet): Promise { const account = await wallet.getAccount(ETHEREUM) return account.getBalance() // wei, as bigint } export async function usdtBalance(wallet: Wallet): Promise { const account = await wallet.getAccount(ETHEREUM) return account.getTokenBalance('0xdAC17F958D2ee523a2206206994597C13D831ec7') // USDT, 6 decimals } ``` ## 7. Send [Section titled “7. Send”](#7-send) Every account has the same `transfer` call on every chain. Quote the fee first, show it to the user, then send: ```ts export async function sendUsdt(wallet: Wallet, recipient: string, amount: bigint) { const account = await wallet.getAccount(ETHEREUM) const transfer = { token: '0xdAC17F958D2ee523a2206206994597C13D831ec7', recipient, amount } const { fee } = await account.quoteTransfer(transfer) // network fee in wei, nothing is signed console.log('fee', fee) const { hash } = await account.transfer(transfer) // simulate → sign → broadcast const receipt = await account.getTransactionReceipt(hash) // undefined until mined return { hash, receipt } } ``` `transfer` simulates the transaction before signing it. A transaction that would revert is refused before anything is signed. ## 8. Sign a message [Section titled “8. Sign a message”](#8-sign-a-message) ```ts export async function signIn(wallet: Wallet, message: string) { const account = await wallet.getAccount(ETHEREUM) const signature = await account.sign(message) // EIP-191 personal_sign const valid = await account.verify(message, signature) return { signature, valid } } ``` ## 9. Policies [Section titled “9. Policies”](#9-policies) Policies run before **every** signature and broadcast, including those made by protocol modules (swaps, staking…) through the same account. Pass them when you create the wallet: ```ts 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 }, } ``` ```ts const wallet = createWallet(seedPhrase, policies) ``` More in [Policies](/concepts/policies/). ## 10. Clean up [Section titled “10. Clean up”](#10-clean-up) `wallet.dispose()` overwrites the seed and every derived key in memory with zeros. Call it when the wallet locks or the user signs out, and build a new `Wallet` when they unlock. ```ts wallet.dispose() ``` ## Try it on a testnet [Section titled “Try it on a testnet”](#try-it-on-a-testnet) The same code runs on a testnet: register the testnet’s chain id with its RPC and `chainId`, and fund the address from a faucet. ```ts wallet.registerWallet('eip155:11155111', WalletManagerEvm, { provider: 'https://sepolia.drpc.org', chainId: 11155111 }) // Sepolia const account = await wallet.getAccount('eip155:11155111') await account.getAddress() // the same address as on mainnet: fund it from a Sepolia faucet ``` Base Sepolia, Arbitrum Sepolia, OP Sepolia, Polygon Amoy, BSC and Monad testnets work the same way. The [Networks](/guides/networks/#testnets) table ships every supported testnet (Solana devnet, Bitcoin testnet4, TON testnet, Tron Nile, NEAR testnet too) switched off, with its RPC list; show a clear “testnet” badge in the UI whenever one is on. # Upgrading > What to change in your app when you move to a new Any Wallet Kit release — the latest security releases first. Versions are `0.x`, so a minor release may change the API. A `^0.y` range never picks up the next minor release by itself: you take these changes when you bump. Each package’s CHANGELOG lists its changes; the lines marked **Breaking** are the ones that need something from you. This page groups them by what your app does. ## 2026-10-30: the re-audit fixes [Section titled “2026-10-30: the re-audit fixes”](#2026-10-30-the-re-audit-fixes) A re-audit of the 2026-10-30 release found five more issues; all are fixed. Only the TON one can change what your app sees. * **TON: a gateway error on a send is “maybe sent”.** `wallet-ton` used to read any JSON error from an endpoint as the node’s refusal. A gateway’s JSON 502 / 504 / 408 or an empty `{}` after the node had taken the message surfaced as a plain `NetworkError`, so a retry could pay twice. Now only toncenter’s refusal envelope (`{ok: false, error}` outside 408/502/503/504, not a timeout) is a refusal; anything else throws `BroadcastUnknownError` (`maybeSent: true`, `hash`). Handle it as for EVM: tell the user to check Activity before sending again, track the hash, never resend by itself. * **USDT0 remote quotes are bound to the requested tokens.** `usdt0VerifyQuote` now requires both the source and the destination token to be the deployment’s USDT / USDT0. Honest routes are unchanged. * **Fynd remote quotes are same-chain only.** `fyndVerifyQuote` refuses a request whose destination chain differs from the source chain. * **Policies: a class prototype is read as it was.** The snapshot of a write keeps a class prototype (so `instanceof` and methods work) but no longer sees fields or methods added to it after the snapshot. * **`verifyNetwork` caps an answer at 1 MiB** (`MAX_RESPONSE_BYTES`); a larger one fails with `too-large: …`. ## 2026-10-30: the scan fixes [Section titled “2026-10-30: the scan fixes”](#2026-10-30-the-scan-fixes) This release fixes all 19 findings of the external scan of 2026-10-30 and makes Jito-style SPL stake-pool delayed exits work under the default Solana guard again. The sections below are grouped by area. ### Policies, snapshots and EVM nonces [Section titled “Policies, snapshots and EVM nonces”](#policies-snapshots-and-evm-nonces) This release fixes six findings of the 2026-10-30 scan in `kit` and `wallet-evm`. #### Allowance increases are approvals (`kit`) [Section titled “Allowance increases are approvals (kit)”](#allowance-increases-are-approvals-kit) A raw `increaseAllowance(spender, x)` or `increaseApproval(spender, x)` call reaches `confirm` as `kind: 'approval'` with the spender in `to` and the increase in `amount` (it was a `send` to the token, and `increaseApproval`’s spender was never checked by the lists). Show it like an approve. #### Permit2 is read through its declared schema (`kit`, `wallet-evm`) [Section titled “Permit2 is read through its declared schema (kit, wallet-evm)”](#permit2-is-read-through-its-declared-schema-kit-wallet-evm) The Permit2 approval `confirm` sees — `approvals`, `token`, `amount`, `unlimited`, `to`, `deadline` — comes only from fields the typed data declares, as Permit2 signs them. A payload whose schema is not Permit2’s (a missing or retyped `details` / `permitted`, both declared, a list where one entry belongs) reaches `confirm` as the worst case: `unlimited: true`, `approvals: []`; one without a declared `spender` has an unknown destination, so an allowlist refuses it. `describeTypedData` flags such a payload `approve-unlimited`. Canonical Permit2 payloads from Uniswap, 1inch and other dApps read as before. #### Case-sensitive addresses are compared exactly (`kit`) [Section titled “Case-sensitive addresses are compared exactly (kit)”](#case-sensitive-addresses-are-compared-exactly-kit) Allowlist and blocklist entries for Solana (and Bitcoin / Zcash base58, and any namespace without its own rule) must be spelled exactly as the address: they were compared lowercased, which let a different Solana key that differs only in letter case pass an allowlist. EVM, NEAR, Aptos and Starknet entries still match in any case, bech32 Bitcoin / Zcash addresses in either case, Tron and TON in any of their forms. If your lists hold lowercased Solana keys, store them as the wallet shows them. #### `nonce too low` from one RPC is not re-signed (`wallet-evm`) [Section titled “nonce too low from one RPC is not re-signed (wallet-evm)”](#nonce-too-low-from-one-rpc-is-not-re-signed-wallet-evm) A send whose transaction the node refuses with “nonce too low”, and whose hash it says it never saw, was signed again with the next nonce. The RPC that refused already holds the first signed bytes, so a lying RPC could broadcast both. Now the account signs again only when at least two distinct configured RPCs show the nonce mined by another transaction. Otherwise the send throws `NonceConflictError` — a `BroadcastUnknownError` (`maybeSent: true`, `hash`), with `nonce` and the node’s `nextNonce`. Apps should show “the network didn’t confirm — check activity, then retry”: keep the operation pending on `hash`, and let the user send again once activity shows the first transaction is not coming; the account’s next send already skips the refused nonce. With one RPC configured, a stale `pending` nonce now costs one retry instead of being fixed silently; configure two RPCs to keep the automatic recovery. ### Remote swap pins [Section titled “Remote swap pins”](#remote-swap-pins) * **THORChain memo.** `thorchainPins().verifyBuild` refuses a remote build whose memo is not exactly the canonical memo for the request (output asset, destination/refund, limit, affiliate). Your server’s `thorchainProvider` already builds that memo; nothing to wire. * **CCTP burn decoded on the device.** `cctpPins` carry `verifyQuote` (`cctpVerifyQuote`): a remote `depositForBurn` must burn exactly the input of the source USDC for the recipient on the requested domain, with no destination caller and a `maxFee` that leaves the quoted minimum. Spread the pins as before. * **USDT0 send decoded on the device.** `usdt0Pins` carry `verifyQuote` (`usdt0VerifyQuote`): the OFT `send` must go to the requested chain’s eid and recipient, move exactly the input with a `minAmountLD` that keeps the quoted minimum, carry no options or compose message, refund the trader, and attach the declared native fee. Spread the pins as before. * **Wrap call checked on the device.** `wrapPins` carry `verifyQuote` (`wrapVerifyQuote`): only `deposit()` with the input as value, or `withdraw(input)` with no value, on the chain’s wrapped native, for the sender. * **LI.FI Diamond call decoded on the device.** `lifiPins` carry `verifyQuote` (`lifiVerifyQuote`): the same calldata check the server’s provider runs on LI.FI’s answer — receiver, minimum, input, output token, destination chain — runs again in the app, with the input’s value and approval. Checked against live LI.FI answers on 32 corridors (every route the provider accepts passes). * **Breaking: a pinned call needs a check of the call.** A remote stub’s EVM calldata route — a call on a contract its `callTargets` pin — now also needs the app’s own check of what the call does: `verifyQuote` or `verifyBuild` on `remoteProvider`. Without either, the router and the single-provider path drop the route with the reason, and the engine refuses it. Every shipped EVM pins export carries one (`lifiPins`, `cctpPins`, `usdt0Pins`, `wrapPins`, `uniswapPins`, `balancerPins`, `fyndPins`, `chainflipPins`; `thorchainPins()` checks builds), so spreading the pins is enough. If you build `callTargets` by hand, pass a `verifyQuote` or `verifyBuild` that decodes the call against the request. Omniston: keep adding `verifyBuild: omnistonVerifyBuild(...)` next to `...omnistonPins(...)` — `omnistonPins` alone has no check, and the app-supplied `verifyBuild` satisfies this rule. NEAR Intents (`nearIntentsPins`) and Jupiter are not affected: they pin no EVM call targets. ### Omniston [Section titled “Omniston”](#omniston) * **TON → TON swaps need `validateTonSwapLeg`.** Same-chain TON swaps are advertised and built only with `validateTonJettonWallet`, `validateTonSwapTarget` and `validateTonSwapLeg`: the leg check is what proves the DEX pays out the quoted token. A server that builds Omniston locally and wires the TON validators one by one loses TON → TON routes until it passes the leg check too — use `createOmnistonTonValidators()` whole (`omnistonProvider({...createOmnistonTonValidators(), ...})`). On the device, `omnistonVerifyBuild` refuses a TON swap body without `validateTonSwapLeg`; spreading `createOmnistonTonValidators()` into it, as the guide shows, already passes it. `tonSwapPayloadCheck: "report"` keeps building without it as an explicit, temporary opt-in. * **FULLY_FILLED needs proof.** `trackIntent` reports an Omniston order `completed` only once every execution’s secret was disclosed or a delivery locator (`locateEvmDelivery`, `locateTonDelivery`) found the payout; a feed that says FULLY_FILLED without that stays `pending` and keeps its secrets. Keep polling pending orders and keep your refund backstop as before; an order that never shows a disclosure or a delivery is closed by TTL or by the backstop, not by the feed. * **Intent ids carry the quote’s digest.** An Omniston intent id now ends its dest segment with `#` of the quote’s terms, and a build whose cached quote differs refuses with “quote was replaced — request a fresh quote”; a reused quote id with other terms is refused at quote time. Quotes issued by the previous version (no digest) are refused on build with “request a fresh quote” — a quote is valid for under a minute, so only a quote taken right across the deploy is affected; re-quote. Orders already signed keep being tracked and disclosed. If your code splits the id, strip `#…` from the dest segment; the quote id, trader and source chain segments are unchanged. ### Chain packages [Section titled “Chain packages”](#chain-packages) * **NEAR AddKey permissions are exactly one variant.** `wallet-near` refuses an AddKey whose permission is not exactly `{ fullAccess: {} }` or `{ functionCall: { receiverId, methodNames, allowance? } }` (`malformed-action`); it used to encode anything else as FullAccess. Keys built from the documented shapes are unchanged. * **Tron history fails instead of hanging on a malformed page.** `listActivity` throws `HistoryUnavailableError` when a TronGrid row’s `block_timestamp` is not a non-negative integer; it used to loop forever. Handle it like any other history outage. * **Tonstakers derives the tsTON jetton wallet.** The unstake destination is computed on the device from the minter’s pinned wallet code and must match the node’s `get_wallet_address`. The default mainnet and testnet deployments need nothing. With a custom `addresses.jetton`, pass that minter’s `jettonWalletCode`, or `allowUnverifiedJettonWallet: true` to keep the old unchecked behaviour. * **SPL stake-pool delayed exits reclaim pre-funded slots.** When someone sent SOL to the wallet’s next delayed-exit slot address, the exit transaction now starts with a System `TransferWithSeed` that returns that SOL to the wallet. A signing review shows one extra incoming transfer; nothing to change in the app. * **SPL stake-pool delayed exits sign with the default Solana guard.** `wallet-solana` refused every delayed exit (`requestWithdrawal({ mode: 'delayed' })`, e.g. Jito) with `sets-authority` (“funds stake account … without making this wallet its staker and withdrawer”) unless `allowAuthorityChanges` was set. It now takes a stake account the wallet funds as the wallet’s when an SPL Stake Pool `WithdrawStake` in the same transaction splits into it and names this wallet as the user stake authority. Nothing to change in the app; drop any `allowAuthorityChanges` you set to work around it. Any other authority or program is refused as before. ### Endpoint checks and CCIP-Read gateways [Section titled “Endpoint checks and CCIP-Read gateways”](#endpoint-checks-and-ccip-read-gateways) This release fixes findings of the 2026-10-30 scan in `@anywalletkit/networks` and `@anywalletkit/names-ens`. * **`verifyNetwork` refuses redirects.** An endpoint that answers through a redirect fails with `redirected: Endpoint redirected to ; enter the final URL` — the URL policy judged the address that was entered, not where it points. Ask the user for the final URL. A custom `fetch` that cannot refuse redirects (it gets `redirect: 'error'`) should report `url` or `redirected` on the response. `http-` and `bad-json` errors no longer carry body text. * **Answer the site generically.** When a site’s `wallet_addEthereumChain` endpoint fails the check, answer `{ code: -32603, message: "The network's RPC failed the check." }`; show `result.error` on the user’s own screen only. * **CCIP-Read answers are capped on the device.** `ensCcipRequest` (behind `ensResolver({ rpcUrl })`) reads a gateway’s answer to at most 1 MiB and gives each gateway 10 s for the request and the whole answer: `ccip: { maxResponseBytes, timeoutMs }`. Real answers are a few KB; nothing to change unless you lowered your own limits. * **On Node, CCIP-Read gateways go through the pinned transport.** With no `ccip.fetch`, `ensResolver({ rpcUrl })` on Node asks gateways the way `createEnsCcipHandler` does: DNS answers checked, the connection pinned. A gateway host that resolves to a private address — or mixes public and private records — is refused. Behind a corporate split-horizon DNS or an outbound proxy, pass `ccip: { fetch }`. Browsers are unchanged (no DNS-level check there; use `ensCcipViaProxy` where it matters); React Native is unchanged. ## 2026-10-29: remote swaps checked on the device [Section titled “2026-10-29: remote swaps checked on the device”](#2026-10-29-remote-swaps-checked-on-the-device) This release fixes four findings of the 2026-10-28 scan in `remoteProvider` setups. If you run swaps through your own server: * **Pins now open the call.** `uniswapPins` and `balancerPins` carry `verifyQuote` (`uniswapVerifyQuote`, `balancerVerifyQuote`): the router or Vault call is decoded on the device and must be exactly the swap the user asked for — tokens, input, minimum, recipient. `chainflipPins` carry `chainflipVerifyQuote`. Spread the pins as before; nothing else to wire. * **Deposit-address routes need an opt-in.** A remote stub’s route that pays an address the server names — a deposit address or a plain value send — is refused unless the stub has `depositAddressRoutes: true`. `nearIntentsPins`, `thorchainPins()` (Tron) and `chainflipPins` (Tron) set it; add `...nearIntentsPins` to your NEAR Intents stub. * **Remote typed orders need `verifyBuild`.** The engine signs a remote EIP-712 order only after the stub’s `verifyBuild` ran. `omnistonVerifyBuild` now opens Omniston’s EVM orders too, with the order extension the server’s build carries: deploy the new server before the new app, or remote EVM → TON and EVM → EVM orders are refused. ## 2026-10-29: the audit fixes [Section titled “2026-10-29: the audit fixes”](#2026-10-29-the-audit-fixes) This release fixes three findings of the 2026-10-29 external audit. ### The signer is no longer a property of an account [Section titled “The signer is no longer a property of an account”](#the-signer-is-no-longer-a-property-of-an-account) Every chain account (`wallet-evm`, `-solana`, `-ton`, `-tron`, `-btc`, `-near`, `-zcash`) keeps its signer in a private field. `account.signer` is `undefined` now, on the accounts your app gets too, and `LocalSigner` has no `key()` method at runtime. A protocol module always gets the policy wrapper, even on a `Wallet` without `policies`; without them the wrapper applies no limits. What to change: * `account.signer.kind` → `account.signerKind` (`'seed' | 'key' | 'external'`); * `account.signer.publicKey` → `account.publicKey`; * an injected `Signer` you need later: keep your own reference to it; * tests that patched `account.signer.sign` to count signatures: spy on your own external signer, or on `LocalSigner.prototype.sign`; * do not compare a module’s account with `wallet.getAccount(chain)` by identity: it is a wrapper. ### `setProtection` reports an old copy it could not remove [Section titled “setProtection reports an old copy it could not remove”](#setprotection-reports-an-old-copy-it-could-not-remove) `@anywalletkit/secure-storage-react-native` used to ignore a failed delete of the old copy after it rewrote a secret. `setProtection(id, 'biometricsOnly')` resolved, `info` and `list` said strict mode, and the old `biometricsOrPasscode` copy stayed in the Keychain. Now: * `setProtection` and `set` reject with `SecureStorageError` code `stale-copy` when the new copy is stored but the old one could not be removed; * `info` and `list` report the weakest protection among the copies still stored; * the store retries the removal on its next operation, on first use after a restart and in `prune` (which rejects with `stale-copy` if a copy still stays). What to change: catch `stale-copy` where you call `setProtection` and do not show strict mode as on. Offer a retry: calling `setProtection` again removes the old copy without another sheet. After `set`, `stale-copy` means the new secret is stored, so do not roll back the account. ### Permit calls in the transaction preview [Section titled “Permit calls in the transaction preview”](#permit-calls-in-the-transaction-preview) `@anywalletkit/tx-preview-evm` decodes the calls that grant an allowance without `approve`: an EIP-2612 `permit()` call (the token is the target), Permit2 `permit()` single and batch (the token is inside the struct) and `increaseApproval`. They now carry `approval`, with the usual `approval-unlimited` and `approval-unknown-spender` warnings. A Permit2 batch lists every allowance in the new `approvals` field. A permit body whose arguments can’t be read gives the new `approval-unparsed` warning. What to change: if your review screen maps check codes to text, add `approval-unparsed`. If it shows `approval`, show every entry of `approvals` when it is set. ## 2026-10-29: names, typed data, Solana and URLs [Section titled “2026-10-29: names, typed data, Solana and URLs”](#2026-10-29-names-typed-data-solana-and-urls) This release fixes the 2026-10-28 scan. Four changes can need something from you: offchain ENS names on React Native, typed-data signatures under policies, Solana transactions that create token accounts, and URLs with a backslash. ### ENS on React Native (`names-ens`) [Section titled “ENS on React Native (names-ens)”](#ens-on-react-native-names-ens) React Native’s fetch follows a redirect whatever `redirect: 'error'` says, so an offchain name’s gateway could redirect the device to a host on the user’s network. There, the resolver no longer asks gateways with the global fetch. **Offchain names (`cb.id`, Basenames, `uni.eth` subnames) fail with `lookup-failed`** until you set `ccip`. The error’s `reason` is `ccip-proxy-required` and its message says what to set. Browsers, extensions and Node are unchanged. The safe fix is one route on your server with the new `createEnsCcipHandler`, and the new `ensCcipViaProxy` in the app: ```ts // server (Node), behind your auth and rate limits import { createEnsCcipHandler } from '@anywalletkit/names-ens/server' const ccip = createEnsCcipHandler() // POST /ccip: const out = await ccip(JSON.parse(rawBody)); reply out.status with JSON out.body // app import { ensCcipViaProxy, ensResolver } from '@anywalletkit/names-ens' const ens = ensResolver({ rpcUrl: ETHEREUM_RPC, ccip: { fetch: ensCcipViaProxy('https://api.example.com/ccip', { headers: { authorization: `Bearer ${token}` } }) }, }) ``` * **The server route** asks only https gateways on public hosts, checks every address the host resolves to, connects to the checked one, and never follows a redirect. See [Names](/guides/names/#react-native) and the reference server’s `POST /ccip`. * **Without a server**, `ccip: { allowRedirectFollowingFetch: true }` brings direct lookups back and accepts that a redirecting gateway can make the device send one blind request. * **Your own viem `client`:** pass the same options as `ccipRead: createEnsCcipRead({ … })`. * **A 3xx answer is never used**, on every runtime. ### Policies and typed data (`kit`) [Section titled “Policies and typed data (kit)”](#policies-and-typed-data-kit) The policies now read typed data that moves value by itself: * EIP-3009 `TransferWithAuthorization` / `ReceiveWithAuthorization` is a `send` to `to`, with the verifying contract as the token and `value` as the amount; * an ERC-2771 `ForwardRequest` and a `MetaTransaction` are judged by the call they make; * a 1inch LOP `Order` (the Omniston EVM order) by its settlement contract, and by a `receiver` other than the maker. What this changes: * **Under an allowlist,** these signatures need their destinations on the list. Typed data the policies cannot read is refused: it is `kind: 'typedData'` with `destinationUnknown: true`. * **Under a blocklist,** unreadable typed data is refused unless you have a `confirm` hook, which then sees `destinationUnknown: true`. * **A blocklisted verifying contract** is refused for all typed data, Permit and Permit2 included. The new `WriteIntent.verifyingContract` carries it. * **`WriteIntent.message`** is now `false` for every `signTypedData`. See [Policies](/concepts/policies/). ### Solana token accounts the transaction creates (`wallet-solana`) [Section titled “Solana token accounts the transaction creates (wallet-solana)”](#solana-token-accounts-the-transaction-creates-wallet-solana) The guard that simulates what a program does by CPI now also watches token accounts the transaction creates, such as an associated token account made with `CreateAssociatedTokenIdempotent`. * A transaction that leaves a close authority or another owner on a token account of this wallet that it creates, or a delegate allowance no top-level `Approve` shows, is refused with `UnsafeTransactionError` (`sets-authority` / `approves-delegate`). * Creating this wallet’s associated token account for another owner is refused. * `allowAuthorityChanges` still overrides the guard, for example for a token launch. * `checkSerialized` asks the simulation only for accounts of the transaction. An address outside it keeps its pre-state. ### URLs with a backslash (`networks`) [Section titled “URLs with a backslash (networks)”](#urls-with-a-backslash-networks) A WHATWG parser (browsers, Node, the extension) and OkHttp end a host at `\` and drop tabs and line breaks, so `https://192.168.1.1\admin` was checked as one host and fetched from another. Now a URL with `\`, a tab, CR, LF or another control character anywhere is refused: * `checkRpcUrl` answers `not-a-url`; * `isPublicHttpsUrl` answers `false`; * a site’s `rpcUrls` entry in `wallet_addEthereumChain` is dropped; * ENS gateway and avatar URLs with one are not used. If you store user-entered RPC URLs, a saved one with a backslash no longer passes: ask the user to enter it again. ## 2026-10-28: the modules security release [Section titled “2026-10-28: the modules security release”](#2026-10-28-the-modules-security-release) This release fixes the second 2026-10-27 security scan: 37 findings in the protocol modules. Most of the changes are stricter checks, so input that used to pass is now refused. Go through the sections for the packages you use. ### Swaps through your own server (`remoteProvider`) [Section titled “Swaps through your own server (remoteProvider)”](#swaps-through-your-own-server-remoteprovider) #### Pins [Section titled “Pins”](#pins) A remote provider now takes the contracts its calldata may call and approve from your app, never from your server. Without pins it gets **no EVM calldata route at all**. Jupiter’s Solana transactions need no pins; deposit-address routes such as NEAR Intents need `depositAddressRoutes` since the next release (above). Every other provider package exports its pins: ```ts import { remoteProvider } from '@anywalletkit/protocol-swap2-core/remote' import { lifiPins } from '@anywalletkit/protocol-swap2-lifi' import { thorchainPins } from '@anywalletkit/protocol-swap2-thorchain' import { chainflipPins } from '@anywalletkit/protocol-swap2-chainflip' import { omnistonPins, omnistonVerifyBuild } from '@anywalletkit/protocol-swap2-omniston' import { AUDITED_OMNISTON_EVM_ORDER_PROTOCOLS, createOmnistonTonValidators } from '@anywalletkit/protocol-swap2-omniston/validators' const PINS = { lifi: lifiPins, chainflip: chainflipPins, // Exactly your server's THORChain affiliate and affiliateBps: the memo must carry both as configured // there. If your server has no affiliate, use thorchainPins(). thorchain: thorchainPins({ affiliate: 'your-affiliate', affiliateBps: '30' }), // integratorAddress: only if your server's Omniston provider sets one. It is the only referral a TON // swap body may name. omniston: { ...omnistonPins(AUDITED_OMNISTON_EVM_ORDER_PROTOCOLS), verifyBuild: omnistonVerifyBuild({ ...createOmnistonTonValidators(), integratorAddress: null }), }, } const remote = (name: string) => remoteProvider({ name, baseUrl: 'https://api.example.com/swap2', ...PINS[name as keyof typeof PINS] }) ``` The other pins are: * `fyndPins`: it carries `verifyQuote: fyndVerifyQuote`, so the quote is also checked on the device; * `cctpPins`, with its `claimTargets`; * `uniswapPins`, `usdt0Pins`, `balancerPins` and `wrapPins`. The reference server’s client shows the whole wiring: [Ship a wallet](/build-a-wallet/ship-a-wallet/). #### The server address [Section titled “The server address”](#the-server-address) `baseUrl` must be https, or a same-origin path such as `/api/swap2`. Leading spaces and scheme-relative values (`//host/…`) are refused too. For a server on your own machine during development, pass `allowInsecureBaseUrl: true`. #### Typed orders [Section titled “Typed orders”](#typed-orders) Typed data is signed only when it is the swap’s own order. That means an EIP-712 `Order` that is: * from the user; * selling the source token, exactly the input; * on the swap’s chain, to the approval address. A permit, or any other message from a provider, is refused. #### TON swaps [Section titled “TON swaps”](#ton-swaps) * **Attached TON.** A TON message may attach at most the input plus the gas ceiling; for a jetton source, the ceiling alone. The ceiling is 1 TON by default. Change it on the swap engine, for example `new RouterSwap2Protocol(account, { …, tonGasCeiling })`, not on `remoteProvider`. * **Jetton source.** It must be a plain TEP-74 transfer of exactly the input to your own jetton wallet, so **the TON signer must have `getJettonWalletAddress`**. * **Server-built bodies.** A TON cell body built by your server is refused unless the app checks it (`verifyBuild`; for Omniston, `omnistonVerifyBuild({...createOmnistonTonValidators()})`). It checks TON → TON swap bodies and, from omniston 0.3.1, TON → EVM escrow bodies too (`verifyTonEscrowBuild`). On 0.3.0 a remote TON → EVM build was refused: upgrade to 0.3.1, and if you turned the route off with a `supportsRoute` filter, turn it back on. #### Omniston quote store [Section titled “Omniston quote store”](#omniston-quote-store) If Omniston runs on your server, give your durable quote store a `claim(quoteId, json)` that writes only if nothing is stored yet (Redis `SET NX`). Without it, two server instances can race on one quote id. ### Swap providers in your own process [Section titled “Swap providers in your own process”](#swap-providers-in-your-own-process) #### Every provider [Section titled “Every provider”](#every-provider) When the swap call is itself a token call (`transfer`, `approve`, `increaseAllowance`), it may only move the source token, by at most the input. It may only pay the user, the deposit address or the declared spender. These are refused as the swap call: * an operator approval (`setApprovalForAll`); * a Permit2 approval; * a `transferFrom`; * calldata that is not hex bytes. Valid hex that calls an unknown function still goes through as a plain contract call. A router’s calldata is not decoded by this check: each provider checks its own targets. #### LI.FI [Section titled “LI.FI”](#lifi) * `apiUrl` must be https; `allowInsecureApiUrl` allows a local mock. * On an EVM source, every step of the Diamond call is checked: * steps must chain; * only the input token may be deposited; * a bridge without source swaps must bridge the input, less at most 1 % (its `minAmount`). What arrives on the destination is still LI.FI’s figure. #### THORChain [Section titled “THORChain”](#thorchain) * **A second node confirms the vault.** The inbound vault must be confirmed by a second node, at quote time and before broadcast. The second node is any node other than the one that quoted. * **The default nodes** are Liquify (`https://gateway.liquify.com/chain/thorchain_api`) and IBS (`https://thorchain.ibs.team/api`). They are run by different operators. * **With one configured `nodeUrl`**, the default nodes confirm it, unless `confirmNodeUrl` names another node; if you name your own, pick one of another operator. A quote is refused when no second node answers, or when one reports another vault or router. For a local mock, name a second mock in `confirmNodeUrl`, because the public nodes report another vault. * **https only.** Nodes must be https; `allowInsecureNodeUrl` allows a local mock. * **Routers are pinned per chain.** Pass `trustedRouters` if THORChain migrates its router before you upgrade. #### Jupiter, Balancer and Fynd [Section titled “Jupiter, Balancer and Fynd”](#jupiter-balancer-and-fynd) * **Jupiter:** every instruction of a `/build` transaction besides the swap must have one of the expected shapes. Anything else is refused. * **Balancer:** `preflightDeposit` refuses calldata that is not the quote’s own batch. * **Fynd:** `preflightDeposit` refuses a transaction whose target, calldata or value differs from the quote’s. Pass the transaction `buildDeposit` returned, unchanged. ### Earn [Section titled “Earn”](#earn) #### Kamino [Section titled “Kamino”](#kamino) Upgrade `@anywalletkit/wallet-solana` together with the Kamino module. * **Chain reads.** Writes and their quotes (`supply`, `withdraw`, `borrow`, `repay` and every `quote…` call) need an account that can read accounts from the chain (`getAccountInfos`), because the module checks the API’s figures against the chain. Reads do not. * **Vaults.** On a vault write or quote, an API `tokensPerShare` more than 1 % away from the share price computed on chain is refused. * **Lend `'max'`.** It is refused when the API reports a deposit or debt more than 1 % away from the obligation’s. APYs and other reads are still the API’s. * **`WITHDRAW_ALL`.** It is deprecated, and Kamino’s API now refuses it (above u64). To withdraw everything, use the vault’s `withdraw({ amount: 'max' })`, which sends the exact share count read from the chain. #### Mellow [Section titled “Mellow”](#mellow) * **Vaults picked with `preset`** work as before. A queue Mellow adds after this release needs a package update or `config.queues`. * **Any other vault needs `config.queues`**: the deposit and redeem queues you expect. That includes a preset’s vault passed only by address as `vault`; pass `preset` for it instead. * **No queue is trusted on the RPC’s word.** Deposits, redemptions, `cancelSupply`, `claimShares` and `claimWithdrawals` go only to a pinned queue. ### Approvals [Section titled “Approvals”](#approvals) #### `isRevokeTransaction(tx, { targets })` [Section titled “isRevokeTransaction(tx, { targets })”](#isrevoketransactiontx--targets-) * Without `targets`, only a Permit2 `lockdown` counts as a revoke. * Pass the approvals your scan returned, a `Map` from address to standard, or a plain object from address to standard (`RevokeTargets`). * `approve(spender, 0)` is a revoke only on an ERC-20. On an ERC-721 the same call approves `spender` for token #0. A bare address list no longer makes it one. * It never throws. #### `revoke()` / `revokeAll()` [Section titled “revoke() / revokeAll()”](#revoke--revokeall) * **ERC-165 first.** An `'erc20'` revoke first asks the token for ERC-165 (one `eth_call`, with CCIP-Read off), so it needs the protocol’s provider (`config.provider` or the account’s). Without one it is refused (`provider-required`). * **Outcomes:** * an ERC-721 is refused (`invalid-approval`); * a token without ERC-165 (the call reverts) is revoked as before; * a read that fails sends nothing (`source-failed`). * **`revokeAll()`** applies this per approval: a refused one comes back as that approval’s `error`, and the others still go out. * **`buildRevokeTransaction()`** does not check. Run your own ERC-165 read before you batch its output. ### Extension kit [Section titled “Extension kit”](#extension-kit) * **Method names.** The relay drops a request whose `method` is not a method name: letters, digits and `_`, at most 128 characters (`isRelayMethod`). Reject such a request in your injected provider with `-32600`, so the page gets an answer instead of a promise that never settles. * **`setSecret` refuses a key that does not open the vault’s secrets** (`locked`). A vault that holds no secret yet has nothing to check the key against, so the first `setSecret` after `create`, or after the last secret is deleted, must get the key that `create` or `rotateVaultKey` returned. After the new `vault.rotateVaultKey(password)`, hand the returned key to `session.unlock`. * **Re-keying.** `changePassword` keeps the same vault key. After a suspected compromise, offer `rotateVaultKey`. It drops the passkeys, so ask the user to add them again: see [Extension kit](/guides/extension-kit/). * **`wallet_addEthereumChain`.** A site’s RPC URL is kept only when it is https on a public host (`isPublicHttpsUrl`). ### Names, tokens and networks [Section titled “Names, tokens and networks”](#names-tokens-and-networks) #### ENS [Section titled “ENS”](#ens) * **Your own client.** If you pass your own viem `client` to `ensResolver`, create it with `ccipRead: ensCcipRead`. With viem’s default, an offchain name’s resolver chooses URLs the device fetches, including addresses on the user’s network. See [Names](/guides/names/). * **https avatars on a public host** come back as written, without a fetch. * **`ipfs://` avatars** come back through `ipfsGateway`: an origin (`/ipfs/` is appended) or a prefix ending in `/ipfs/`. * **Every other avatar now comes back `null`:** NFT avatars, and `http://`, `data:`, `ipns://` and `ar://` ones. Show your placeholder. #### Tokens [Section titled “Tokens”](#tokens) * **Length.** A user token’s symbol is stored trimmed and cut to 32 characters, and its name to 128. Longer stored symbols are cut when the table loads. * **Look-alikes.** The check now also folds ASCII look-alikes (“DAl” reads as DAI) and spacing accents. * **`odd-characters` at `danger`** applies when a symbol has: * any letter of a script whose letters pass for Latin ones: Cyrillic, Greek, Armenian, Cherokee and others, the whole script (`ЖЛБ` too); * small capitals, Roman numerals, enclosed or math letters; * characters that hide in text: zero-width, bidi, controls, line breaks, tag characters. Emoji (™ included), CJK, other scripts and accented Latin stay a `caution`. #### Networks [Section titled “Networks”](#networks) `isPrivateHost` now also treats these as private: * IPv4 in every ASCII spelling a URL parser accepts (`127.1`, `2130706433`, `0x7f.0.0.1`); * any host that is not printable ASCII, which a URL parser would map to ASCII (`127.0.0.1`); * CGNAT and link-local ranges; * all of 0/8, 192.0.0/24, the 198.18/15 benchmark range, multicast and reserved addresses (224/4 and up); * a numeric host that is no valid IPv4 (`256.1.1.1`); * IPv6 addresses that carry a private IPv4 one (IPv4-mapped `::ffff:…`, IPv4-compatible, NAT64 `64:ff9b::…`, 6to4 `2002::…`), IPv6 multicast, and hosts with `%` (an IPv6 zone); * `*.internal`, `*.home.arpa` and `*.lan`. What this changes: * **`checkRpcUrl`** reports them as `local`. http to them is accepted behind `allowHttpLocal`; it used to be refused as `http-not-local`. https is accepted as before. * **`parseAddChainParams`** now drops a site’s RPC URL on them, https included, unless `allowHttpLocal` is on. * **For your own fetches,** use the new `isPublicHttpsUrl`. Import it from `@anywalletkit/networks/url`, together with `isPrivateHost`, `checkRpcUrl` and `parseUrl`, to leave the catalog out: the package root loads it. * **CCIP-Read is off** in the viem clients the SDK creates (wallet-evm and the protocol modules; names-ens has its own filter). A contract’s `OffchainLookup` revert, such as a spam token’s, no longer makes the device fetch the URLs it names. # Approvals > Find every token approval an account has granted, flag the risky ones, and revoke them one by one or all at once. Every swap, bridge and lending deposit leaves an approval behind: a contract that may move the user’s tokens later. The approvals module finds them, flags the risky ones and revokes them through the account, so the user can answer “who can spend my USDC?” in your app. | Standard | What it is | How it is revoked | | ------------------- | ------------------------------------------------------ | ---------------------------------------------- | | `erc20` | Token allowance (`approve`) | `approve(spender, 0)` | | `erc721-token` | One NFT approved to one address | `approve(0x0, tokenId)` | | `erc721`, `erc1155` | Operator over a whole collection (`setApprovalForAll`) | `setApprovalForAll(operator, false)` | | `permit2` | Uniswap Permit2 allowance | Permit2 `lockdown`, several in one transaction | | `eip7702` | The account’s EIP-7702 delegation | A type-4 transaction (`revokeDelegation`) | The module is EVM only for now. Snippets come from [`examples/approvals`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/approvals), which our CI typechecks and tests. ```sh npm install @anywalletkit/protocol-approvals-evm ``` ## Set up [Section titled “Set up”](#set-up) ```ts import { Wallet, type Policies, type WriteIntent } from '@anywalletkit/kit' import { WalletManagerEvm, type EvmConfig } from '@anywalletkit/wallet-evm' import ApprovalsProtocolEvm, { BlockscoutSource, type Approval, type ApprovalsCache, type CacheEntry, type RevokeResult, } from '@anywalletkit/protocol-approvals-evm' export interface ApprovalsSetup { /** An RPC that answers eth_getLogs WITHOUT an address filter (your node, most paid plans). */ logsRpc: NonNullable cache: ApprovalsCache policies?: Policies /** The account's RPC (quotes, sends). Default: a public endpoint — pass your own in production. */ ethereumRpc?: EvmConfig['provider'] } export function walletWithApprovals(seedPhrase: string, { logsRpc, cache, policies, ethereumRpc = 'https://ethereum-rpc.publicnode.com' }: ApprovalsSetup): Wallet { const wallet = new Wallet(seedPhrase, policies ? { policies } : {}) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: ethereumRpc, chainId: 1 }) wallet.registerProtocol('approvals', ApprovalsProtocolEvm, { chain: 'eip155:1', config: { provider: logsRpc, // discovery only; revokes are sent through the account's own provider fromBlock: 23_000_000, // the block your app created this wallet in: no approval can be older blockRange: 10_000, // your RPC's eth_getLogs window cache, // rescans read only the blocks since the last one }, }) return wallet } ``` Discovery asks for every approval event whose owner is the account, across all contracts. That is an `eth_getLogs` call with no `address` filter, and **most free public RPCs refuse it** (publicnode, for one, answers “Please specify an address”). Use your own node or a paid plan for `logsRpc`, or switch discovery to an indexer (see [below](#a-faster-first-scan)). Revokes don’t use `logsRpc`: they go out through the account’s own provider. Two settings decide how long a scan takes: * **`fromBlock`.** A scan starts here. Pass the block your app created the wallet in: no approval can be older. An imported wallet has no such block; use an indexer for its first scan. * **`blockRange`.** The largest `eth_getLogs` window your RPC accepts (QuickNode: 10 000). A range the node refuses is halved and retried. A block that still fails becomes a gap in `coverage` instead of an error. ### Cache [Section titled “Cache”](#cache) Give the module a cache, and the next scan reads only the blocks since the last one: ```ts /** Scan state per `chainId:owner`. Plain JSON: persist it as-is (file, MMKV, IndexedDB). */ export function memoryCache(store = new Map()): ApprovalsCache { return { get: (key) => { const json = store.get(key) return json ? (JSON.parse(json) as CacheEntry) : null }, set: (key, entry) => { store.set(key, JSON.stringify(entry)) }, } } ``` An entry is plain JSON keyed by `chainId:owner`. Store it wherever your app keeps state. Only complete scans are written. ## List approvals [Section titled “List approvals”](#list-approvals) ```ts export async function listApprovals(wallet: Wallet) { const approvals = await wallet.getProtocol('approvals') const { approvals: list, coverage } = await approvals.getApprovals() // complete: false = some block range could not be scanned; the list may be missing entries. if (!coverage.complete) console.warn('partial scan, gaps:', coverage.gaps) return { // Unlimited allowances, spenders without code, live EIP-7702 delegations. risky: list.filter((a) => a.risks.length > 0), all: list, } } ``` The events only say which pairs to check. The values come from the chain as it is now: `allowance`, `isApprovedForAll`, `getApproved` and the Permit2 `allowance`, read in one Multicall3 call. Anything already used up, expired or revoked is dropped (pass `includeZero: true` to keep it). | Field | Meaning | | ------------------ | --------------------------------------------------------------------------------------------------------------------- | | `id` | Stable key: `chainId:standard:token:spender[:tokenId]` | | `token`, `spender` | The contract approved, and who may spend from it | | `amount` | Allowance in base units; `null` for operators and delegations | | `unlimited` | 2²⁵⁵ or more, or the Permit2 maximum | | `risks` | `unlimited`; `eoa-spender` (the spender has no code, a classic drainer sign); `expired` (Permit2, with `includeZero`) | | `expiration` | Unix seconds, Permit2 only | | `spenderLabel` | Verified contract name, when a `labels` resolver is set | `getApprovals` filters by `standards`, `tokens` and `spenders`, and takes `fromBlock` / `toBlock` for one-off ranges. An explicit `fromBlock` bypasses the cache. ### Spender names [Section titled “Spender names”](#spender-names) A raw spender address means nothing to the user. `EtherscanLabels` turns verified contracts into their verified contract names: ```ts import { EtherscanLabels } from '@anywalletkit/protocol-approvals-evm' config: { labels: new EtherscanLabels({ chainId: 1, baseUrl: 'https://your-proxy/etherscan' }) } ``` Keep the Etherscan key on your server: point `baseUrl` at a proxy that adds it. A label lookup that fails leaves `spenderLabel` empty and never fails the scan. ## A faster first scan [Section titled “A faster first scan”](#a-faster-first-scan) An indexer returns the whole history in one request instead of thousands of block windows. On a real Ethereum wallet with 11 approval events over 4 M blocks, the RPC walk took 398 requests and 121 s at a 10 000-block window. `EtherscanSource` and `BlockscoutSource` took 1 request and about 1 s. The indexer source also works without a seed. A read-only account needs only `getAddress()`, so you can audit any address: ```ts /** Any address, no key and no seed: a read-only account only needs getAddress(). */ export async function auditAddress(address: string): Promise { const approvals = new ApprovalsProtocolEvm( { getAddress: async () => address }, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1, // The whole history in one request instead of a block-by-block eth_getLogs walk. source: new BlockscoutSource({ chainId: 1 }), }, ) const { approvals: list } = await approvals.getApprovals({ standards: ['erc20', 'permit2'] }) return list } ``` | Source | Key | Limits | | -------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BlockscoutSource({ chainId })` | None; an `apiKey` from dev.blockscout.com raises the limit | Keyless instances allow about 10 requests per window. Built-in hosts: Ethereum, Optimism, Gnosis, Polygon, Base, Arbitrum, Scroll, Linea, Sepolia; others take `baseUrl` | | `EtherscanSource({ chainId, apiKey })` | One Etherscan V2 key for every chain | Free tier: 5 requests/s, no Base, Optimism, BNB or Avalanche. Proxy the key through `baseUrl` | | `RpcLogsSource` (default) | Your RPC | Address-less `eth_getLogs`, see [Set up](#set-up) | A common pattern: an indexer for an imported wallet’s first scan, then the default RPC source with a cache for every scan after. ## Revoke [Section titled “Revoke”](#revoke) ```ts export async function revokeOne(wallet: Wallet, approval: Approval) { const approvals = await wallet.getProtocol('approvals') const { fee } = await approvals.quoteRevoke(approval) // gas in wei, nothing is signed const { hash } = await approvals.revoke(approval) return { hash, fee } } export async function revokeRisky(wallet: Wallet): Promise { const approvals = await wallet.getProtocol('approvals') const { approvals: list } = await approvals.getApprovals() // Permit2 entries fold into one `lockdown`; every other approval is its own transaction. const results = await approvals.revokeAll(list.filter((a) => a.risks.length > 0)) for (const r of results) { // revokeAll never throws for one item: a failure rides on its result. if (r.error) console.warn('not revoked:', r.ids, r.error.message) } return results } ``` * `quoteRevoke` returns the gas fee in wei without signing. Show it before the user confirms. * `revoke` sends one transaction and resolves to `{ hash, fee }`. An ERC-20 revoke goes through the account’s `approve({ amount: 0 })`. An EIP-7702 delegation is revoked with `revokeDelegation()`. * `revokeAll` folds every Permit2 entry into one `lockdown` and sends the rest one by one. On an account that takes a list in `sendTransaction` (ERC-4337, gasless 7702), set `batch: true` and everything goes in one user operation. * `buildRevokeTransaction(approval)` returns the raw `{ to, data, value }` without sending, for apps that build their own batches. A revoke costs gas. An account with no ETH cannot revoke anything. Check the balance against `quoteRevoke` first. ## With policies [Section titled “With policies”](#with-policies) A revoke is a signature like any other, so [policies](/concepts/policies/) see it. `confirm` gets `kind: 'revoke'`, with the spender in `to` and the full list in `revokes`. New approvals arrive as `kind: 'approval'`: `approve`, `signPermit`, and EIP-2612 or Permit2 typed data. That makes `confirm` the place to warn about unlimited allowances before they are granted: ```ts /** `ask` is your confirmation sheet: resolve true when the user taps Confirm. */ export function approvalPolicies(ask: (intent: WriteIntent, warning?: string) => Promise): Policies { return { confirm: (intent) => { // A revoke: intent.revokes lists each { token, spender } being taken back. if (intent.kind === 'revoke') return ask(intent) // A new approval: approve, signPermit, EIP-2612 or Permit2 typed data; intent.to is the spender. if (intent.kind === 'approval' && intent.unlimited) { return ask(intent, `${intent.to} could spend all of this token, now and later.`) } return ask(intent) }, } } ``` A revoke is not checked against the allowlist or the spender’s blocklist entry, so the user can always take an allowance back from a spender you have blocklisted. See [Revokes](/concepts/policies/#revokes). ## Activity [Section titled “Activity”](#activity) `describeActivity` recognises approve and revoke transactions in the account’s history: “Approved USDC for Permit2”, “Revoked USDC for 0x1231…”. Pass `tokens` (lowercase address → symbol and decimals) in the config for readable titles. It runs offline on any EVM chain. ## Errors [Section titled “Errors”](#errors) Failures throw `ApprovalsError` with a `reason`: | `reason` | When | | ---------------------- | --------------------------------------------------------------------------------------------------- | | `provider-required` | No `provider` in the config and none on the account | | `account-required` | `revoke` on a read-only account | | `unsupported-standard` | A standard this chain cannot revoke, or an EIP-7702 revoke on an account without `revokeDelegation` | | `invalid-approval` | A malformed approval object | | `source-failed` | The indexer refused or rate-limited the request. The message says when it resets | Policy refusals arrive as `UserRejectedError` and `PolicyViolationError` from `kit`, as on every other write. # Balances > Native and token balances on every chain, the token list behind them, exact formatting with bigint, and where a price API plugs in. Every account reads its own balances: `getBalance()` for the network’s gas coin and `getTokenBalance(token)` for a token contract. The wallet decides which tokens to read, formats the amounts and adds prices. This page builds that in a few small functions. Snippets come from [`examples/balances`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/balances), which our CI typechecks and tests. The live run reads real balances on Base. ```sh npm install @anywalletkit/kit @anywalletkit/wallet-evm @anywalletkit/networks @anywalletkit/swap2-assets ``` ```ts import { bigintToUnits, type IWalletAccountReadOnly } from '@anywalletkit/core' import { Wallet } from '@anywalletkit/kit' import { builtinNetwork } from '@anywalletkit/networks' import { ccChain } from '@anywalletkit/swap2-assets' import { WalletManagerEvm, type EvmProvider } from '@anywalletkit/wallet-evm' ``` ## One account per chain [Section titled “One account per chain”](#one-account-per-chain) ```ts /** One registration per EVM network, each with its own RPC list. */ export function balancesWallet(seedPhrase: string, rpcs: Record): Wallet { const wallet = new Wallet(seedPhrase) for (const [id, provider] of Object.entries(rpcs)) { wallet.registerWallet(`eip155:${id}`, WalletManagerEvm, { provider, chainId: Number(id) }) } return wallet } ``` Register each EVM network on its own chain id so it gets its own RPC list. The same seed gives the same address on every EVM network, but the balances are separate. To take the RPC lists from the user’s settings, see [Networks](/guides/networks/). ## Which tokens to read [Section titled “Which tokens to read”](#which-tokens-to-read) A chain has no call that lists the tokens an address holds. The wallet keeps a list. ```ts /** A token the wallet reads a balance for. `token: null` is the network's gas coin. */ export interface ListedToken { chain: string token: string | null symbol: string decimals: number /** `catalog`: shipped with the SDK; `user`: the user added it by contract address. */ source: 'catalog' | 'user' } /** The gas coin from the network table, the catalog's tokens, then the tokens the user added. */ export function tokenList(chain: string, userTokens: ListedToken[] = []): ListedToken[] { const network = builtinNetwork(chain) const native: ListedToken[] = network ? [{ chain, token: null, symbol: network.symbol, decimals: network.decimals, source: 'catalog' }] : [] const catalog: ListedToken[] = (ccChain(chain)?.assets ?? []) .filter((a) => a.contract) // the native entry is already in from the network table .map((a) => ({ chain, token: a.contract!, symbol: a.symbol, decimals: a.decimals, source: 'catalog' })) const known = new Set(catalog.map((t) => t.token!.toLowerCase())) const own = userTokens.filter((t) => t.chain === chain && t.token && !known.has(t.token.toLowerCase())) return [...native, ...catalog, ...own] } ``` * **The gas coin** comes from the network table in `networks`: its symbol and decimals per network. POL on Polygon, MNT on Mantle. Never hardcode “ETH”. * **The catalog** in `swap2-assets` lists the native coin and the major tokens per chain, with contract addresses and decimals. It is a short curated list, not a full token list. * **The user’s tokens** are the ones they added by contract address. Keep them in your own storage, mark them as user-added, and drop duplicates of catalog tokens. [Custom tokens](/guides/tokens/) does this with `@anywalletkit/tokens`: it reads the token from the chain and warns about look-alikes before it is saved. To find tokens outside the list, use a holdings indexer. [Balances and prices](/build-a-wallet/balances-and-prices/) lists providers per chain. Show what it finds behind a “hidden” filter: most unknown tokens are spam. ## Read the balances [Section titled “Read the balances”](#read-the-balances) ```ts export interface BalanceRow extends ListedToken { /** Base units. `null` when this read failed; never show a failed read as 0. */ amount: bigint | null error?: string } /** Every listed balance of one account. One failing token does not hide the others. */ export async function readBalances(account: IWalletAccountReadOnly, tokens: ListedToken[]): Promise { const reads = await Promise.allSettled(tokens.map((t) => (t.token ? account.getTokenBalance(t.token) : account.getBalance()))) return tokens.map((t, i) => { const r = reads[i]! return r.status === 'fulfilled' ? { ...t, amount: r.value } : { ...t, amount: null, error: String((r.reason as Error)?.message ?? r.reason) } }) } ``` * **Amounts are `bigint` in base units.** 1 USDC is `1_000_000n`, 1 ETH is `10n ** 18n`. * **A failed read is not zero.** One token contract can revert or one RPC can time out. Keep the row with its error and show “couldn’t load”, never `0`. A wrong zero makes users think their funds are gone. * **Chains without tokens.** Bitcoin and Zcash accounts throw on `getTokenBalance`. Read only `getBalance()` there. Solana, TON, Tron and NEAR accounts read tokens the same way as EVM: an SPL mint, a jetton master, a TRC-20 contract or a NEP-141 account. ## Format the amount [Section titled “Format the amount”](#format-the-amount) ```ts /** Exact decimal string from base units, cut to `maxFraction` digits (rounded down). No Number on the way. */ export function formatAmount(amount: bigint, decimals: number, maxFraction = 6): string { const negative = amount < 0n const abs = negative ? -amount : amount const base = 10n ** BigInt(decimals) const whole = (abs / base).toLocaleString('en-US') const fraction = (abs % base).toString().padStart(decimals, '0').slice(0, maxFraction).replace(/0+$/, '') return `${negative ? '-' : ''}${whole}${fraction ? '.' + fraction : ''}` } ``` Format from the `bigint` itself. `Number(amount)` loses precision above 2⁵³, and some Hermes builds return wrong values above 2⁶³, so an 18-decimal balance can show a wrong number. When you need a `Number` for math (a USD value, a chart), use `bigintToUnits(amount, decimals)` from `core`. It is exact for display and pricing. Never use it for an amount you sign. ## Every chain at once [Section titled “Every chain at once”](#every-chain-at-once) ```ts /** Balances on every chain at once. A chain whose RPC is down comes back with its error, not as zero. */ export async function portfolio(wallet: Wallet, chains: string[], userTokens: ListedToken[] = []) { const results = await Promise.allSettled(chains.map(async (chain) => readBalances(await wallet.getAccount(chain), tokenList(chain, userTokens)))) return chains.map((chain, i) => { const r = results[i]! return r.status === 'fulfilled' ? { chain, rows: r.value } : { chain, rows: [] as BalanceRow[], error: String((r.reason as Error)?.message ?? r.reason) } }) } ``` Read the chains in parallel. A chain whose RPC is down comes back with its rows marked as failed, and the rest of the screen still loads. **When to refresh:** * when the screen opens and when the app returns to the foreground; * after the user sends, once the receipt arrives. Until then, show your own pending row: a balance read right after a broadcast may not include it yet; * on pull to refresh. Avoid fast timers: each refresh is one call per token per chain, and public RPCs rate-limit quickly. ## Prices [Section titled “Prices”](#prices) The SDK has no price source. You bring a price API and call it through your server, so its key stays off the device. The function below takes it as a parameter. ```ts /** Your price API, behind your server: USD per whole token, keyed by `chain/token` (token = contract, or `native`). */ export type PriceSource = (keys: string[]) => Promise> export const priceKey = (t: ListedToken) => `${t.chain}/${t.token ? t.token.toLowerCase() : 'native'}` /** USD value per row and a total. A token with no price is `null` and stays out of the total. */ export async function valued(rows: BalanceRow[], prices: PriceSource) { const quotes = await prices(rows.filter((r) => r.amount !== null && r.amount > 0n).map(priceKey)) const out = rows.map((r) => { const price = quotes[priceKey(r)] const usd = r.amount !== null && price !== undefined ? bigintToUnits(r.amount, r.decimals) * price : null return { ...r, usd } }) return { rows: out, totalUsd: out.reduce((sum, r) => sum + (r.usd ?? 0), 0), unpriced: out.filter((r) => r.usd === null && (r.amount ?? 0n) > 0n).length } } ``` * **Look up by contract address**, never by symbol. Symbols collide, and fake tokens copy real ones. * **No price is `null`, not $0.** Show “no price” and leave the token out of the total. * **Price only what the user holds.** Zero balances never reach the API. * **Cache** prices for a minute or more and show when a price is stale. Providers to compare are listed on [Balances and prices](/build-a-wallet/balances-and-prices/#prices). # Custom signer > Keep the key outside the app — a hardware wallet, an MPC node, an HSM or a signing service — and use it as any other account. A signer is the only object that touches a private key. Accounts build transactions, talk to the network and hand the signer exactly the bytes to sign. Replace the signer, and the key can live anywhere: on a hardware wallet, in an MPC network, in an HSM, behind your own service. Everything else (addresses, balances, transfers, swaps, policies) works unchanged. Snippets come from [`examples/signer`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/signer), which our CI typechecks and tests against the same addresses and signatures a seed wallet produces. ```ts import { UserRejectedError, externalSigner, type SignOptions, type SignScheme, type Signer } from '@anywalletkit/core' import { Wallet, type Policies } from '@anywalletkit/kit' import { WalletManagerBtc } from '@anywalletkit/wallet-btc' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' ``` ## What a signer receives [Section titled “What a signer receives”](#what-a-signer-receives) `externalSigner({ curve, publicKey, sign })` wraps your callback. The SDK calls `sign` with the payload and options, and checks the length of what comes back. | `scheme` | Curve | Payload | Return | | ------------------- | --------- | --------------- | ------------------------------ | | `ecdsa-recoverable` | secp256k1 | 32-byte hash | 65 bytes `r‖s‖v`, `v` = 0 or 1 | | `ecdsa` | secp256k1 | 32-byte hash | 64 bytes `r‖s`, low-S | | `schnorr` | secp256k1 | 32-byte message | 64 bytes, BIP-340 (Taproot) | | `ed25519` | ed25519 | message bytes | 64 bytes | `options.context` says what the bytes are, so a device can show the right screen: | Context | Chain | Payload | | --------------------------------------------------- | ------- | ------------------------------------------------------------------------- | | `evm-tx`, `evm-191`, `evm-712` | EVM | Transaction hash, `personal_sign` hash, EIP-712 hash | | `btc-sighash`, `btc-taproot-keypath`, `btc-message` | Bitcoin | SegWit sighash, Taproot sighash (with `tweak`), BIP-137 hash | | `tron-tx`, `tron-message` | Tron | Transaction hash, TIP-191 hash | | `solana-message`, `solana-offchain` | Solana | Transaction message bytes, off-chain message | | `ton-external`, `ton-message` | TON | Signing-cell hash, message bytes (never 32 bytes) | | `ton-proof`, `ton-sign-data` | TON | TON Connect `ton_proof` digest, `signData` digest | | `near-tx`, `near-message`, `near-nep413` | NEAR | sha256 of the transaction, message bytes (never 32 bytes), NEP-413 digest | One ed25519 key behind several chains (an external signer registered for Solana, TON and NEAR) is guarded across them: TON and NEAR `sign()` refuse bytes that name the key as a Solana signer, and Solana `sign()` on an external key refuses 32-byte messages (a TON or NEAR transaction digest). A signer advertises the `schemes` it can produce. By default that is the curve’s main one: `ecdsa-recoverable` for secp256k1, `ed25519` for ed25519. Add `ecdsa` for Bitcoin SegWit inputs and `schnorr` for Taproot. Asking for a scheme the signer doesn’t list throws `SignerError` before your callback runs. ## A hardware wallet [Section titled “A hardware wallet”](#a-hardware-wallet) ```ts /** What a hardware wallet SDK typically gives you. */ export interface Device { publicKey(path: string): Promise // 33-byte compressed secp256k1 signDigest(path: string, digest: Uint8Array, prompt: string): Promise } const PROMPTS: Record = { 'evm-tx': 'Sign transaction', 'evm-191': 'Sign message', 'evm-712': 'Sign typed data', } export async function deviceSigner(device: Device, path = "m/44'/60'/0'/0/0"): Promise { return externalSigner({ curve: 'secp256k1', publicKey: await device.publicKey(path), sign: async (digest, options) => { // options.context says what the bytes are, so the device can show the right screen. const sig = await device.signDigest(path, digest, PROMPTS[options.context ?? ''] ?? 'Sign') if (sig === 'rejected') throw new UserRejectedError('Rejected on the device.') return sig // 65 bytes r‖s‖v (v = 0 or 1) }, }) } export async function walletOnDevice(device: Device, policies: Policies): Promise { const wallet = new Wallet(await deviceSigner(device), { policies }) // no seed in the app at all wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) return wallet } ``` * **Throw `UserRejectedError` when the user declines on the device.** Every module treats it as “cancelled”, not as a failure to retry. * **The app never sees a seed.** `new Wallet(signer)` takes the signer instead of a phrase. * **Policies still apply.** Fee caps, allowlists and your `confirm` step run before the device is asked, so the screen on the phone and the screen on the device agree. ## A signing service [Section titled “A signing service”](#a-signing-service) ```ts /** A signing service you run (MPC node, HSM, KMS proxy): it holds the key, the app holds a session token. */ export function remoteSigner(options: { url: string token: string curve: 'secp256k1' | 'ed25519' publicKey: Uint8Array schemes?: readonly SignScheme[] fetch?: typeof fetch }): Signer { return externalSigner({ curve: options.curve, publicKey: options.publicKey, ...(options.schemes ? { schemes: options.schemes } : {}), sign: async (payload: Uint8Array, opts: SignOptions & { scheme: SignScheme }) => { const res = await (options.fetch ?? fetch)(options.url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${options.token}` }, body: JSON.stringify({ payload: toHex(payload), scheme: opts.scheme, context: opts.context ?? null }), }) if (res.status === 403) throw new UserRejectedError('The signing service refused.') if (!res.ok) throw new Error(`Signing service: HTTP ${res.status}`) const { signature } = (await res.json()) as { signature: string } return fromHex(signature) // the SDK checks the length for the scheme }, }) } ``` This is the shape for an MPC node, an HSM, a cloud KMS behind your proxy, or a backend signer for bots and treasury accounts. The service returns a signature over the bytes it is given. A digest signer signs blindly The service receives a hash. It cannot tell a payment from a drain by looking at it. Protect it like the key it is: * **Check before the call.** Run [policies](/concepts/policies/) and your review on the client, so nothing unreviewed reaches `sign`. * **Authenticate every request.** Use a short-lived session token bound to the user and the device, never a static key shipped in the app. * **Limit on the server.** Rate limits, per-account caps, and an audit log of every signature. * **For bots, sign on the server.** Build and check the transaction where the key is, rather than accepting hashes from a client. ## Several chains, two curves [Section titled “Several chains, two curves”](#several-chains-two-curves) ```ts /** * One external signer = one key = one curve. Two curves → two wallets. * The secp256k1 key serves EVM and Bitcoin, but it is ONE key: the Bitcoin * address is that key's SegWit address, not the m/84' one a seed wallet shows. */ export function walletsOnService(service: { url: string; token: string; secpKey: Uint8Array; edKey: Uint8Array; fetch?: typeof fetch }) { const evmAndBitcoin = new Wallet( remoteSigner({ ...service, curve: 'secp256k1', publicKey: service.secpKey, schemes: ['ecdsa-recoverable', 'ecdsa'], // EVM and BTC messages use the first, SegWit inputs the second }), ) evmAndBitcoin.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) evmAndBitcoin.registerWallet('bip122', WalletManagerBtc, { client: { type: 'esplora-http', clientConfig: { url: 'https://mempool.space/api' } }, }) const solana = new Wallet(remoteSigner({ ...service, curve: 'ed25519', publicKey: service.edKey })) solana.registerWallet('solana', WalletManagerSolana, { provider: 'https://api.mainnet-beta.solana.com' }) return { evmAndBitcoin, solana } } ``` * **One external signer is one key.** It gives one account per chain (index 0), and it signs on one curve. EVM, Bitcoin, Tron and Zcash share secp256k1; Solana, TON and NEAR use ed25519. Cover both families with two signers and two wallets. * **The same secp256k1 key on EVM and Bitcoin gives that key’s addresses on both.** A seed wallet derives Bitcoin at `m/84'/0'/0'/0/0`, a different key, so the addresses differ. Tell users who move from a seed wallet. * **Several accounts** need several keys from the device or service: one signer per path, or a signer that derives (`kind: 'seed'` with `derive`). The [Keyring](/guides/keyring/) handles many accounts for seed-based wallets. ## Checklist [Section titled “Checklist”](#checklist) * **Prove the public key matches before the first send:** sign a test message and verify it with `account.verify`. * **Return signatures in the exact shape from the table.** The SDK rejects a wrong length or a recovery id other than 0 or 1. * **Implement `dispose`** if your signer holds a session or a connection; `wallet.dispose()` calls it. # Earn > Staking and lending through two shared interfaces — stake, read positions and rates, withdraw in two phases, supply and borrow. Earn modules come in two shapes, and every module of a shape has the same methods: | Interface | Modules | Flow | | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `StakingProtocol` | Lido (ETH), SSV Network, Tonstakers (TON), SPL stake pools (Jito on Solana), native SOL staking, native MON staking (Monad) | Stake → request a withdrawal → wait for the queue → claim | | `LendingProtocol` | Aave v3, Morpho, Mellow, Kamino | Supply / withdraw, and on markets that allow it, borrow / repay | This page uses Lido and Aave on Ethereum. The other modules work the same way, with different packages and chains (see [Packages](/getting-started/packages/#earn)). Snippets come from [`examples/earn`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/earn), which our CI typechecks and tests. ```sh npm install @anywalletkit/protocol-staking-lido-evm @anywalletkit/protocol-lending-aave-evm ``` ## Set up [Section titled “Set up”](#set-up) Register each module as a protocol, bound to the chain its account lives on: ```ts import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import LidoProtocolEvm from '@anywalletkit/protocol-staking-lido-evm' import { AaveProtocolEvm } from '@anywalletkit/protocol-lending-aave-evm' export function walletWithEarn(seedPhrase: string): Wallet { const wallet = new Wallet(seedPhrase) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) // A protocol is bound to the account of one chain. wallet.registerProtocol('lido', LidoProtocolEvm, { chain: 'eip155:1' }) wallet.registerProtocol('aave', AaveProtocolEvm, { chain: 'eip155:1', config: { market: 'core' } }) return wallet } ``` Every write has a `quote…` twin that returns the gas fee without signing anything. Show it before the user confirms. [Policies](/concepts/policies/) apply to every write a module makes, because modules sign through the same account. ## Staking [Section titled “Staking”](#staking) ### Stake [Section titled “Stake”](#stake) ```ts export async function stakeEth(wallet: Wallet, amount: bigint) { const lido = await wallet.getProtocol('lido') const { fee } = await lido.quoteStake({ amount }) // gas in wei, nothing is signed const { hash } = await lido.stake({ amount }) // ETH → stETH return { hash, fee } } ``` ### Position and rate [Section titled “Position and rate”](#position-and-rate) ```ts export async function stakingPosition(wallet: Wallet) { const lido = await wallet.getProtocol('lido') const position = await lido.getStakedBalance() return { staked: position.total, // the whole position in stETH, wei apr: await lido.getApr(), // percent: 2.7 = 2.7 % } } ``` ### Unstake: two phases [Section titled “Unstake: two phases”](#unstake-two-phases) Staked funds do not come back in one transaction. A withdrawal request joins the protocol’s queue, finalizes later (hours to days, depending on the protocol), and only then can be claimed. The interface names the queue instead of pretending `withdraw()` returns money: ```ts export async function unstake(wallet: Wallet, amount: bigint) { const lido = await wallet.getProtocol('lido') // Phase 1: join the withdrawal queue. await lido.requestWithdrawal({ amount }) } export async function claimIfReady(wallet: Wallet) { const lido = await wallet.getProtocol('lido') const queue = await lido.getWithdrawalRequests() // queue.pendingAmount: still in the queue; queue.claimableAmount: ready to claim if (queue.claimableIds.length === 0) return null // Phase 2: claim the finalized requests back as ETH. return lido.claimWithdrawals({ ids: queue.claimableIds }) } ``` Show `pendingAmount` as “unstaking” and `claimableAmount` with a **Claim** button. Check the queue when the screen opens; there is nothing to watch in between. ## Lending [Section titled “Lending”](#lending) ### Supply and withdraw [Section titled “Supply and withdraw”](#supply-and-withdraw) ```ts const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' export async function lendUsdc(wallet: Wallet, amount: bigint) { const aave = await wallet.getProtocol('aave') const apy = await aave.getSupplyApy(USDC) // 4.1 = 4.1 % const { fee } = await aave.quoteSupply({ token: USDC, amount }) const result = await aave.supply({ token: USDC, amount }) // permit when the token supports it, else an exact approval return { apy, fee, hash: result.hash } } export async function withdrawAll(wallet: Wallet) { const aave = await wallet.getProtocol('aave') return aave.withdraw({ token: USDC, amount: 'max' }) // principal + interest } ``` Tokens are contract addresses; `'native'` supplies the chain’s coin (ETH) through Aave’s gateway. Where the token supports EIP-2612, the module signs a permit instead of sending a separate approval transaction. `amount: 'max'` withdraws or repays everything, interest included. ### Position and health [Section titled “Position and health”](#position-and-health) ```ts export async function lendingPosition(wallet: Wallet) { const aave = await wallet.getProtocol('aave') const { supplies, borrows, account } = await aave.getPosition() return { supplies, // [{ token, symbol, amount, supplyApy, collateral }] borrows, // [{ token, symbol, amount, borrowApy }] healthFactor: account.healthFactor, // < 1 = liquidation; Infinity without debt } } ``` With debt, keep an eye on `healthFactor`: below `1.0` the position can be liquidated. Show it on every borrow and withdraw screen, and warn well before it gets close (for example under `1.5`). ## Native SOL staking [Section titled “Native SOL staking”](#native-sol-staking) `@anywalletkit/protocol-staking-native-solana` delegates SOL straight to validators through Solana’s Stake program: no pool, no token. Your app decides which validators it offers; the module has no default list and refuses to start without one. ```sh npm install @anywalletkit/protocol-staking-native-solana @anywalletkit/wallet-solana ``` ### Choose the validators [Section titled “Choose the validators”](#choose-the-validators) ```ts import { Wallet } from '@anywalletkit/kit' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' import { NativeStakingProtocolSolana } from '@anywalletkit/protocol-staking-native-solana' const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' export function walletWithNativeStaking(seedPhrase: string, rpc = 'https://api.mainnet-beta.solana.com'): Wallet { const wallet = new Wallet(seedPhrase) wallet.registerWallet('solana', WalletManagerSolana, { provider: rpc }) wallet.registerProtocol('sol-staking', NativeStakingProtocolSolana, { chain: SOLANA, config: { // Required: the vote accounts your wallet delegates to. Replace these with your own choice. validators: [ { voteAccount: 'he1iusunGwqrNtafDtLdhsUQDFvo13z9sUa36PauBtk', name: 'Validator A' }, { voteAccount: 'CatzoSMUkTRidT5DwBxAC2pEtnwMBTpkCepHkFgZDiqb', name: 'Validator B' }, ], maxCommission: 10, // refuse a validator whose commission is above 10 % (checked at every delegation) }, }) return wallet } ``` Each stake goes to a validator from this list (or any voting validator with `validators: 'any'`, when your users pick from the whole cluster). Before every delegation the module reads the validator live and refuses it when it is delinquent or its commission is above `maxCommission` (10 % unless you set it). `enrich` can attach an explorer’s APR or a logo to each validator; the on-chain checks do not depend on it. ### Stake [Section titled “Stake”](#stake-1) ```ts export async function stakeSol(wallet: Wallet, lamports: bigint) { const staking = await wallet.getProtocol('sol-staking') const validators = await staking.listValidators() // live commission, stake, APR; `eligible` and `refusals` const pick = validators.find((v) => v.eligible)! const quote = await staking.quoteStake({ amount: lamports, validator: pick.voteAccount }) // ≥ 1 SOL; nothing is signed // quote.total = amount + the stake account's rent (returned on withdraw); quote.fee = network fee const result = await staking.stake({ amount: lamports, validator: pick.voteAccount }) return { hash: result.hash, stakeAccount: result.stakeAccount, activeFrom: result.readyEpoch } } ``` A stake is one transaction: a new stake account at an address derived from the wallet (seed `stake:`), initialized with the wallet as its only authority and delegated. Only the wallet signs, and the position can be rebuilt from the wallet address alone. The minimum is 1 SOL; the account also holds about 0.0017 SOL of rent, which comes back on withdrawal. The stake starts earning from the next epoch boundary (an epoch is about two days). ### Position [Section titled “Position”](#position) ```ts export async function nativePosition(wallet: Wallet) { const staking = await wallet.getProtocol('sol-staking') const accounts = await staking.getStakeAccounts() // one per delegation: status, validator, amounts, readyAt const { balance, total } = await staking.getStakedBalance() // delegated lamports; everything in stake accounts return { accounts, balance, total, apr: await staking.getApr() } } ``` `status` is computed from the StakeHistory sysvar the way the Stake program computes it: `activating`, `active`, `deactivating` or `inactive`, with `readyEpoch` / `readyAt` while it changes. Rewards compound into each account at every epoch boundary; `getRewards()` lists them per epoch. ### Unstake and claim [Section titled “Unstake and claim”](#unstake-and-claim) ```ts export async function unstakeSol(wallet: Wallet, lamports: bigint) { const staking = await wallet.getProtocol('sol-staking') // Whole accounts are deactivated; a partial amount is split into a new account first. const { steps, readyAt } = await staking.requestWithdrawal({ amount: lamports }) return { steps, readyAt } // the SOL can be claimed after the epoch boundary (~2 days) } export async function claimSol(wallet: Wallet) { const staking = await wallet.getProtocol('sol-staking') const { claimableIds } = await staking.getWithdrawalRequests() if (claimableIds.length === 0) return null return staking.claimWithdrawals({ ids: claimableIds }) // every lamport, rent included, back to the wallet } ``` Unstaking deactivates whole accounts and, for a partial amount, splits it off one account into a new one first; both parts must keep the 1 SOL minimum. The SOL can be withdrawn after the cool-down (the next epoch boundary). A stake deactivated in the epoch it was made is free at once, and `delegate({ account })` in the same epoch undoes an unstake. `merge` joins two compatible accounts on the same validator. ## Native MON staking [Section titled “Native MON staking”](#native-mon-staking) `@anywalletkit/protocol-staking-native-monad` delegates MON straight to Monad validators through the staking precompile (`0x…1000`): no pool, no token. Your app decides which validator ids it offers; the module has no default list and refuses to start without one. ```sh npm install @anywalletkit/protocol-staking-native-monad @anywalletkit/wallet-evm ``` ### Choose the validators [Section titled “Choose the validators”](#choose-the-validators-1) ```ts import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { NativeStakingProtocolMonad } from '@anywalletkit/protocol-staking-native-monad' export function walletWithMonadStaking(seedPhrase: string, rpc = 'https://rpc.monad.xyz'): Wallet { const wallet = new Wallet(seedPhrase) wallet.registerWallet('eip155:143', WalletManagerEvm, { provider: rpc, chainId: 143 }) wallet.registerProtocol('mon-staking', NativeStakingProtocolMonad, { chain: 'eip155:143', config: { // Required: the validator ids your wallet delegates to. Replace them with your own choice. validators: [{ id: 97n, name: 'Validator A' }, { id: 21n, name: 'Validator B' }], defaultValidator: 97n, maxCommission: 10, // refuse a validator whose commission is above 10 % (checked at every delegation) }, }) return wallet } ``` `validators: 'any'` lets users pick from the whole active set (`listValidators()`). Before every delegation the module reads the validator live and refuses it when it does not exist, is not in the consensus set in good standing, or charges more than `maxCommission` (10 % unless you set it; many validators charge 10–15 %). `enrich` can attach an explorer’s APR or a logo. ### Stake and position [Section titled “Stake and position”](#stake-and-position) ```ts export async function stakeMon(wallet: Wallet, wei: bigint) { const staking = await wallet.getProtocol('mon-staking') const validators = await staking.listValidators() // live commission, standing, APR; `eligible` and `refusals` const quote = await staking.quoteStake({ amount: wei }) // simulated, nothing is signed; keeps the 10 MON reserve const result = await staking.stake({ amount: wei }) // delegate(97) with the amount as value return { validators, fee: quote.fee, hash: result.hash, activeFrom: result.activatesAtEpoch } // epoch n+1, or n+2 after the boundary block } export async function monadPosition(wallet: Wallet) { const staking = await wallet.getProtocol('mon-staking') const positions = await staking.listPositions() // per validator: active, activating, unclaimed rewards const { balance, total, unclaimedRewards } = await staking.getStakedBalance() return { positions, balance, total, unclaimedRewards, apr: await staking.getApr() } } ``` A stake becomes active at the next epoch, or the one after when it is sent after the epoch’s boundary block (`getEpoch().activatesAtEpoch`; epochs are about 4 hours). Monad keeps a 10 MON reserve on every account: a stake that would leave less is refused (`getMaxStakeable()`). Rewards accrue separately in `unclaimedRewards`; claim them or compound them into the stake. ### Unstake and claim [Section titled “Unstake and claim”](#unstake-and-claim-1) ```ts export async function unstakeMon(wallet: Wallet, wei: bigint) { const staking = await wallet.getProtocol('mon-staking') // Only active stake can be undelegated; the module picks a free withdrawal slot (0..255). const { id, withdrawableAtEpoch } = await staking.requestWithdrawal({ amount: wei, validator: 97n }) return { id, withdrawableAtEpoch } // claimable one epoch after the stake deactivates } export async function claimMon(wallet: Wallet) { const staking = await wallet.getProtocol('mon-staking') const { claimableIds } = await staking.getWithdrawalRequests({ validator: 97n }) if (claimableIds.length > 0) await staking.claimWithdrawals({ ids: claimableIds }) // one withdraw per request const position = (await staking.listPositions()).find((p) => p.validatorId === 97n) if (position && position.unclaimedRewards > 0n) await staking.claimRewards({ validator: 97n }) // or compound({ validator }) return { claimed: claimableIds.length } } ``` Only active stake can be undelegated. Each unstake takes a withdrawal slot (0–255 per validator) and can be withdrawn one epoch after the stake deactivates. The precompile withdraws one request per transaction, so `claimWithdrawals` may send several. After a full exit the precompile no longer lists the validator among the wallet’s delegations: keep the `id` that `requestWithdrawal` returns, or pass the validator in `validators`, so the withdrawal is found again. ## Module-specific extras [Section titled “Module-specific extras”](#module-specific-extras) The shared interfaces cover what every module can do. Modules add their own methods on top: Lido wraps stETH into wstETH, Aave toggles collateral and lists reserves with their rates and caps, Morpho lists vaults, SSV reports APR history. They are listed in each package’s API reference. # Browser extension > A Chromium (MV3) wallet extension on extension-kit — the encrypted vault, passkey unlock, auto-lock, the dApp request router, approvals and signing checks, and publishing to the Chrome Web Store. `extension-kit` is the security core of a non-custodial Chromium (MV3) wallet extension. It is a library, not an extension. You build the popup, the approval window, the injected provider and the release pipeline. The kit gives you the parts that must be right: * **Vault:** every secret encrypted at rest in `chrome.storage.local`. A password opens it (scrypt), and a passkey can open it too (WebAuthn PRF). * **Session:** the unlocked key lives only in `chrome.storage.session`, with an expiry that is checked on every read and an alarm that locks the wallet. * **Relay and router:** page → content script → service worker. The worker trusts the origin the browser recorded for the port, never the one the page claims. Per-site permissions decide what a site may call. * **Approval queue:** one approval window at a time, at most three waiting requests per site, one waiting connect. * **Signing checks:** the signing address must be connected, typed data must be for the connected chain, `eth_sign` is never offered, contract deployments and site-made EIP-7702 delegations are refused. Nonce and fees come from your RPC. The kit has no UI and makes no network requests of its own. It doesn’t need `@types/chrome`: its adapters take plain objects shaped like `chrome.storage` and `chrome.alarms`, so the same code runs in tests with in-memory fakes. Snippets come from [`examples/extension-kit`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/extension-kit), which our CI typechecks and tests. ```sh npm install @anywalletkit/extension-kit @anywalletkit/keyring @anywalletkit/wallet-evm ``` ```ts import { ApprovalQueue, NetworksTable, PAGE_TARGET, PORT_NAME, PermissionStore, PhishingDetector, RequestRouter, Session, Vault, checkSenderOrigin, chromeAlarms, chromeJsonStorage, chromeKeyringStorage, chromeLocalVaultStorage, chromeSessionStorage, createSignHandler, isRelayRequest, isRelayToContent, pageRequestFrom, stampOrigin, toRelayError, vaultSecretStorage, type AlarmsLike, type ApprovalRequest, type PageMessageEvent, type PageWindow, type ApprovalWindow, type EvmNetwork, type EvmSigner, type PermissionsState, type PortSender, type RelayResponse, type RelayToContent, type StorageAreaLike, } from '@anywalletkit/extension-kit' import { Keyring } from '@anywalletkit/keyring' import { WalletManagerEvm, type WalletAccountEvm } from '@anywalletkit/wallet-evm' ``` ## Open the wallet [Section titled “Open the wallet”](#open-the-wallet) The service worker can be stopped at any moment, so it rebuilds everything from storage on start-up. ```ts /** Service worker start-up. The worker can be stopped at any time; everything lives in storage. */ export async function openWallet(chrome: ChromeLike) { const vault = new Vault({ storage: chromeLocalVaultStorage(chrome.storage.local) }) const session = new Session({ storage: chromeSessionStorage(chrome.storage.session), // memory-only, cleared when the browser quits alarms: chromeAlarms(chrome.alarms), // locks on time even if the worker was asleep }) const keyring = await Keyring.load({ storage: chromeKeyringStorage(chrome.storage.local), // names, addresses: no secrets secrets: vaultSecretStorage(vault, session), // every secret read goes through the unlocked vault chains: { evm: { Manager: WalletManagerEvm, config: {} } }, }) return { vault, session, keyring } } export type WalletParts = Awaited> ``` The [keyring](/guides/keyring/) keeps its account list in `chrome.storage.local` as plain JSON: names and addresses, no secrets. Every secret read goes through the vault and needs an unlocked session; a locked session throws `VaultError` with the code `locked`. ## Create the wallet [Section titled “Create the wallet”](#create-the-wallet) ```ts /** First run: create the vault, unlock the session, import the phrase. */ export async function createWallet({ vault, session, keyring }: WalletParts, password: string, phrase: string) { const dek = await vault.create(password) // refuses if a vault exists or the password is under 8 characters await session.unlock(dek) // keeps the key in session storage and zeroizes `dek` return keyring.importMnemonic(phrase) // sealed in the vault, never in plain storage } ``` * `vault.create` refuses when a vault already exists. Resetting is a separate, explicit `vault.destroy()`; build it as its own screen with its own warning. * Passwords shorter than 8 characters are refused by the vault itself. * `session.unlock` zeroizes the key you pass in. ## Unlock: password and passkey [Section titled “Unlock: password and passkey”](#unlock-password-and-passkey) ```ts export async function unlockWithPassword({ vault, session }: WalletParts, password: string) { await session.unlock(await vault.unlock(password)) // VaultError 'wrong-password' on a bad password } /** * Passkey unlock. Your extension page runs navigator.credentials.get() with the WebAuthn PRF * extension and passes the 32-byte PRF output here; the vault zeroizes it after use. * The password stays the recovery path. */ export async function addPasskey({ vault }: WalletParts, password: string, passkey: { id: string; prf: Uint8Array; label: string }) { const dek = await vault.unlock(password) // the user confirms with the password once try { await vault.addPasskey(dek, passkey) } finally { dek.fill(0) } } export async function unlockWithPasskey({ vault, session }: WalletParts, id: string, prf: Uint8Array) { await session.unlock(await vault.unlockWithPasskey(id, prf)) } ``` | `VaultError` code | When | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `wrong-password` | Wrong password, wrong passkey, or an envelope that was edited on disk. AES-GCM can’t tell these apart. | | `locked` | A secret was read after the session locked, or `setSecret` got a key that does not open the vault (one `rotateVaultKey` retired) | | `exists` | `create` while a vault exists | | `weak-kdf` | The stored KDF settings are below the floor this build accepts | | `unsupported-envelope` | An envelope of an unknown shape, or one whose KDF cost or size is above `VAULT_LIMITS` (refused before any KDF runs) | | `invalid-input` | Empty or short password, bad key length, bad id | A passkey is a second key to the same vault, not a replacement for the password. Keep the password as the recovery path, and let users remove a passkey (`vault.removePasskey`). `changePassword` re-wraps the same vault key, and removing a passkey does not take back a key it already opened. After a suspected compromise (a copied browser profile, a leaked session, a lost passkey), offer **re-key the vault**: `vault.rotateVaultKey(password)` makes a new vault key, re-seals every secret under it and writes the vault once, so a failure leaves the old one as it was. Hand the key it returns to `session.unlock`, which replaces the old one. Passkeys are dropped, because they wrap the old key; ask the user to add them again. The old key opens nothing in the new vault, and `setSecret` refuses it. ```ts await session.unlock(await vault.rotateVaultKey(password)) ``` The relay drops a request whose `method` is not a method name (letters, digits and `_`, at most 128 characters, `isRelayMethod`). Reject such a request in your injected provider with `-32600`, so the page gets an answer. Auto-lock defaults to 15 minutes (`SESSION_DEFAULT_AUTOLOCK_MS`). Change it with `session.setAutoLockMs(ms)`, and call `session.touch()` when the user is active in your UI. ## The request router [Section titled “The request router”](#the-request-router) Everything a site can ask for goes through `RequestRouter`. You give it your RPC transport, your approval window, and the signer for an address. ```ts /** Everything a dApp can ask for goes through this router. */ export function createRouter(wallet: WalletParts, options: WorkerOptions) { const { keyring, session } = wallet const local = options.chrome.storage.local const phishing = new PhishingDetector(options.phishing) // A site on the shipped phishing list is refused (4100) before any window opens; lookalikes and // punycode only add warnings to the sheet. const approvals = new ApprovalQueue(options.window, undefined, { annotate: (origin) => phishing.check(origin).warnings, refuse: (origin) => phishing.refusal(origin) }) const ports = new Map void>>() // Signing: the account lives only for one signature, then its key is wiped. const signerFor = async (address: string) => { const meta = keyring.listAccounts().find((a) => a.addresses.evm?.toLowerCase() === address.toLowerCase()) if (!meta) throw new Error('This address is not in the wallet.') const handle = await keyring.getAccount(meta.id, 'evm') const signer: EvmSigner = { signMessage: (m) => handle.account.sign(m), signTypedData: (t) => handle.account.signTypedData(t), signTransaction: (tx) => handle.account.signTransaction(tx), } return { signer, dispose: () => handle.dispose() } } const router = new RequestRouter({ permissions: new PermissionStore(chromeJsonStorage(local, 'my-wallet.permissions')), networks: new NetworksTable(chromeJsonStorage(local, 'my-wallet.networks')), approvals, refuse: (origin) => phishing.refusal(origin), // reads and an earlier permission included // A locked wallet shows no accounts; eth_requestAccounts then opens the window to unlock. accounts: async () => { const address = keyring.activeAccount?.addresses.evm return (await session.isUnlocked()) && address ? [address] : [] }, rpc: options.rpc, // reads go to your RPC, never to the site's emit: (origin, event, data) => { for (const post of ports.get(origin) ?? []) post({ kind: 'event', chain: 'eip155', event, data }) }, sign: createSignHandler({ approvals, rpc: options.rpc, signerFor }), }) // Locking the wallet answers every waiting request with "declined". session.onLock(() => approvals.declineAll()) return { router, approvals, ports } } export type WorkerParts = ReturnType ``` What the router does with a request: | Request | What happens | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `eth_chainId`, `eth_accounts` | Answered without a prompt. A site that is not connected, or a locked wallet, gets `[]` | | `eth_requestAccounts` | Opens the approval window (your window asks to unlock first when the wallet is locked) and stores the permission for that origin | | `wallet_switchEthereumChain`, `wallet_addEthereumChain` | Through the approval window; only catalog chains, https only, `eth_chainId` verified | | Read-only RPC (`READ_METHODS`) | Sent to your RPC for the site’s chain, never to an endpoint the site chose | | `personal_sign`, `eth_signTypedData_v4`, `eth_sendTransaction` | Checked, reviewed in the approval window, signed, and a transaction is broadcast through your RPC | | Anything else | Error `4200` (unsupported method) | Errors come back as EIP-1193 codes: `4001` the user declined, `4100` unauthorized, `4900` disconnected, `-32002` a connect request is already waiting, `-32005` too many waiting requests. A node’s own error (a revert: code `3` with its data) passes through without the RPC URL. Anything else reaches the page as `-32603 Internal error`: pass `log` to `toRelayError` to keep the details in the extension’s console, since transport errors name the RPC URL and often the provider key in it. The signer is built for one signature and its key is wiped right after (`handle.dispose()`). Pass a `preview` function to `createSignHandler` to show a simulation in the approval window; [Transaction preview](/guides/tx-preview/) builds one. ## The approval window [Section titled “The approval window”](#the-approval-window) The queue shows one request at a time. Your approval page reads the request, shows it, and answers with `approvals.resolve(id, value)` or `approvals.decline(id)`. Closing the window declines the request. ```ts /** One popup per request. Your approval page reads the request by id and answers through the queue. */ export function popupApprovalWindow(windows: WindowsLike, pageUrl: string): ApprovalWindow & { shown: Map } { const byWindow = new Map() const shown = new Map() const closed: Array<(requestId: string) => void> = [] windows.onRemoved.addListener((windowId) => { const requestId = byWindow.get(windowId) if (requestId === undefined) return byWindow.delete(windowId) shown.delete(requestId) for (const handler of closed) handler(requestId) // closing the popup declines the request }) return { shown, async open(request) { shown.set(request.id, request) const win = await windows.create({ url: `${pageUrl}?request=${encodeURIComponent(request.id)}`, type: 'popup', width: 380, height: 640 }) if (win.id !== undefined) byWindow.set(win.id, request.id) }, async close(requestId) { shown.delete(requestId) for (const [windowId, id] of byWindow) if (id === requestId) { byWindow.delete(windowId); await windows.remove(windowId) } }, onClosed(handler) { closed.push(handler) }, } } ``` Show on every approval: * **The origin from the request** (`request.origin`), which the browser recorded. Never show a name or URL the page sent about itself. * **`request.originWarnings`**: lookalike and punycode warnings from your `annotate` hook. The router snippet wires `PhishingDetector`, which reads a list in the `eth-phishing-detect` format. Ship the list inside your build; the kit never downloads it. A site ON the list never reaches the sheet: `refuse: (origin) => phishing.refusal(origin)` on the queue and the router answers it with 4100 before any window opens. * **What will happen**: for transactions, the prepared fee (`payload.prepared.maxFee`) and your preview. The nonce on the sheet is a guess; the one signed is read after the approval. When the network’s fee outgrew the shown cap while the sheet was open, the same transaction comes back with `payload.repriced: true` and the new numbers: say the fee went up. ## The relay [Section titled “The relay”](#the-relay) The worker side checks the origin of every message against the port’s sender: ```ts /** chrome.runtime.onConnect.addListener((port) => onPort(worker, port)) */ export function onPort({ router, ports }: WorkerParts, port: PortLike) { if (port.name !== PORT_NAME) return let origin: string | null = null const post = (message: RelayToContent) => port.postMessage(message) port.onMessage.addListener(async (message) => { if (!isRelayRequest(message)) return // The origin the browser recorded for this port, not the one the page claims. const trusted = checkSenderOrigin(port.sender, message.origin) if (!trusted) return // forged: dropped, never answered if (origin === null) { origin = trusted if (!ports.has(origin)) ports.set(origin, new Set()) ports.get(origin)!.add(post) } let response: RelayResponse try { response = { kind: 'response', id: message.id, result: (await router.handle(trusted, message.chain, message.method, message.params)) ?? null } } catch (e) { // The page gets the EIP-1193 code and a URL-free message; anything internal is // "Internal error" there, and the details stay in the extension's own log. response = { kind: 'response', id: message.id, error: toRelayError(e, { log: (err) => console.error('[wallet] request failed', err) }) } } post(response) }) port.onDisconnect.addListener(() => { if (origin) ports.get(origin)?.delete(post) }) } ``` The content script only stamps the page’s origin and forwards messages both ways: ```ts /** Content script: stamps the page's origin and forwards both ways. It holds no wallet logic. */ export function startContentScript(env: ContentLike) { const port = env.connect(PORT_NAME) env.addPageListener((event) => { // Only what the page itself posted: an iframe's parent.postMessage is not the site speaking. const request = pageRequestFrom(event, env.page) if (request) port.postMessage(stampOrigin(request, env.page.location.origin)) }) port.onMessage.addListener((message) => { if (!isRelayToContent(message)) return if (message.kind === 'response') { env.postToPage({ target: PAGE_TARGET.toPage, id: message.id, ...('result' in message ? { result: message.result } : {}), ...(message.error ? { error: message.error } : {}) }) } else { env.postToPage({ target: PAGE_TARGET.toPage, chain: message.chain, event: message.event, data: message.data }) } }) } ``` Your injected provider (the `window.ethereum` object, EIP-6963 announcements) posts `PageRequest` messages with `target: PAGE_TARGET.toContent` and listens for responses and events. Keep it small: it runs in the page’s world, where the page can see and change it. ## What you still build [Section titled “What you still build”](#what-you-still-build) * The popup, the onboarding and unlock screens, and the approval page. * The injected providers for the chains you support. * The extension’s content security policy: `script-src 'self'`, no remote scripts or images. * An allowlist of hosts your worker’s `fetch` may reach. * A reproducible release build, and the store listing below. ## Publishing to the Chrome Web Store [Section titled “Publishing to the Chrome Web Store”](#publishing-to-the-chrome-web-store) The SDK ships no store listing. The store account is yours, and whoever controls it can push an update to every user of your wallet. Treat it like a signing key. **The account** * Use a dedicated Google account for publishing, not someone’s personal mail. * Turn on 2-Step Verification with a passkey or a hardware security key, not SMS. The store requires 2-Step Verification before anyone can publish or update an item ([policy](https://developer.chrome.com/docs/webstore/program-policies/two-step-verification)). * Working as a team: invite members to the publisher with the right roles instead of sharing one login ([publisher members](https://developer.chrome.com/docs/webstore/group-publishers)). * The developer email can’t be changed after the account is created ([registration](https://developer.chrome.com/docs/webstore/register)). **Identity and disclosures** * Every publisher declares whether it is a trader. A trader’s legal name, phone number and address are shown on the listing ([trader FAQ](https://developer.chrome.com/docs/webstore/program-policies/trader-verification-faq)). * Verify your publisher identity. The “Established Publisher” badge comes later, on its own, after a track record ([discovery](https://developer.chrome.com/docs/webstore/discovery)). * Fill in the Privacy tab: a single purpose, a reason for every permission, the remote code declaration, the data use disclosures, and a privacy policy URL ([privacy fields](https://developer.chrome.com/docs/webstore/cws-dashboard-privacy)). **The package** * Ask for as few permissions as you can. Broad host permissions (``, `https://*/*`) and powerful ones (`tabs`, `cookies`, `webRequest`) get a longer review ([review process](https://developer.chrome.com/docs/webstore/review-process)). * Bundle all code. MV3 doesn’t allow code loaded from outside the extension’s own files ([remote hosted code](https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code)). Submit readable code: obfuscation is not allowed. **Releasing** * Publish by hand from the dashboard. Don’t keep a publish token (an OAuth refresh token for the [Web Store API](https://developer.chrome.com/docs/webstore/using-api)) in CI: anyone who gets it can ship an update to all your users. * Use deferred publishing to choose when a reviewed version goes live; you have 30 days. Once the item has more than 10,000 weekly users, roll out to a percentage of users first ([updates](https://developer.chrome.com/docs/webstore/update)). * Plan for review time. Most reviews take a few days, some take a few weeks. After three weeks, contact developer support. Check the current store policy yourself Store rules, review times and dashboard fields change. We checked the facts above against Chrome’s documentation on 2026-10-24. Read the current [program policies](https://developer.chrome.com/docs/webstore/program-policies) before you submit. # History > One activity feed across every chain, with titles the modules write, paging, refresh, pending rows from an intent journal, and spam hidden. `wallet.history()` returns one activity list for the account across every chain you registered a source for, newest first. Each row has a title a person can read (“Received USDC”, “Staked ETH”, “Swapped USDC → ETH”), the amounts that left and arrived, a fee, and an explorer link. Three layers make it: 1. **Sources** read the chain: one per chain id, from an explorer or the RPC. 2. **Recognizers** turn a transaction into a row. Every registered protocol module recognizes its own transactions; a generic fallback covers sends, receives, approvals and unknown calls. 3. **The intent journal** records what your app meant when it broadcast, so a swap shows as one pending row before any explorer has it. Snippets come from [`examples/history`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/history), which our CI typechecks and tests. ```sh npm install @anywalletkit/kit @anywalletkit/history-evm ``` ```ts import { Wallet, createMemoryIntentJournal, type FeedItem, type HistoryFeedPage, type Intent, type IntentJournal } from '@anywalletkit/kit' import type { ActivityAsset, ChainTx } from '@anywalletkit/core' import { createEvmHistory } from '@anywalletkit/history-evm' import LidoProtocolEvm from '@anywalletkit/protocol-staking-lido-evm' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerSolana, createSolanaHistory } from '@anywalletkit/wallet-solana' import { WalletManagerTon, createTonHistory } from '@anywalletkit/wallet-ton' import { WalletManagerTron, createTronHistory } from '@anywalletkit/wallet-tron' ``` ## Set up [Section titled “Set up”](#set-up) ```ts /** Keys for explorer APIs: on the client, point the URLs at your proxy instead. */ export interface HistoryKeys { etherscan?: string; blockscoutPro?: string; tonapi?: string; trongrid?: string } export function walletWithHistory(seedPhrase: string, keys: HistoryKeys = {}): Wallet { const wallet = new Wallet(seedPhrase, { activity: { // A label for contract calls no module recognizes. known: { 'eip155:1': { '0x66a9893cc07d91d95644aedd05d03f95e1dba8af': 'Uniswap Universal Router' } }, onError: (report) => console.warn('history', report.stage, report.chain ?? report.module, report.error), }, }) // The accounts: history reads account `index` on every chain that has a source. // One registration per EVM network, each with its own RPC (see Chain ids: one RPC per EVM network). wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) wallet.registerWallet('eip155:8453', WalletManagerEvm, { provider: 'https://base-rpc.publicnode.com', chainId: 8453 }) wallet.registerWallet('solana', WalletManagerSolana, { provider: 'https://api.mainnet-beta.solana.com' }) wallet.registerWallet('ton', WalletManagerTon, { tonClient: { url: 'https://toncenter.com/api/v2/jsonRPC' } }) wallet.registerWallet('tron', WalletManagerTron, { provider: 'https://api.trongrid.io' }) // One source per chain. const evmKeys = { ...(keys.etherscan ? { etherscanApiKey: keys.etherscan } : {}), ...(keys.blockscoutPro ? { blockscoutProApiKey: keys.blockscoutPro } : {}) } wallet.registerHistorySource(createEvmHistory({ chainId: 1, ...evmKeys })) wallet.registerHistorySource(createEvmHistory({ chainId: 8453, ...evmKeys })) wallet.registerHistorySource(createSolanaHistory({ rpcUrl: 'https://api.mainnet-beta.solana.com' })) wallet.registerHistorySource(createTonHistory(keys.tonapi ? { apiKey: keys.tonapi } : {})) wallet.registerHistorySource(createTronHistory(keys.trongrid ? { apiKey: keys.trongrid } : {})) // Every registered protocol labels its own transactions: a Lido stake reads "Staked ETH". wallet.registerProtocol('lido', LidoProtocolEvm, { chain: 'eip155:1' }) return wallet } ``` | Chain | Source | Keys | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | EVM (one per chain id) | `createEvmHistory` from `history-evm`: Etherscan v2, Blockscout PRO, Alchemy; keyless Blockscout and Routescan as fallbacks | Etherscan v2 needs a key; a free Etherscan key plus a free Blockscout PRO key cover most networks | | Solana | `createSolanaHistory`: plain RPC, no indexer | Your RPC; public endpoints rate-limit fast | | TON | `createTonHistory`: tonapi events, toncenter v3 fallback | Keyless tonapi is about 1 request/s | | Tron | `createTronHistory`: TronGrid | Keyless is 1 request/s | | Bitcoin, NEAR, Zcash | `createBtcHistory`, `createNearHistory`, `createZcashHistory` from the chain packages | See [Build a wallet → History](/build-a-wallet/history/) | Explorer keys are secrets. On a mobile app or an extension, point the source at your own proxy (`etherscanUrl` and similar options) and keep the key on the server. ## Pages, refresh and errors [Section titled “Pages, refresh and errors”](#pages-refresh-and-errors) ```ts export async function firstPage(wallet: Wallet): Promise { const page = await wallet.history({ limit: 20 }) // account 0, every chain with a source // A source that failed is listed in page.errors and retried on the next call; the rest still loads. return page } export async function nextPage(wallet: Wallet, cursor: string): Promise { return wallet.history({ limit: 20, cursor }) } export async function refresh(wallet: Wallet, lastSeen: number): Promise { const { items } = await wallet.history({ since: lastSeen }) // only what is newer return items } ``` * **`cursor`** packs every source’s position into one string. Rows stay in time order across chains: a slow chain’s older rows are never shown after newer ones. * **`since`** (unix seconds) returns only newer rows: use it for pull-to-refresh. * **`errors`** lists what failed in this call: a source that is down, a recognizer that threw. The other chains still load, and the failed source keeps its place for the next call. Show a quiet “some activity may be missing” rather than an error screen. * **Spam** is hidden by default: zero-value transfers and airdrops the source flags. Pass `includeSpam: true` for a “hidden” tab. ## Pending rows: the intent journal [Section titled “Pending rows: the intent journal”](#pending-rows-the-intent-journal) ```ts /** Call right after broadcasting a swap: the row shows at once, with what the user meant. */ export async function recordSwap(journal: IntentJournal, swap: { id: string; from: string; sourceHash: string; out: ActivityAsset; in: ActivityAsset }): Promise { const intent: Intent = { id: swap.id, kind: 'swap', createdAt: Math.floor(Date.now() / 1000), chain: 'eip155:1', address: swap.from, hashes: [{ chain: 'eip155:1', hash: swap.sourceHash }], // source leg first expect: { assetsOut: [swap.out], assetsIn: [swap.in] }, subtitle: 'LI.FI', status: 'pending', } await journal.put(intent) } export function attachJournal(wallet: Wallet): IntentJournal { const journal = createMemoryIntentJournal() // or your own { list, put, remove } over SQLite / MMKV wallet.setIntentJournal(journal) return journal } ``` The chain alone can’t say “swapped USDC → ETH via LI.FI”: it shows a USDC transfer to a router. It can’t pair a bridge’s two legs on two chains, and nothing is there until the explorer indexes it. Record an intent right after broadcast: * **Source leg first** in `hashes`. When the swap’s status reports the destination hash, `put` the intent again with it appended. * **`expect`** holds the quote amounts, shown while the row is pending. * **An intent that never gets its row** is marked failed after `pendingTtl` (24 hours by default). * **Persist the journal** (SQLite, MMKV, a file): a pending swap must survive an app restart. Earn modules and approvals don’t need intents: they recognize their own transactions. ## Rendering a row [Section titled “Rendering a row”](#rendering-a-row) ```ts /** One row of the activity list. Amounts are base units: format with the asset's decimals. */ export function row(item: FeedItem): { title: string; subtitle: string; amount: string; pending: boolean } { const fmt = (a: ActivityAsset, sign: string) => `${sign}${units(a.amount, a.decimals ?? 0)} ${a.symbol ?? ''}`.trim() const amount = [...item.assetsOut.map((a) => fmt(a, '−')), ...item.assetsIn.map((a) => fmt(a, '+'))].join(' ') return { title: item.title, subtitle: item.subtitle ?? '', amount, pending: item.status === 'pending' } } function units(amount: bigint, decimals: number): string { const s = amount.toString().padStart(decimals + 1, '0') const whole = s.slice(0, s.length - decimals) const frac = s.slice(s.length - decimals).replace(/0+$/, '') return frac ? `${whole}.${frac}` : whole } ``` | Field | Meaning | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `send`, `receive`, `self`, `swap`, `bridge`, `stake`, `unstake`, `claim`, `supply`, `withdraw`, `borrow`, `repay`, `approve`, `revoke`, `wrap`, `unwrap`, `contract`, `unknown` | | `title`, `subtitle` | Readable title, and the venue or counterparty | | `assetsOut`, `assetsIn` | What left and what arrived, in base units with `symbol` and `decimals` | | `status` | `confirmed`, `failed`, `pending` | | `counterparty`, `related` | The other party; the other leg of a cross-chain operation | | `approval` | On `approve` / `revoke` rows: token, spender, amount (`null` = unlimited) | | `fee`, `explorerUrl` | Network fee in the native coin; a link for the details | **Address poisoning:** scammers send tiny transfers from an address that looks like one the user uses, hoping the user copies it from history. Never offer “copy address” on an incoming row without a warning, and don’t use history as the address book. ## One transaction [Section titled “One transaction”](#one-transaction) ```ts /** A detail screen for one transaction, through the same recognizers as the list. */ export async function detail(wallet: Wallet, tx: ChainTx): Promise { return wallet.describeTransaction(tx) } ``` `describeTransaction` runs one transaction through the same recognizers and journal: use it for a detail screen, or for a transaction your app just got from a push notification. # Keyring > Many accounts from one seed, imported keys, secure storage on the device, restore with discovery, and signing that holds the key only for one call. `keyring` is the account list of a real wallet: several seeds and imported keys, accounts 0, 1, 2… on every chain, names, hidden accounts, the active one. It is also where the key handling rules live. * **Secrets go only to your secure storage**, one entry per seed or key. * **Everything else is public metadata**: names, addresses, flags, a fingerprint of the public key. Plain storage is fine for it. * **The SDK holds no secret between calls.** Signing reads the secret, builds the account, signs, and wipes the key when the callback ends. Snippets come from [`examples/keyring`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/keyring), which our CI typechecks and tests. ```sh npm install @anywalletkit/keyring @anywalletkit/kit @anywalletkit/wallet-evm ``` ```ts import { Keyring, type AccountMeta, type ChainKey, type KeyringState, type KeyringStorage, type SecretStorage } from '@anywalletkit/keyring' import { applyPolicies, type Policies } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' import { WalletManagerTon } from '@anywalletkit/wallet-ton' import { WalletManagerTron } from '@anywalletkit/wallet-tron' ``` ## Storage [Section titled “Storage”](#storage) The keyring takes two adapters: `secrets` (one entry per seed or key) and `storage` (names, addresses, flags). ### Secrets [Section titled “Secrets”](#secrets) On a phone, use [`@anywalletkit/secure-storage-react-native`](/build-a-wallet/keys-and-storage/#key-storage): every secret is a Keychain item (iOS) or a Keystore-wrapped file (Android) that only Face ID / Touch ID / a fingerprint, with the device passcode as the fallback, opens, on every read. ```ts import { createSecureStorage } from '@anywalletkit/secure-storage-react-native' const secrets = createSecureStorage({ namespace: 'my-wallet' }) // a SecretStorage ``` It is an Expo native module: it needs a development or Release build (not Expo Go) and `NSFaceIDUsageDescription` in `Info.plist`. **Your own adapter** (Expo Go, another store, a test): `SecretStorage` is three methods. `delete` must resolve for an id that is not there: the keyring retries an interrupted delete on the next load. Over `expo-secure-store` it looks like this: ```ts /** The shape of expo-secure-store (getItemAsync / setItemAsync / deleteItemAsync). */ export interface SecureStoreLike { getItemAsync(key: string, options?: O): Promise setItemAsync(key: string, value: string, options?: O): Promise deleteItemAsync(key: string, options?: O): Promise } /** * One secure-storage entry per source. `options` is where the protection lives: * with expo-secure-store, `{ requireAuthentication: true, * keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY }`. */ export function secureSecrets(store: SecureStoreLike, options: O): SecretStorage { const key = (sourceId: string) => `awk.secret.${sourceId}` // letters, digits, . - _ only return { get: (sourceId) => store.getItemAsync(key(sourceId), options), set: (sourceId, secret) => store.setItemAsync(key(sourceId), secret, options), delete: (sourceId) => store.deleteItemAsync(key(sourceId), options), } } ``` The protection is then in the storage options, so choose them on purpose: | Option | What it does | Trade-off | | ---------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | Ask for authentication on every read | Face ID / fingerprint / passcode before each signature | One prompt per signing session; batch reads where you can | | Device only (`…THIS_DEVICE_ONLY`) | The secret never leaves the device in an OS backup | A new phone needs the recovery phrase | | Passcode required (`WHEN_PASSCODE_SET…`) | No secret on a device without a passcode | Users without a passcode can’t create a wallet | expo-secure-store and Face ID With `requireAuthentication: true`, expo-secure-store 15 on iOS accepts **only** the currently enrolled biometrics (`.biometryCurrentSet`): * **No passcode fallback.** A user whose Face ID fails cannot unlock. * **Re-enrolling Face ID makes the item unreadable for good.** Only the recovery phrase gets the wallet back. `secure-storage-react-native` allows biometrics **or** the device passcode and survives re-enrolment, which is why it exists. With your own adapter, use an access control that does the same, and test “Face ID fails → passcode” and “re-enroll Face ID” on a real device before you ship. ### Metadata [Section titled “Metadata”](#metadata) ```ts /** The shape of AsyncStorage / MMKV / chrome.storage: plain storage is fine, nothing here is secret. */ export interface PlainStoreLike { getItem(key: string): Promise setItem(key: string, value: string): Promise } export function plainMetadata(store: PlainStoreLike): KeyringStorage { return { load: async () => { const json = await store.getItem('awk.keyring') return json ? (JSON.parse(json) as KeyringState) : null }, save: (state) => store.setItem('awk.keyring', JSON.stringify(state)), } } ``` ## Set up [Section titled “Set up”](#set-up) ```ts export function openKeyring(storage: KeyringStorage, secrets: SecretStorage): Promise { 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' } }, }, }) } ``` Register every chain the wallet supports: each seed account gets an address on each of them. A chain added later is filled in with `missingChains(accountId)` and `completeAddresses(sourceId)`, with one secret read per seed. ### More than one EVM network [Section titled “More than one EVM network”](#more-than-one-evm-network) `chains.evm` takes one network (one RPC and `chainId`), and an EVM address is the same on every network. To sign on Base, Arbitrum or any other EVM network, open one more keyring per network over the **same** `secrets`, with a `storage` that never saves: the main keyring owns the metadata. ```ts /** * The same accounts on another EVM network. `chains.evm` takes one network, and the address is * the same on all of them, so a second keyring over the same secrets signs on Base (or any EVM * network) while the main one keeps the metadata. */ export function keyringOn(network: { provider: string; chainId: number }, storage: KeyringStorage, secrets: SecretStorage): Promise { return Keyring.load({ storage: { load: () => storage.load(), save: async () => {} }, // read-only: the main keyring owns the metadata secrets, chains: { evm: { Manager: WalletManagerEvm, config: network } }, }) } ``` ```ts const base = await keyringOn({ provider: BASE_RPC, chainId: 8453 }, storage, secrets) await base.withAccount(accountId, 'evm', (account) => account.sign(message)) // account.chain = eip155:8453 ``` Create, import, rename and remove accounts on the main keyring only, then reopen the others (or keep them per screen): they read the metadata on `load`. ## Create a wallet [Section titled “Create a wallet”](#create-a-wallet) ```ts 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 } } ``` This is the only time the phrase is handed to your code without asking. Show it on a screen that blocks screenshots, ask the user to confirm a few words, then let the variable go. ## Restore a wallet [Section titled “Restore a wallet”](#restore-a-wallet) ```ts /** Your check: any balance or history on any of the addresses. */ export type IsUsed = (addresses: Partial>) => Promise export async function restoreWallet(keyring: Keyring, phrase: string, isUsed: IsUsed) { const { accounts } = await keyring.importMnemonic(phrase, { // Adds account 0, then 1, 2… while they have activity; stops at the first unused one. discover: { isUsed: (addresses) => isUsed(addresses), gapLimit: 1, maxIndex: 20 }, }) return accounts } ``` A restored seed may have used accounts beyond 0. `discover` derives the next ones and asks your `isUsed` whether each has a balance or history, stopping after `gapLimit` unused ones in a row (1 is what MetaMask and Phantom do). The keyring has no indexer, so `isUsed` is yours: a balance read, or the [history](/build-a-wallet/history/) source of each chain. Importing the same seed twice throws `KeyringError` with `code: 'duplicate'`. The check compares a fingerprint of the public key, so no secret is kept for it. The same holds for the same key in another format. ## Manage accounts [Section titled “Manage accounts”](#manage-accounts) ```ts export async function manageAccounts(keyring: Keyring, sourceId: string): Promise { const second = await keyring.addAccount(sourceId, { name: 'Savings' }) // the next index of this seed await keyring.renameAccount(second.id, 'Long-term') await keyring.setActiveAccount(second.id) // Reading accounts never touches a secret: no biometric prompt, no key in memory. return keyring.listAccounts() // hidden accounts are left out unless { includeHidden: true } } ``` `listAccounts()`, `state` and `getAccountMeta()` never read a secret, so the account picker, the portfolio and the receive screen open without a biometric prompt. ## Sign [Section titled “Sign”](#sign) ```ts export async function signMessage(keyring: Keyring, accountId: string, policies: Policies, message: string) { // One secret read, one live account, zeroized when the callback returns or throws. return keyring.withAccount(accountId, 'evm', async (account) => { const guarded = applyPolicies(account, policies, account.chain) // fee caps, lists, confirm return guarded.sign(message) }) } ``` `withAccount` reads the secret once, builds the chain manager, runs your callback and disposes the key whatever happens. Accounts from the keyring are not wrapped by [policies](/concepts/policies/) on their own: wrap them with `applyPolicies`, as above, so fee caps, lists and your `confirm` step apply like on accounts from `Wallet`. Before it returns the account, the keyring checks that the address still matches the stored one. A changed chain config or a swapped secret fails loudly instead of signing from an unexpected address. ## Import keys [Section titled “Import keys”](#import-keys) ```ts export async function importKey(keyring: Keyring, chain: ChainKey, key: string, confirm: (address: string) => Promise) { const address = await keyring.previewPrivateKey(chain, key) // stores nothing if (!(await confirm(address))) return null return keyring.importPrivateKey(chain, key, { label: 'Imported' }) } export async function importTonkeeper(keyring: Keyring, words: string) { return keyring.importTonMnemonic(words) // the address Tonkeeper shows for these 24 words } ``` | Source | Accounts | Formats | | ----------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Seed phrase (BIP-39, optional passphrase) | 0, 1, 2… on every registered chain | 12 or 24 English words | | Private key | One, on one chain | EVM / Tron hex; BTC WIF or hex; Solana base58 or JSON byte array; TON hex; NEAR `ed25519:…`; Zcash WIF or hex | | TON 24-word mnemonic | One TON account | Tonkeeper, Tonhub, MyTonWallet | Always show the address from `previewPrivateKey` before importing: a key pasted into the wrong chain imports a different, empty account. ## Back up and remove [Section titled “Back up and remove”](#back-up-and-remove) ```ts /** "Show recovery phrase": ask for biometrics or the passcode first, every time. */ export async function revealSecret(keyring: Keyring, sourceId: string, authenticate: () => Promise) { if (!(await authenticate())) throw new Error('Not authenticated.') return keyring.exportSecret(sourceId) // { type, secret, passphrase?, chain? } } /** "Export private key" for one account on one chain: the same gate as the phrase. */ export async function revealPrivateKey(keyring: Keyring, accountId: string, chain: ChainKey, authenticate: () => Promise) { if (!(await authenticate())) throw new Error('Not authenticated.') // Checked against the account's address before it is returned; TON 24-word wallets are refused. const { privateKey, format, address, compatibleWith } = await keyring.exportPrivateKey(accountId, chain) return { privateKey, format, address, compatibleWith } // e.g. format 'hex' (EVM 0x…), 'wif' (BTC) } export async function removeWallet(keyring: Keyring, sourceId: string) { await keyring.removeSource(sourceId) // its accounts, and its entry in secure storage } ``` `exportSecret` returns the phrase or the key itself. Gate it behind authentication every time, block screenshots on that screen, and never copy it to the clipboard without a warning. `removeSource` removes the source and its accounts from the metadata first, then deletes the secure-storage entry. A failed save leaves everything as it was; a delete that fails (or never runs because the app was killed) stays in `pendingSecretDeletes` and `Keyring.load` finishes it. `exportPrivateKey(accountId, chain)` returns one account’s key on one chain, for an “Export private key” screen. Treat it like the phrase: authenticate first, show it once. * **The format other wallets import.** EVM gets `0x` hex (MetaMask, Rabby), Tron plain hex, Bitcoin and Zcash compressed WIF, Solana base58 of the 64-byte secret (Phantom), NEAR `ed25519:…`. `compatibleWith` names wallets that accept it. * **Checked before it is returned.** The SDK builds an account from the exported key and compares its address with the account’s. If they differ, nothing is returned. * **One key, not the wallet.** It controls this account on this chain only. Say so on the screen: a user who wants everything back needs the phrase. * **TON wallets imported from 24 TON words** are refused: Tonkeeper and other TON wallets import only those words, so export them with `exportSecret` instead. * **Bitcoin in Electrum** needs a `p2wpkh:` prefix before the WIF to land on the same SegWit address. ## How the key is kept in memory [Section titled “How the key is kept in memory”](#how-the-key-is-kept-in-memory) * **A phrase becomes the 64-byte seed** the moment the SDK gets it. A JS string cannot be wiped, bytes can. * **No seed or key is a property of any SDK object**, so logging an account or a crash report shows addresses and public keys only. * **Each account holds only its own key**, never the master seed, and `dispose()` wipes it. These rules are checked by the conformance suite on every chain. See [Keys and storage](/build-a-wallet/keys-and-storage/) for the wider picture. # Names > ENS names in the recipient field — the address for the right chain, lookalike-letter warnings, a re-check before signing, primary names and avatars. Users type `vitalik.eth`, `jesse.base.eth` or `alice.cb.id` instead of an address. `names-ens` turns the name into the address **for the chain the user is sending on**, flags names written to look like other names, and re-checks the name right before signing. The resolver implements `NameResolver` from `core`. Solana (`.sol`), TON (`.ton`) and NEAR names will plug into the same interface. Snippets come from [`examples/names`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/names), which our CI typechecks and tests; the live run resolves real names on Ethereum and Base. ```sh npm install @anywalletkit/names-ens ``` ```ts import { NameError, assertNameUnchanged, isNameLike, nameResolvers, type NameResolver, type ResolvedName } from '@anywalletkit/core' import { ensResolver } from '@anywalletkit/names-ens' ``` ## Set up [Section titled “Set up”](#set-up) ```ts export function names(): NameResolver { return nameResolvers([ // More specific resolvers (.sol, .ton) go before ENS when they exist: ENS also serves DNS names. ensResolver({ rpcUrl: 'https://ethereum-rpc.publicnode.com', // always Ethereum mainnet, whatever chain you send on cacheTtlMs: 5 * 60_000, ipfsGateway: 'https://ipfs.io/ipfs/', // point at your own proxy in production }), ]) } ``` ENS lives on Ethereum mainnet, so the resolver always needs an Ethereum RPC, even when the user sends on Base or Arbitrum. Names that resolve off-chain (Coinbase `cb.id`, Basenames, `uni.eth`) go through CCIP-Read without extra setup in a browser, an extension or Node. On React Native they go through your server ([React Native](#react-native) below). Results are cached for five minutes by default. ## The recipient field [Section titled “The recipient field”](#the-recipient-field) ```ts export type Recipient = | { kind: 'address'; address: string } | { kind: 'name'; resolved: ResolvedName } | { kind: 'error'; message: string } /** The recipient field: an address passes through, a name is resolved for THIS chain. */ export async function readRecipient(resolver: NameResolver, input: string, chain: string): Promise { const text = input.trim() // On NEAR, `alice.near` IS the account address; elsewhere a dotted string is never an address. if (!isNameLike(text) || chain.startsWith('near:')) return { kind: 'address', address: text } // validate it as an address next // Not served: an invalid name, a lookalike-letter mixture (`vitаlik.eth` with a Cyrillic "а"), or a TLD with no resolver yet. if (!resolver.supports(text)) return { kind: 'error', message: 'This name is not valid, or names like it are not supported yet.' } try { return { kind: 'name', resolved: await resolver.resolve(text, chain) } } catch (e) { if (!(e instanceof NameError)) throw e const messages: Record = { 'invalid-name': 'This is not a valid name.', 'not-found': 'No one owns this name.', 'no-address-for-chain': 'This name has no address for this network.', // never send to its Ethereum address instead unsupported: 'Names like this are not supported yet.', 'lookup-failed': 'Could not look the name up. Try again.', } return { kind: 'error', message: messages[e.code] ?? e.message } } } ``` * **One chain per lookup.** `resolve(name, 'eip155:8453')` reads the name’s Base address. A name with no Base record is an error, `no-address-for-chain`, and **never** its Ethereum address: sending to the mainnet address on another chain is how funds get lost. * **Bitcoin and Solana records** are read the same way: `bip122:…` and `solana:…` as the chain. * **Invalid names are refused before any network call**, with the reason. Names are normalized per ENSIP-15, so mixed scripts like `vitаlik.eth` (a Cyrillic “а”) don’t resolve at all. * **On NEAR**, `alice.near` is the account address itself, not a name to resolve. | `NameError` code | What to tell the user | | ---------------------- | ------------------------------------------------- | | `invalid-name` | Not a valid name | | `not-found` | No one owns this name | | `no-address-for-chain` | The name has no address for this network | | `unsupported` | Names like this aren’t supported yet | | `lookup-failed` | The lookup failed; try again (the name may exist) | | `changed` | The name points elsewhere now (see below) | ## Review and signing [Section titled “Review and signing”](#review-and-signing) ```ts /** The review screen shows the address the name resolved to, and every warning. */ export function reviewLines(resolved: ResolvedName): string[] { return [`${resolved.name} → ${resolved.address}`, ...resolved.warnings.map((w) => `⚠ ${w.message}`)] } /** Right before signing: resolve again; refuse if the name now points elsewhere. */ export async function beforeSigning(resolver: NameResolver, resolved: ResolvedName): Promise { await assertNameUnchanged(resolver, resolved.name, resolved.chain, resolved.address) // throws NameError 'changed' } ``` * **Show the resolved address, not only the name.** The address gets the funds. * **Show every warning** that comes with the result: `confusable` (Latin letters replaced by lookalikes), `mixed-script`, `punycode`. Each has a sentence you can show as-is. * **Resolve again right before signing.** An owner can point the name somewhere else between the review screen and the tap on “Send”. `assertNameUnchanged` throws `changed` when that happens; go back to the review screen with the new address. ## Primary names and avatars [Section titled “Primary names and avatars”](#primary-names-and-avatars) ```ts /** The account header: the verified primary name and its avatar URL, if any. */ export async function profile(resolver: NameResolver, address: string, chain: string) { const name = await resolver.reverse(address, chain) // counts only if it resolves back to this address const avatar = name ? await resolver.avatar(name) : null // a URL: fetch it through your proxy return { name, avatar } } ``` `reverse` returns the primary name of an address only when that name resolves back to the same address. Anyone can set any primary name on their own address, so an unverified one proves nothing. L2 primary names (ENSIP-19) are read for the chain you pass. `avatar` returns the name’s avatar record when it is an https URL on a public host, or an IPFS one through your gateway, and never fetches it. NFT avatars come back `null`: resolving one would fetch the metadata URL the NFT’s issuer chose. Fetch avatars through your own proxy, so the user’s IP and address list don’t reach arbitrary servers. Offchain names (CCIP-Read) make the device fetch gateway URLs the name’s resolver chooses. The resolver built from `rpcUrl` asks only https gateway URLs whose host, as written, is not a private one, refuses redirects, and reads at most 1 MiB within 10 s per gateway (`ccip: { maxResponseBytes, timeoutMs }`). Where the host really resolves depends on the runtime: * **Node**, no `ccip.fetch`: gateways go through the same pinned transport as `createEnsCcipHandler` — every DNS answer is checked and the connection is pinned to it, so a public-looking name that resolves to a private address is not asked. A host whose records mix public and private addresses is refused; behind a corporate split-horizon DNS or an outbound proxy, pass `ccip.fetch`. * **Browser** and other runtimes: the global fetch connects. The URL is checked, the address it resolves to is not — no DNS-level isolation. Send gateway calls through your server (`ensCcipViaProxy`, below) where that matters. If you pass your own viem `client`, create it with `ccipRead: ensCcipRead` from `@anywalletkit/names-ens` (or `ccipRead: false`): viem’s default would fetch any URL, a router’s admin page on the user’s network included. ## React Native [Section titled “React Native”](#react-native) React Native’s fetch follows redirects whatever `redirect: 'error'` says, so a gateway could redirect the device to a LAN or loopback address, and the request goes out before the SDK can see the final URL. There, offchain names are off by default: they fail with `lookup-failed`, and the error’s `reason` is `ccip-proxy-required`. The safe setup sends each gateway call to your own server, which refuses redirects and checks the address it connects to. On the server, one route with `createEnsCcipHandler` from `@anywalletkit/names-ens/server` (Node: it connects with `node:https`). Authenticate and rate-limit it like your other routes: without that it fetches https URLs for anyone. ```ts // app/api/ccip/route.ts (Next.js; any framework works the same way) import { createEnsCcipHandler } from '@anywalletkit/names-ens/server' export const runtime = 'nodejs' const ccip = createEnsCcipHandler() // options: allowHosts, denyHosts, timeoutMs, maxResponseBytes export async function POST(req: Request): Promise { await requireUser(req) // your auth + rate limit: throw a 401 / 429 Response const raw = await req.text() if (raw.length > 160 * 1024) return Response.json({ error: 'request too large' }, { status: 413 }) let body: unknown try { body = JSON.parse(raw) } catch { return Response.json({ error: 'invalid JSON' }, { status: 400 }) } const out = await ccip(body) // { url, method, body? } → { data } or { error } return Response.json(out.body, { status: out.status }) } ``` The handler asks only https gateways on public hosts. It resolves the host and refuses it when any address is private, loopback, link-local, CGNAT or an IPv6 address carrying one of those, then connects to the checked address, so a second DNS answer cannot move the request (DNS rebinding). It never follows a redirect, applies a timeout (10 s) and an answer cap (1 MiB), sends GET or POST exactly as EIP-3668 defines them, and forwards none of the app’s headers or cookies. In the app, `ensCcipViaProxy` is the `fetch` for the resolver. It needs no Node API and runs on Hermes: ```ts /** * ENS names on React Native: offchain names (cb.id, Basenames, uni.eth) ask their CCIP-Read gateways through * the server's `/ccip` route, because the device's fetch cannot refuse a gateway's redirect. */ export function namesOverServer(api: string, token: string, ethereumRpc: string): NameResolver { return ensResolver({ rpcUrl: ethereumRpc, ccip: { fetch: ensCcipViaProxy(`${api}/ccip`, { headers: { authorization: `Bearer ${token}` }, // https in production; plain http only for a server on this machine (the tests). allowInsecureProxyUrl: /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(api), }), }, }) } ``` The reference server has this route at `POST /ccip`, behind the same token check and rate limits as its other routes: ```ts 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) } ``` If you would rather not run the route, `ccip: { allowRedirectFollowingFetch: true }` asks gateways directly and accepts that a redirecting gateway can make the device send one blind request (the answer is never used) to a host on the user’s network. With your own `client`, the same options go to `ccipRead: createEnsCcipRead({ … })`. # Networks > One table of networks and RPC endpoints — shipped failover lists, the user's own RPCs, testnets, sites that add chains — and how to wire it into the wallet. `networks` is the wallet’s list of networks and the endpoints to reach them: * the catalog mainnets (30 EVM networks, Solana, TON, Tron, Bitcoin, NEAR, Zcash), each with a shipped list of public RPCs to fail over through; * default testnets, shipped switched off; * the RPCs and custom networks the user adds, and the ones sites add through `wallet_addEthereumChain`; * a verifier that proves an endpoint serves the network it claims before the wallet trusts it. It is plain TypeScript with no runtime dependencies beyond the asset catalog, and runs under Hermes. Snippets come from [`examples/networks`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/networks), which our CI typechecks and tests. ```sh npm install @anywalletkit/networks ``` ```ts import { NetworksTable, SOLANA_CHAIN, checkRpcUrl, isAddChainError, parseAddChainParams, verifyNetwork, type FetchLike, type JsonStorage, type Network, type NetworksState, } from '@anywalletkit/networks' import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { WalletManagerSolana } from '@anywalletkit/wallet-solana' ``` ## The table [Section titled “The table”](#the-table) ```ts /** Load once at cold start. Storage holds only the user's changes; the shipped lists stay current. */ export async function openNetworks(storage: JsonStorage): Promise { const table = new NetworksTable(storage) // AsyncStorage, MMKV, chrome.storage… await table.load() return table } ``` The table stores only what the user changed. The shipped lists live in the package, so an app update with new endpoints reaches every install, and the user’s own RPCs stay on top. | Rule | Why | | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Built-in networks can’t be removed; the user can add RPCs before the shipped list, or switch a network off | The fallback is always there | | “Only my RPC” (`setOnlyOwnRpc`) drops the shipped fallback | For users who don’t want any public provider to see their addresses | | A custom network needs at least one RPC and gets no fallback | There is nothing to fall back to | | A stored custom entry for a built-in chain id is ignored | A site must not replace Ethereum’s endpoints | | A failing user RPC is parked with the reason (`markRpc`) until it passes again | Failover skips it; the settings screen shows why | ## Wire it into the wallet [Section titled “Wire it into the wallet”](#wire-it-into-the-wallet) ```ts export function walletOnNetworks(seedPhrase: string, table: NetworksTable, evmChainIds: number[]): Wallet { const wallet = new Wallet(seedPhrase) for (const id of evmChainIds) { // One registration per EVM network: its own failover list, user RPCs first. wallet.registerWallet(`eip155:${id}`, WalletManagerEvm, { provider: table.rpcUrlsForSync(`eip155:${id}`), chainId: id }) } wallet.registerWallet('solana', WalletManagerSolana, { provider: table.rpcUrlsForSync(SOLANA_CHAIN) }) return wallet } ``` `rpcUrlsForSync` answers from memory, so client factories can call it synchronously after `load()`. Register each EVM network on its own chain id to give it its own list. Two names for the same chain: the table names Solana `solana:mainnet`, while accounts report the genesis-hash form (`solana:5eykt4…`). Use the table’s id when you read endpoints, and the account’s `chain` everywhere else. ## The user’s own RPC [Section titled “The user’s own RPC”](#the-users-own-rpc) ```ts /** "Add your own RPC": check the URL, prove the endpoint serves this chain, then store it. */ export async function addUserRpc(table: NetworksTable, caip2: string, url: string, fetch?: FetchLike) { const policy = checkRpcUrl(url, { allowHttpLocal: table.allowHttpLocalSync() }) if (!policy.ok) return { ok: false as const, error: policy.message } // http, credentials in the URL… const network = (await table.get(caip2))! const result = await verifyNetwork(network, { rpc: url, ...(fetch ? { fetch } : {}) }) if (!result.ok) return { ok: false as const, error: result.error! } // e.g. "chain-id-mismatch: …" await table.addRpc(caip2, url) // goes before the shipped list await table.markRpc(caip2, url, result) return { ok: true as const, latencyMs: result.latencyMs } } ``` `checkRpcUrl` allows `https://` anywhere, and `http://` only for private addresses (`localhost`, `192.168.x.x`, `*.local`…) with the “allow local HTTP” setting on. Credentials in the URL are refused: use a keyed path or a header. `verifyNetwork` asks the endpoint who it is and compares it with the network: | Family | Identity checked | Tip | | -------------- | ------------------------------ | ------------------- | | EVM | `eth_chainId` | `eth_blockNumber` | | Solana | genesis hash | slot | | TON | global id from config param 19 | masterchain seqno | | Tron | genesis block id | latest block | | Bitcoin, Zcash | genesis block | tip height | | NEAR | `chain_id` | latest block height | It never throws. A failure comes back with a coded reason: `chain-id-mismatch`, `url-policy`, `redirected`, `http-`, `timeout`, `unreachable`… An endpoint that changes its chain id later is treated as an attack, not an outage. Redirects are refused: the URL policy judged the address that was entered, so an endpoint that answers through a redirect fails with `redirected` until the user enters its final URL. Show the reason to the user; a site that supplied the endpoint gets a generic error. ```ts /** Label screens that ran through an endpoint you didn't ship. */ export function reviewLabel(table: NetworksTable, caip2: string): string | null { return table.usesUserRpcSync(caip2) ? 'Custom RPC: the simulation comes from a node you added' : null } ``` A simulation or a balance from a node you didn’t ship is only as honest as that node. Say so on the review screen. ## Testnets [Section titled “Testnets”](#testnets) Testnets ship switched off: Sepolia, Base Sepolia, Arbitrum Sepolia, OP Sepolia, Polygon Amoy, BSC testnet, Monad testnet, Solana devnet, Bitcoin testnet4, TON testnet, Tron Nile, NEAR testnet. A Zcash testnet entry exists, but no public API answers for it, so the user brings a Blockbook. Turn one on with `table.enable(caip2, true)`, and show a clear “testnet” badge wherever its balances appear. ## Sites that add a network [Section titled “Sites that add a network”](#sites-that-add-a-network) ```ts /** A site calls wallet_addEthereumChain. Never add silently: ask, verify, then store. */ export async function onAddEthereumChain( table: NetworksTable, origin: string, params: unknown, ask: (network: Network) => Promise, fetch?: FetchLike, ): Promise { // `known` decides which chains a site may add: by default only the catalog (already built in), // here any chain the user approves. const parsed = parseAddChainParams(params, { origin, known: () => true }) if (isAddChainError(parsed)) return parsed // the EIP-1193 error to answer with if (await table.get(parsed.caip2)) return null // known already: nothing to add if (!(await ask(parsed))) return { code: 4001, message: 'User rejected the request.' } const result = await verifyNetwork(parsed, fetch ? { fetch } : {}) // The site gets a generic answer: what the endpoint said (or where it redirected) is for the user's // own screen, not for the page that supplied the endpoint. if (!result.ok) return { code: -32603, message: "The network's RPC failed the check." } await table.add(parsed) return null } ``` `parseAddChainParams` turns a `wallet_addEthereumChain` request into a network, or into the EIP-1193 error to answer with: `-32602` for bad params, `4902` for a chain the wallet refuses. By default only catalog chains are accepted, and those are built in already. Pass `known` to let users add others. The new network records the site’s origin. Never add a network silently. Show the chain id, the name, the RPC and the requesting site, verify the endpoint, and only then store it. # Swaps and bridges > Quote every swap provider at once, let the user pick a route, execute it safely and track delivery across chains. One interface covers same-chain swaps and cross-chain transfers. A **router** asks every provider you install in parallel, checks each quote the same way, ranks the routes and executes the one you choose. Snippets on this page come from [`examples/swap`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/swap), which our CI typechecks and tests. ## Install [Section titled “Install”](#install) ```sh npm install @anywalletkit/protocol-swap2-router \ @anywalletkit/protocol-swap2-lifi @anywalletkit/protocol-swap2-near-intents \ @anywalletkit/protocol-swap2-thorchain @anywalletkit/protocol-swap2-chainflip \ @anywalletkit/protocol-swap2-cctp \ @anywalletkit/protocol-swap2-usdt0 \ @anywalletkit/protocol-swap2-uniswap @anywalletkit/protocol-swap2-wrap \ @anywalletkit/protocol-swap2-jupiter ``` The router bundles no provider. Only the providers you install and pass in end up in your app, and each provider also works on its own without the router. ```ts import type { IWalletAccount, Swap2Options } from '@anywalletkit/core' import { RouterSwap2Protocol, type Swap2Route } from '@anywalletkit/protocol-swap2-router' import { lifiProvider } from '@anywalletkit/protocol-swap2-lifi/provider' import { nearIntentsProvider } from '@anywalletkit/protocol-swap2-near-intents/provider' import { thorchainProvider } from '@anywalletkit/protocol-swap2-thorchain/provider' import { chainflipProvider } from '@anywalletkit/protocol-swap2-chainflip/provider' import { jupiterProvider } from '@anywalletkit/protocol-swap2-jupiter/provider' import { cctpProvider } from '@anywalletkit/protocol-swap2-cctp/provider' import { usdt0Provider } from '@anywalletkit/protocol-swap2-usdt0/provider' import { uniswapProvider } from '@anywalletkit/protocol-swap2-uniswap/provider' import { wrapProvider } from '@anywalletkit/protocol-swap2-wrap/provider' import { Swap2FeeLimitError, Swap2InFlightError, Swap2InsufficientGasError, Swap2MaybeSentError, Swap2QuoteError, } from '@anywalletkit/protocol-swap2-core' ``` ## 1. Pick providers [Section titled “1. Pick providers”](#1-pick-providers) ```ts const RPC = { 1: ['https://ethereum-rpc.publicnode.com'], 8453: ['https://base-rpc.publicnode.com'], 42161: ['https://arbitrum-one-rpc.publicnode.com'], } as const export function providers() { return [ // Cross-chain venues: their APIs quote and build the route. lifiProvider({}), // apiKey: raises LI.FI's rate limit nearIntentsProvider({}), // jwt: removes 1Click's fee thorchainProvider({}), chainflipProvider(), // native BTC / SOL / TRX and stables; no key, 0 bps cctpProvider({ rpcUrls: { 'eip155:1': RPC[1][0], 'eip155:8453': RPC[8453][0] } }), usdt0Provider({ rpcUrls: { 'eip155:1': RPC[1], 'eip155:42161': RPC[42161] } }), // RPC for each source chain // Same-chain venues priced straight from the contracts: no API, no key. uniswapProvider({ rpcUrls: RPC }), wrapProvider({ rpcUrls: RPC }), // Same-chain Solana: Jupiter's /build instructions, checked and assembled locally; apiKey optional. jupiterProvider({ rpcUrls: ['https://api.mainnet-beta.solana.com'] }), ] } ``` | Provider | Kind | Good at | | --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lifi` | Aggregator API | Same-chain and cross-chain, many bridges and DEXes behind one quote | | `near_intents` | Intents (1Click) | Cross-chain between different ecosystems: EVM, Solana, Tron, Bitcoin, NEAR… | | `thorchain` | Native cross-chain | Native BTC and other L1 coins | | `chainflip` | Native cross-chain (vault swaps) | Native BTC, ETH, SOL, TRX and USDC / USDT; from Ethereum, Arbitrum and Tron to those plus Solana and Bitcoin; no key, 0 bps | | `omniston` | STON.fi | TON swaps, TON ↔ EVM and EVM ↔ EVM stablecoin orders | | `cctp` | Circle burn-and-mint | USDC between EVM chains, no slippage | | `usdt0` | Tether’s LayerZero OFT | USDT / USDT0 between 16 EVM chains in one transaction; the direct USDT path into HyperEVM | | `uniswap`, `balancer` | On-chain DEX | Same-chain, priced from the contracts, no API key | | `wrap` | WETH9 | Native ↔ wrapped native, 1:1 | | `jupiter` | Solana aggregator (Metis) | Same-chain Solana swaps; instructions from Jupiter’s `/build`, checked and assembled locally, no Jupiter fee; key optional | | `fynd` | EVM aggregator (Propeller Heads) | Same-chain swaps on Ethereum, Base, Arbitrum, BNB Chain, Polygon, Unichain and Robinhood Chain through the Tycho router; key or your own Fynd server. Its outputs are under the [Fynd License](https://github.com/propeller-heads/fynd/blob/main/LICENSE.md): read it before you ship | ## 2. Create the router [Section titled “2. Create the router”](#2-create-the-router) The router is bound to one account: the source of the swap. Its chain is the source chain. ```ts export function swapsFor(account: IWalletAccount): RouterSwap2Protocol { return new RouterSwap2Protocol(account, { fromChain: account.chain, // the source chain is the account's chain providers: providers(), prefer: 'best', // or 'fastest' // Progress for your UI: approving → requoting → preflight → signing → broadcast. onExecutionPhase: (phase, detail) => console.log('swap', phase, detail ?? ''), }) } ``` ## 3. Describe the swap [Section titled “3. Describe the swap”](#3-describe-the-swap) ```ts // 100 USDC on Ethereum → USDC on Base, delivered to the same address. export async function usdcToBase(account: IWalletAccount): Promise { return { fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum (or the symbol 'USDC') toToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base: the destination chain's own id toChain: 'eip155:8453', fromTokenAmount: 100_000_000n, // 100 USDC, 6 decimals recipient: await account.getAddress(), } } ``` * `fromToken`: a token on the **source** chain, as its contract address or a catalog symbol (`'USDC'`, `'ETH'`). * `toToken`: the token on the **destination** chain, as that chain’s own id: a contract address, a Solana mint or a TON jetton master. A symbol that does not exist in the catalog of the destination chain leads to “no route”. * Tokens outside the catalog (the user’s own) need `allowUnlistedTokens` on the engine: see [Custom tokens](/guides/tokens/#swap-it). * Omit `toChain` for a same-chain swap. * Amounts are `bigint` in base units. Use `toTokenAmount` instead of `fromTokenAmount` for exact-output swaps. ## 4. Quote [Section titled “4. Quote”](#4-quote) ```ts export async function bestQuote(swaps: RouterSwap2Protocol, options: Swap2Options) { const quote = await swaps.quoteSwap2(options) // asks every provider in parallel, nothing is signed return { receive: quote.toTokenAmount, // expected output atLeast: quote.toTokenAmountMin, // guaranteed output: show this one seconds: quote.estimatedDuration, fees: quote.fees, // network / protocol / affiliate, each in its own token } } ``` Show the user `toTokenAmountMin`, not `toTokenAmount`: it is what the route guarantees. ## 5. Let the user pick a route [Section titled “5. Let the user pick a route”](#5-let-the-user-pick-a-route) ```ts export async function routePicker( swaps: RouterSwap2Protocol, options: Swap2Options, render: (routes: Swap2Route[], searching: boolean) => void, ): Promise { return swaps.quoteSwap2Routes(options, { // Called each time another provider answers: show the first price at once. onUpdate: (routes, pending) => render(routes, pending.length > 0), }) } // route = { provider, quote, netValueUsd, relativeValue, tags: ['best'] | ['fastest'] | … } ``` Routes come back ranked by value. Two of them are tagged: * **`best`**: the highest value. Routes within 0.2% of each other count as the same price; inside that band the faster and more reliable route wins. * **`fastest`**: the lowest estimated time. For a real USDC Ethereum → Base quote on 100 USDC, this returned four routes: CCTP tagged `best` (99.989 USDC guaranteed, \~3 minutes), LI.FI tagged `fastest` (99.735 USDC, seconds), then NEAR Intents and THORChain. For 25 USDT from Ethereum to USDT0 on Arbitrum, USDT0 came out `best` (exactly 25 guaranteed, \~17 minutes) and LI.FI `fastest`. Pass `valueUsd: usdValue(prices)` to the router to rank on net USD value. Gas and native bridge fees are then subtracted. ## 6. Execute [Section titled “6. Execute”](#6-execute) ```ts 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 } ``` Before anything is signed, the router fetches a live quote from the chosen provider and checks it against what you showed: * the output is not below your `minAmountOut`; * the recipient, amounts and chains match the request; * the transaction sends exactly the quoted amount, and any transfer or approval inside it names only your account, the quoted deposit address or the declared spender; * aggregator calls go only to that provider’s audited contracts. A route that fails any check is refused, and nothing is sent. ERC-20 approvals are for the exact amount, never unlimited, and are revoked again if the swap fails after approving. On Ethereum, USDT is reset to zero first, as that token requires. ### Show a review screen [Section titled “Show a review screen”](#show-a-review-screen) ```ts export function swapsWithReview(account: IWalletAccount, confirm: (text: string) => Promise) { return new RouterSwap2Protocol(account, { fromChain: account.chain, providers: providers(), // Runs after every check passed and BEFORE the first approval or signature. // It receives exactly what will be signed; throwing cancels with nothing sent. reviewExecution: async (plan) => { const ok = await confirm( `${plan.sourceAmount} → at least ${plan.minDestAmount} on ${plan.destChain} via ${plan.provider}, ` + `${plan.approvals.length} approval(s), ~${plan.etaSeconds}s`, ) if (!ok) throw new Error('cancelled by the user') }, }) } ``` `plan` holds exactly what will be signed: provider, amounts, guaranteed output, approvals, ETA and the source transaction itself. ## 7. Track delivery [Section titled “7. Track delivery”](#7-track-delivery) A cross-chain swap finishes on another chain, minutes later. Poll its status with the id returned by `swap2()`: ```ts const DONE = new Set(['completed', 'failed', 'refunded', 'cancelled', 'expired', 'partial']) export async function waitForDelivery( swaps: RouterSwap2Protocol, id: string, destinationAccount: () => Promise, { 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.`) } ``` | Status | Meaning | | ------------------------------------------- | -------------------------------------------------------------------- | | `pending` | In flight | | `action-required` | Two-phase route (CCTP): the user must claim on the destination chain | | `completed` | Delivered | | `refund-pending` / `refunded` | The provider is returning / has returned the funds | | `failed`, `expired`, `cancelled`, `partial` | Final, not delivered in full | The id is a string (`awk1..…`). Store it with the user’s activity: it is all you need to resume tracking after an app restart. ## 8. Handle errors [Section titled “8. Handle errors”](#8-handle-errors) ```ts export async function swapSafely(swaps: RouterSwap2Protocol, options: Swap2Options) { try { return await swaps.swap2(options) } catch (e) { if (e instanceof Swap2MaybeSentError) { // The broadcast may have reached the network. Do NOT retry: track e.id / e.hash. return { pending: e.id ?? e.hash } } if (e instanceof Swap2InFlightError) return null // the same swap is already running (double tap) if (e instanceof Swap2InsufficientGasError) throw new Error('Not enough native coin for gas') if (e instanceof Swap2FeeLimitError) throw new Error('Fees are above your cap') if (e instanceof Swap2QuoteError) throw new Error('No route right now: ' + e.message) throw e } } ``` Never retry a `Swap2MaybeSentError` The source transaction may already be on its way. Retrying can send the money twice. Track the id or hash instead. ## Fee caps [Section titled “Fee caps”](#fee-caps) `maxNetworkFeeBps` and `maxProtocolFeeBps` (basis points of the input) refuse routes whose **itemized** fees are above the cap. The check is fail-closed: a route whose provider does not itemize its fees is refused whenever a cap is set. With caps on, the USDC example above keeps only the CCTP route. To protect the user without losing routes, compare the guaranteed output (`toTokenAmountMin`) instead. ## With the kit [Section titled “With the kit”](#with-the-kit) Register the router as a protocol and the [policies](/concepts/policies/) wrap the account it signs with: ```ts import { Wallet } from '@anywalletkit/kit' import { WalletManagerEvm } from '@anywalletkit/wallet-evm' import { RouterSwap2Protocol } from '@anywalletkit/protocol-swap2-router' import { providers } from './swap.ts' export function walletWithSwaps(seedPhrase: string): Wallet { const wallet = new Wallet(seedPhrase, { // Policies wrap the account the router signs with: swaps cannot skip them. policies: { maxFee: { 'eip155:1': 5_000_000_000_000_000n } }, }) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) wallet.registerProtocol('swap', RouterSwap2Protocol, { chain: 'eip155:1', config: { fromChain: 'eip155:1', providers: providers() }, }) return wallet } // const swaps = await wallet.getProtocol('swap') ``` # Custom tokens > Let users add a token by its address, at their own risk: read it from the chain, warn about look-alikes and issuer powers, keep the list, show balances and swap it. The swap catalog in `swap2-assets` is a short curated list. Users hold other tokens too. This page lets them add one by its address — an EVM contract or a Solana mint — see its balance and swap it. The wallet reads everything about the token from the chain and tells the user what it found before anything is saved. Snippets come from [`examples/balances`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/balances), which our CI typechecks and tests. `packages/tokens/scripts/verify-live.mjs` runs the same steps on Ethereum and Solana mainnet. ```sh npm install @anywalletkit/tokens @anywalletkit/protocol-swap2-router ``` ```ts import type { IWalletAccount, IWalletAccountReadOnly } from '@anywalletkit/core' import { inspectToken, swapSource, TokensTable, type JsonStorage, type TokenInspection, type TokensState, type UserToken } from '@anywalletkit/tokens' import { RouterSwap2Protocol, type Swap2Route } from '@anywalletkit/protocol-swap2-router' import type { CrossChainProvider } from '@anywalletkit/protocol-swap2-core' import type { ListedToken } from './balances.ts' ``` ## Look the token up [Section titled “Look the token up”](#look-the-token-up) ```ts /** * Step 1 of "Add token": read what the address is and what to warn about. Nothing is saved yet. * A token the catalog already lists comes back as `listed`: show it, there is nothing to add. */ export async function lookUp(account: IWalletAccountReadOnly, address: string): Promise { const found = await inspectToken(account, address) if (!found.listed) { // found.assessment.level: 'danger' | 'caution'; found.assessment.risks: [{ code, level, message }] // Show every message, worst first, before the user confirms. } return found } ``` `inspectToken` asks the account’s chain package (`getTokenInfo` in `wallet-evm` and `wallet-solana`) for the token’s decimals, symbol, name and supply. An address that is not a token — a wallet, an NFT collection, a plain contract — throws `NotATokenError`. A token the catalog already lists comes back as `{ listed }`: nothing to add. The assessment lists every warning, worst first. A user token is never “safe”: the floor is `caution`. | Code | Level | Meaning | | ---------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unlisted` | caution | Always. Anyone can deploy a token with any name and logo. | | `impersonation` | danger | Its symbol reads as a catalog token of the same chain, or as a native coin or major stablecoin anywhere. Look-alike letters (Cyrillic С, `$` for S, `₮`, fullwidth, invisible characters) are folded first. | | `permanent-delegate` | danger | Solana Token-2022: the issuer can move or burn the user’s tokens. | | `transfer-hook` | danger | Token-2022: a program of the issuer’s choosing runs on every transfer and can block selling. | | `non-transferable` | danger | It cannot be sent or swapped. | | `upgradeable` | caution | An EVM proxy: the owner can replace the code (USDC is one too). | | `mint-authority`, `freeze-authority` | caution | Solana: the issuer can mint more, or freeze balances. | | `transfer-fee`, `default-frozen`, `pausable` | caution | Token-2022 extensions. | | `odd-characters`, `no-symbol`, `zero-supply`, `unusual-decimals` | caution | What the token reports about itself. | Show the symbol and the name as the token reports them, next to the address. Never let a user token borrow a catalog token’s icon: match icons by address, not by symbol. ## Keep the list [Section titled “Keep the list”](#keep-the-list) ```ts /** The user's tokens, kept in the app's storage (AsyncStorage, chrome.storage…). Load once at start. */ export async function openTokens(storage: JsonStorage): Promise { const tokens = new TokensTable(storage) await tokens.load() return tokens } /** Step 2, after the user accepted the warnings: save the token the inspection returned. */ export async function confirmAdd(tokens: TokensTable, found: TokenInspection): Promise { if (found.listed) return null return tokens.add(found.token) } /** The user's tokens on one chain as rows for the balances screen (source 'user'). */ export function userRows(tokens: TokensTable, chain: string): ListedToken[] { return tokens.list(chain).map((t) => ({ chain, token: t.address, symbol: t.symbol, decimals: t.decimals, source: 'user' })) } ``` `TokensTable` stores the list in any JSON storage the app has (`AsyncStorage`, `chrome.storage`, memory in tests). Load it once at start; reads are then synchronous. Writes queue behind each other, and a failed save leaves the list as it was. A failed read leaves the table unloaded instead of empty, so a later write cannot erase the user’s tokens. Entries are keyed by chain and address; Solana is stored under the catalog’s `solana:mainnet` whichever id the account reports. The rows feed the token list of the [Balances](/guides/balances/) guide, marked `source: 'user'`. Balances are read with the same `getTokenBalance(address)` as catalog tokens. ## Swap it [Section titled “Swap it”](#swap-it) ```ts /** * Routes that sell a user token. `allowUnlistedTokens` lets the engine take a token outside the * catalog; only providers that trade any token (LI.FI, Uniswap, Balancer, Fynd, Jupiter) answer. */ export function swapsForUserTokens(account: IWalletAccount | IWalletAccountReadOnly, chain: string, providers: CrossChainProvider[]): RouterSwap2Protocol { return new RouterSwap2Protocol(account, { fromChain: chain, providers, allowUnlistedTokens: true }) } export function sellRoutes(swaps: RouterSwap2Protocol, token: UserToken, amount: bigint, toToken: string): Promise { return swaps.quoteSwap2Routes({ ...swapSource(token), toToken, fromTokenAmount: amount }) } ``` * **Opt in per engine.** `allowUnlistedTokens: true` lets the swap engine take a token outside the catalog. It is off by default. * **Decimals come from the chain.** The engine reads them once with `getTokenInfo`. A `fromTokenDecimals` that disagrees is refused. * **Which providers answer.** LI.FI, Uniswap, Balancer, Fynd and Jupiter trade any token. NEAR Intents, THORChain, Chainflip, Omniston, CCTP and USDT0 only trade their own lists, so they stay out of these routes. * **Approvals.** The approval goes to the token the user named, for exactly the amount, to the spender the quote declares — the same checks as catalog tokens. * **No USD value.** A user token has no price in the router, so the loss warning cannot judge the route. Show the amounts in and out. * **Destination tokens.** An EVM destination was always an address. A Solana destination mint outside the catalog needs the same flag. **Server.** With a remote swap engine, set `allowUnlistedTokens: true` on `createSwap2Handler` too. Otherwise the server drops the token’s decimals, and providers refuse the unlisted source. # Transaction preview > Show what an EVM transaction will do before it is signed — decoded calls, approvals, simulated balance changes and named checks for your review screen. A review screen that shows only “to: 0x1231…” and “data: 0x095ea7b3…” asks the user to trust blindly. `tx-preview-evm` turns one call, or an approve → call sequence, into what the user needs to see before signing: * **Decoded calls.** Approvals (ERC-20 `approve`, `increaseAllowance` and `increaseApproval`, an EIP-2612 `permit` call, Permit2 `approve` and `permit`, single or batch, `setApprovalForAll`), token transfers, and the name of every contract you list as known. A Permit2 batch puts every allowance in `approvals` (the first one is also `approval`). * **Simulated balance changes.** The whole sequence runs in one simulated block through `eth_simulateV1` with transfer tracing: “-100 USDC, +0.03 ETH”. * **Checks.** Named outcomes (`approval-unlimited`, `simulation-reverted`, `recipient-foreign`…) that your app turns into its own words. Nothing is signed or sent. The module only reads, through your RPC. Snippets come from [`examples/tx-preview`](https://github.com/ultratestbro/anywalletkit/tree/main/examples/tx-preview), which our CI typechecks and tests. ```sh npm install @anywalletkit/tx-preview-evm ``` ```ts import { previewEvmTx, type PreviewCall, type Rpc, type TxPreview } from '@anywalletkit/tx-preview-evm' import type { Policies, WriteIntent } from '@anywalletkit/kit' import type { Swap2ExecutionPlan } from '@anywalletkit/protocol-swap2-core' import { encodeFunctionData, erc20Abi, formatUnits, type Hex } from 'viem' ``` ## RPC [Section titled “RPC”](#rpc) The module takes a function `(method, params) → result`, so any transport works: ```ts /** The module takes any JSON-RPC transport: (method, params) → result. */ export function jsonRpc(url: string): Rpc { return async (method, params) => { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), }) const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string; data?: string } } // Keep code and data: the module reads them to tell "no eth_simulateV1" from a revert. if (body.error) throw Object.assign(new Error(body.error.message), body.error) return body.result } } ``` Pass JSON-RPC errors through with their `code` and `data`: the module uses them to tell a revert from a node that has no `eth_simulateV1`. Balance changes need `eth_simulateV1`. On public endpoints we checked (October 2026) it is served on Ethereum, Base, Arbitrum, Optimism, Polygon and BNB Chain, and missing on Avalanche. Where it is missing, a single call falls back to `eth_call`, which tells pass from revert but shows no balance changes. A sequence can’t be simulated there at all (`simulation.status: 'unavailable'`). ## Preview a transaction [Section titled “Preview a transaction”](#preview-a-transaction) ```ts const WETH_BASE = '0x4200000000000000000000000000000000000006' export async function previewWrap(from: string, amount: bigint): Promise { return previewEvmTx({ chainId: 8453, from, rpc: jsonRpc('https://base-rpc.publicnode.com'), own: [from], // every address of this user: sends between them are not "foreign" known: { [WETH_BASE]: 'Wrapped Ether' }, // contracts you trust, with the name to show calls: [{ to: WETH_BASE, data: '0xd0e30db0', value: amount }], // WETH deposit() // Simulate as if the account held 1 ETH: preview a flow before it is funded. stateOverrides: { [from]: { balance: 10n ** 18n } }, }) } ``` | Input | Meaning | | ------------------- | ----------------------------------------------------------------------------------------------------- | | `calls` | Run in order in one block: approvals first, then the call | | `own` | Every address of this user; a transfer to one of them is not “foreign” | | `known` | Contracts you trust → the name to show (a provider’s routers, your tokens) | | `expectedRecipient` | Where the funds should arrive; anyone else is flagged | | `expectedApproval` | The allowance the flow needs; a larger approval is flagged | | `stateOverrides` | Simulate with a different balance, nonce or code, e.g. to preview a flow before the account is funded | | `native` | Symbol and decimals of the gas coin (default ETH, 18) | The result has three parts: * **`calls`.** Each call with `method`, `label`, and `approval` or `transfer` when decoded. An approval’s `amount` is `null` when it is unlimited (2²⁵⁵ or more) or an operator approval. * **`simulation`.** One of three statuses: * `ok`: `changes` (signed deltas in base units, with symbol and decimals) and `gasUsed`; * `reverted`: `callIndex` and `reason`; * `unavailable`: `reason`. * **`checks`.** `{ code, status: 'ok' | 'warn' | 'unknown', detail }`. Balance changes count ERC-20 `Transfer` events to and from the sender, plus native transfers. WETH-style `Deposit` / `Withdrawal` events count only for contracts in `known`, so a look-alike event from an arbitrary contract can’t fake a balance change. ## Turn checks into a verdict [Section titled “Turn checks into a verdict”](#turn-checks-into-a-verdict) Checks are codes, not text. The wording, the language and how strict to be are your app’s decisions: ```ts 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}` }) } ``` | Code | Status | Meaning | | ------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `contract-known` / `contract-unknown` | ok / warn | A contract a call goes to is in `known`, or isn’t — one per target, every call but approvals (a transfer-shaped call included) | | `simulation-ok` | ok | Simulated; balance changes are in `simulation.changes` | | `simulation-reverted` | warn | It will fail; `detail.reason` says why | | `simulation-unavailable` | unknown | The network can’t simulate it | | `recipient-own` / `recipient-foreign` | ok / warn | A recipient is, or isn’t, one of `own` | | `approval-exact` | ok | Approves no more than `expectedApproval` | | `approval-unchecked` | unknown | A bounded approval and no `expectedApproval` to compare it with | | `approval-excess` | warn | Approves more than `expectedApproval` | | `approval-unlimited` | warn | Unlimited allowance (≥ 2^128 − 1) or operator approval | | `approval-unknown-spender` | warn | The spender is not in `known` | | `approval-unparsed` | warn | A `permit` selector (EIP-2612 or Permit2) whose arguments can’t be read: it may grant an allowance nobody can size | | `native-value` | ok / warn | A call sends native coin into a contract (`detail.amount`, per call); warn when that contract is not in `known` (`detail.address`) | ## Preview every write [Section titled “Preview every write”](#preview-every-write) Policies call `confirm` before every write, which makes it the place to simulate. The `intent` carries the call as `raw`: ```ts /** The EVM calls behind a kit write, when it has any. */ function callsOf(intent: WriteIntent): PreviewCall[] | null { const raw = intent.raw as Record if (intent.method === 'sendTransaction' || intent.method === 'signTransaction') { const txs = (Array.isArray(raw) ? raw : [raw]) as { to: string; data?: string; value?: bigint }[] return txs.map(({ to, data, value }) => ({ to, ...(data ? { data } : {}), value: BigInt(value ?? 0) })) } if ((intent.kind === 'approval' || intent.kind === 'revoke') && intent.method === 'approve') { const { token, spender, amount } = raw as { token: string; spender: string; amount: bigint } return [{ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender as Hex, amount] }) }] } return null // messages, typed data, other chains: confirm without a simulation } /** Policies whose confirm step simulates EVM writes before asking the user. */ export function previewPolicies( rpcFor: (chainId: number) => Rpc, own: string[], ask: (intent: WriteIntent, preview: TxPreview | null) => Promise, ): Policies { return { confirm: async (intent) => { const calls = intent.chain.startsWith('eip155:') ? callsOf(intent) : null if (!calls) return ask(intent, null) const chainId = Number(intent.chain.split(':')[1]) const preview = await previewEvmTx({ chainId, from: intent.from, rpc: rpcFor(chainId), own, calls }) return ask(intent, preview) }, } } ``` `ask` receives the preview, or `null` for writes with nothing to simulate (message signatures, typed data, non-EVM chains), and shows your sheet. With this in the wallet’s policies, no EVM transaction reaches the key without a simulation the user has seen. See [Policies](/concepts/policies/). ## Preview a swap [Section titled “Preview a swap”](#preview-a-swap) The swap router hands the exact plan to `reviewExecution` before the first approval. Preview the approvals and the source call together, the way they will run: ```ts /** A swap's approvals and source call, previewed as the one sequence they will run as. */ export async function previewSwap(plan: Swap2ExecutionPlan, from: string, rpc: Rpc): Promise { if (plan.source.kind !== 'call') return null // serialized / typed-order legs are checked by the provider module const approvals: PreviewCall[] = plan.approvals.map((a) => ({ to: a.token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [a.spender as Hex, a.amount] }), })) return previewEvmTx({ chainId: Number(plan.fromChain.split(':')[1]), from, rpc, own: [from], // The router the provider named is the contract the user is trusting. known: Object.fromEntries([plan.source.to, ...plan.approvals.map((a) => a.spender)].map((a) => [a, plan.provider])), calls: [...approvals, { to: plan.source.to, ...(plan.source.data ? { data: plan.source.data } : {}), value: plan.source.value }], expectedApproval: plan.sourceAmount, // anything above the swap input is flagged ...(plan.destChain === plan.fromChain ? { expectedRecipient: plan.recipient } : {}), }) } ``` `expectedApproval: plan.sourceAmount` flags a route that asks for more than it spends. For a same-chain swap, `expectedRecipient` flags output going to anyone but the user. Wire it into the router as shown in [Swaps → Show a review screen](/guides/swaps/#show-a-review-screen).