Core concepts
This page defines the terms the other pages use, each with a small example. It is the compact version: the Developer tour tells the story behind each term, and the learning path explains the blockchain ideas underneath from scratch.
The examples use a fake-chain handle bc = env.bc from createFakeEnv(); wrap each awaited call in env.run(...), as the tutorial shows. Real-network examples use the EVM family.
The layers
your application (exchange, wallet, payment system)
| bc.transfer() bc.scanner() aio.monitor aio.on()
v
Blockchain handle ........ immutable: chain + network + library + provider + wallet
|
v
engine / monitor / scanner ... Operations, Attempts, leases, evidence, scan cursors
| \---> signers (hold the keys) and stores (operations, locks, sequences, cursors)
v
driver ................... one per chain family and library, loaded lazily
|
v
transport ................ timeouts, retries by class, rate limits, circuit breaker,
| health and identity checks, proof quorum, redaction
v
provider endpoints (RPC and indexer URLs)The engine runs a transfer: it prepares, signs, stores and broadcasts. The monitor follows Operations after broadcast, and the scanner reads blocks for deposits. The core never imports a blockchain SDK. Only drivers do, and they are loaded on first use.
Chain, network and library
A chain is a blockchain id from the registry. A network is one of its deployments. A library is the SDK the driver uses. A handle is bound to one of each. The built-in chains are the EVM chains, such as ethereum with mainnet and ethers, plus bitcoin, tron, solana and ton; the fake chains come from crypto-aio/testing.
[bc.chain, bc.network, bc.library]; // ['fakechain', 'local', 'fake-sdk']Provider and transport
A provider is a named set of endpoints, or a preset name plus an API key. A handle may list several providers for failover. The transport is the core-owned HTTP layer under every driver. It applies timeouts, retries by retry class, rate limits, a circuit breaker per endpoint, health and identity checks, and quorum reads for proofs. SDKs never see real URLs. One answer is at most transport.maxResponseBytes (64 MiB by default); a longer one is cut off and retried elsewhere, so one endpoint cannot exhaust the process's memory. An endpoint more than maxLagBlocks behind the best known height is lagging, and the monitor and scanner never decide anything from a view that far behind. The tolerance comes from chains.<id>.maxLagBlocks, then the root transport.maxLagBlocks, then the network's own value, then the built-in default of 5. The BSC, Arbitrum, OP and Base mainnets set about 60 s of blocks (134, 240, 30 and 30); set your own for fast testnets with several endpoints. A health check that an endpoint answers with HTTP 429 learns nothing: the endpoint keeps its last good height and identity, and is checked again after its Retry-After. The highest height a verified endpoint reported is the mark every view must stay within maxLagBlocks of; a mark that no verified endpoint comes that close to for three health refreshes in a row is dropped, so one endpoint that once reported a far-future head cannot leave every view stale.
A proof read needs proofQuorum endpoints (2 by default) to agree. The quorum's size counts every endpoint not proven to serve another network, including lagging ones, ones whose identity is not yet confirmed and ones whose circuit breaker is open, so one endpoint never proves a fact alone while the others are only briefly unavailable. That costs liveness: proofs wait at startup, and while an honest endpoint's breaker is briefly open. After about three health intervals (healthIntervalMs, 15 s by default) of an endpoint failing its probes or its requests, it stops counting, but a proof still needs proofQuorum endpoints, capped only by the endpoints you configured: with two endpoints and one down, proofs wait for it, and the one left never proves alone. Use three or more endpoints, from independent providers, so one outage leaves enough. A recovering endpoint is tried alongside the others and can only block a proof; once it answers, it rejoins at the next health refresh, if its probes answer; the next proof read triggers that refresh. transport.degradedProofs: true trades this for liveness: proofs then need only the endpoints still counted, down to one, which keeps them going through an outage but lets the one endpoint left decide alone.
const aio = new CryptoAio({
providers: { node: { endpoints: [{ name: 'main', url: secret('https://node.example/KEY') }] } },
transport: { timeoutMs: 15_000, maxAttempts: 3, proofQuorum: 2 }, // root container only
chains: { fakechain: { provider: 'node', maxLagBlocks: 10 } },
});Wallet, signer and address
A wallet is named configuration that says which key sends. It can name a signer, give a public key only (watch-only), or give an xpub for deposit addresses. A signer is the only place private keys live (see Keys, signers and secrets). An Address is bound to one chain. Compare addresses with canonical or equals(), and show display.
const { signer } = localSigner.generate({ curves: ['secp256k1'], id: 'hot' });
const wallets = { main: { signer: 'hot', tier: 'hot' }, cold: { publicKey: '02ab…' } };
const me = await bc.walletAddress(); // an Address; me.canonical === 'fk1…'bc.deriveAddress(wallet, index) derives deposit addresses from a wallet's xpub (with xpubPath, default 0/{index}, and xpubVersions for formats other than xpub and tpub). On UTXO chains, a key whose version is a Bitcoin SLIP-0132 version (mainnet xpub/ypub/ zpub/Ypub/Zpub, test tpub/upub/vpub/Upub/Vpub) must match the network: a mainnet key on a test network, or a test key on mainnet, is refused with CONFIG_INVALID. Versions outside that table, including custom xpubVersions that are not in it, are not checked. Account-model chains (EVM, Tron) and the Avalanche X-Chain and P-Chain accept an xpub on every network, as their wallets export it. A private extended key (xprv, tprv, zprv and the rest) is refused with CONFIG_INVALID as soon as the wallet is used, and the message never repeats it.
Asset and Amount
An asset is identified by an AssetId string bound to one chain and network, such as fakechain:local/native. Token ids look like ethereum:mainnet/erc20:0xdAC17F…. The metadata (symbol, decimals) is for display only. An alias resolves only within the handle's chain and network. An Amount is an exact, non-negative quantity of one asset, in base units, as a bigint. When you pass an amount (AmountInput):
- a
bigintmeans base units; - a
stringmeans decimal units, parsed with the asset's decimals, never rounded; - a JS
numberis rejected withINVALID_AMOUNT.
const fake = await bc.resolveAsset('native'); // FAKE has 8 decimals
Amount.parse('0.001', fake).base; // 100_000n
await bc.transfer({ to, amount: 100_000n }); // 0.001 FAKE
await bc.transfer({ to, amount: '0.001' }); // the same amountCapability
A capability is a named feature that a handle may support, such as replace-fee, block-scan, tokens or address-history. It comes from the adapter, the network and the configured providers. An unsupported call throws UnsupportedCapabilityError (UNSUPPORTED_CAPABILITY). KNOWN_CAPABILITIES lists the built-in names. On fakechain, bc.supports('replace-fee') is true and bc.supports('tokens') is false.
Container, scope and handle
- The container (
CryptoAio) owns configuration, stores, signers, hooks, plugins, the driver pool and the event bus. Separatenew CryptoAio()instances share only the built-in registry data. Use one per tenant, each with its ownnamespace.configure()sets up the default container behindBlockchain.create(). - A scope (
aio.scope(overrides)) inherits the container's configuration and overrides parts of it. It shares the container's pool and stores, so it is not a tenant boundary. - A handle (
Blockchain) is frozen.with()returns a new handle. Each Operation also stores a frozen copy of its context, so later changes never affect it.
const tenant = new CryptoAio({ namespace: 'tenant-a', plugins: [fakePlugin()] /* … */ });
const eu = tenant.scope({ wallets: { payouts: { signer: 'hot' } } });
const bc = eu.blockchain({ chain: 'fakechain', wallet: 'payouts' }); // bc.with() copies itConfiguration precedence
The most specific value wins: call > handle > scope > root > env > built-ins.
- Call options, such as
waitForConfirmation(id, { confirmations: 12 }). - Handle options:
aio.blockchain(…),Blockchain.create(…)orwith(…). - Scopes, the child before its parent.
- The root container:
new CryptoAio(…)orconfigure(…). - The environment:
CRYPTO_AIO_[<PROFILE>_]<CHAIN>_<KEY>, such asCRYPTO_AIO_FAKECHAIN_RPC_URL, withCRYPTO_AIO_ENVnaming the profile. It carries routing only: network, library, provider name, and RPC or indexer URL (wrapped as aSecret). It never carries private keys, mnemonics or signers. - Built-in defaults: the chain's default network, its first library, and the network's default confirmations.
How layers merge: chains.<id> merges field by field, and its options merge deeply. lifecycle and hooks merge key by key. A named entry in providers, wallets or signers is replaced whole, and arrays, such as a provider list, are replaced too. Pass env: false to a container to ignore the environment.
const aio = new CryptoAio({ wallets: { main: { signer: 'hot', tier: 'hot' } } /* , … */ });
const warm = aio.scope({ wallets: { main: { tier: 'warm' } } }); // main has no signer hereOperation and Attempt
- An Operation is one business transfer. It is stored, survives restarts, and is unique per namespace and idempotency key. The key is your own id, such as a withdrawal id. Without a key, a random one is generated, so a retry would create a new transfer. Set
lifecycle.requireIdempotencyKey: truein production. - An Attempt is one signed transaction for that Operation. It never changes once stored. Replace, cancel and rebuild add more Attempts. One Attempt is the active one.
- The
intentHashis a sha256 of the normalized intent: chain, network, asset id, outputs in base units, sender, memo and fee. The same key with the same hash returns the same Operation. The same key with another hash throwsIDEMPOTENCY_CONFLICT.
const a = await bc.transfer({ to, amount: '0.001' }, { idempotencyKey: 'w-1' });
const b = await bc.transfer({ to, amount: 100_000n }, { idempotencyKey: 'w-1' });
// b.operationId === a.operationId: the same intent in another input formOrdering slot and address lease
An ordering slot is what orders a wallet's transactions on chain: a nonce (nonce ordering), a seqno (TON), the inputs a transaction spends (UTXO) or an expiry (Tron, Solana). The engine reserves one per Operation when it prepares it, and a replacement reuses it. An address lease is a short, renewable lock on one sending address. It is held while a slot is allocated and signed, so concurrent transfers from one wallet get distinct, consecutive nonces (tutorial step 6).
Stores
The container keeps its state in four stores, passed together as stores:
OperationStore: Operations, Attempts, observations, and worker claims.LockManager: leases, each with a token that grows on every acquisition (a fencing token).SequenceStore: the next nonce per address, and nonces released for reuse. Seqno values are read from the chain, not stored.CursorStore: scanner positions.
A write with an older fencing token or version fails (FENCING, VERSION_CONFLICT), so a paused process never overwrites newer work. Only in-memory stores ship (createMemoryStores()); they work in one process and lose everything on restart.
Operation states
| State | Meaning |
|---|---|
created | Stored; nothing built yet |
prepared | Unsigned transaction built and stored; ordering slot (nonce) reserved |
awaiting-signature | Waiting for an asynchronous or offline signer (submitSignatures) |
signed | Signed bytes stored, not broadcast yet |
submitted | Broadcast (or possibly broadcast, when ambiguous) |
stalled | A node refused it (for example insufficient funds); needs your action |
included | In a block, not final yet |
final | Terminal. Proven final; outcome is executed or cancelled |
failed | Terminal. Failed before signing, or proven reverted, replaced or rejected |
expired | Terminal. Expiry proven (expiry- and seqno-based chains) |
abandoned | Terminal. Abandoned by you before anything was signed |
TERMINAL_STATES, NON_TERMINAL_STATES (frozen arrays) and isTerminal(state) expose it.
Evidence and finality
Every status carries its evidence:
observed: the current view of one endpoint. It can change (a reorg, a drop). After signing, observed data never makes an Operation terminal.proven: finalized chain data read with the proof quorum. Every terminal state after signing needs it, with one exception: when nodes reject every Attempt as never valid, the Operation fails withTX_REJECTEDwithout chain proof, because those bytes can never land. Absence is never proof:droppedandrefusedare never terminal. A reorg verdict also needs the proof quorum to serve a different block hash.
finality is none, probabilistic (included) or final. Complete a withdrawal (your own Operation) only on final with proven evidence. A deposit is read, not proven: every family's scanner, history and getTransaction report it with observed evidence, so credit it as Crediting deposits says.
const { status } = await bc.waitForConfirmation(operationId, { finality: 'final' });
// status: { state: 'final', evidence: 'proven', finality: 'final', … }On TON, final needs masterchain inclusion and a completed message trace: the transfer's message was delivered and, for a jetton, the jettons reached the recipient's jetton wallet. A bounce, or a message the wallet skipped, is proven failed instead. A masterchain block is final once it exists, so on TON getFinalizedHeight() is the masterchain head, while the block a proof attests trails it by 10 blocks, a few seconds: the two differ by design (TON networks).
Ambiguous errors
error.ambiguous === true means the outcome is unknown, for example because a broadcast reply was lost. The transaction may still land. Retry with the same idempotency key. The library then resends the stored bytes and never signs again. Never retry with a new key.
try {
await bc.transfer(intent, { idempotencyKey: 'w-1' });
} catch (error) {
if (isCryptoAioError(error) && error.ambiguous) scheduleRetry('w-1'); // same key
}Errors
Every error is a CryptoAioError with code, category, retryable, ambiguous and a redacted context (ids such as operationId). ERROR_CODES maps each code to its category and default retryable flag. Errors lists every class and code, and the safe action for each.
if (isCryptoAioError(error)) log.warn({ code: error.code, operationId: error.context.operationId });Events and logging
aio.on(type, handler) and aio.onAny(handler) deliver typed events with a type and an at timestamp: rpc.request, rpc.response, rpc.error, provider.health, provider.misconfigured, provider.inconsistent, operation.state, operation.stalled, attempt.state, tx.reorged, nonce.allocated, nonce.gap, signer.requested, signer.completed, scanner.block, scanner.rollback and recovery.skipped.
Events and logs carry operational data only: ids, states, codes, heights, timings and sizes. They never carry addresses, amounts, raw transactions, signatures or URLs. The default logger writes through debug (DEBUG=crypto-aio:*). createLogger(namespace, writer) redacts URLs in messages and sensitive fields before your writer sees them.
const aio = new CryptoAio({ logger: createLogger('payments', writer) /* , … */ });
aio.on('operation.state', (event) => metrics.increment(`operation.${event.to}`));