Skip to content
docs

@anywalletkit/kit

npm

The orchestrator. Chain packages and protocol modules are registered; the kit hands out accounts by CAIP-2 chain id and protocols by name, all fed by one seed phrase (or its 64-byte seed) or one external Signer, all wrapped by the same policies. The phrase is turned into seed bytes in the constructor and never kept; no seed or key is ever an own property of a Wallet, manager, account or signer, so logs and serializers show no secret.

Terminal window
npm install @anywalletkit/kit
# or: pnpm add @anywalletkit/kit · yarn add @anywalletkit/kit

On npm: @anywalletkit/kit, published from the release workflow with provenance.

import { Wallet } from '@anywalletkit/kit'
import WalletManagerEvm from '@anywalletkit/wallet-evm'
import WalletManagerSolana from '@anywalletkit/wallet-solana'
const wallet = new Wallet(seedPhrase, {
policies: {
maxFee: { 'eip155:1': 10n ** 16n }, // 0.01 ETH
allowlist: { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': [TREASURY] },
confirm: async (intent) => ui.confirm(intent), // last word before broadcast
},
})
wallet.registerWallet('eip155', WalletManagerEvm, { provider: ['https://rpc-a', 'https://rpc-b'] })
wallet.registerWallet('solana', WalletManagerSolana, { provider: SOL_RPC })
wallet.registerProtocol('lido', LidoProtocolEvm, { chain: 'eip155:1' })
const eth = await wallet.getAccount('eip155:1') // policies applied
await eth.transfer({ recipient: '0x…', amount: 10n ** 15n })
const lido = await wallet.getProtocol<LidoProtocolEvm>('lido')
wallet.dispose() // zeroizes the seed and every chain's keys; later calls throw

Policies run before anything is signed or broadcast, in this order: fee cap (quotes first, where the account can quote), allowlist, blocklist, confirm. maxFee (native base units) is checked on the quote of sendTransaction, signTransaction and transfer, then handed to the account as WriteOptions.maxFee so it holds for what is signed; approve (EVM), sendSerialized (Solana), signRaw (Tron), revokeDelegation and swap2’s sendSerializedTransaction hook get it the same way, checked by the account on the transaction it signs. Under a cap, a write that pays a fee but has nowhere to take one (signBuilt, signCompiled, signTransactionMessage) is refused; off-chain signatures are not capped. Every write surface of an account is covered — sendTransaction, transfer, signTransaction (a module that signs and broadcasts on its own cannot skip them), sign, NEAR’s signNep413 and TON’s signTonProof / signTonConnectData (message signatures: confirm only, nothing moves), the approval surfaces approve, signPermit and EIP-2612 / Permit2 signTypedData (checked against the spender; confirm sees kind: 'approval' with unlimited), EIP-3009 TransferWithAuthorization / ReceiveWithAuthorization, ERC-2771 ForwardRequest, MetaTransaction and the 1inch LOP Order (kind: 'send', judged by whom they pay), any other signTypedData (kind: 'typedData' with destinationUnknown: true: an allowlist refuses it, so does a blocklist without confirm; a blocklisted verifyingContract is refused), and chain-specific ones such as Tron’s signRaw, NEAR’s signBuilt, Solana’s sendSerialized. The rule is deny-by-default: any account method named sign* / send* / approve* / revoke* / transfer* is guarded unless it is listed in UNGUARDED_WRITES, so a new signing method in a chain package cannot bypass the policies by not being named. Reads are never intercepted. Nothing hands out a way around the wrapper: its signer is hidden, an account a method returns (NEAR’s withAccountId) comes back under the same policies, and wallet.getManager(chain) hands out guarded accounts (getAccount, getAccountByPath, getNamedAccount) and no seed or signer accessors. The wrappers hide the signer from descriptors and key listings too, refuse writes to the account (_config reads as a frozen copy), and under a maxFee rule refuse a sign* / send* helper the policies cannot quote. The boundary is what the wallet hands out: the Wallet object itself is the app’s — give modules and dApp bridges accounts, managers or protocols, never the Wallet. A protocol module always gets the wrapper, with policies or without: without any it is a wrapper with no rules (no limits, but no signer, no internal helpers and no writable settings). Chain accounts keep their signer in a private field, so not even the app’s own account (getAccount on a wallet without policies) has a signer property; signerKind says what holds the key.

The lists check every destination a write names (intent.destinations), not only the contract it calls: the recipient or spender inside standard token calls (ERC-20 / 721 / 1155 transfers, approve, increaseAllowance, setApprovalForAll, Permit2 approve, an on-chain EIP-2612 permit, ERC-677 transferAndCall), TRC-20 calls (by parameters and inside raw_data_hex), NEAR ft_transfer(_call) / nft_transfer(_call) / storage_deposit / deleteAccount (also in base64 borsh), TON jetton and NFT transfers (destination and excess receiver), the owner of the token account an SPL transfer pays (read through the account’s getTokenAccountOwner, only when a list or confirm applies), and whom a Solana Stake instruction pays or empowers. An allowlist must hold all of them. When part of a write cannot be read, the intent says destinationUnknown: an allowlist refuses it, and so does a blocklist with no confirm hook. transfer is judged by recipient, the field every chain pays. An approval is unlimited at type(uint128).max or above (core’s isUnlimitedAllowance), and when its amount cannot be read; a Permit2 batch lists every token in intent.approvals. The checks and the signature use one copy of the arguments, taken before the first check, and confirm gets its own: a getter, a confirm hook or a timer cannot change what is signed after it was judged.

A revoke is kind: 'revoke': an approve / signPermit / EIP-2612 Permit of 0, raw revoke calldata (approve(0x0, tokenId), setApprovalForAll(…, false), Permit2 lockdown, or a batch of only these) and revokeDelegation(). Raw approve(spender, 0) is NOT a revoke: on an ERC-721 it approves spender for token #0, so it is checked as an approval of spender. It skips the allowlist and the spender’s blocklist entry (taking an allowance back from a blocklisted drainer must stay possible); the blocklist still applies to the contract called, the fee cap and confirm apply, and confirm sees revokes: [{ token, spender }].

history() is the wallet’s activity feed: one page across every chain that has a history source, newest first, every row described by the first registered protocol module whose describeActivity recognizes it (registration order), else by the generic fallback. Rows the source flagged as spam are hidden unless asked for.

import { createEvmHistory } from '@anywalletkit/history-evm'
const wallet = new Wallet(seedPhrase, {
activity: {
known: { 'eip155:8453': { '0x3fC9…7FAD': 'Uniswap Universal Router' } }, // subtitle for otherwise-anonymous contract calls
natives: { 'eip155:999999': { symbol: 'XYZ', decimals: 18 } }, // overrides the built-in table / swap2-assets catalog
onError: (e) => log.warn('activity', e), // a source down, a recognizer that threw, a journal write that failed
pendingTtl: 24 * 3600, // seconds a pending intent waits for its row (default)
},
})
wallet.registerWallet('eip155', WalletManagerEvm, { provider })
wallet.registerProtocol('aave', AaveProtocolEvm, { chain: 'eip155:8453' }) // every registered protocol is a recognizer
wallet.registerHistorySource(createEvmHistory({ chainId: 8453, etherscanApiKey }))
wallet.registerHistorySource(createEvmHistory({ chainId: 1, etherscanApiKey }))
const page = await wallet.history({ limit: 20 }) // account 0 on every chain with a source
const older = await wallet.history({ limit: 20, cursor: page.cursor })
// or explicit addresses / chains:
await wallet.history({ addresses: [{ chain: 'eip155:1', address }], includeSpam: true, since: lastRefresh })
const item = await wallet.describeTransaction(tx) // one ChainTx through the same pipeline, for a detail screen

history(query) → { items: FeedItem[], cursor?, errors? }

  • query.addresses ({chain, address}[]) or query.index (account index on every chain with a source, default 0); chains restricts; limit (default 20); includeSpam; since (unix seconds, for refresh); cursor from the previous page.
  • items are ActivityItems (id = chain:hash, kind, title, subtitle, assetsOut / assetsIn in base units with symbol and decimals, counterparty, related, spam, fee, explorerUrl) plus intentId when the row belongs to a journal intent and approval (token, spender, amount, null = unlimited) for approve / revoke.
  • cursor packs every source’s own cursor into one opaque string; absent when every source is exhausted. Ordering is time desc, then chain, then hash. A source that returned a short page is never overtaken: older rows of other chains wait for its next page, so a row can never appear on a later page than a newer one. Sources with block-window pagination that answer empty pages are followed up to maxEmptyHops (default 3) per call.
  • errors lists what failed this call (stage: 'source' | 'recognizer' | 'journal', with chain / module). A failed source keeps its position in the cursor and is retried on the next call; a recognizer that throws is skipped for that row and the next recognizer / the fallback still runs. Nothing is silently dropped.

describeGeneric(tx, ctx, opts) is exported and pure. From the owner’s point of view:

kind when title / subtitle
send only own → others “Sent USDC”, recipient label
receive only others → own (also airdrops through someone else’s contract call) “Received ETH”, sender label
self own → own (two accounts of the wallet) “Self-transfer”, moved amount in assetsOut
approve / revoke owner called ERC-20 approve (0x095ea7b3); amount 0 = revoke, ≥ 2^255 = unlimited “Approved USDC for Permit2”, approval filled
contract any other call the owner made (amounts attached when something moved) “Contract call”, opts.known[chain][to] label or 0x1234…abcd
unknown nothing moved, no call “Transaction”

Failed sends keep the intended amount (native value, or ERC-20 transfer calldata). Symbols and decimals come from the transfer rows, then the swap2-assets catalog, then the native table (natives option → built-in: GRAM on TON, SOL, TRX, BTC, NEAR → catalog natives for EVM chains → namespace default). spam is read from raw.spam / tx.spam (history-evm’s rules).

On-chain rows cannot say “swapped USDC → ETH via 1Click” (the chain shows a USDC send to a deposit address), cannot pair a bridge’s two legs, and are not there until the explorer indexes them. The app records what it meant and the kit folds it in:

const journal = createMemoryIntentJournal() // or your own { list, put, remove } over SQLite / AsyncStorage
wallet.setIntentJournal(journal)

Write one Intent per user action, right after broadcast:

await journal.put({
id: crypto.randomUUID(), // or the provider's id (1Click deposit address, LI.FI route id)
kind: 'swap', // swap | bridge | stake | unstake | claim | supply | withdraw | borrow | repay | send | wrap | unwrap
createdAt: Math.floor(Date.now() / 1000),
chain: 'eip155:1', address: from, // where it started, who signed
hashes: [{ chain: 'eip155:1', hash: depositHash }], // source leg first; push the destination leg when the provider reports it
expect: { assetsOut: [{ token: USDC, amount, symbol: 'USDC', decimals: 6 }], assetsIn: [{ token: null, amount: quoteOut, symbol: 'ETH', decimals: 18 }] },
protocol: '@anywalletkit/protocol-swap2-near-intents',
subtitle: 'NEAR Intents',
status: 'pending',
})

What the app writes, per flow:

  • Swap / bridge (swap2): kind swap (same chain) or bridge, hashes[0] = the source transaction (deposit / router call), expect from the quote. When the provider’s status endpoint returns the destination hash, put the intent again with it appended to hashes (a relayer fill that is not the wallet’s own tx goes in related). For 1Click, where the user sends to a deposit address and the provider fills later, the deposit tx is hashes[0] and the fill hash is appended once GET /status returns it.
  • Earn (Aave / Morpho / Lido / Tonstakers / Jito …): kind supply / withdraw / stake / unstake / claim, hashes[0] the transaction, protocol the module name, subtitle the market label. Modules recognize these rows on their own; the intent matters only before the explorer indexes the row.
  • Send: optional (kind: 'send'); useful for the pending row and for a contact label in subtitle.
  • Approvals: not needed (the fallback and approvals module recognize them).

What history() does with the journal:

  1. A row whose hash is in an intent inherits kind, protocol, subtitle and the expected assets when the recognizer returned a generic item; a recognizer’s verdict is kept. Every matched item carries intentId and related (the other legs).
  2. A pending intent with no row on the first page is looked up with each leg’s getTransaction (explorer lag); found → emitted confirmed; not found → emitted at the top as status: 'pending', id: intent:<id>, titled from expect.
  3. Two legs of one intent on the same page collapse into one item (the source leg), assetsIn from the destination row, related pointing at it; the destination row is dropped. Legs that fall on different pages each carry intentId; collapse by it in the app if you keep pages in memory.
  4. Pending longer than pendingTtl (default 24 h) with no row → status: 'failed', persisted. Failed intents keep showing at the top of the first page until the app removes them (dismiss action).
  5. Matched intents are written back as confirmed / failed. The kit never removes intents; prune confirmed ones older than your retention window.

Timestamps are unix seconds like ChainTx.time; amounts are base-unit bigints.

Renames and re-exports Wallet