@anywalletkit/kit
v0.6.0
Published
The Any Wallet Kit orchestrator: one seed or signer, registered chain wallets and protocol modules, accounts by CAIP-2 chain, policies that run before anything is broadcast.
Maintainers
Readme
@anywalletkit/kit
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.
Install
npm install @anywalletkit/kit
# or: pnpm add @anywalletkit/kit · yarn add @anywalletkit/kitOn 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 throwPolicies 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), other signTypedData (confirm only),
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.
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 }].
Activity history
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 screenhistory(query) → { items: FeedItem[], cursor?, errors? }
query.addresses({chain, address}[]) orquery.index(account index on every chain with a source, default 0);chainsrestricts;limit(default 20);includeSpam;since(unix seconds, for refresh);cursorfrom the previous page.itemsareActivityItems (id = chain:hash,kind,title,subtitle,assetsOut/assetsInin base units with symbol and decimals,counterparty,related,spam,fee,explorerUrl) plusintentIdwhen the row belongs to a journal intent andapproval(token,spender,amount,null= unlimited) forapprove/revoke.cursorpacks 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 tomaxEmptyHops(default 3) per call.errorslists what failed this call (stage: 'source' | 'recognizer' | 'journal', withchain/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.
Generic fallback
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).
Intent journal — the app-side contract
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):
kindswap(same chain) orbridge,hashes[0]= the source transaction (deposit / router call),expectfrom the quote. When the provider's status endpoint returns the destination hash,putthe intent again with it appended tohashes(a relayer fill that is not the wallet's own tx goes inrelated). For 1Click, where the user sends to a deposit address and the provider fills later, the deposit tx ishashes[0]and the fill hash is appended onceGET /statusreturns it. - Earn (Aave / Morpho / Lido / Tonstakers / Jito …):
kindsupply/withdraw/stake/unstake/claim,hashes[0]the transaction,protocolthe module name,subtitlethe 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 insubtitle. - Approvals: not needed (the fallback and approvals module recognize them).
What history() does with the journal:
- A row whose hash is in an intent inherits
kind,protocol,subtitleand the expected assets when the recognizer returned a generic item; a recognizer's verdict is kept. Every matched item carriesintentIdandrelated(the other legs). - 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 asstatus: 'pending',id: intent:<id>, titled fromexpect. - Two legs of one intent on the same page collapse into one item (the source leg),
assetsInfrom the destination row,relatedpointing at it; the destination row is dropped. Legs that fall on different pages each carryintentId; collapse by it in the app if you keep pages in memory. - 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 appremoves them (dismiss action). - 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.
