Skip to content

Testing & Reproducibility

The core on-chain behaviours of OhShii — creating a Liquidity Generation Event (LGE), buying into one, creating and voting on governance proposals, upgrading a canister by vote, locking and unlocking tokens, and the identity-verification gate — are covered by automated end-to-end suites that anyone in the DAO can run from a clone of the repository. The tests exercise the real production code paths inside a faithful local replica of the network, so a green run is meaningful evidence that a change behaves the way it will on mainnet.

Coverage is broad on the canister side and still being extended; the exact figures, what is excluded and why, are in Coverage at a glance below rather than asserted here, because a number written into prose goes stale the day after it is written.

This page explains what the suites verify, how to reproduce each one, how to drive the same flows interactively, and — importantly — the guarantees that keep the test-only tooling from ever reaching a mainnet build.

Derive these numbers, never trust them. They are correct at the time of writing and the commands beside them are the point — an earlier version of this programme carried a hand-written total that was wrong by 24, because the needle counted #[test] where it appeared inside comments. Anchor to the start of the line:

Terminal window
ls test-env/harness/tests/*.rs | wc -l
grep -rh '^[[:space:]]*#\[test\]' test-env/harness/tests/*.rs | wc -l
grep -rh '^[[:space:]]*#\[ignore' test-env/harness/tests/*.rs | wc -l

Do not quote a count from this page — derive it. Run bash scripts/test_census.sh, which produces every figure below with a positive and a negative control beside it, and reconciles each split against its own population. A written count rots silently, and a rotted count is indistinguishable from a correct one: the figures this paragraph used to carry (80 suite files · 236 test cases) were 7 and 10 short by the time anyone re-measured.

Snapshot 2026-08-31, for orientation only: 97 suite files · 263 test functions in the PocketIC harness, of which 12 are #[ignore]d and 251 actually execute; plus in-crate unit tests reached by cargo test --workspace in each repo (38 launcher files and 10 locker files carry a #[cfg(test)] module).

This snapshot is dated for a reason: it goes stale within days. Re-run bash scripts/test_census.sh rather than quoting this line — the command is the answer, the number beside it is only orientation.

⚠⚠ The two harnesses are DISJOINT, and “the tests pass” is usually false as intended. test-env/harness is a standalone cargo workspace — its empty [workspace] table exists precisely to stop cargo walking up to the production one, so that joining them cannot mutate the production Cargo.lock and break the reproducible Docker build. cargo test --workspace from either repo root does not run the PocketIC suites, and never has. Always name which harness a green result came from.

ⓘ The per-surface split below is a hand-classified snapshot and is older than the totals above — its rows sum to less than the current 87/246. Nothing derives it, so treat it as a shape, never as a count.

SurfaceSuite filesCases
Locker — locks, vesting & unlocks2175
Governance — OHSHII (ONS)1547
LGE, purchase & refunds1325
Cycles, timers, upgrades & lifecycle824
Cross-canister, guardian & commit points519
Shield (in-canister firewall)518
Governance — per-campaign (SONS)513
ICPSwap & liquidity45
Storage & disclosure25
Identity & verification25
Total80236

Coverage is tracked as an explicit backlog of identified behaviours rather than as a percentage of lines. Of 83 tracked coverage items, 47 are complete, 10 are partial (one half proven, the other blocked or deferred) and 25 are not yet implemented. Work is ongoing, and two of the gaps are structural rather than a matter of time:

  • The browser frontend has no automated runner. There is no unit or end-to-end test framework wired into the React app today, so frontend behaviour is verified by hand against a live local world. Frontend logic that matters for correctness is instead pinned indirectly, by Rust tests that read the JSX as text and assert its shape.
  • A few behaviours are only provable on mainnet — real threshold-ECDSA signatures, real external services, and the irreversible one-shot steps that cannot be rehearsed against a local replica.

Two further honest caveats, because a reader deserves them before reading a green run as a guarantee:

  • cargo test --workspace runs none of the suites on this page. The harness is a deliberately separate workspace, so “the tests pass” in the production workspace means the unit tests passed and says nothing about the end-to-end suites. Name both, or name neither.
  • The suites run on demand, not on a schedule. There is no continuous- integration runner executing them on every change today; each suite is run by a person typing the command, which is why this page lists them explicitly.

Twelve cases carry #[ignore], and none of them is a disabled or failing test — each needs something the default run does not build or does not have:

SuiteWhy it is opt-in
icpswap_stack (2), icpswap_liquidity_properties, mainnet_snapshot (pool half)need the vendored ICPSwap V3 stack built first (./test-env/build-icpswap.sh)
timer_freeze (2)slow: builds WASM and spins a world per case
verification_gating (threshold-ECDSA probe)exercises real signature machinery, run explicitly
sons_vesting_batch_costa measurement fixture, not an assertion — run to read its numbers
pm1_injection_probea probe: prints a table and asserts only that the sweep ran
lge_finalize_liquidity_probea probe: reports the raw finalization amounts for an independent derivation
icpswap_claim_ordering_probeneeds the vendored ICPSwap stack; an A/B measurement rather than a pin
get_my_vote_liveneeds a live world started with start.sh

Run them with -- --ignored, for example:

Terminal window
cd test-env/harness
./../build-icpswap.sh
cargo test --test icpswap_stack -- --ignored --nocapture

The tests boot a PocketIC world (a deterministic, in-process replica of the Internet Computer) that installs the launcher and locker canisters at their real mainnet ids, alongside real system canisters (the ICP ledger, the Cycles Minting Canister, Internet Identity) and the real ICPSwap V3 stack. See Local Test Environment for standing the world up interactively; this page is about the automated suites and the simulator that run on top of it.

The harness lives in test-env/harness/ and is a standalone Rust workspace, deliberately separate from the production workspace so the reproducible build is never affected by test dependencies.

The one-command form builds the canisters and runs the whole suite:

Terminal window
npm run testenv:test

To run an individual suite (from test-env/harness/), and rebuild the canister WASMs first when you have changed canister code:

Terminal window
cd test-env/harness
# Rebuild WASMs before running (needed after any canister change):
OHSHII_TESTENV_BUILD=1 cargo test --test lge_participation -- --nocapture
# Reuse the already-built WASMs (faster, no rebuild):
cargo test --test ons_proposal_lifecycle -- --nocapture
cargo test --test sons_proposal_lifecycle -- --nocapture
cargo test --test verification_gating -- --nocapture

Each suite boots its own isolated world and takes roughly one to two minutes.

What runs on GitHub, and what runs only on a developer machine

Section titled “What runs on GitHub, and what runs only on a developer machine”

Two workflows run in the repository’s CI. Both are readable in .github/workflows/, and everything they do can be reproduced locally with the commands above.

workflowwhenwhat it runs
ci.ymlevery push and pull requestthe static gates, the in-crate unit tests, and the harness suites that need no booted world
suites.ymlweekly, Sunday 03:00 UTC, and on demanda selected set of PocketIC end-to-end suites

Both check out the sibling repository as well, because several suites read locker source directly — some at runtime, and at least one at COMPILE time through an include_str!. Without it the harness does not merely fail to run: as a whole it does not build.

A green CI is NOT a green suite set, and the gap is large. The end-to-end harness holds far more suites than CI executes — the exact figures are in Coverage at a glance, deliberately not repeated here. CI runs a selected subset; the rest are run locally, by hand, before a deploy. Reading a green checkmark on a commit as “the suites pass” is the single easiest mistake to make on this page.

Why weekly rather than nightly. A matrix run bills the sum of its jobs, not its elapsed time, and the end-to-end job is minutes of world boot per suite. Run nightly it consumed more than the account’s entire monthly Actions allowance on its own — which would have exhausted the budget and taken the every-push checks down with it, at a twentieth of the cost. The weekly cadence keeps a periodic signal; the every-push checks are the ones that catch a regression on the commit that introduced it.

ⓘ If you change that cadence, measure by summing job durations, not by reading the elapsed time the Actions UI shows. A matrix run’s displayed time is a fraction of what it bills, which is why the cost was invisible until someone added the jobs up. The arithmetic is recorded at the schedule itself.

What CI cannot tell you, stated because a checkmark is read as more than it is:

it provesit does NOT prove
the tree compiles and the static gates passthat the deployed wasm carries the change — see the chain-clean warning further down
the in-crate unit tests passanything about multi-canister behaviour; those live in the PocketIC suites
the CI-selected suites passthat the suites it does not run would pass
the two repositories agree on their vendored artefactsthat either one is correct — only that they are identical

Drives the real purchase flow (an ICRC-2 approval followed by the backend’s purchase_tokens_icrc2) across the Voter Benefit Protocol tiers, and checks that:

  • verified buyers in each tier receive tokens and a contribution is recorded;
  • the OHSHII guest fee for unverified buyers is charged at most once per campaign — including the real-world edge where, at the start of the bonding curve, the Guest tier’s token cap costs less than the minimum purchase, so a Guest is temporarily unable to participate until the price rises;
  • the per-caller payment rate limit engages on a burst and recovers afterwards.

ONS proposal lifecycle — ons_proposal_lifecycle

Section titled “ONS proposal lifecycle — ons_proposal_lifecycle”

Exercises the OHSHII (ONS) governance canister end to end:

  • a Motion reaches quorum and is finalized by the on-chain timer, while a second Motion without enough voters expires;
  • the chunked WASM upload path — a multi-megabyte module is uploaded across many chunks and the canister-reassembled hash is verified against the local hash;
  • a canister-upgrade proposal is created, driven to immediate approval, executed, and the target canister’s installed module hash is checked against the proposed WASM.

SONS proposal lifecycle — sons_proposal_lifecycle

Section titled “SONS proposal lifecycle — sons_proposal_lifecycle”

The same, for a per-campaign (SONS) governance canister created for an imported DAO or an LGE: a configuration-change proposal that is voted and executed, a child-canister upgrade, and the verification gate — a SONS configured to require verification rejects an unverified proposer, then accepts them after they verify, exercising the cross-canister call from SONS to ONS that checks personhood.

Verification gating — verification_gating

Section titled “Verification gating — verification_gating”

Confirms the World ID / DecideID gate behaves correctly: when either method is enabled an unverified voter is rejected and a verified one is accepted (the gate is the logical OR of the two methods), and that a freshly installed governance canister converges on the verification modes currently active on mainnet after an upgrade, while never overriding a configuration an operator or the DAO has deliberately set.

Two further suites cover related surfaces: lge_creation_slot (LGE and imported-DAO creation, payment-memo and slot-allocation hardening) and the asset-batch cleanup suites for both governance canisters.

For interactive testing, a live world can be driven with the same real flows. Multi-participant scenarios (many buyers, many voters) cannot come from a single browser wallet, so they are issued through a small command-line tool that talks to the running world directly:

Terminal window
npm run testenv # start the live world
npm run testenv:sim -- plan dao-0000000012 --to-percent 60
npm run testenv:sim -- quote dao-0000000012 --amount-icp 1 --tier human
npm run testenv:sim -- participants dao-0000000012 --n 5 --tier guest
npm run testenv:sim -- participants dao-0000000012 --n 30 --mode burst --fresh
npm run testenv:sim -- proposal ons motion "adopt the thing"
npm run testenv:sim -- wasm list
npm run testenv:sim -- vote ons 1 --immediate
npm run testenv:sim -- execute ons 1
npm run testenv:sim -- status ons 1
./test-env/sim.sh help # full command list

The simulator is diagnostic, not just a runner:

  • plan starts from the goal instead of the knobs: state the completion level the campaign should reach and it resolves that into a concrete cohort — how many participants, which participation tier, and how much ICP each — from the live bonding curve, then previews it. Nothing is bought until the plan is confirmed. It also reports when the participant budget, not the target, is the binding constraint.
  • quote is a curve-accurate pre-flight. It reads the campaign’s live bonding-curve position and reports how many tokens a chosen ICP amount buys now, the tier’s purchase cap, the remaining headroom, and the maximum ICP that fits the cap — so you choose a valid amount instead of guessing. Purchase caps are denominated in tokens, so at the start of the curve a small tier’s cap can be reached with very little ICP; the quote makes that explicit. Tiers are earned, not bought: the capital tiers also require voting discipline, so a simulated participant is given both the voting power and the ballots that keep it an active voter — otherwise the platform correctly demotes it and enforces the lower cap (the quote reports the demotion when it happens).
  • participants reports each buyer on its own line, classified as success, tier-cap rejection, rate-limit, high-load (proof-of-work) rejection, or below-minimum, with a summary of counts and total ICP spent. A sequential run paces the buyers; a burst run submits every purchase at once, which is how you exercise the platform’s in-canister rate-limiting and high-load defences under real concurrency. --fresh gives each run brand-new participants so earlier runs don’t skew the per-participant cap accounting.
  • wasm list shows the upgrade payloads available for an upgrade-by-vote test. Every payload is read and scanned server-side before it can be attached to a proposal; a payload carrying a development-only method is refused up front (the on-chain governance check would reject it too).

The same commands are also reachable from the launcher UI when it runs against a local world: a small floating bubble in the bottom-left corner opens a development-only panel — participation on a campaign page, proposals and voting on the governance page, environment controls on the wallet page — and closes it again, so the interface otherwise looks exactly like production. None of this tooling is present in a production build.

The no-backdoor guarantee (why the test tooling is safe)

Section titled “The no-backdoor guarantee (why the test tooling is safe)”

To make local testing practical, the test build compiles in development-only methods — most importantly one that lets a caller grant itself admin rights, and methods that mark a principal as verified without the real proof-of-personhood flow. These are gated behind a Cargo feature (dev-admin) and are compiled out of every production build. This matters for a DAO, so the codebase guards it in four independent layers rather than trusting a single check:

  1. Build isolation. The development build is written to a separate output directory, never to the path a mainnet deployment installs from.
  2. Compile-time opt-in. Every affected canister contains a block that fails to compile unless an explicit environment opt-in is set, so the feature flag alone cannot produce a backdoored module.
  3. Artefact scan. scripts/check_no_dev_admin.py reads a compiled WASM’s export section and fails if any development method is present. It runs in the Docker build, the deploy scripts, and the voter-side proposal verification.
  4. On-chain gate. Both governance canisters scan any WASM proposed for upgrade and reject it if it exports a development method — the one layer a proposer cannot bypass, enforced under consensus.

Note that this applies to every OhShii canister that carries the feature, including sons_governance (the per-campaign governance canister). A test that proposes a canister upgrade therefore has to use a clean WASM as the payload — the on-chain gate rejects anything else — which is exactly the discipline the upgrade suites follow.

Anyone can check that a canister running on mainnet was built from a specific, reviewed source, and that its module carries no development methods:

Terminal window
# 1. On-chain module hash equals the local reproducible build:
scripts/verify-hash.sh ic <canister-name>
# 2. That same local build carries no development-only methods:
python3 scripts/check_no_dev_admin.py <path-to-local-build.wasm>

A matching hash proves provenance (the running code was built from the source you reviewed); the scan proves the content carries no test methods. Neither can, on its own, detect a hand-edited authorization check hidden in the source — only a reproducible build bound to a reviewed commit gives that assurance. The Voter Verification Guide and the Docker Deploy Guide cover the reproducible-build chain in full.

The gates, by name — and what each one CANNOT see

Section titled “The gates, by name — and what each one CANNOT see”

npm run check:did and its siblings are referred to throughout this page; this section names them, because a gate you cannot name is one you cannot run before pushing.

commandwhat it proveswhat it does NOT prove
npm run check:diddeclaration/vendored .did sync across both repos, plus the Rust-mirror drift gatenothing about the DEPLOYED interface — it reads the tree
npm run check:iccbounded inter-canister calls under its scan rootsanything outside those roots; it prints its own SCOPE GAP, read it
npm run check:leversthe FE mirrors of ONS/SONS admin-method lists and criticality categories match Rust, both directionsthat any individual lever is correct — a wrong target id or param order passes
node scripts/check_admin_method_doors.mjsevery allowlisted method has a UI doorwhether the door opens. The string guard appears zero times in it
python3 scripts/check_rust_mirror_drift.pytype-mirror // from <did>:<line> anchors resolve to the right constructfunction-level anchors. It resolves above a struct/enum, not above a pub fn
bash scripts/test_census.shthe suite/backlog/decision counts, each with a controlcoverage. A suite that EXISTS is not a flow that is COVERED

Source clean is not chain clean. Every gate above reads the working tree. A fix can be landed, reviewed, and green in all of them while the deployed wasm still serves the old surface — measured twice in August 2026. Before calling a change done on a published Candid surface, ask the chain: dfx canister info <id> --network ic --identity anonymous for the module hash (the identity flag is NOT optional — without it dfx fails to load an identity even for a read), and icp canister call <id> <method> '()' -n ic --query --identity anonymous -o candid for the surface itself.

⚠ Provenance anchors: .did files are line-number TARGETS

Section titled “⚠ Provenance anchors: .did files are line-number TARGETS”

Rust mirrors of another canister’s wire types carry a comment recording where the type came from:

/// from ../dev-ohshii-locker/src/backend/token_locker_backend.did:351 (`LockStatus`)

test-env/harness/src/flows.rs alone carries 65 of these, and check_rust_mirror_drift.py resolves them to prove a mirror still matches its source.

The consequence, and it is not obvious: editing a .did is not free even when you only touch a comment. Inserting or deleting lines shifts every anchor below the edit, and they fail silently — an anchor pointing at a blank line produces no diagnostic, and one pointing at a different type produces a confident, wrong comparison.

Measured 2026-08-25. A three-line comment was replaced with a nine-line block in token_locker_backend.did. The +6 shift moved type LockStatus from :351 to :357 and broke thirty anchors at once. The gate caught it only because the harness had been added to its scan roots one commit earlier.

So:

  • Edit a .did LINE-COUNT-NEUTRALLY where you can — the fix above was rewritten to exactly three lines for three, and git diff --numstat reading 3 3 is the check.
  • When a net change is unavoidable, re-derive every anchor below the edit before committing. git show --numstat <commit> names the insertion point in one command.
  • Never revert a .did by re-editing it. Restore the pristine copy — a re-edit can land a different line count than the original.