Skip to content
docs

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.

Terminal window
npm install @anywalletkit/keyring @anywalletkit/kit @anywalletkit/wallet-evm
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'

The keyring takes two adapters: secrets (one entry per seed or key) and storage (names, addresses, flags).

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 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:

/** 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
/** 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)),
}
}
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.

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: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.

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.

/** 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.

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.

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.

/** "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 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.
  • 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.