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

  1. Connect a wallet at tonnode.io/dashboard.
  2. 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.
  3. Download global.config.json from 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

JSON
{
  "@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

Bash
npm install ton-lite-client @ton/core

ton-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.

JavaScript
// 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 }) };
}
JavaScript
// 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 Error carrying only the message text — the numeric code is dropped. 403 archive history is not included in this plan reaches you as Error: 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 the await in connect() is for.

Python — pytoniq

Bash
pip install pytoniq
Python
import 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. NogetBlockProof 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:

Python
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 without

Two, not one: the config lists the gateway twice, so the balancer sees two peers and probes both.

Go — tonutils-go

Bash
go get github.com/xssnick/tonutils-go@v1.17.1

tonutils-go reads the global config file directly, so there is nothing to parse.

Go
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:

Go
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:

Bash
lite-client -C ~/tonnode/global.config.json

Inside 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 lt and no utime. 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. getAccountState at the tip is a current read; the same getAccountState at 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 -400 and 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 -400 is what you get. This is the one place where hitting the history boundary looks like a node error rather than a 403.

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

Text
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.BlockState

Chapter 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:

Text
current_master_seqno − request_seqno >= archive_threshold  →  archive node
otherwise                                                   →  light node

A 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:

Text
code: 403
text: archive history is not included in this plan

That 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 a waitMasterchainSeqno prefix plus one query was sent. Adding archive to your plan will not change it.
  • -400 … from getTransactions — 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:

Python
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
// Go — the code survives
var lsErr ton.LSError
if errors.As(err, &lsErr) && lsErr.Code == 403 {
	// plan or method problem
}
Python
# Python — code and message on the exception
from pytoniq.liteclient.client import LiteServerError

try:
    ...
except LiteServerError as e:
    print(e.code, e.message)
JavaScript
// 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.

JavaScript
// 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.

Go
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:

Python
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.

Python
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:

JavaScript
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):

JavaScript
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:

Python
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:

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).

Go
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:

Python
# 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-client and tonutils-go do 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 getTransactions at 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 (pytoniq trust_level=0, anything calling getBlockProof). 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 plan is an unambiguous answer.

  • 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/mcp runs 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).
  • GitHubgithub.com/tonnode
  • Consoletonnode.io/dashboard