Skip to content

TESTING.md — sun test: Runner, Cheatcodes & Traces

Living reference for the Solidity-native testing layer (internal/runner, assets/sail.sol, the sun test command). It runs on the S4 interpreter over internal/state — the in-memory backend by default, or a pinned-fork overlay of a live network with --fork-url (S6; model, endpoints and DEM composition in docs/FORKING.md and docs/TVM.md §3b). Tier-1 authority is docs/ARCHITECTURE.md §5.2/§5.3/§5.6/§6.


1. What sun test does

sun test [-v|-vv|-vvv] [--json] [--fork-url <network> [--fork-block <height>]]
  1. Compiles the project (internal/compiler), injecting the cheatcode interface into every test/ unit (see §2).
  2. Discovers test contracts from the compiled artifacts under out/: any contract in test/**/*.t.sol that declares test* / testFail* functions. An optional setUp() runs before each test.
  3. Runs each test on a fresh state — deploy the test contract, run setUp(), then the test function — so tests never leak state into one another. The default is pure memState (zero network); --fork-url swaps in a fresh overlay over one pinned solidified block per test (docs/FORKING.md), where (energy: N + P penalty) reports the DEM penalty for chain-read energy_factor > 0 contracts.
  4. Reports results, with source-mapped traces at higher verbosity.

Pass/fail. A test* passes iff its call does not revert and every armed cheatcode expectation is satisfied. A testFail* passes iff its call reverts. sun test exits non-zero if any test fails.

Parametrized tests (D2). A test*/testFail* with parameters (e.g. testSetNumber(uint256)) runs once with a deterministic zero/empty argument vector. Property fuzzing is a later sprint; a parametrized test today asserts its property for the zero vector only.


2. Cheatcodes (assets/sail.sol)

Cheatcodes are calls to a magic address the interpreter intercepts:

CheatAddress = 0x41 || keccak256("sunsail.cheatcode")[12:]
             = 41d33363c27632c67bc7d944ca33fcbb1935db4acb   (TRON, 21 bytes)
EVM 20-byte form = d33363c27632c67bc7d944ca33fcbb1935db4acb

assets/sail.sol declares the Sail interface and a file-level sail handle bound to that address; the compiler injects it as the virtual source unit sail.sol into every test/ unit (import {sail} from "sail.sol"; — no file to add). The single source of truth for the address is tvm.CheatAddress; the Solidity handle derives it from the same preimage, so they cannot drift.

Interception. A CALL whose target is the magic address is dispatched to internal/runner/cheatcodes.go before it is treated as a message call — the interpreter owns execution semantics, the runner owns selector/ABI decoding (internal/tvm never imports internal/runner). Cheatcode calls consume no forwarded energy, so they never perturb the E_basic of the code under test.

Etching note. Solidity emits an extcodesize > 0 check before a high-level external call, so the runner etches one byte of code at the magic address in each fresh state (as Foundry does at its VM address). The interpreter still intercepts the call before that code would ever run.

v1 cheatcode set (CLOSED — a new one needs an ARCHITECTURE.md change first)

Cheatcode Signature Semantics
prank prank(address) Override msg.sender for all calls from the caller until stopPrank (persistent; there is no startPrank). Not applied to DELEGATECALL.
stopPrank stopPrank() End the active prank.
deal deal(address,uint256) Set an account's TRX balance to an absolute amount (in sun).
mockEnergy mockEnergy(uint256) Set the environment energy price read by GASPRICE (version-1 contracts) and BASEFEE — the TRON analog of Foundry txGasPrice.
warp warp(uint256) Set block.timestamp.
roll roll(uint256) Set block.number.
expectRevert expectRevert() / expectRevert(bytes) The next contract call from the caller must revert (optionally with matching data). The revert is verified and swallowed; the caller sees success and the test proceeds. An armed expectRevert that never fires is a test failure.
expectEmit expectEmit() Arm → emit expected → call: the next event the caller emits is captured as the expected pattern (topics + data), and the following call must emit an identical event.

Cheatcode env changes (warp/roll/mockEnergy) apply within the transaction that invokes them (the common sail.warp(t); target.foo(); in one test function). deal writes state and therefore persists across the deploy/setUp/ test calls of a single test.


