@anywalletkit/wallet-solana
v0.5.0
Published
Solana wallet for Any Wallet Kit: SLIP-10 m/44'/501'/i'/0' accounts, SOL and SPL transfers with ATA creation, noble ed25519 signing (no WebCrypto needed), built on @solana/kit.
Maintainers
Readme
@anywalletkit/wallet-solana
Solana accounts at m/44'/501'/i'/0' (SLIP-10 ed25519 — the Phantom / Solflare
default), pinned by golden vectors in @anywalletkit/conformance.
Install
npm install @anywalletkit/wallet-solana
# or: pnpm add @anywalletkit/wallet-solana · yarn add @anywalletkit/wallet-solanaOn npm: @anywalletkit/wallet-solana, published from the release workflow with provenance.
Signing uses noble ed25519 over the compiled message bytes, so there is no WebCrypto dependency: the same code runs in Node and in React Native Hermes without the Ed25519 subtle polyfill.
import WalletManagerSolana from '@anywalletkit/wallet-solana'
const wallet = new WalletManagerSolana(seedPhrase, { provider: ['https://rpc-a', 'https://rpc-b'] })
const account = await wallet.getAccount(0)
await account.transfer({ recipient: 'So1…', amount: 10_000_000n }) // SOL (lamports)
await account.transfer({ token: USDC_MINT, recipient: 'So1…', amount: 1_000_000n }) // SPL, creates the ATA if missing
await account.sendTransaction(transactionMessage) // any @solana/kit message
wallet.dispose()What you sign, what it costs, and when the wallet refuses
Every transfer / sendTransaction / signTransaction goes through one plan,
so the network is consulted once per fact and a failing step throws before the
key is used:
- Describe —
account.describeTransaction(tx)(ordescribeTransaction(tx, {account}), pure/offline) decodes System, Token, Token-2022, Associated-Token and Compute-Budget instructions and reportskind(sol-transfer,spl-transfer,token2022-transfer,ata-create,program-call,mixed) plus warnings:unknown-program,sets-authority,closes-account,approves-delegate,unlimited-approve,foreign-fee-payer,missing-signers,unresolved-lookup-tables,burn-address,durable-nonce, andlifetime:blockhash, ordurable-noncewhen the first instruction isAdvanceNonceAccount(the signature never expires). - Hard checks — fee payer must be this wallet (
allowForeignFeePayerto override), a provider transaction must not need other signatures (MissingSignersError), transfers to the System-program address are refused (UnsafeTransactionError,allowBurnto override). EverysendTransaction/signTransaction(message, kitTransactionor base64) also refuses the drainer shapes: SystemAssign/Allocate/CreateAccountAllowPrefund(and theWithSeedforms) of this wallet's account or aCreateAccountof it,AuthorizeNonceAccountby this wallet, a tokenSetAuthority(or the metadata / group update-authority handoff), a tokenBatch, lamports moved to anyone but this wallet by an instruction that is not a visible transfer (CloseAccount,WithdrawExcessLamports,UnwrapLamports,WithdrawNonceAccount,TransferSolWithSeed), a Stake-program instruction this wallet signs that hands a stake account over (Authorize*to another key, aWithdrawto anyone else, aSetLockup*with a foreign custodian or a lockup), a stake account the wallet funds that the same transaction does not initialize (or split into) with this wallet as staker and withdrawer, an undecodable System / Token / Stake instruction (UnsafeTransactionErrorwithcodecore-instruction,sets-authority,closes-accountormoves-lamports;allowAuthorityChanges: trueto override, e.g. for a token launch that renounces its mint authority). A durable-nonce transaction is refused (codedurable-nonce) unlessallowDurableNonceis set in the config or the call's options.transfer/quoteTransferrefuse a recipient that is not a wallet: a token account, a mint or a program (recipient-not-wallet); an address with no account yet is a wallet. The same checks run insignCompiledandsignTransactionMessage. What a program the wallet signs for does by CPI is proved by the simulation (step 4). Swap-aggregator transactions belong onsendSerialized, which adds a program allowlist and a simulated balance / ownership proof and ignores that flag. - Compute budget — by default the price is the 75th percentile of
getRecentPrioritizationFeesover the writable accounts (clamped byminPriorityMicroLamports/maxPriorityMicroLamports) and the unit limit is the simulated usage × 1.1. SetpriorityFee: {microLamports, computeUnitLimit}for explicit values orpriorityFee: falseto add nothing; a budget the caller already put in the message is kept. - Simulation —
simulateTransaction(sigVerify: false) runs before signing; a failure throwsSimulationErrorwith the program logs. It also returns the post-state of the wallet, of its token accounts the transaction writes and of every written account the transaction may create (one that does not exist yet, or a bare System account; one extra read for the pre-state), and refuses what CPI would do there: the wallet's account handed to a program or given data (wallet-owner), a token account's owner or close authority changed, or set on a token account of this wallet that the transaction creates, this wallet's associated token account created for another owner (sets-authority), a delegate allowance no top-level Approve shows (approves-delegate);allowAuthorityChangesskips this too.simulate: falsedisables the gate (and the CPI proof with it). - Fee quote —
quoteSendTransaction/quoteTransferreturn{fee, baseFee, priorityFee, microLamports, computeUnitLimit, tokenFee?};transferMaxFee/transactionMaxFeecompare againstfee.
const q = await account.quoteTransfer({ token: MINT, recipient, amount: 10_000n })
q.priorityFee // lamports added by the compute budget
q.tokenFee // Token-2022 transfer fee withheld by the mint (0 for plain SPL)Token-2022
transfer reads the mint once: owner program, decimals and (for Token-2022) the
TransferFee extension in force this epoch. Transfers use transferChecked
(mint and decimals verified on chain); fee-bearing mints use
transferCheckedWithFee so a changed fee makes the transaction fail instead of
silently taking more. The recipient's ATA is derived for the right program.
Token info
getTokenInfo(mint) reads a mint from the chain, for a token the user adds by
address: { standard: 'spl' | 'spl-token-2022', decimals, symbol, name,
totalSupply, flags }. Name and symbol come from the Token-2022 metadata
extension, else the Metaplex metadata account ("" when there is neither);
anyone can name a token "USDC", so only the address identifies it. flags lists
what a holder should know: mint-authority, freeze-authority, and the
Token-2022 extensions permanent-delegate (someone can move or burn any
balance), transfer-fee (non-zero), transfer-hook, non-transferable,
default-frozen, pausable. An address that is not an initialized Token /
Token-2022 mint throws NotATokenError.
const pyusd = await account.getTokenInfo('2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo')
// { standard: 'spl-token-2022', decimals: 6, symbol: 'PYUSD', name: 'PayPal USD',
// flags: ['mint-authority', 'freeze-authority', 'permanent-delegate'], … }Versioned transactions and provider wires
transfer({..., lookupTables: [table]})/sendTransaction({to, value, lookupTables})fetch the tables and compress the v0 message.- A swap or DeFi provider's base64 wire transaction is accepted directly:
signTransaction(base64)/sendTransaction(base64)decode it, run the hard checks, simulate, add this wallet's signature (others are preserved) and return / broadcast the wire. The message bytes are never altered. sendSerialized(base64, guard)/checkSerialized(base64, guard)prove a provider-built wire before signing: the program allowlist, the hard checks, and a simulated balance proof againstguard.expect(maxLamportsOut,tokenIn: {mint, minAmount, maxAmount?},maxTokenOut). Every bound must be a non-negative bigint (invalid-expectationotherwise, before any RPC call);tokenIn.maxAmountrefuses a wire that pays the wallet more than asked (token-in-excess). A module that knows the exact instructions its operation takes passesguard.instructions: every top-level instruction must match one rule — program, data prefix (data, hex), optional exactdataLength, and the accounts at given positions, compared after lookup tables are resolved — or the wire is refused (unexpected-instruction). Net balances alone let a deposit that also withdraws collateral and transfers it away pass; the rules do not.getAccountInfos(addresses)reads raw accounts (owner, lamports, data) in the order asked,nullwhere none exists, for modules that check a provider's figures against chain state.
Broadcast outcome and timeouts
A url provider gives up after timeoutMs (default 15 000) per request
(AbortSignal.timeout, or a timer where the runtime lacks it); a read that
times out is a NetworkError. A provider list fails over with the same signed
bytes, never a new signature. A send the node refused (preflight failure, bad
signature, an HTTP 4xx such as a rate limit) throws that error: nothing was
forwarded, plan and sign again. Anything else — a timeout, a dropped
connection, a 408 / 5xx, or a refusal after another provider went unanswered —
throws BroadcastUnknownError
(maybeSent: true, hash = the signature): the transaction may land, so do
not sign it again; watch getTransactionReceipt(hash) until its blockhash
expires.
Activity history
createSolanaHistory is the chain's HistorySource (@anywalletkit/core): newest-first ChainTx pages for an address over plain JSON-RPC, no indexer.
import { createSolanaHistory } from '@anywalletkit/wallet-solana/history'
const history = createSolanaHistory({ rpcUrls: ['https://api.mainnet-beta.solana.com'] })
const page = await history.listActivity('2tKRnQTAXmryVYxDNSajdZg1DfX2FbHKNboWr5EBiKhW', { limit: 25 })
const older = await history.listActivity(address, { cursor: page.cursor })
const one = await history.getTransaction(page.items[0].hash, address)
history.explorerTxUrl(one.hash) // https://solscan.io/tx/…| option | default | notes |
|---|---|---|
| rpcUrl / rpcUrls | — | One or several JSON-RPC endpoints, tried in order per request (HTTP 429 / 5xx / network → next). |
| network | mainnet | devnet / testnet switch the CAIP-2 id and add ?cluster= to Solscan links. |
| rateLimit | 5 rps | Requests per second (a JSON-RPC batch counts as one). |
| batchSize | 10 | getTransaction calls per JSON-RPC batch (1–10). |
| maxTokenAccounts | 50 | Token accounts merged into the address's history. |
| fetch, timeoutMs | global, 15 000 | AbortSignal.timeout when the runtime has it. |
Endpoints. getTokenAccountsByOwner (Token + Token-2022, jsonParsed, cached per address) → getSignaturesForAddress for the wallet and each token account → getTransaction (jsonParsed, maxSupportedTransactionVersion: 0, confirmed) in batches of ≤ 10. A transaction the node pruned still yields a row (status from the signature list, no transfers, raw.tx === null).
Pages and cursor. Signatures of every list are merged by slot (newest first), deduplicated, and the page takes the top limit plus ties at the boundary slot, so each list's consumed rows form a prefix of what the node returned. The cursor is base64 JSON { v: 1, l: { <list address>: <before signature> | '' | null } }: one before signature per list, '' = not started, null = exhausted. The list set is frozen in the cursor; a token account created mid-pagination joins on the next fresh listing. Pagination is exact even when one transaction touches several lists.
Mapping. Native transfers come from preBalances/postBalances with the fee added back to the payer, token transfers from pre/postTokenBalances per mint attributed to the token accounts' owners (standard = spl / token2022 from programId, decimals from the balance), senders paired with receivers largest-first; a receiver without a sender is a mint (from = mint), a sender without a receiver a burn (to = mint). fee = meta.fee, status from meta.err, block = slot. call.programs lists every top-level instruction program id, then inner ones; call.to is the first program that is not plumbing (System, ComputeBudget, Memo, Token, Token-2022, Associated Token) and call.data that instruction's base58 data — what @anywalletkit/protocol-staking-spl-stake-pool-solana reads. A transaction made only of plumbing (a SOL or SPL transfer) has no call; to is then the recipient owner. nativeValue is the SOL the payer sent in a plain transfer, 0 for program calls. Symbols are not resolved here (no catalog dependency); the kit fills them.
Fixtures in test/fixtures/history/ were captured on 2026-10-14 from api.mainnet-beta.solana.com for the test wallet: signature pages for the wallet, its JitoSOL and USDC accounts, both token-account lists, the Jito DepositSol (3SmwE6qf…, the same transaction as the stake-pool module's activity fixture), a SOL send and receive, a USDC send and a Token-2022 → USDC aggregator swap. test/history.test.ts checks that the row this source builds for the Jito deposit is described as stake by the stake-pool module.
