Querying history: archive access patterns

Why liteservers return "lt not in db", how to page transaction history with lastTx {lt, hash} in ton-lite-client, and how to plan archive backfills.

Transaction history on TON is a linked list, not an index. Every transaction stores the logical time (lt) and hash of the previous transaction on the same account, and you walk that chain backwards from the account's current state. This page covers the pagination pattern, why it breaks on public liteservers, and how to plan a full-history backfill against an archive node.

Why public liteservers answer "lt not in db"

A regular (non-archive) liteserver prunes old blocks and states. It keeps enough recent history to serve current balances, run get methods, and return the last stretch of transactions — typically days, not years. Ask it for a transaction older than its garbage-collection horizon and it fails with:

LiteServer error: cannot compute block with specified transaction: lt not in db

This is not a bug in your client and no retry will fix it. The node genuinely does not have the data. Public-config liteservers (the ones in global.config.json) are all pruned, so any history walk that starts on an active account hits this wall within the first few pages.

The reason few nodes keep full history is cost: an archive node in 2026 needs 16–20 TB of NVMe, 128 GB of RAM, and 1–2 weeks of initial sync. That is why archive access is a separate tier everywhere, including TONNode ($30–$999/mo depending on guaranteed rate, vs $20–$500 for pruned lite nodes).

The lastTx pagination pattern

The walk has two steps:

  1. getAccountState returns the account's most recent transaction pointer as lastTx: { lt, hash }.
  2. getAccountTransactions(address, lt, hash, count) returns up to count transactions starting at that pointer and going backwards. Liteservers cap the batch at 16.

Each returned transaction contains prevTransactionLt and prevTransactionHash. Feed the oldest transaction's prev* pair into the next call. When prevTransactionLt === 0n, you have reached the account's first transaction ever.

Because each page starts exactly at the pointer you pass (inclusive) and the next pointer is the previous transaction of the last item, pages never overlap and you never miss a transaction — even if new transactions land on the account while you are paging. The chain you are walking is immutable.

Walking history with ton-lite-client

Install ton-lite-client and @ton/core. The liteserver host, port, and base64 Ed25519 key below come from your TONNode dashboard — the entry is drop-in for any global-config-compatible client.

import { LiteClient, LiteRoundRobinEngine, LiteSingleEngine } from 'ton-lite-client';
import { Address, Cell, loadTransaction } from '@ton/core';

const engine = new LiteRoundRobinEngine([
  new LiteSingleEngine({
    host: 'tcp://<your-host>:41000',
    publicKey: Buffer.from('<base64-key>', 'base64'),
  }),
]);
const client = new LiteClient({ engine });

// bigint hash -> 32-byte Buffer (pad to 64 hex chars, always)
const hashToBuffer = (h: bigint) =>
  Buffer.from(h.toString(16).padStart(64, '0'), 'hex');

async function walkHistory(rawAddress: string) {
  const address = Address.parse(rawAddress);
  const master = await client.getMasterchainInfo();
  const state = await client.getAccountState(address, master.last);

  if (!state.lastTx) return; // account has no transactions

  let lt = state.lastTx.lt.toString(10);
  let hash = hashToBuffer(state.lastTx.hash);

  while (true) {
    const batch = await client.getAccountTransactions(address, lt, hash, 16);
    const txs = Cell.fromBoc(batch.transactions).map((cell) =>
      loadTransaction(cell.beginParse()),
    );
    if (txs.length === 0) break;

    for (const tx of txs) {
      console.log(`lt=${tx.lt} utime=${tx.now} outMsgs=${tx.outMessagesCount}`);
    }

    const oldest = txs[txs.length - 1];
    if (oldest.prevTransactionLt === 0n) break; // reached the first tx

    lt = oldest.prevTransactionLt.toString(10);
    hash = hashToBuffer(oldest.prevTransactionHash);
  }

  engine.close();
}

walkHistory('EQ...');

Two details that commonly bite:

  • lastTx.hash and prevTransactionHash are bigints, but getAccountTransactions wants a 32-byte Buffer. Pad the hex to 64 characters — a hash with leading zero bytes will otherwise produce a short buffer and a failed lookup.
  • batch.ids (parallel to the transactions) tells you which block each transaction was found in, if you need block anchoring.

Against a pruned liteserver this loop runs until it crosses the node's horizon, then throws lt not in db. Against an archive liteserver it runs to prevTransactionLt === 0n — the account's genesis.

What archive depth enables

An archive liteserver answers the same protocol, just without a horizon:

  • Full transaction history — walk any account back to its first transaction, on a chain whose genesis is May 2021.
  • State at any blockgetAccountState and runMethod against any historical BlockID, not just recent ones. Balance-at-height, historical jetton wallet data, point-in-time contract state for accounting or dispute resolution.
  • Deterministic reindexing — rebuild your database from scratch at any time instead of treasuring an unbroken local copy.

If your product shows "all transfers" for arbitrary user accounts, archive access is not optional: any account older or busier than the pruning horizon will 404 on a lite node.

Backfill: pay-per-request vs subscription

A history walk costs one request per 16 transactions, plus one getAccountState per account. Estimate first, then pick the billing mode:

Workload Requests Pay-per-request ($0.02/req archive) Subscription
One account, 1,000 txs ~64 ~$1.28
100 accounts, ~200 txs each ~1,400 ~$28 $30/mo (10 req/s) covers it in ~2.5 min
Indexer backfill, 10M txs ~625,000 ~$12,500 — do not 400 req/s plan: ~26 min of runtime

The crossover is low: at $0.02/request, ~1,500 archive requests already cost as much as the smallest archive subscription ($30/mo, 10 req/s guaranteed). Practical guidance:

  • Exploratory or small one-off pulls — pay-per-request from a prepaid balance. It bursts to 500 req/s, so small jobs finish fast with no monthly commitment. Balance is funded in GRAM or USDT.
  • One-time large backfill — take a high-rate archive plan on weekly billing, run the backfill flat-out, drop back down. A 60 req/s archive plan ($120/mo) moves ~5.2M requests per day of sustained paging.
  • Steady indexing — monthly or yearly subscription (yearly is −20%). Size it below: your regular sync load only needs to keep up with chain production, which is far cheaper than the backfill.

Rate planning for the steady state

After backfill, ongoing load is driven by chain speed, not history depth. TON produces blocks roughly every 0.4 s with ~800 ms finality, so a per-account poller that checks lastTx and pages only new transactions needs:

requests/s ≈ accounts_polled / poll_interval_s
           + new_txs_per_s / 16

Batch-friendly patterns keep this small: poll lastTx via getAccountState (1 request), and only call getTransactions when the pointer moved. A 10 req/s archive plan sustains ~860k requests/day — enough for a few thousand actively polled accounts at a 5-minute interval. Leave headroom for reorg-safe re-reads and get-method calls; if you regularly see queueing at your guaranteed rate, move up one tier rather than adding client-side retry storms.

Questions about sizing a backfill: https://t.me/tonnode_support.