3. Traces

Verbosity is cumulative (-v ⊂ -vv ⊂ -vvv):

Level Shows
(none)/-v Per-test PASS/FAIL, energy, and the decoded failure reason.
-vv Also emitted events (emit <addr> topic0=… data=…).
-vvv Also the full call tree, one line per frame, with per-frame energy and, for a reverting frame, the decoded reason and resolved file:line.

How file:line is resolved. The runner builds a registry keyed by keccak256(deployedBytecode) from every compiled artifact, so any executing address resolves (via StateDB.GetCode) to its contract. It walks the deployed bytecode to map PC → instruction index → source-map entry (start:len:file) → sources[file] path + offset → line. This needs three artifact fields the S5 compiler now persists (completing §5.2): deployedBytecode (the runtime object), deployedSourceMap, and sources (the source-id→path list). Constructor (CREATE) frames run init code, not the deployed object, so they do not resolve.

Revert decoding. Error(string) (0x08c379a0) → revert: <msg>; Panic(uint256) (0x4e487b71) → panic: 0x<code>; empty → revert (no data); anything else → raw hex.

A failing test that ran untraced (the fast path) is re-run once with the tracer so its failure line is available without tracing every green test.


4. Determinism

The runner's output is byte-identical across runs regardless of goroutine scheduling (runner-agent doctrine #2), achieved by:

  • One worker goroutine per test contract, each writing only its own result slot; the final report is assembled by iterating a pre-sorted discovery order (by source path, then contract name; tests sorted by name), never completion order.
  • Fresh state per test + the deterministic S4 interpreter + deterministic synthetic txids and a fixed deployer ⇒ identical energy, addresses, and outcomes every run.

5. Hermeticity and the real-solc fixtures

go test ./... and make e2e are fully hermetic — zero network.

  • Runner unit tests (internal/runner) execute genuine TVM bytecode from committed artifacts under internal/runner/testdata/proj/out/, compiled once by the real, checksum-verified solc-tron 0.8.23.
  • E2E init → compile → test (test/e2e) stays network-free by replaying committed real-solc output for the scaffold template units: the hermetic solc stub, given SOLC_STUB_REPLAY_DIR, emits the real Standard JSON output so sun test runs genuine bytecode.
  • E2E sun script (S7) plans the scaffold deployment script against the in-process rpctest node plus a loopback JSON-RPC storage stub — the plan with txid-derived addresses is asserted BEFORE any broadcast, and a lost transaction under --policy included must trigger the suffix re-derivation (docs/SCRIPTING.md).
  • E2E sun audit (S7) runs against a fake aderyn (SAIL_ADERYN_BIN) reproducing the observed v0.6.8 contract: a TVM-syntax file degrades to SKIPPED with the other findings intact, and --fail-on high exits non-zero on an injected high finding (docs/AUDIT.md).

Fixture provenance and regeneration (D3)

The committed real-solc fixtures MUST match the current sources, or the e2e proves nothing. Guardrails:

  • Generator. scripts/regen-fixtures.py (make regen-fixtures) drives the real solc-tron 0.8.23 ($SAIL_HOME/bin/solc-0.8.23, pinned digest) over the actual fixture and template sources. It is a manual / real-binary step, never part of make check or make e2e.
  • Provenance. test/e2e/testdata/solc_replay/provenance.json records the solc version, the generation command, and the SHA-256 of every template + sail.sol source the replay was built from.
  • Drift guard. The e2e recomputes those hashes from the current repo sources and fails if any differs — so editing a template or sail.sol without running make regen-fixtures cannot silently pass against stale bytecode.

Run make regen-fixtures whenever a fixture source, sail.sol, or a scaffold template changes.


6. Documented simplifications (v1)

  • Parametrized tests use a single zero vector, not a fuzzer (§1, D2).
  • expectRevert consumes the next contract call (calls to empty-code accounts / precompiles do not consume it).
  • CREATE/constructor frames are not source-resolved in traces (creation source maps are not persisted).
  • Contracts using immutables may not resolve in traces (their deployed code differs from the artifact's zero-slotted object).
  • Cheatcode env changes are transaction-scoped, not persisted across the deploy/setUp/test boundary (except state writes like deal).