What has actually run
268, all green, all on every push. Everything but the LiteSVM suite runs with no phone and no validator — which is the whole reason the arithmetic was pulled out of the app and the program in the first place.
The 40 LiteSVM tests were the long-standing gap. They went unexecuted for weeks because no machine that had the repo had the Solana toolchain —
release.anza.xyz is unreachable from the development environment. The first run scored 39 of 40, and the one failure was worth more than the 39 passes.
What CI runs
.github/workflows/ci.yml, on every push and every pull request:
The Rust job is deliberately separate from the Anchor one: the curve carries no Anchor types precisely so it can be checked without a validator, and it answers in seconds instead of waiting on a toolchain install. The Anchor job pins from
Anchor.toml’s own solana_version and anchor_version, and is week 4’s graded deliverable — clone → install → anchor test green — run on a machine that starts with nothing.
The typecheck job exists because of a pattern, not a principle. Every defect that has reached
main was a TypeScript error found by a person reading code. See below.Each check is negative-tested rather than trusted: removing the ScrollView import gives TS2304, renaming an export in @nelo/pay gives TS2724, and a malformed function makes cargo fmt --check exit 1. A job that cannot fail is not worth adding.What still has never run
What that gap already cost
Not hypothetical. While adding the balance display:packages/pay/src/index.ts never re-exported either of them. awaitPayment would have been undefined at runtime — the till would have crashed the moment a merchant pressed Charge.
No test caught it, and at the time none could have: the app had never been bundled and there was no CI. It was found by reading imports while adding an unrelated feature.
Then it happened again. While wiring onboarding:
ScrollView was never imported. Tapping Today would have thrown. Found the same way — by eye, adding something else.
Then a third, when tsc was pointed at the apps for the first time: they could not typecheck the packages at all. The config error stopped the check before it reached a line of app code.
All three are TypeScript errors. All three were found by a person reading code. None needed an emulator, a handset or an Android toolchain to catch.That is why the typecheck job exists, and why it is four separate invocations rather than a hopeful one. Reading is not a method; it is what is left when there is nothing to run — and now there is.
How the tests are written
Negative tests before positive ones
Negative tests before positive ones
Everywhere in the vault. The first test in
vault.rs is the one that must fail: a valid P-256 signature over different bytes has to be rejected.Get the precompile offsets wrong and every positive test below still passes while the program verifies nothing at all. A test that passes because nothing was checked is worse than no test.Golden vectors across the language boundary
Golden vectors across the language boundary
packages/voucher/vectors/voucher-v1.json is generated by the Rust side — the chain is authoritative — and asserted by both. TypeScript also verifies signatures that Rust produced, so the check covers the crypto and not only the byte layout.Properties, not just examples
Properties, not just examples
The curve is tested for sublinearity at every doubling across six orders of magnitude, not at one convenient point.
isqrt is brute-forced against its floor property including at u128::MAX. The money splits are checked to add back exactly, at every amount in a list chosen to include the awkward ones.Read the balance, not the field
Read the balance, not the field
The LiteSVM tests read the SPL token account, deliberately not the vault’s
balance field. The two must agree, and reading the field to check the field proves nothing.Things that went wrong, and were caught
Kept because they are more informative than the passes:
Most were caught by a tool or a test rather than by reading. The one that was not — the FX legs — was a design error, and the ledger’s own balance rule would have rejected it at runtime anyway.
The one worth reading twice
The first-everanchor test run scored 39 of 40. The failure was only_the_risk_authority_can_publish_reputation, and it was not the program.
The test helper passed the same keypair twice — once as fee payer, once as the instruction’s signer. A message dedupes its account keys, so it required one signature while the transaction builder was handed two, and failed with NotEnoughSigners before the program was entered.
So the test got an error, is_err() was satisfied, and the property it names — reputation you can set yourself is not reputation — had never been checked. The authority constraint was there and correct, but correct-by-inspection is not proven.
Failing was the good outcome. Had the assertion been on
is_err() alone, it would be green today and still empty. Its sibling test passes only because an impostor keypair happens to differ from the payer, so it never hit the trap at all.That is the argument for asserting on the reason rather than the fact of a refusal, and it is why every check added to CI was negative-tested before being trusted.The devnet gate
The week-1 trust model has been run end to end on devnet against the Trust Stake build: vault funded,RiskConfig initialised, voucher redeemed with the device signature verified by the secp256r1 precompile on a real validator, double-spend refused, signature-over-other-bytes refused.
That distinction matters. The gate had passed before, but against a build that predated the Trust Stake. This run is the first time RiskConfig has existed on a real validator and the first time redeem_voucher has been exercised on chain with its new account struct — exactly the kind of change LiteSVM can accept and a validator reject.
#[ignore] so it never runs in the default suite. It creates its own 6-decimal mint, so a run is self-contained, and it costs devnet SOL.
It uses a software P-256 key in place of StrongBox. That is deliberate: it isolates “does the chain do what we think” from “does the handset do what we think”. The second question is still open.