LiteServers
A TONNode LiteServer plan gives you a dedicated liteserver endpoint on TON mainnet, reachable with the ADNL liteserver protocol that every TON library already speaks. You get two things from the console: a liteserver keypair that belongs only to your account, and a global.config.json that points that key at TONNode's gateway.
Nothing about your code changes. ton-lite-client, pytoniq, tonutils-go, tonlib and the lite-client binary all take a global config file, and TONNode's is a standard one — the file is built from the live public mainnet config each time you download it, with the validator and dht sections untouched and the liteservers array replaced by two entries pointing at our gateway with your key.
The gateway terminates your ADNL connection, works out which customer you are from the key you connected to, checks the request against a fixed method table, applies your rate limit and your concurrency cap, and forwards the query to a light node or an archive node depending on how far back the request reaches and what your plan includes.
Not launched. Mempool streaming and a REST/HTTP API are not available. This page documents the liteserver product only; if you need JSON over HTTP or pending-transaction feeds today, TONNode does not sell them yet.
Chapter 1 — Get a config
- Connect a wallet at tonnode.io/dashboard.
- Top up the balance and choose a plan. The balance is funded on-chain in GRAM or USDT, or through xRocket inside Telegram. The plan you pick decides two things: your rate limit and whether archive history is included.
- Download
global.config.jsonfrom the Nodes section and point your tooling at it.
The purchase is not finished when the money moves — it is finished when the gateway confirms it is serving your key. If provisioning fails, the charge is reversed in the same tick, so you are never left paid-up with a credential nothing honours.
What is in the file
{
"@type": "config.global",
"dht": { "…": "standard mainnet DHT section, unchanged" },
"liteservers": [
{
"ip": 1234567890,
"port": 7445,
"id": {
"@type": "pub.ed25519",
"key": "PLACEHOLDER_BASE64_ED25519_PUBLIC_KEY="
}
},
{
"ip": 1234567890,
"port": 7445,
"id": {
"@type": "pub.ed25519",
"key": "PLACEHOLDER_BASE64_ED25519_PUBLIC_KEY="
}
}
],
"validator": { "…": "standard mainnet validator section, unchanged" }
}There are two entries and they address the same gateway. Every TON client library treats liteservers as a failover pool, and a one-entry list gives them nowhere to retry — so the file lists the endpoint twice on purpose. Nothing is load-balanced across two machines by it.
The ip, port and key above are placeholders — the values in your downloaded file are the real ones. Do not hand-edit them, and do not merge TONNode's entries into a config that also lists public liteservers: a client that spreads queries across both sends some of them somewhere else entirely, with different history and different limits.
Treat the file as a credential
The id.key in your liteservers entries is how the gateway recognises your account. Anyone holding the file can spend your rate limit.
There is no self-serve key rotation. The keypair is generated once when you first buy, and a renewal or an upgrade deliberately keeps it so your config file keeps working instead of becoming a migration. Keep the file out of git and ship it as a mounted secret.
The only address you ever see is the gateway's. The nodes behind it are not addressable directly, and their addresses are not published.
Chapter 2 — Connect
Four stacks, four quickstarts. Every JavaScript, Python and Go snippet below was run against mainnet before it was written down.
Verified against: ton-lite-client@3.1.1 + @ton/core@0.63.1, pytoniq==0.1.43 (pytoniq-core==0.1.46), tonutils-go v1.17.1.
JavaScript / TypeScript — ton-lite-client
npm install ton-lite-client @ton/coreton-lite-client does not read a global config for you, so parse it once and build the engine yourself. Save this as client.mjs; the recipes later on import it.
Wait for the engine before you query it. LiteSingleEngine starts in the closed state and only leaves it when the ADNL socket connects; a query issued in the same tick as the constructor throws Error: Engine is closed. The helper below resolves on the engine's ready event, which is emitted once the handshake is finished.
// client.mjs
import { readFileSync } from "node:fs";
import { LiteClient, LiteSingleEngine } from "ton-lite-client";
function intToIP(int) {
return `${(int >> 24) & 0xff}.${(int >> 16) & 0xff}.${(int >> 8) & 0xff}.${int & 0xff}`;
}
export async function connect(configPath = "./global.config.json") {
const config = JSON.parse(readFileSync(configPath, "utf8"));
const ls = config.liteservers[0];
const engine = new LiteSingleEngine({
host: `tcp://${intToIP(ls.ip)}:${ls.port}`,
publicKey: Buffer.from(ls.id.key, "base64"),
});
await new Promise((resolve, reject) => {
engine.once("ready", resolve);
engine.once("error", reject);
});
return { engine, client: new LiteClient({ engine }) };
}// hello.mjs
import { Address } from "@ton/core";
import { connect } from "./client.mjs";
const { engine, client } = await connect();
const mc = await client.getMasterchainInfo();
console.log("masterchain seqno:", mc.last.seqno);
const acc = await client.getAccountState(
Address.parse("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_"),
mc.last,
);
console.log("balance (nanoton):", acc.balance.coins.toString());
console.log("last tx:", acc.lastTx); // { lt: bigint, hash: bigint } | null
engine.close();Two things to know about this client:
- A liteserver error arrives as a plain
Errorcarrying only the message text — the numeric code is dropped.403 archive history is not included in this planreaches you asError: archive history is not included in this plan. Match on the text. - The engine reconnects on its own after the socket closes, and queries issued while it is reconnecting are queued and flushed when it comes back. It is only the very first query, before the first connect, that throws
Engine is closed— which is what theawaitinconnect()is for.
Python — pytoniq
pip install pytoniqimport asyncio
import json
from pytoniq import LiteClient
with open("global.config.json") as f:
CONFIG = json.load(f)
async def main():
client = LiteClient.from_config(CONFIG, ls_i=0, trust_level=2, timeout=15)
await client.connect()
try:
info = await client.get_masterchain_info()
print("masterchain seqno:", info["last"]["seqno"])
acc = await client.get_account_state("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_")
print("balance (nanoton):", acc.balance)
print("state:", acc.state.type_)
finally:
await client.close()
asyncio.run(main())connect() also starts a background block updater, which sends a waitMasterchainSeqno-prefixed query. Those are supported — see the prefix note in Chapter 7 — so client.last_mc_block advances on its own.
Pick trust_level deliberately. LiteClient.from_config defaults to 2; from_mainnet_config defaults to 0. They behave very differently on a TONNode plan:
trust_level |
What it does | Works without archive? |
|---|---|---|
2 |
Trusts the server's answers. Fewest requests. | Yes |
1 |
Verifies the proofs that come back inside each answer, locally. No extra requests. | Yes |
0 |
Also syncs a key-block chain from the config's init block and calls getBlockProof. |
No — getBlockProof is archive-only (see Chapter 4) |
If you want verification, use trust_level=1. trust_level=0 on a plan without archive fails with 403 archive history is not included in this plan during connect().
LiteBalancer also works, and its archive detection lines up with the gateway on purpose — it probes with lookup_block(wc=-1, shard=-2**63, seqno=random.randint(1, 1024)), which is exactly the kind of query the gateway routes to archive:
from pytoniq import LiteBalancer
balancer = LiteBalancer.from_config(CONFIG, trust_level=2)
await balancer.start_up()
print("archival peers:", balancer.archival_peers_num) # 2 on an archive plan, 0 withoutTwo, not one: the config lists the gateway twice, so the balancer sees two peers and probes both.
Go — tonutils-go
go get github.com/xssnick/tonutils-go@v1.17.1tonutils-go reads the global config file directly, so there is nothing to parse.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/xssnick/tonutils-go/address"
"github.com/xssnick/tonutils-go/liteclient"
"github.com/xssnick/tonutils-go/ton"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pool := liteclient.NewConnectionPool()
if err := pool.AddConnectionsFromConfigFile("global.config.json"); err != nil {
log.Fatalln("connect:", err)
}
defer pool.Stop()
// WithRetry is deprecated in v1.17.1. WithRetryTimeout keeps the retry
// count and the per-attempt timeout together; set the timeout above the
// gateway's own backend window (Chapter 7).
api := ton.NewAPIClient(pool).WithRetryTimeout(3, 30*time.Second)
master, err := api.CurrentMasterchainInfo(ctx)
if err != nil {
log.Fatalln("masterchain:", err)
}
fmt.Println("masterchain seqno:", master.SeqNo)
addr := address.MustParseAddr("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_")
acc, err := api.GetAccount(ctx, master, addr)
if err != nil {
log.Fatalln("account:", err)
}
// GetAccount returns a NON-NIL *tlb.Account for an account that was never
// deployed, with IsActive false and State nil. Reading acc.State without
// this check panics with a nil pointer dereference.
if !acc.IsActive {
fmt.Println("account is not deployed; balance is zero")
return
}
fmt.Println("balance:", acc.State.Balance.String())
fmt.Println("last tx lt:", acc.LastTxLT)
}Liteserver errors arrive as ton.LSError, with the code intact:
var lsErr ton.LSError
if errors.As(err, &lsErr) {
fmt.Println(lsErr.Code, lsErr.Text) // e.g. 403 archive history is not included in this plan
}Raw lite-client and tonlib
The config is a plain TON global config, so the reference CLI takes it as-is:
lite-client -C ~/tonnode/global.config.jsonInside the prompt, help lists the commands your node build supports; last and getaccount <address> are the fastest way to confirm the config works. Any tonlib-based stack (tonlibjson, pytonlib, ton-kotlin, the C++ tonlib-cli) takes the same file wherever it expects a global config path. There is no TONNode-specific flag anywhere.
Chapter 3 — Method reference
The gateway classifies every liteserver request before it touches a node. The table is exhaustive and deny-by-default: a request type that is not named below never reaches a node, on any plan, and comes back as 403 method not supported.
Three classes:
- current — the request carries no block id, no
ltand noutime. It cannot reach history however you call it, so the light node always answers and no plan check applies. - depth — the request carries a block id (or a mode-gated seqno). Whether it needs archive depends on which block, so the decision is made per request, not per method.
getAccountStateat the tip is a current read; the samegetAccountStateat seqno 78,000,000 is a historical one. - archive — the request reaches back by construction, whatever the arguments. Refused outright on a plan without archive.
Current — always allowed, always the light node
| TL request | Non-archive plan | Notes |
|---|---|---|
liteServer.getMasterchainInfo |
✅ | Last masterchain block id. |
liteServer.getMasterchainInfoExt |
✅ | mode must be 0; anything else is 400 non zero mode is not supported. |
liteServer.getTime |
✅ | Answered by the gateway itself, not the node. |
liteServer.getVersion |
✅ | Answered by the gateway itself, not the node. |
liteServer.sendMessage |
✅ | External messages; only the tip can accept one. |
liteServer.getLibraries |
✅ | Addressed by library hash, not by time. |
liteServer.getOutMsgQueueSizes |
⚠️ | Passes the method table, but the request handler does not implement it: you get -400 unknown request type. |
liteServer.waitMasterchainSeqno |
⚠️ | Sent bare it waits and then answers 0 Not enough data to read at 0, because there is no following query to run. As a prefix to another query it works normally — see Chapter 7. |
Depth — allowed on every plan, routed per request
| TL request | Non-archive plan | Notes |
|---|---|---|
liteServer.getBlock |
✅ at the tip, 403 deep |
|
liteServer.getBlockHeader |
✅ at the tip, 403 deep |
|
liteServer.getAccountState |
✅ at the tip, 403 deep |
The workhorse. |
liteServer.getAccountStatePrunned |
✅ at the tip, 403 deep |
|
liteServer.runSmcMethod |
✅ at the tip, 403 deep |
Get-methods run against the block you pass. |
liteServer.getShardInfo |
✅ at the tip, 403 deep |
|
liteServer.getAllShardsInfo |
✅ at the tip, 403 deep |
|
liteServer.getOneTransaction |
✅ at the tip, 403 deep |
|
liteServer.listBlockTransactions |
✅ at the tip, 403 deep |
|
liteServer.listBlockTransactionsExt |
✅ at the tip, 403 deep |
|
liteServer.lookupBlock |
✅ at the tip, 403 deep |
The gateway reads the seqno field of the block id whatever the mode. A negative seqno — what pytoniq sends for an lt or utime lookup — means "no seqno" and stays on the light node; a seqno left at 0 is treated as maximally deep and routes to archive. |
liteServer.lookupBlockWithProof |
⚠️ | Passes the method table, but the request handler does not implement it: -400 unknown request type. |
liteServer.getConfigAll |
✅ at the tip, 403 deep |
|
liteServer.getConfigParams |
✅ at the tip, 403 deep |
|
liteServer.getValidatorStats |
⚠️ | Passes the method table, but the request handler does not implement it: -400 unknown request type. |
liteServer.getLibrariesWithProof |
✅ at the tip, 403 deep |
|
liteServer.getShardBlockProof |
✅ at the tip, 403 deep |
|
liteServer.getBlockOutMsgQueueSize |
✅ at the tip, 403 deep |
|
liteServer.getDispatchQueueInfo |
✅ at the tip, 403 deep |
|
liteServer.getDispatchQueueMessages |
✅ at the tip, 403 deep |
|
liteServer.getTransactions |
✅, with a limit | Carries an lt, not a block id — see below. |
getTransactions is the special one. Its signature is count:# account:accountId lt:long hash:int256 — no block id at all. The lt is only where the walk starts; the node then follows prev_trans_lt backwards for up to 16 transactions, and on a quiet account that walk can cross months. The gateway cannot inspect its way to the answer, so it always starts on the light node:
- If the walk stays inside what the light node holds, you get your transactions.
- If it runs off the end, the node answers
-400(for example,cannot locate transaction in block with specified logical time). - On a plan with archive, the gateway catches that
-400and retries the same query against the archive node, so the walk continues. The retry is best-effort: only real data replaces the original-400, and an error from the archive node leaves the original answer alone. - On a plan without archive, the
-400is what you get. This is the one place where hitting the history boundary looks like a node error rather than a403.
Archive — refused without archive in the plan
| TL request | Non-archive plan | Notes |
|---|---|---|
liteServer.getBlockProof |
❌ 403 |
known_block may be seqno 1, and the node builds a key-block proof chain across all of history. Archive whatever the arguments. |
liteServer.getState |
❌ 403 |
The node itself refuses this above seqno 1000, so any call that can succeed is ancient. |
getBlockProof is why pytoniq's trust_level=0 and any "fully verify from the init block" mode need an archive plan.
Everything else
Anything not in the three tables — including liteserver response types sent as requests — is refused with 403 method not supported and never forwarded. There is no allowlist to opt into and no override: an unbounded method never reaches a node.
TL signatures of every accepted request
liteServer.getMasterchainInfo = liteServer.MasterchainInfo
liteServer.getMasterchainInfoExt mode:# = liteServer.MasterchainInfoExt
liteServer.getTime = liteServer.CurrentTime
liteServer.getVersion = liteServer.Version
liteServer.sendMessage body:bytes = liteServer.SendMsgStatus
liteServer.getLibraries library_list:(vector int256) = liteServer.LibraryResult
liteServer.getOutMsgQueueSizes mode:# wc:mode.0?int shard:mode.0?long = liteServer.OutMsgQueueSizes
liteServer.waitMasterchainSeqno seqno:int timeout_ms:int = Object
liteServer.getBlock id:tonNode.blockIdExt = liteServer.BlockData
liteServer.getBlockHeader id:tonNode.blockIdExt mode:# = liteServer.BlockHeader
liteServer.getAccountState id:tonNode.blockIdExt account:liteServer.accountId = liteServer.AccountState
liteServer.getAccountStatePrunned id:tonNode.blockIdExt account:liteServer.accountId = liteServer.AccountState
liteServer.runSmcMethod mode:# id:tonNode.blockIdExt account:liteServer.accountId method_id:long params:bytes = liteServer.RunMethodResult
liteServer.getShardInfo id:tonNode.blockIdExt workchain:int shard:long exact:Bool = liteServer.ShardInfo
liteServer.getAllShardsInfo id:tonNode.blockIdExt = liteServer.AllShardsInfo
liteServer.getOneTransaction id:tonNode.blockIdExt account:liteServer.accountId lt:long = liteServer.TransactionInfo
liteServer.listBlockTransactions id:tonNode.blockIdExt mode:# count:# after:mode.7?liteServer.transactionId3 reverse_order:mode.6?true want_proof:mode.5?true = liteServer.BlockTransactions
liteServer.listBlockTransactionsExt id:tonNode.blockIdExt mode:# count:# after:mode.7?liteServer.transactionId3 reverse_order:mode.6?true want_proof:mode.5?true = liteServer.BlockTransactionsExt
liteServer.lookupBlock mode:# id:tonNode.blockId lt:mode.1?long utime:mode.2?int = liteServer.BlockHeader
liteServer.lookupBlockWithProof mode:# id:tonNode.blockId mc_block_id:tonNode.blockIdExt lt:mode.1?long utime:mode.2?int = liteServer.LookupBlockResult
liteServer.getConfigAll mode:# id:tonNode.blockIdExt = liteServer.ConfigInfo
liteServer.getConfigParams mode:# id:tonNode.blockIdExt param_list:(vector int) = liteServer.ConfigInfo
liteServer.getValidatorStats mode:# id:tonNode.blockIdExt limit:int start_after:mode.0?int256 modified_after:mode.2?int = liteServer.ValidatorStats
liteServer.getLibrariesWithProof id:tonNode.blockIdExt mode:# library_list:(vector int256) = liteServer.LibraryResultWithProof
liteServer.getShardBlockProof id:tonNode.blockIdExt = liteServer.ShardBlockProof
liteServer.getBlockOutMsgQueueSize mode:# id:tonNode.blockIdExt want_proof:mode.0?true = liteServer.BlockOutMsgQueueSize
liteServer.getDispatchQueueInfo mode:# id:tonNode.blockIdExt after_addr:mode.1?int256 max_accounts:int want_proof:mode.0?true = liteServer.DispatchQueueInfo
liteServer.getDispatchQueueMessages mode:# id:tonNode.blockIdExt addr:int256 after_lt:long max_messages:int want_proof:mode.0?true one_account:mode.1?true messages_boc:mode.2?true = liteServer.DispatchQueueMessages
liteServer.getTransactions count:# account:liteServer.accountId lt:long hash:int256 = liteServer.TransactionList
liteServer.getBlockProof mode:# known_block:tonNode.blockIdExt target_block:mode.0?tonNode.blockIdExt = liteServer.PartialBlockProof
liteServer.getState id:tonNode.blockIdExt = liteServer.BlockStateChapter 4 — Depth, archive, and the 403
Where the boundary is
The light node keeps a limited window of history — roughly two weeks, but that is an approximation, not a promise. The gateway does not guess: at startup it walks each light node backwards and then binary-searches for the oldest masterchain block the node still serves, and derives a routing threshold from the result. That threshold is measured once, at startup, and stays fixed until the gateway is restarted. With several light nodes, the smallest of their thresholds is the one that applies.
At request time the rule is:
current_master_seqno − request_seqno >= archive_threshold → archive node
otherwise → light nodeA request with no readable seqno never routes to archive; it stays on the light node. So does a request whose seqno is ahead of the current masterchain seqno.
What a refusal looks like
If the router picks the archive node and your plan does not include archive, the gateway answers with a liteserver error instead of dropping the connection:
code: 403
text: archive history is not included in this planThat error is the only signal that matters. It means: this exact query needed history the light node no longer holds.
Two nearby errors that are not the same thing:
403 method not supported— the method is not in the table above, or a batch of more than awaitMasterchainSeqnoprefix plus one query was sent. Adding archive to your plan will not change it.-400 …fromgetTransactions— the transaction walk ran past the light node's storage. On an archive plan the gateway retries against archive automatically; without archive, this is your boundary.
Deciding whether you need archive
Rule of thumb first: if every query you make is about the recent past, you do not need archive. Balance checks, payment watchers, get-methods against the current state, indexing blocks as they are produced — all of that is current-only work.
You need archive if you: backfill history at start-up, resolve a transaction by an lt older than the light node's window, replay old blocks, verify a proof chain from the init block, or run pytoniq with trust_level=0.
If you are not sure, measure. This probe walks back from the tip and reports the depth at which your plan starts refusing:
import asyncio
import json
from pytoniq import LiteClient
from pytoniq.liteclient.client import LiteServerError
with open("global.config.json") as f:
CONFIG = json.load(f)
SHARD = -2 ** 63
async def reachable(client, seqno):
"""True if this plan can read that masterchain block."""
try:
await client.lookup_block(wc=-1, shard=SHARD, seqno=seqno)
return True
except LiteServerError as e:
if e.code == 403:
return False
raise
async def main():
client = LiteClient.from_config(CONFIG, ls_i=0, trust_level=2, timeout=20)
await client.connect()
try:
tip = client.last_mc_block.seqno
if await reachable(client, 1):
print("archive is included in this plan: block 1 is readable")
return
# binary search for the deepest readable block
lo, hi = 1, tip # lo is known-refused, hi is known-readable
while hi - lo > 1:
mid = (lo + hi) // 2
if await reachable(client, mid):
hi = mid
else:
lo = mid
print(f"tip : {tip}")
print(f"deepest readable : {hi} ({tip - hi} blocks back)")
print(f"first refused : {lo}")
finally:
await client.close()
asyncio.run(main())On an archive plan this prints the first line and stops. On a current-only plan it prints the boundary in blocks, which you can compare against the depth your workload actually needs.
Chapter 5 — Error reference
Errors come back as liteServer.error — the same envelope a real node uses — so your library surfaces them the way it always has.
| Code | Text | What happened | What to do |
|---|---|---|---|
403 |
archive history is not included in this plan |
The query needed a block the light node no longer holds. | Add archive to the plan, or query a more recent block. |
403 |
method not supported |
The request type is not in the method table, or it was sent as a prefixed pair. | Use a documented method, unprefixed. Not a plan problem. |
429 |
too many requests |
Your rate limit, per key. | Back off and retry; raise the plan's rate if it is sustained. |
429 |
too many concurrent requests for this key |
More than 8 queries from this key were open at the same time. | Lower your concurrency; this is a ceiling on in-flight work, not on rate. |
429 |
gateway is saturated, retry shortly |
The global in-flight ceiling across all customers was reached. | Retry with backoff. |
502 |
backend node timeout |
The node did not answer inside the gateway's window (7 s for the light node, 20 s for archive). | Retry. Set your client timeout above those numbers so you see the real error rather than your own. |
400 |
canceled |
Your context or connection went away mid-query. | Client-side; usually your own timeout firing. |
400 |
non zero mode is not supported |
getMasterchainInfoExt with mode != 0. |
Send mode: 0. |
-400 |
unknown request type |
The method passes the plan check but the gateway's request handler does not implement it — see the ⚠️ rows in Chapter 3. | Nothing to retry. Use a method marked ✅. |
-400 |
node text, e.g. cannot locate transaction in block with specified logical time |
The node itself could not serve it — most often a getTransactions walk past the light node's storage. |
See Chapter 4. |
500 |
failed to … |
The gateway could not assemble an answer. | Retry; if it persists it is ours, not yours. |
Reading the code, per stack:
// Go — the code survives
var lsErr ton.LSError
if errors.As(err, &lsErr) && lsErr.Code == 403 {
// plan or method problem
}# Python — code and message on the exception
from pytoniq.liteclient.client import LiteServerError
try:
...
except LiteServerError as e:
print(e.code, e.message)// JavaScript — ton-lite-client keeps only the message text
try {
await client.getBlockHeader(oldBlock);
} catch (e) {
if (e.message.includes("archive history is not included")) { /* … */ }
}Chapter 6 — Recipes
6.1 Watch an account balance
Poll the masterchain tip, then read the account against that block. Two requests per tick — a current-class getMasterchainInfo and a depth-class getAccountState that lands on the light node because the block is the tip — so this runs on every plan.
// watch-balance.mjs
import { Address } from "@ton/core";
import { connect } from "./client.mjs";
const ADDR = Address.parse("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_");
const { engine, client } = await connect();
let seen = null;
async function tick() {
const mc = await client.getMasterchainInfo();
const acc = await client.getAccountState(ADDR, mc.last);
const balance = acc.balance.coins; // bigint, nanoton
if (seen === null) {
console.log("start:", balance.toString());
} else if (balance !== seen) {
const delta = balance - seen;
console.log(`${delta > 0n ? "+" : ""}${delta.toString()} → ${balance.toString()}`);
}
seen = balance;
}
setInterval(() => tick().catch((e) => console.error("tick failed:", e.message)), 5000);
process.on("SIGINT", () => { engine.close(); process.exit(0); });acc.lastTx ({ lt, hash }) changes whenever the account is touched, even when the balance does not — watch that instead if you care about activity rather than value.
6.2 Walk transaction history
getTransactions returns at most 16 transactions per call, oldest first, and each page tells you where the previous one starts. Keep walking until the oldest transaction reports PrevTxLT == 0 (the account's first transaction) or the node stops being able to serve it.
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/xssnick/tonutils-go/address"
"github.com/xssnick/tonutils-go/liteclient"
"github.com/xssnick/tonutils-go/ton"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pool := liteclient.NewConnectionPool()
if err := pool.AddConnectionsFromConfigFile("global.config.json"); err != nil {
log.Fatalln(err)
}
defer pool.Stop()
api := ton.NewAPIClient(pool).WithRetryTimeout(3, 30*time.Second)
master, err := api.CurrentMasterchainInfo(ctx)
if err != nil {
log.Fatalln(err)
}
addr := address.MustParseAddr("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_")
acc, err := api.GetAccount(ctx, master, addr)
if err != nil {
log.Fatalln(err)
}
if !acc.IsActive {
fmt.Println("account is not deployed; no transactions")
return
}
lt, hash := acc.LastTxLT, acc.LastTxHash
for lt != 0 {
txs, err := api.ListTransactions(ctx, addr, 16, lt, hash)
if err != nil {
if errors.Is(err, ton.ErrNoTransactionsWereFound) {
break
}
var lsErr ton.LSError
if errors.As(err, &lsErr) {
// -400 here means the walk left the light node's storage.
// 403 means an archive-class query on a plan without archive.
fmt.Printf("stopped at lt %d: %d %s\n", lt, lsErr.Code, lsErr.Text)
break
}
log.Fatalln(err)
}
// newest first for printing; the slice itself is oldest-first
for i := len(txs) - 1; i >= 0; i-- {
tx := txs[i]
fmt.Printf("lt %d %s fees %s\n",
tx.LT,
time.Unix(int64(tx.Now), 0).UTC().Format(time.RFC3339),
tx.TotalFees.Coins.String())
}
oldest := txs[0]
lt, hash = oldest.PrevTxLT, oldest.PrevTxHash
}
}The same walk in Python, where get_transactions already loops in pages of 16 for you:
txs = await client.get_transactions(ADDR, count=64) # 4 liteserver round-trips
for tx in txs:
print(tx.lt, tx.now, tx.total_fees.grams)6.3 Call a get-method
Get-methods run against a block, so they are depth-class: at the tip they work on every plan.
import asyncio
import json
from pytoniq import LiteClient
from pytoniq.liteclient.client import RunGetMethodError
from pytoniq_core import Address, begin_cell
with open("global.config.json") as f:
CONFIG = json.load(f)
USDT = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
OWNER = "UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_"
async def main():
client = LiteClient.from_config(CONFIG, ls_i=0, trust_level=2, timeout=15)
await client.connect()
try:
# jetton master: get_wallet_address(slice owner) -> slice
stack = await client.run_get_method(
address=USDT,
method="get_wallet_address",
stack=[begin_cell().store_address(Address(OWNER)).end_cell().begin_parse()],
)
wallet = stack[0].load_address()
print("jetton wallet:", wallet.to_str(is_user_friendly=True))
# jetton master: get_jetton_data() -> (int, int, slice, cell, cell)
data = await client.run_get_method(address=USDT, method="get_jetton_data", stack=[])
print("total supply:", data[0])
except RunGetMethodError as e:
print("exit code", e.exit_code)
finally:
await client.close()
asyncio.run(main())Stack values map like this in pytoniq: int → integer, Cell → cell, Slice → slice, None → null. A non-zero TVM exit code raises RunGetMethodError; -256 means the contract is not initialized, and 11 is what a method that does not exist on that contract returns.
In JavaScript, build the stack with @ton/core and parse the base64 result:
import { Address, Cell, TupleBuilder, TupleReader, serializeTuple, parseTuple } from "@ton/core";
import { connect } from "./client.mjs";
const { engine, client } = await connect();
const mc = await client.getMasterchainInfo();
const args = new TupleBuilder();
args.writeAddress(Address.parse("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_"));
const res = await client.runMethod(
Address.parse("EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"),
"get_wallet_address",
serializeTuple(args.build()).toBoc(),
mc.last,
);
if (res.exitCode !== 0) throw new Error(`exit code ${res.exitCode}`);
const wallet = new TupleReader(parseTuple(Cell.fromBase64(res.result))).readAddress();
console.log("jetton wallet:", wallet.toString());
engine.close();6.4 Index blocks
A masterchain block plus its shard blocks is one indexing unit. Take the tip, walk forward, and list the transactions of every shard block referenced by each masterchain block.
ton-lite-client bundles that walk into getFullBlock(seqno):
import { connect } from "./client.mjs";
const { engine, client } = await connect();
let next = (await client.getMasterchainInfo()).last.seqno;
for (;;) {
const tip = (await client.getMasterchainInfo()).last.seqno;
if (next > tip) {
await new Promise((r) => setTimeout(r, 2000)); // ~one masterchain block
continue;
}
const block = await client.getFullBlock(next);
for (const shard of block.shards) {
for (const tx of shard.transactions) {
console.log(
`mc ${next} | ${shard.workchain}:${shard.seqno} | ${tx.account.toString("hex")} lt ${tx.lt}`,
);
}
}
next++;
}The explicit version in Python, when you want the block ids themselves:
blk, _ = await client.lookup_block(wc=-1, shard=-2 ** 63, seqno=seqno)
for shard in await client.get_all_shards_info(blk):
for tx in await client.raw_get_block_transactions(shard, count=1024):
print(shard.workchain, shard.seqno, tx["account"].to_str(), tx["lt"])and in Go:
shards, err := api.GetBlockShardsInfo(ctx, master)
if err != nil {
log.Fatalln(err)
}
for _, shard := range shards {
txs, more, err := api.GetBlockTransactionsV2(ctx, shard, 100)
if err != nil {
log.Fatalln(err)
}
for _, tx := range txs {
fmt.Printf("%d:%d %x lt %d\n", shard.Workchain, shard.SeqNo, tx.Account, tx.LT)
}
_ = more // pass the last TransactionID3 back in to page
}Indexing that keeps up with the chain is current-class work and needs no archive. Backfilling past the light node's window does.
Do not index by polling the tip and re-listing the same block. One masterchain block arrives every few seconds; a loop that polls faster than that spends your rate limit on nothing.
6.5 Check a jetton wallet
Two get-methods: ask the jetton master for the wallet address, then ask that wallet for its data. TEP-74's get_wallet_data returns (int balance, slice owner, slice jetton, cell jetton_wallet_code).
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/xssnick/tonutils-go/address"
"github.com/xssnick/tonutils-go/liteclient"
"github.com/xssnick/tonutils-go/ton"
"github.com/xssnick/tonutils-go/tvm/cell"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pool := liteclient.NewConnectionPool()
if err := pool.AddConnectionsFromConfigFile("global.config.json"); err != nil {
log.Fatalln(err)
}
defer pool.Stop()
api := ton.NewAPIClient(pool).WithRetryTimeout(3, 30*time.Second)
master, err := api.CurrentMasterchainInfo(ctx)
if err != nil {
log.Fatalln(err)
}
usdt := address.MustParseAddr("EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs")
owner := address.MustParseAddr("UQBSsW5yNgxiyjuS3pwnhuTVRO3vG2oKRfixcF-4kE99a-v_")
res, err := api.RunGetMethod(ctx, master, usdt, "get_wallet_address",
cell.BeginCell().MustStoreAddr(owner).EndCell().MustBeginParse())
if err != nil {
log.Fatalln("get_wallet_address:", err)
}
jw := res.MustSlice(0).MustLoadAddr()
fmt.Println("jetton wallet:", jw.String())
data, err := api.RunGetMethod(ctx, master, jw, "get_wallet_data")
if err != nil {
// A wallet that has never received the jetton is not deployed:
// the TVM exit code is -256 (contract not initialized) and the
// effective balance is zero.
fmt.Println("no jetton wallet deployed → balance 0:", err)
return
}
balance := data.MustInt(0) // raw units; USDT has 6 decimals
ownerBack := data.MustSlice(1).MustLoadAddr()
jettonMaster := data.MustSlice(2).MustLoadAddr()
fmt.Println("raw balance:", balance.String())
fmt.Println("owner:", ownerBack.String())
fmt.Println("master:", jettonMaster.String())
}Always divide by the jetton's own decimals — USDT on TON is 6, not 9 — and always handle the not-deployed case. An address that has never held the jetton has no wallet contract, and the correct answer is a zero balance, not an error.
6.6 Confirm a payment landed
For a deposit watcher, watch the account's last transaction rather than the balance, then pull only the new transactions.
get_account_state returns a SimpleAccount, which carries only the address, the balance and the state — no last-transaction pointer. The pointer lives on the ShardAccount that raw_get_account_state returns alongside the raw account:
# raw_get_account_state -> (Account | None, ShardAccount | None)
_, shard_acc = await client.raw_get_account_state(ADDR) # cheap, current-class
if shard_acc is None:
return # account does not exist yet
if (shard_acc.last_trans_lt, shard_acc.last_trans_hash) != seen:
txs = await client.get_transactions(ADDR, count=16, to_lt=seen_lt)
for tx in txs:
msg = tx.in_msg
if msg is None or msg.info.src is None:
continue # external message
print(msg.info.src.to_str(), msg.info.value_coins)
seen = (shard_acc.last_trans_lt, shard_acc.last_trans_hash)to_lt stops the walk at the last logical time you already processed, so a watcher that ran an hour ago costs a couple of requests, not a full history walk.
Chapter 7 — Limits and operational notes
Rate limits
Your plan's rate is enforced as a leaky bucket, per API key. Bursts are absorbed up to the bucket's capacity and then drained at the plan's sustained rate; over the edge you get 429 too many requests rather than a queue.
The bucket is denominated in cost units, not in requests. A clock read and a whole-block fetch are not the same load on a node, so each method is charged what it plausibly costs:
| Cost | Methods |
|---|---|
| 1 | getMasterchainInfo, getMasterchainInfoExt, getTime, getVersion, waitMasterchainSeqno, sendMessage |
| 5 | getAccountState, getAccountStatePrunned, runSmcMethod, getShardInfo, getAllShardsInfo, getOneTransaction, getBlockHeader, lookupBlock, getConfigParams, getLibraries, getOutMsgQueueSizes, getBlockOutMsgQueueSize |
| 16 | getTransactions |
| 50 | getBlock, listBlockTransactions, listBlockTransactionsExt, getBlockProof, getShardBlockProof, lookupBlockWithProof, getLibrariesWithProof, getConfigAll, getValidatorStats, getDispatchQueueInfo, getDispatchQueueMessages, getState |
Your plan's RPS number is stated in ordinary requests — the cost-5 lookups that make up most traffic. A 10 RPS plan refills at 50 cost units a second, which is exactly ten getAccountStates. Cheaper methods run proportionally faster, dearer ones proportionally slower:
| Plan | Refill | Burst capacity | getTime (1) |
ordinary (5) | getTransactions (16) |
whole block (50) |
|---|---|---|---|---|---|---|
| 10 RPS | 50/s | 100 | 50/s | 10/s | ~3/s | 1/s |
| 20 RPS | 100/s | 150 | 100/s | 20/s | ~6/s | 2/s |
| 60 RPS | 300/s | 350 | 300/s | 60/s | ~18/s | 6/s |
Capacity is a full second of refill plus the dearest single query, so every method is admissible on every plan — the expensive ones simply draw more of the budget. That "plus" is not cosmetic: an earlier build of this gateway sized the bucket in requests rather than cost units, and a leaky bucket never admits an amount larger than its capacity, so getTransactions answered 429 forever on the smallest plan no matter how long you waited. It is fixed, and a regression test now asserts that every cost admits on the cheapest tier we sell.
So size the plan against the weight of what you call, not only the count. A watcher polling balances and a backfiller pulling whole blocks are the same request count and about ten times apart in load.
One more consequence worth designing for: the limit follows the key, not the machine. Spreading one key across several servers divides a single budget rather than multiplying it — if you run a fleet that genuinely needs more, buy the rate rather than the instances.
Concurrency
Rate is not the only ceiling. A leaky bucket meters how fast queries arrive; it says nothing about how many are open at once, and the node behind the gateway has no in-flight accounting of its own. So the gateway also caps concurrency:
- 8 queries in flight per key. Over that:
429 too many concurrent requests for this key. - 64 queries in flight across all customers. Over that:
429 gateway is saturated, retry shortly.
Client libraries open one TCP connection and multiplex over it, so connection counts do not throttle this for you — an unbounded Promise.all or goroutine fan-out will hit the per-key cap long before it hits the rate limit.
Connections and timeouts
- Reuse one ADNL connection. Every library above pools internally; opening a connection per query is the most common cause of self-inflicted
429s and connection refusals. - The gateway caps concurrent connections per IP and closes connections that sit idle past its keep-alive window. A closed idle connection is normal — reconnect and carry on.
ton-lite-clientandtonutils-godo this for you. - Backend query windows are 7 s for the light node and 20 s for archive. Set your own client timeout above those, or you will convert a real error into a local timeout and lose the diagnosis.
- Archive queries are slower by nature. If a request suddenly takes seconds instead of milliseconds, you crossed the history boundary.
The waitMasterchainSeqno prefix
Several clients attach a waitMasterchainSeqno prefix to a query — ton-lite-client's awaitSeqno option, tonutils-go's api.WaitForBlock(seqno), and pytoniq's background block updater. Prefixed queries are supported. The pair is judged and priced by the request it wraps: the plan check applies to the real query, and the cost is the wait (1) plus that query's own.
Only the pair is accepted. A longer batch is refused, because a run of concatenated queries cannot be priced as one.
This was broken until recently — the gateway had no case for a two-object body and answered 403 method not supported. Since WaitForBlock is wired unconditionally into tonutils-go's wallet, jetton, NFT and DNS helpers, that made most of the high-level Go API fail on a valid key. If you are reading an older copy of this page that told you to poll getMasterchainInfo instead, you no longer need to.
Retries
Retry 429 with backoff and 502 immediately once. Do not retry 403 — neither variant becomes true on a second attempt. Do not retry -400 from a transaction walk either; it is a boundary, not a blip.
Chapter 8 — Plans
A LiteServer plan sets two things: the rate you may sustain, and whether archive history is included. Everything on this page that says "archive" means the second one.
What you can buy
Four self-serve plans, each 30 days, paid from the account balance:
| Plan | Rate | Archive history | Price |
|---|---|---|---|
| Litenode · 10 RPS | 10 | No | $20 / 30 days |
| Litenode · 60 RPS | 60 | No | $90 / 30 days |
| Litenode · 450 RPS | 450 | No | $490 / 30 days |
| Litenode + Archive · 20 RPS | 20 | Yes | $90 / 30 days |
| Litenode + Archive · 60 RPS | 60 | Yes | $150 / 30 days |
| Litenode + Archive · 450 RPS | 450 | Yes | $800 / 30 days |
The rate is stated in ordinary (cost-5) requests per second. Every method works on every plan; the heavy ones just draw more of the budget, so a whole-block read costs about ten ordinary ones. Chapter 7 has the per-method arithmetic if your workload is block-heavy.
Every plan includes the light node; the archive column adds the history the light node no longer holds, which is the whole difference in price.
The 450 RPS tier is sold rather than merely advertised. The archive node was measured from outside both it and the gateway, with an open-model generator at a fixed offered rate: 1600 requests a second of archive account reads and 200 a second of whole shard blocks, zero errors, no ceiling reached, and the node never fell behind the masterchain. 450 is a quarter of the lower of those figures. Beyond it, or for a dedicated node, talk to support.
Renewing or upgrading adds to whatever time is left rather than overwriting it, and keeps your existing keypair, so buying early never burns the remainder and your global.config.json keeps working.
Choosing
- A payment watcher, a wallet backend, a bot. Mainnet. Balances, last-transaction polling and get-methods at the tip never leave the light node. 10 RPS is ten account reads a second; if you also walk history, budget each
getTransactionsat about three of those. - An indexer that keeps up with the chain. Mainnet 60 RPS — that is roughly six whole blocks a second, comfortably ahead of the chain, with headroom for the account reads alongside.
- An indexer that backfills, an explorer, an analytics job, anything replaying old blocks. Archive. Backfill speed is the point, and it is whole-block work, so 60 RPS (
6 blocks/s) rather than 20 (2 blocks/s). - Proof-verifying clients (
pytoniqtrust_level=0, anything callinggetBlockProof). Archive — without it the method is refused as out of plan. 20 RPS admits it at about two proofs a second, 60 RPS at six. - Not sure? Start on mainnet and run the probe in Chapter 4 against your real workload. A
403 archive history is not included in this planis an unambiguous answer.
Related
- MCP — the same chain data as tools for AI agents. The paid product is the hosted endpoint at
https://mcp.tonnode.io/mcp, with your key in a header;npx -y @tonnode/mcpruns the same server locally against the public liteserver config and takes no key at all. 16 tools; plans are rate limits (Hobby $0 / 60 rpm, Pro $29 / 300 rpm, Scale $199 / 1200 rpm). - GitHub — github.com/tonnode
- Console — tonnode.io/dashboard
