Skip to content

FORKING.md — The Pinned-Fork Backend (sun test --fork-url)

Living reference for internal/state/fork.go and the --fork-url / --fork-block flags of sun test. Tier-1 authority is docs/ARCHITECTURE.md §5.6 with Erratum E2; the empirical arbiter is the S6 differential suite (test/differential/).


1. What a fork is

sun test --fork-url <network> [--fork-block <height>]

Tests run on the local interpreter over the live state of a real network, pinned to one solidified block: accounts, code and storage are fetched lazily on first touch, cached for the fork's lifetime, and never written back — every write lands in a per-test local overlay. <network> is a name declared in sail.toml carrying three endpoints:

[networks.nile]
fullnode = "grpc.nile.trongrid.io:50051"   # protocol.Wallet
solidity = "grpc.nile.trongrid.io:50061"   # protocol.WalletSolidity
jsonrpc  = "https://nile.trongrid.io/jsonrpc"

A raw host:port is rejected: TRON needs the Wallet/WalletSolidity pair (and the jsonrpc endpoint) — a single URL cannot carry the trio, and mis-pairing endpoints across networks is exactly the mistake the config prevents.

2. Where each datum comes from

Datum Source Service
Pinned block header (number, timestamp, witness) GetNowBlock2 / GetBlockByNum2 WalletSolidity (solidified)
Chain id genesis block id (GetBlockByNum2(0)) WalletSolidity
Energy price GetChainParameters (getEnergyFee) — never hardcoded Wallet
Accounts (balance, type, TRC-10, FrozenV2/UnfreezingV2, votes, permissions) GetAccount WalletSolidity (solidified)
Contract code + energy_factor GetContractInfo (runtimecode) Wallet (the only service exposing it)
Storage eth_getStorageAt — JSON-RPC, Erratum E2 the jsonrpc endpoint

Erratum E2. java-tron's gRPC surface exposes no contract-storage read (verified across every service in the vendored api.proto). The only storage read the node ships is eth_getStorageAt on its JSON-RPC module. The reader lives in internal/rpc/jsonrpc.go, its sole consumer is state/fork.go, and the gRPC-only rule stands everywhere else (check-invariants enforces both directions).

3. The pin — and the storage-tag caveat

A fork fixes one solidified block at creation (--fork-block, or the current solidified head). "Pinned" means:

  • The BlockContext (NUMBER/TIMESTAMP/coinbase/chain id, energy price) is captured once, at the pin.
  • Every gRPC state read goes to the WalletSolidity service.
  • Every fetched key is cached for the fork's lifetime — a datum is observed exactly once; a fork never queries two different heights.
  • Storage reads request the pinned height as a hex block tag.

Caveat (empirically settled on Nile): java-tron's JSON-RPC serves only the latest tag — a numeric tag answers QUANTITY not supported, just support TAG as latest (differential probe, S6). The capability is detected at runtime from the node's response (one probe at fork creation, mirroring the EstimateEnergy pattern — never version sniffing). When the node is latest-only, StoragePinned() reports false, sun test prints a warning, and reads fall back to latest — surfaced, never silently mixed. On a tag-capable node the pin is exact. TRON nodes serve no historical state over gRPC either, so --fork-block pins the block context while state reads remain latest-solidified; for stable target contracts (the common case) the distinction is immaterial, and the differential metering suite is the proof the pragmatics hold (±0 on Nile).

4. Caching, concurrency, failure

  • LRU + singleflight. Storage slots live in a bounded LRU (4096 entries); accounts and code in plain maps (few per run). All fetches go through singleflight: two goroutines asking for the same cold key produce exactly one network call — the per-contract worker goroutines of the runner share one ForkSource.
  • Overlay. ForkSource.NewState() opens a fresh per-test overlay: remote data is seeded into it unjournaled (base state — it survives RevertToSnapshot), local writes journal normally, frame reverts restore the seeded remote values, and nothing is ever written back to the network.
  • Zero == absent. A zero word from eth_getStorageAt is served as an absent row (GetStorage → nil): on chain a zeroed slot has no row, and the absent-row distinction is what java-tron's SSTORE pricing keys on.
  • Sticky errors. tvm.StateDB has no error returns, so the first fetch failure poisons the fork: later reads answer zero without further network calls, and Fork.Err() reports it. The runner checks it after every test and fails the test with the fork error — incomplete state never passes (or fails) silently on fabricated zeros.
  • Not carried by gRPC (documented zeros for remote accounts): per-delegation detail (GetDelegatedOut*), legacy Stake 1.0 expiries.

5. The DEM layer on a fork

Each contract's energy_factor is read via GetContractInfo and frozen at first fetch for the fork's lifetime (factors are constant within a 6-hour maintenance cycle). The interpreter attributes consumed energy per contract and reports EnergyPenalty = Σ⌊usage_c × factor_c / 10000⌋ (docs/TVM.md §DEM); sun test shows (energy: N + P penalty) when P > 0. Locally deployed test contracts have factor 0.

6. Hermetic testing and recorded fixtures

go test ./internal/state is fully hermetic: rpctest (in-process gRPC node) plus an in-process JSON-RPC stub replay real recorded Nile responses from internal/state/testdata/nile/fixtures.json.

To re-record the fixtures (manual, network required, no key needed):

go run scripts/record-fork-fixtures.go

The recorder captures the solidified head, genesis id, chain parameters, the Nile USDT account/contract info, four storage slots, and the observed storage-tag probe result, stamping the recording time and block.

7. Differential validation (S6 keyless; S7 full key-enabled run)

  • Fork-backed local balanceOf on Nile USDT vs the node's TriggerConstantContract (solidity service): return data byte-identical, E_basic_local == energy_used − energy_penalty ±0 (935 == 935 − 0, S6 block 69328028; re-confirmed S7 at 69329318).
  • Storage pin probe: Nile is latest-only (see §3), surfaced as designed (re-confirmed S7).
  • The full deploy-based suite ran with a funded key on 2026-07-19 and is green: the seven-scenario metering battery ±0 on a fork of the freshly deployed contract, the CREATE2 duality resolution (Erratum E3), the 0x03 on-chain confirmation (E1 final), the E_total composition ±0 with the EstimateEnergy search-bound reproduction (Erratum E4), and the sun script plan-vs-receipts battery ±0. Full numbers: docs/TVM.md §3c.