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¶
- Compiles the project (
internal/compiler), injecting the cheatcode interface into everytest/unit (see §2). - Discovers test contracts from the compiled artifacts under
out/: any contract intest/**/*.t.solthat declarestest*/testFail*functions. An optionalsetUp()runs before each test. - 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-urlswaps 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-readenergy_factor > 0contracts. - 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 > 0check 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 underinternal/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, givenSOLC_STUB_REPLAY_DIR, emits the real Standard JSON output sosun testruns genuine bytecode. - E2E
sun script(S7) plans the scaffold deployment script against the in-processrpctestnode plus a loopback JSON-RPC storage stub — the plan with txid-derived addresses is asserted BEFORE any broadcast, and a lost transaction under--policy includedmust 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 highexits 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 ofmake checkormake e2e. - Provenance.
test/e2e/testdata/solc_replay/provenance.jsonrecords the solc version, the generation command, and the SHA-256 of every template +sail.solsource 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.solwithout runningmake regen-fixturescannot 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).
expectRevertconsumes 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).