TON networks
The TON family serves the ton chain on mainnet (the default) and testnet through @ton/ton 16, which needs @ton/core 0.63 and @ton/crypto 3 next to it (npm install @ton/ton @ton/core @ton/crypto). The coin is Gram (ticker GRAM, formerly Toncoin), with 9 decimals: a bigint amount is in nanograms, and asset: 'GRAM' and its alias 'TON' both name it. A transfer moves Gram or one jetton to one recipient, with an optional text memo. The Build guides cover what else differs on TON: verdicts and proofs, refusals and receiving.
| Network | Global id (identity) | Explorer |
|---|---|---|
mainnet (default) | -239 | tonviewer.com |
testnet | -3 | testnet.tonviewer.com |
A provider and an indexer.
provideris toncenter's API v2, a liteserver proxy (account state, get-methods, fee emulation, sending), andindexeris its API v3 (which transaction a message became, message traces, history). The indexer is required: without it no transfer could ever be provenfinal. A handle without aprovider, or without anindexer, falls back to the keylesspublicpreset for it, with a logged warning, so name both. Every endpoint's health check reads the network's global id, so an endpoint of the other network is refused (PROVIDER_MISCONFIGURED). A custom pair is any toncenter-compatible v2 and v3:{ endpoints: [{ url: 'https://…/api/v2' }] }and{ endpoints: [{ url: 'https://…/api/v3', kind: 'indexer' }] }.Presets and rate limits.
toncenterneeds anapiKey, which travels in theX-API-Keyheader as aSecret, never in a URL; named as aproviderit is API v2, and as anindexerAPI v3.publicis the same service without a key, for trying things out, not for production. toncenter allows one request per second per network without a key (per IP) and 10 with a free key (per key), shared by v2 and v3, so each preset endpoint takes half: 0.5 requests per second keyless, 5 with a key, each with a burst of 1. Health checks take their tokens from the same buckets, and a 429 answer is a retryableRATE_LIMITED. A paid key allows more: configure it as a custom pair with your ownrateLimit, half of your plan's limit on each (on testnet,testnet.toncenter.com):tsconst headers = { 'X-API-Key': secret(process.env.TONCENTER_KEY ?? '') }; const rateLimit = { rps: 12.5, burst: 2 }; // half of your plan's limit (25 per second here) const providers = { 'tc-v2': { endpoints: [{ url: 'https://toncenter.com/api/v2', headers, rateLimit }] }, 'tc-v3': { endpoints: [{ url: 'https://toncenter.com/api/v3', kind: 'indexer' as const, headers, rateLimit }], }, };A rate limit belongs to a transport, and every container, and every handle whose
optionsormaxLagBlocksdiffer from another's, gets its own driver and transport. So several of them in one process can together exceed toncenter's limit for your IP or key: keep one configuration per network, set once inchains.ton.Proof endpoints and their burst. The presets' burst of 1 keeps them near toncenter's limit, and costs liveness: a proof read first refreshes a recovering endpoint's health, which spends a burst-1 endpoint's only token, so the endpoint may skip the trial request that would let it rejoin the proof quorum, for as long as proof reads keep it busy. On endpoints you configure for proofs, set
burst: 2or more where your plan allows it.Two or three independent providers.
toncenterandpublicare one service: configured together they count as two endpoints, yet they are one source, so toncenter alone then decides finality, bounces, a jetton's decimals and whether a transfer is proven absent, whichrebuildacts on. Proofs read the provider and the indexer under the proof quorum, so configure two or more independent toncenter-compatible pairs, ideally three, for example toncenter and your own: with three, a quorum of two survives one outage. With two, once one has failed its health checks for about three refreshes, the other decides alone until the first recovers.Archival endpoints for proofs. Proofs walk a wallet's transactions back through a message's lifetime and run jetton get-methods at a transfer's own block, and history runs those get-methods at each jetton deposit's block. A node that has pruned a block answers "not ready", or "no state" (exit code -13) for the jetton wallet asked; neither decides anything: the proof, or the history read that holds the deposit, waits until an endpoint that has the block answers. Run archival nodes behind the endpoints you configure. toncenter answered for old blocks on both networks when this was written.
Wallets.
wallets.<name>.tonnames the wallet contract, and every field decides the address:{ version: 'v4r2', workchain?, subwalletId? }(default698983191plus the workchain) or{ version: 'v5r1', workchain?, subwalletNumber?, networkGlobalId? }(default subwallet 0; the network id defaults to the network's and may only equal it, so a v5r1 wallet has another address on each network).workchainis 0 (the default) or -1.bc.walletAddress()shows the address as wallet apps do (UQ…, or0Q…on testnet): compare it with your wallet app before you fund it. An undeployed wallet can receive, and its first transfer deploys it. A watch-only wallet needs itstonsettings too:{ publicKey: '<hex>', ton: { version: 'v5r1' } }.Blockchain.estimateFeeneeds the handle's wallet, since the fee is emulated with its code: without one it fails withINVALID_INTENT, or withCONFIG_INVALIDwhen the intent names afrom. For keys, see Local signers.One output per transfer. A TON wallet delivers each output of a batch in its own transaction, so a batch can partly land: one output can bounce while the others are credited, and a single verdict for the whole transfer would either invite a resend that pays the landed outputs twice or hide a failed one. So in this release a TON transfer carries exactly one output:
batch-transferis not a TON capability,bc.limits()answers{ maxOutputs: 1 }, and more than one output fails withUNSUPPORTED_CAPABILITYbefore anything is built. Batch payouts are not supported yet.Addresses and bounce. Canonical addresses are raw (
0:<hex>, lower case);displaykeeps the form you wrote, andaddress.format({ raw: true })gives the raw one. The form decides bounce:UQ…and0Q…never bounce, whileEQ…,kQ…and raw addresses do. To pay a fresh (undeployed) wallet, use itsUQ…form: a bounceable message to it comes back, less fees, and the transfer fails withtransfer bounced. Some wallet apps switch a non-bounceable address to bounceable when the recipient is an active contract; the library never changes the flag: it sends what you wrote. Mainnet refuses the testnet-only formskQ…and0Q…(INVALID_ADDRESS).Address forms and idempotency. The bounce flag is part of the intent, so the same recipient written as
UQ…and asEQ…(or raw) is two intents: reusing an idempotency key with the other form fails withIDEMPOTENCY_CONFLICT. The testnet flag and the base64 alphabet only change how an address is shown: every spelling with the same bounce flag is the same intent.Transfer ids. An Attempt's id is the normalized hash (TEP-467) of its external message, in hex (
idKind: 'message-hash',canonical: false). Once the indexer has the transaction, its hash appears astxHashin the status.getTransactiontakes either.Seqno and expiry. A wallet sends one transfer at a time: another transfer from it fails with
SEQUENCE_BUSY(retryable, withcontext.blockingOperationId) until the first isincludedor has ended. For more throughput, send from several wallets: another subwallet id or number gives the same key another address. A message lives 60 seconds of chain time from its build (validForSeconds, fixed in this release). A build refuses an endpoint whose chain time is more than 5 minutes from your server's clock (a retryablePROVIDER_INCONSISTENT), so keep the clock in sync: builds refused for chain-time skew mean fix the clock, not the endpoint. An endpoint and a clock both more than 5 minutes ahead at a build could let a later proof miss a wallet reset, the one case this check cannot catch. A message that never lands is provenexpiredabout a minute after its build, andbc.rebuild(id)then signs it again at the wallet's next seqno.rebuildsigns on the spot, so it needs a synchronous signer; withprepareTransferor apendingsigner, sign within that minute. TON has no replace and no cancel (UNSUPPORTED_CAPABILITY).Refusals. toncenter answers every refused message with HTTP 500, which the library treats as "maybe sent": the transfer surfaces as ambiguous, and a node that answers "not ready" or "busy" is treated the same way. Another v2-compatible endpoint's definitive refusal leaves the Operation
stalled(NONCE_CONFLICT,TX_EXPIRED,TX_REFUSEDorINSUFFICIENT_FUNDS). Either way the message may still land within its lifetime, and the Operation ends only on proof:finalorfailedif it landed, orexpired, after whichrebuildsends it again. Only bytes that are no TON message at all arerejected, and the library never builds those. Never pay again with a new key while the Operation lives.Resends. A wallet reset (emptied and deleted, then re-funded or deployed again with its seqno back at 0) makes a message that already ran runnable again. So before the TON driver sends signed bytes again (a caller's retry with the same key, a rebroadcast of a dropped transfer,
bc.rebroadcast,recover(), orbc.broadcastof a wallet message), it checks the wallet's chain: from the newest state an endpoint serves, linked to the state the proof quorum attests, back to the message's build time (for bytes this process did not build, the longest lifetime a message can have, a day). A message that already ran is not sent again (already-known). While a deletion or a new deployment cannot be ruled out, or the check cannot finish (a rate limit, an indexer that is behind), nothing is sent: the call fails with a retryable, ambiguousPROVIDER_UNAVAILABLE, and the proof decides later. The first send of bytes built in the same process, and any send once the wallet's seqno is past the message's, go out without the check. That first send skips it only within 2 seconds of assembling the signed bytes, so a later one (after a slow store write or a paused process) is checked too: several processes sharing one store are covered only through that bound. The check costs a few quorum reads, plus one per 32 wallet transactions it has not read before; on the keyless preset allow several seconds. It protects only the library's own sends: after a reset under a shared key, anyone who saw the message can send it again while it is valid, and no client can prevent that.Busy wallets. The proof that a message never landed, and the check before a resend, walk at most 512 of the wallet's transactions, back to 5 minutes before the message's build. A wallet that runs more than that in the meantime (incoming deposits count) leaves a message that never landed undecided, logged as
TX_CHAIN_WALK_EXHAUSTED: it is never provenexpired, sorebuildcannot follow, and the wallet's next transfer waits behind it (SEQUENCE_BUSY). Send from wallets that do not also take a stream of deposits.Replaced. TON never reports an observed
replacedfor your own message: masterchain state is final, so a transfer that landed but is not indexed yet stayssubmittedorincludeduntil the indexer catches up. A provenreplaced(TX_REPLACED) means that a request signed with the wallet's key, not this transfer, used its seqno: something else is sending from the wallet. Never share a wallet's key with other software (why).Fees (
ton).slow,normalandfastgive the same estimate: the network config sets every price. A Gram transfer's fee is onenetworkcharge, with anexpectedbound: the import, gas and storage fees from the endpoint's emulation, plus the forward fee, never below the config's own formula (one more request per estimate).fee.details(TonFeeDetails) splits it and says whether the transfer deploys the wallet. A jetton transfer adds anattachedcharge, the Gram sent along to your jetton wallet (0.05 GRAM by default, the unspent part refunded), and its bound isupper. The only override is{ attached }in nanograms, as a bigint (TonFeeOverride), on jetton transfers; it must exceed the 1-nanogram forward amount. An estimate whosenetworkcharge exceeds 1 GRAM (a basechain wallet) or 100 GRAM (a masterchain wallet) is not trusted: it is a retryablePROVIDER_INCONSISTENT, so one endpoint cannot fail a transfer for good with an inflated fee. TON signs no fee, so the chain charges its own prices either way. Set the ceiling in nanograms with themaxNetworkFeeoption,{ basechain?, masterchain? }as bigints, inchains.ton.optionsor a handle'soptions(a network of a plugin may also set it in itsparams); for each workchain the option wins, then the network's value, then the default. It is the TON driver's only option: any other key is refused withCONFIG_INVALID. Set it once per chain rather than per handle: handles with different options get their own transports, so two keyless handles make twice the requests toncenter allows one address.Funds. Before signing, the wallet needs Gram for the fee and the amount, or for the fee and the attached value plus the jettons (
INSUFFICIENT_FUNDS). A wallet that cannot pay can already fail the estimate withINSUFFICIENT_FUNDS; itsdetails.requiredis then a lower bound: the amount (or the attached value), the forward fee, and the flat gas price every wallet run pays (config param 21, or 20 for a masterchain wallet). Leave room for the rest of the gas: a wallet that covers that bound but not the whole run gets a retryablePROVIDER_INCONSISTENTfrom the estimate instead, so a transfer of nearly the whole balance keeps failing with it. Right after a wallet's first transfer deploys it, an endpoint that lags can answer that way too until it catches up.Finality.
finalneeds masterchain inclusion and a completed message trace in which the value moved. A masterchain block is final once it exists, sogetFinalizedHeight()is the masterchain head, while proofs attest a block 10 below it. An endpoint more than 150 masterchain blocks (about a minute) behind the best is lagging.Bounces rest on the chain. A transfer that reads as failed invites a second payment, so
transfer bouncedandjetton transfer bouncednever rest on the indexer's word: a lying indexer could report a bounce for a transfer that landed. The indexer only names the transactions. The provider (API v2) serves each one's raw record, which the library hashes and checks against its id: your wallet's transaction that sent the message, and the delivery that received it (both jetton wallets, for a jetton that reached the recipient's), linked by the message's own hash, so another delivery's bounce never stands in for yours. The transfer isfailedonly when the delivery's own record shows the value sent back (its bounce phase) or, for a jetton, a jetton wallet that ran nothing or moved nothing. Proofs read these under the proof quorum; the monitor's observed status reads them from one provider endpoint. While the provider does not hold them yet, or its endpoints disagree, the transfer stays undecided (submittedorincluded), neverfinaleither; a record that contradicts the indexer is logged asBOUNCE_UNPROVEN. Each check of a bounced transfer costs two or three more provider requests. With a single provider service, that service alone decides a bounce, as it decides a transfer's absence: configure two or three independent ones, as above.Jettons.
asset: { standard: 'jetton', contract: <master> }, or the aliasUSDTon mainnet (Tether's masterEQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs, 6 decimals); testnet has no catalog tokens. A transfer goes through your jetton wallet, the one the master names for your wallet under the proof quorum (bc.ext.ton.jettonWallet(owner, master)shows it). It forwards 1 nanogram so the recipient gets a notification, which carries the memo, and the excess comes back to you. A jetton transfer ignores the recipient address's bounce flag: the jetton standard (TEP-74) decides bouncing on the jetton wallets' own messages. It isfinalonly when the recipient's jetton wallet, the one the master names for the recipient, received a positive amount from yours.Jetton decimals. A jetton's symbol and decimals are read under the proof quorum and cached for the container's life. Metadata kept wholly on chain states them, or means 9 decimals (TEP-64's default). Metadata that links to an off-chain document takes them from the indexer's copy of that document, never from a default: while it states no decimals, or the indexer has not fetched it, the jetton stays unresolved, and that is never cached, so a later read resolves it once the indexer has it. A history page or
getTransactionthat holds a transfer of it still reads: the transfer arrives unresolved, with its raw amount in base units and the codePROVIDER_UNAVAILABLE(unresolved assets), and never counts as a deposit until you know its decimals. Anyone can send such a jetton to your address. A call that needs the decimals, such as a transfer of the jetton, fails with a non-retryablePROVIDER_UNAVAILABLEbefore anything is signed. That refusal records nothing, not even an Operation under the idempotency key, so the same call can simply be repeated later, once the indexer has the metadata. Register the decimals of a jetton you use yourself, from its issuer, with a plugin of your own; the library then trusts them as you wrote them:tsaio.use({ name: 'my-jettons', assets: [{ chain: 'ton', network: 'mainnet', aliases: ['XYZ'], ref: { standard: 'jetton', contract: '0:<master, raw, lower case>' }, metadata: { symbol: 'XYZ', decimals: 9 }, }], });A jetton you register this way is never read from the chain, so never register one you do not accept: leave it unresolved, and never credit it.
Memos are text comments of at most 1,024 UTF-8 bytes, public forever. A deposit's memo arrives as
transfer.memo: a Gram deposit's from its message, a jetton deposit's from the forward payload its arrival carries (the notification repeats it). An incoming comment longer than 256 cells (about 32 KB) arrives with no memo.Receiving. There is no block scanner (TON is sharded):
bc.scanner()throwsUNSUPPORTED_CAPABILITY.bc.history(address, { cursor?, limit? })lists the address's transactions from the indexer, newest first, at most 1,000 per page, and only those the indexer reports final. A Gram deposit is themsg:intransfer of the transaction that received it; a bounce that brings your own value back issource: 'internal', a refund, not a deposit. A jetton deposit is decoded only from a jetton wallet that the master itself names for the owner, since anyone can deploy a contract that claims a master. Credit a jetton deposit only from its arrival: themsg:in:jettontransfer in the history of the owner's jetton wallet (bc.ext.ton.jettonWallet(owner, master)), whosetois the owner. Every jetton transfer arrives there, one sent with no forward amount included. The owner's own history shows the same movement a second time, from the notification the jetton wallet sends on (alsomsg:in:jetton, with another transfer id and the samedetails.traceId): never credit that one, or one deposit counts twice. One trace (one external request) may carry several genuine transfers to the same owner, so a trace id is not a dedupe key; dedupe on the arrival's transfer id.Crediting deposits. History entries are reads of one provider and one indexer, so their evidence is
observed(withfinality: 'final'), neverproven, as every family's deposits are (Crediting deposits), and a jetton deposit's genuineness also rests on the provider's get-methods. So before you credit any deposit automatically (or any above your risk threshold), read it again through an independent provider and indexer pair, for examplebc.with({ provider: 'own-v2', indexer: 'own-v3' }).getTransaction(tx.id), and credit it only when both reads are final and agree on the transaction hash, the recipient, the asset, the amount and the memo.Extras and types.
bc.ext.ton.getSeqno(address)reads a wallet's seqno (0 while it is undeployed), andbc.ext.ton.jettonWallet(owner, master)the jetton wallet the master names for an owner.crypto-aio/tonexportsTON_CAPABILITIES,TON_INDEXER_CAPABILITIESandTON_PEER_DEPENDENCIES, and importing it typesnative(bc, '@ton/ton')as aTonClient(Keys, signers and secrets); the root entry exports theTon*types, and both entries exportTonSeqnoOrdering, an Attempt's TON ordering, for store authors (below).Stores. A custom
OperationStoremust keep each Attempt'sorderingwhole and unchanged, every property included. On TON it is aTonSeqnoOrdering,{ kind: 'seqno', seqno, validUntil, validFrom }: the seqno as abigint;validUntil, the chain time in seconds at which the message expires; andvalidFrom, the chain time the build ran at. The proofs read all three from the store, not from the signed bytes. A changed seqno, or avalidUntilchanged or rounded down, can prove a transfer that landedexpiredorreplaced. AvalidFromthat is lost only costs time (the proof then reads back through the longest lifetime a message can have), but one moved later hides a wallet reset that happened before it: a false "not included", andrebuildthen pays twice. The contract suites do not check this yet (stores).Live checks (this repository).
CRYPTO_AIO_INTEGRATION=1runs the read-only checks intest/integration/ton.test.tsagainst testnet (CRYPTO_AIO_IT_TON_NETWORK=mainnetfor mainnet) through the keylesspublicpreset.CRYPTO_AIO_IT_TON_RPC_URLandCRYPTO_AIO_IT_TON_INDEXER_URLpoint them at other v2 and v3 endpoints: use them for keyed or self-hosted endpoints. The suite gives each such endpoint the keyless preset's rate limit,rateLimit: { rps: 0.5 }; set one on any keyless toncenter endpoint you configure yourself, since without it toncenter answers a burst with HTTP 429.