Keyring
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, which our CI typechecks and tests.
npm install @anywalletkit/keyring @anywalletkit/kit @anywalletkit/wallet-evmimport { 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”The keyring takes two adapters: secrets (one entry per seed or key) and storage (names,
addresses, flags).
Secrets
Section titled “Secrets”On a phone, use @anywalletkit/secure-storage-react-native:
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.
import { createSecureStorage } from '@anywalletkit/secure-storage-react-native'const secrets = createSecureStorage({ namespace: 'my-wallet' }) // a SecretStorageIt 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:
/** The shape of expo-secure-store (getItemAsync / setItemAsync / deleteItemAsync). */export interface SecureStoreLike<O> { getItemAsync(key: string, options?: O): Promise<string | null> setItemAsync(key: string, value: string, options?: O): Promise<void> deleteItemAsync(key: string, options?: O): Promise<void>}
/** * 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<O>(store: SecureStoreLike<O>, 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 |
Metadata
Section titled “Metadata”/** The shape of AsyncStorage / MMKV / chrome.storage: plain storage is fine, nothing here is secret. */export interface PlainStoreLike { getItem(key: string): Promise<string | null> setItem(key: string, value: string): Promise<void>}
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”export function openKeyring(storage: KeyringStorage, secrets: SecretStorage): Promise<Keyring> { return Keyring.load({ storage, // names, addresses, flags secrets, // one entry per seed or key chains: { evm: { Manager: WalletManagerEvm, config: { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 } }, solana: { Manager: WalletManagerSolana, config: { provider: 'https://api.mainnet-beta.solana.com' } }, ton: { Manager: WalletManagerTon, config: { tonClient: { url: 'https://toncenter.com/api/v2/jsonRPC' } } }, tron: { Manager: WalletManagerTron, config: { provider: 'https://api.trongrid.io' } }, }, })}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”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.
/** * 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<Keyring> { return Keyring.load({ storage: { load: () => storage.load(), save: async () => {} }, // read-only: the main keyring owns the metadata secrets, chains: { evm: { Manager: WalletManagerEvm, config: network } }, })}const base = await keyringOn({ provider: BASE_RPC, chainId: 8453 }, storage, secrets)await base.withAccount(accountId, 'evm', (account) => account.sign(message)) // account.chain = eip155:8453Create, 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”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”/** Your check: any balance or history on any of the addresses. */export type IsUsed = (addresses: Partial<Record<ChainKey, string>>) => Promise<boolean>
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 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”export async function manageAccounts(keyring: Keyring, sourceId: string): Promise<AccountMeta[]> { 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.
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 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”export async function importKey(keyring: Keyring, chain: ChainKey, key: string, confirm: (address: string) => Promise<boolean>) { 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”/** "Show recovery phrase": ask for biometrics or the passcode first, every time. */export async function revealSecret(keyring: Keyring, sourceId: string, authenticate: () => Promise<boolean>) { 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<boolean>) { 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
0xhex (MetaMask, Rabby), Tron plain hex, Bitcoin and Zcash compressed WIF, Solana base58 of the 64-byte secret (Phantom), NEARed25519:….compatibleWithnames 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
exportSecretinstead. - 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”- 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 for the wider picture.