Go: tonutils-go with a private liteserver

Connect tonutils-go to a private TON liteserver: connection pool setup, proof-check policy, failover on 651/-400, and a runnable balance example in Go.

tonutils-go is a pure-Go SDK that speaks the liteserver ADNL protocol directly — no HTTP gateway in between. Point it at a private liteserver and every call goes over one authenticated TCP connection with cryptographic proof verification on the client side.

This page covers connecting the pool to a TONNode private endpoint, choosing a proof-check policy, understanding failover behavior, and fetching an account balance.

Install

go get github.com/xssnick/tonutils-go

Your liteserver credentials

The TONNode dashboard gives you a liteserver entry with three values:

  • host — e.g. <your-host>.tonnode.io
  • port — e.g. 41000
  • key — a base64-encoded Ed25519 server public key, e.g. <base64-key>

This is the same triple that appears under liteservers in the TON global config, so any global-config-compatible client accepts it. tonutils-go takes it directly, no config file needed.

Create the pool and add your endpoint

liteclient.NewConnectionPool() creates an empty pool. AddConnection dials one liteserver and performs the ADNL handshake against its public key:

client := liteclient.NewConnectionPool()

err := client.AddConnection(ctx, "<your-host>:41000", "<base64-key>")
if err != nil {
    log.Fatalln("connection error:", err)
}

The signature is:

func (c *ConnectionPool) AddConnection(ctx context.Context, addr, serverKey string, clientKey ...ed25519.PrivateKey) error

addr is host:port, serverKey is the base64 key as-is from the dashboard. The optional clientKey is generated randomly when omitted — you almost never need to pass it.

API client and proof-check policy

ton.NewAPIClient wraps the pool. Its second argument selects how aggressively responses are verified:

func NewAPIClient(client LiteClient, proofCheckPolicy ...ProofCheckPolicy) *APIClient

The three policies:

  • ton.ProofCheckPolicyFast (default) — verifies Merkle proofs of the data itself, skips the masterchain block chain-of-trust checks. Good default against a server you trust, like your own private endpoint.
  • ton.ProofCheckPolicySecure — additionally validates blocks back to a trusted key block. Requires seeding trust via api.SetTrustedBlockFromConfig(cfg) (or SetTrustedBlock). Use this when talking to public liteservers you don't control.
  • ton.ProofCheckPolicyUnsafe — no verification. Don't use it in production.
api := ton.NewAPIClient(client, ton.ProofCheckPolicyFast)

For ProofCheckPolicySecure, load a global config first so the client has a trusted starting block:

cfg, err := liteclient.GetConfigFromUrl(ctx, "https://ton.org/global.config.json")
if err != nil {
    log.Fatalln("get config err:", err)
}

api := ton.NewAPIClient(client, ton.ProofCheckPolicySecure)
api.SetTrustedBlockFromConfig(cfg)

Failover: what WithRetry actually does

Wrap the client with WithRetry to get automatic failover:

api := ton.NewAPIClient(client, ton.ProofCheckPolicyFast).WithRetry()

When a liteserver returns a transient error, the wrapper transparently re-runs the query on another node in the pool (via a balanced sticky-context switch). The retried codes, from the tonutils-go source:

  • 651 — block/state not in this node's db (common on pruned public nodes when you ask for older data)
  • 652 — ADNL-level failure
  • -400 — requested block is unknown to the node yet (you hit a node that lags behind)
  • -503, 502 — node not ready / gateway-style failures
  • 228, 429 — rate limiting (toncenter shared plans return 228 on their ~50 rps sliding window)

WithRetry() with no argument retries until nodes are exhausted, then makes one more round over the original node set; WithRetry(3) caps attempts at 3. Note that retries mask latency, not capacity — if a public node rate-limits you, the query still costs a full extra round trip. A private endpoint with a guaranteed req/s budget makes the retry path a rarity instead of the hot path.

Complete example: account balance in GRAM

tlb.Coins.String() renders the balance in whole coins (1 GRAM = 10⁹ nanogram):

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/xssnick/tonutils-go/address"
	"github.com/xssnick/tonutils-go/liteclient"
	"github.com/xssnick/tonutils-go/ton"
)

func main() {
	ctx := context.Background()

	client := liteclient.NewConnectionPool()

	// Private endpoint — host, port, and key from the TONNode dashboard.
	err := client.AddConnection(ctx, "<your-host>:41000", "<base64-key>")
	if err != nil {
		log.Fatalln("liteserver connection error:", err)
	}

	api := ton.NewAPIClient(client, ton.ProofCheckPolicyFast).WithRetry()

	block, err := api.CurrentMasterchainInfo(ctx)
	if err != nil {
		log.Fatalln("get masterchain info error:", err)
	}

	addr := address.MustParseAddr("EQCD39VS5jcptHL8vMjEXrzGaRcCVYto7HUn4bpAOg8xqB2N")

	account, err := api.GetAccount(ctx, block, addr)
	if err != nil {
		log.Fatalln("get account error:", err)
	}

	if !account.IsActive {
		fmt.Println("account is not active")
		return
	}

	fmt.Printf("Balance: %s GRAM\n", account.State.Balance.String())
	fmt.Printf("At block seqno: %d\n", block.SeqNo)
}

Run it:

go run main.go
# Balance: 41.749 GRAM
# At block seqno: 46181991

For nanogram precision, use account.State.Balance.Nano() — it returns a *big.Int.

Private endpoint plus public fallback

The pool accepts any mix of manual connections and config-sourced ones. Add your private endpoint first, then layer the public config on top as a degraded-mode fallback:

client := liteclient.NewConnectionPool()

// Primary: private liteserver (guaranteed req/s, full history on archive plans).
err := client.AddConnection(ctx, "<your-host>:41000", "<base64-key>")
if err != nil {
	log.Fatalln("private liteserver error:", err)
}

// Fallback: public liteservers from the global config.
err = client.AddConnectionsFromConfigUrl(ctx, "https://ton.org/global.config.json")
if err != nil {
	// Non-fatal: the private connection is already up.
	log.Println("public config fallback unavailable:", err)
}

api := ton.NewAPIClient(client, ton.ProofCheckPolicyFast).WithRetry()

Two things to know about this setup:

  • The pool balances across all healthy connections, so some queries will land on public nodes even while your private endpoint is fine. Public nodes are pruned — historical queries routed there fail with 651 and get retried elsewhere, which is exactly what WithRetry is for.
  • If any node in the pool is untrusted, prefer ProofCheckPolicySecure with SetTrustedBlockFromConfig, since a Fast-mode client extends more trust to whichever node answers.

When you need several requests to hit the same node — for example a RunGetMethod against the block you just fetched — bind them with a sticky context:

sctx := client.StickyContext(ctx)
block, _ := api.CurrentMasterchainInfo(sctx)
res, _ := api.RunGetMethod(sctx, block, addr, "seqno")
_ = res

Troubleshooting

  • connection error: dial tcp ...: i/o timeout — wrong host/port, or a firewall blocking outbound TCP to the liteserver port.
  • Handshake fails immediately — the base64 key doesn't match the server. Re-copy it from the dashboard; it's the server's Ed25519 public key, not yours.
  • Frequent 651 / -400 in logs — queries are landing on pruned or lagging public nodes. Either drop the public fallback or accept the retry overhead.
  • 652 / ADNL timeouts on public configs — a known pain of shared public liteservers under load; TON docs themselves recommend a dedicated liteserver for production.

TONNode private liteservers start at $20/mo for a guaranteed 10 req/s (archive plans include full history from the May 2021 genesis) — see pricing. Questions: t.me/tonnode_support.