Solana networks
The Solana family serves the solana chain on three clusters with @solana/web3.js (v1), installed next to crypto-aio: npm install @solana/web3.js. A transfer moves SOL or one classic SPL token to one recipient, with an optional memo. The driver sends every request itself, through the handle's transport, and builds its messages and token account addresses itself; the SDK only backs native(), so @solana/web3.js 1.99 or later, and 3.x, work alike. Configuring a real network (Solana) shows a configuration.
Node.js 22.12 or later, as for all of crypto-aio. @solana/web3.js 1.99 depends, through rpc-websockets, on an ESM-only uuid, which require can load only from Node 22.12. On Node 22, installing @solana/web3.js 3 warns that one of its dependencies asks for Node 24 (EBADENGINE); it loads and works on Node 22 all the same.
A harmless install warning. Installing @solana/web3.js can warn of an unmet peer utf-8-validate@^5: its jayson depends on ws 7, whose optional helper is utf-8-validate 5, while ws 8 elsewhere in the tree brings 6. Ignore it. ws runs without that helper, and the driver sends every request through the handle's transport, never over a WebSocket.
| Network | Identity (genesis hash) | Presets |
|---|---|---|
mainnet | 5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d | public, alchemy, infura, ankr |
devnet | EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG | public, alchemy, infura, ankr |
testnet | 4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY | public |
- Endpoints and presets. Every endpoint must report its cluster's genesis hash (
getGenesisHash), or it is refused (PROVIDER_MISCONFIGURED).alchemy,infuraandankrneed anapiKey, which goes in the URL as aSecret, and serve mainnet and devnet only (testnet isCONFIG_INVALID); Infura documents its Solana access as limited to select customers.publicis the cluster's own endpoint (https://api.<cluster>.solana.com), which Solana says is not for production; asolanahandle with no provider falls back to it, with a logged warning. An endpoint of your own is{ endpoints: [{ url }] }. A node that reports itself unhealthy or behind (JSON-RPC-32005) shows asRATE_LIMITED, and is retried like one. - The
publicpreset proves that a transfer never landed only slowly. Solana publishes 100 requests per 10 s per IP, and 40 per 10 s for one method, and thepublicpreset paces each endpoint at 4 requests per second. But in September 2026 devnet and testnet answered HTTP 429 (Retry-After: 10) after about 6 or 7getBlockcalls per 10 s. Proving a transaction absent reads every block of its window, over 150getBlockcalls, and a proof read that meets a 429 fails at once. Each pass keeps what it has verified in memory (the window's frame, and how far the read got), and the next pass resumes there, so overpublicthe proof completes only after many monitor passes: about 26 at 6 calls per 10 s, several minutes or more. Until then a transfer that never landed is notexpired, andrebuildstays refused; a restart of the process starts the read over. No funds are at risk. A block scan also reads twogetBlockcalls per block (its header, then its transactions, and inheadmode a few more lookups per block that is not final yet), but it waits out each 429, so it falls behind without stopping. Senders should use keyed or self-hosted providers for their proofs. - Proven verdicts need independent providers. Each proof is a quorum over your endpoints, and an endpoint is a URL: a single URL, load-balanced or not, is trusted for everything it answers. A backend that lags, was pruned or lacks blocks never decides anything, but only a second, independent provider catches one that answers wrongly, and a wrong "not included" would let
rebuildpay twice. Configure two or more independent providers, ideally three, for exampleprovider: ['alchemy', 'ankr', 'own-node']: with exactly two, both must answer, and once one has been down for about three health intervals the other decides alone until the first rejoins; a third keeps proofs cross-checked meanwhile. If you set arateLimiton an endpoint that serves proofs, give itburst: 2or more (the default is the rate rounded up): with a burst of 1, an endpoint that recovers from an outage may never rejoin the proof quorum. - Retention. Endpoints must serve transaction history (
getTransaction,getBlock,getSignaturesForAddress), and providers keep different amounts: Ankr documents about 16 hours of ledger. A proof reads the block of the transaction's blockhash and every block of its window. An endpoint that no longer holds them decides nothing (the call waits and retries) rather than guessing, so an Operation left unwatched for longer than your providers keep, such as through a day-long outage, waits for an endpoint that still holds those blocks. History ends at a provider's retention too. - Transfers. SOL (a System Program transfer) or classic SPL tokens (
transferChecked), one output per transfer. Tokens move between associated token accounts: the sender's for the mint pays, and the recipient's receives. When the recipient has none, the transfer creates it (CreateIdempotent) and the sender pays its rent-exempt deposit (1,488,440 lamports today, read from the node). The estimate shows that deposit as arentcharge, anupperbound: if someone creates the account first, no rent is paid.getBalanceof a token sums every classic token account the owner holds for the mint, andbc.ext.solana.getTokenAccounts(owner, mint?)lists them; tokens outside the sender's associated token account count in that balance but cannot be sent (INSUFFICIENT_FUNDS). Token-2022 mints throwUNSUPPORTED_CAPABILITY. - Checks before signing. These are refused before anything is signed: SOL to an account the System Program does not own, such as a program, a token account or a stake account (
INVALID_INTENT; to fund one on purpose, usenative()); SOL that would create an account below the rent-exempt minimum (INVALID_AMOUNT); tokens to a program itself (send them to a wallet or a PDA instead) or to a token account instead of its owner (INVALID_INTENT); a frozen token account, the sender's or the recipient's (INVALID_INTENT); and a transfer that would leave the sender with lamports between 0 and its rent-exempt minimum (650,240 lamports for an empty account today), which fails withINSUFFICIENT_FUNDSand itsdetails.requiredanddetails.available. - Fees (
solana). Every charge is in SOL:network(the signature fee, 5,000 lamports per signature of the message; a transfer has one),priority(the compute-unit price, in micro-lamports, times the compute-unit limit, rounded up to whole lamports; it is charged on the limit, not on what the transaction uses) and, when the recipient's token account is created,rent. The bound isexact, orupperwith arentcharge;fee.detailsisSolanaFeeDetails. The library computes thenetworkandprioritycharges from the message itself, and one endpoint'sgetFeeForMessagequote for that message must equal their sum exactly. Any other quote, inflated or deflated, fails the estimate with a retryablePROVIDER_UNAVAILABLE(malformed getFeeForMessage answer), so no endpoint can overstate an estimate (the balance check would then refuse a transfer the wallet can pay) or understate it (the node would then refuse the transfer, and it stalls). The quote also shows that the endpoint knows the message's blockhash. Should Solana change how it charges fees, estimates fail this way until the library follows, rather than misstate the fee.slow,normalandfasttake the 25th, 50th and 75th percentile of the node'sgetRecentPrioritizationFeesfor the accounts the transfer writes (0 when it reports none), and the limit is a simulation plus 20% and 1,000 units (the runtime's default of 200,000 units per instruction when the simulation fails). One endpoint reports that price and runs that simulation, so the price is also bounded by your ownchains.solana.options.maxComputeUnitPrice, which no endpoint can raise: micro-lamports per compute unit as a bigint, from 999 to 2^64 − 1, and 10,000,000 by default (DEFAULT_MAX_COMPUTE_UNIT_PRICE). A speed's percentile is clamped to the bound less 999 before the build's variant is added, so a speed never signs more than the bound. At the 1,400,000-unit maximum limit, the default bound caps a transfer's priority fee at 14,000,000 lamports (0.014 SOL), whatever an endpoint answers. The override is{ computeUnitPrice, computeUnitLimit? }(SolanaFeeOverride), as bigints only: the price in micro-lamports per compute unit, kept exactly, and optionally a limit from 1 to 1,400,000 instead of the simulation. An override price above the bound is refused withINVALID_INTENTbefore anything is signed: the message namesmaxComputeUnitPrice, anderror.detailscarriesrequiredandmaxComputeUnitPrice(decimal strings), so raise the option for a costlier transfer. The build checks the same bound, and that the priority fee matches the price and limit, wherever the estimate came from. Any other key inchains.solana.optionsisCONFIG_INVALID. For a tighter policy per transfer, comparectx.feein yourbeforeSignhook (Solana safeguards) with your own limit. - Identical transfers. ed25519 signatures are deterministic, so two identical transfers on the same blockhash would be one transaction, and one payment would be lost. Each build adds 0 to 1,023 units to the compute-unit limit, an explicit limit included, and a speed adds 0 to 999 micro-lamports to the price, which costs at most about one lamport per 1,000 compute units. When two Operations would still sign the same bytes (an explicit fee at the 1,400,000-unit maximum, where no variant fits, or a rare collision between processes), the library refuses the second one before anything is sent (
NONCE_CONFLICTwithdetails.heldBy). - Expiry instead of replacement. A transaction names a recent blockhash, valid for 150 blocks, about a minute at today's block times, and it can still land in the block after its last valid block height (
lastValidBlockHeight + 1). There is no replace and no cancel (UNSUPPORTED_CAPABILITY). While the transaction is not seen, the workers resend the same bytes. It becomesexpired(TX_EXPIRED) only once the proof quorum attests the blockhash's own block (its height plus 150 is the last valid height; the height the build recorded is only a hint), has finalized the block after the last valid one, and serves every block of the window, each chained to the one before by height and parent hash, without the transaction. Thenbc.rebuild(id)signs a new one on a fresh blockhash. WithprepareTransferor apendingsigner, submit the signatures while the blockhash is valid;rebuildsigns on the spot, so it needs a synchronous signer. - A refusal is not a failure. A node's refusal leaves the Operation
stalledwithTX_REFUSEDorINSUFFICIENT_FUNDS, and the transaction may still land until its window has passed: never pay again with a new key (what to do). The common case isblockhash not foundon the first broadcast, from an endpoint that lags behind the one that served the blockhash. While the blockhash is valid,bc.rebroadcast(id)retries it; otherwise the workers prove itexpired, andbc.rebuild(id)signs a new one. A node's claim that the signature is invalid ends a transfer only when the library finds, with its own check, that the bytes it sent really carry a bad signature; otherwise the claim is aTX_REFUSEDrefusal too, since a lying endpoint may have relayed the bytes anyway. The workers never resend astalledtransfer on their own, so after any refusal, even a false one, retry only withbc.rebroadcast(id)or by repeating the call with the same idempotency key, never as a new transfer. - Finality and heights.
finalis thefinalizedcommitment.waitForConfirmationwaits for inclusion atconfirmedby default, so credit deposits onfinal. Heights are block heights, not slots, so skipped slots never leave a gap in scans or confirmations, andbc.getBlock()takes a height (a hash isUNSUPPORTED_CAPABILITY). The driver finds a height's slot by counting back from the head withgetBlocks, at most about 8 million slots (about five weeks) deep: an older height, ingetBlock()or as a scan's start, fails with a retryablePROVIDER_UNAVAILABLE. - Verdicts. A verdict reads the finalized transaction under the proof quorum. A transaction that failed on chain is proven
failed(TX_REVERTED, with the reasontransaction failed), and its fee is paid. An SPL transfer counts as executed only when the token balances show tokens leaving the sender's account and reaching the recipient's; missing or contradictory balances decide nothing. - Memos are UTF-8 text of at most 256 bytes, sent through the Memo program, and public forever. A received memo (Memo v1 or v2) arrives as
transfer.memoon the transfers of a transaction that carries exactly one memo. - Scanning. Blocks carry SOL transfers, including those a program makes (
source: 'internal'), and classic SPL transfers, whosefromandtoare the token accounts' owners when the node reports them. Transfers are checked against the transaction's balance changes: whatever they do not explain, such as a Token-2022 transfer, makes itdecoding: 'partial'. Vote transactions are skipped.filter.addressesmay hold wallets, token accounts (associated or not) or both. A filtered scan returns every transaction that may move funds for a watched address, an SPL transfer into or out of a watched token account included; one it cannot fully attribute is returned aspartialrather than dropped. An SPL transfer'stois the owner wallet whenever the node reports the owner, never the token account (only apartialtransaction whose owner was not reported names the token account), so credit token deposits by owner: a service that watches token accounts matchestransfer.toagainst their owners too. A token transfer that creates the recipient's token account also shows the rent deposit as an internal SOL transfer to that account. - History comes from the RPC (
getSignaturesForAddress), newest first, without an indexer:bc.history(address)lists transactions fromconfirmedon, at most 1,000 per page, and reads each one back, so a page costs two requests per item (getTransaction, then a header-onlygetBlockfor its height and hash). An SPL deposit into an existing token account appears in that account's history, not the owner's. Behind a load-balanced URL, a backend that does not hold a page's cursor answers-32020, and the page fails with a retryablePROVIDER_UNAVAILABLE: ask again. A handle'sindexer, when you set one, serves history instead; any Solana RPC endpoint can be one. - Tokens. USDC (mainnet, devnet) and USDT (mainnet) are registered by alias. Any other classic mint resolves by address (
{ standard: 'spl', contract: '<mint>' }), with its decimals read from the chain under the proof quorum and the first eight characters of its address as its symbol, since SPL mints carry no symbol. - Keys. Solana keys are ed25519.
localSigner({ ed25519: secret(seedHex) })takes the 32-byte seed; a Solana CLI key file holds 64 bytes, the seed and then the public key. Mnemonic signers derive with SLIP-10, hardened segments only, at the wallet'skeyRef.path; wallets commonly usem/44'/501'/0'/0'. An address is the base58 public key. - Stores. A custom
OperationStoremust keep each Attempt'sorderingwhole and unmodified,blockhashincluded: the expiry proof relies on it (why). - Types.
crypto-aio/solanaexportsSOLANA_CAPABILITIES,SOLANA_PEER_DEPENDENCIESand the SDK-free types (SolanaExpiryOrdering,SolanaExt,SolanaFeeDetails,SolanaFeeOverride,SolanaTokenAccount, also exported fromcrypto-aio), and typesnative(bc, '@solana/web3.js'). It names@solana/web3.js'sConnection, so a project withskipLibCheck: falseneeds@solana/web3.jsinstalled to import it. - Integration tests. The opt-in suite (
CRYPTO_AIO_INTEGRATION=1) reads devnet throughpublicby default.CRYPTO_AIO_IT_SOLANA_NETWORKpicks the cluster, andCRYPTO_AIO_IT_SOLANA_RPC_URLan endpoint (paced at 4 requests per second); the test that proves a transaction absent from a whole window runs only withCRYPTO_AIO_IT_SOLANA_WINDOW=1, and needs a keyed or self-hosted endpoint in that URL. - Not in this release: durable nonces, Token-2022, building versioned transactions with address lookup tables (received ones are decoded), a whole signed transaction in
submitSignatures(submit signature bundles), a Solana network of your own (a local test validator has its own genesis hash), and@solana/kit.
native(bc, '@solana/web3.js') returns a Connection wired to the handle's transport, so it never sees the real URL or key. It speaks HTTP JSON-RPC only: subscriptions have no bridge. Import crypto-aio/solana once to type it. The Connection parses JSON itself, so numbers above 2^53 are rounded there; the driver's own reads keep u64 amounts exact. A transaction you send through it is not an Operation, and the library does not track it.
Solana safeguards
On Solana, one endpoint's answers set a transfer's price and compute limit, and a proof decides whether a transfer can be sent again. The driver's guards:
- Priority fee. The compute-unit price is at most
chains.solana.options.maxComputeUnitPrice(10,000,000 micro-lamports per compute unit by default,DEFAULT_MAX_COMPUTE_UNIT_PRICE), which no endpoint can raise. A speed is clamped below it, an override above it fails withINVALID_INTENTbefore signing (naming the option, withdetails.requiredanddetails.maxComputeUnitPrice), and the build checks it again. With the limit at its 1,400,000-unit maximum, the default bound caps a transfer's priority fee at 0.014 SOL, so one lying endpoint cannot spend the wallet. Any other key inchains.solana.optionsfails withCONFIG_INVALID. For a tighter policy per transfer, comparectx.feeinbeforeSign. - Fee quote. The signature fee is computed from the message, never taken from an endpoint: a
getFeeForMessagequote that differs from the message's fee by a single lamport fails the estimate with a retryablePROVIDER_UNAVAILABLE, so one endpoint can neither inflate an estimate nor deflate it. - Proof providers. A proof that a transfer never landed is what lets
rebuildsign a new one, so a wrong one pays twice. Each proof is a quorum over your endpoints: use two or three independent keyed or self-hosted providers. Thepublicpreset's rate limits let it prove absence only slowly, over many passes, so a transfer that never landed stays unresolved for minutes there. - A refusal is not a failure. A
stalledtransfer (TX_REFUSED,INSUFFICIENT_FUNDS) may still land until its blockhash's window has passed. Retry only withbc.rebroadcast(id)or by repeating the call with the same idempotency key, never as a new transfer. A node's claim that the signature is invalid ends a transfer only when the driver confirms it for the bytes it sent. - Stores. A custom
OperationStoremust keep each Attempt'sorderingwhole and unmodified,blockhashincluded: a changed blockhash misplaces the expiry proof's window (why). - The native client.
native(bc, '@solana/web3.js')returns aConnectionwired to the handle's transport, so it never sees the real URL or key. It speaks HTTP JSON-RPC only (no subscriptions), and it parses numbers itself, so values above 2^53 are rounded there; the driver's own reads keep u64 amounts exact. Importcrypto-aio/solanaonce to type it; withskipLibCheck: false, that needs@solana/web3.jsinstalled.
The sections above give the defaults, how a refused transfer resolves, and which addresses a scan filter matches.