Invariants and gas
Example tests check the cases someone thought of. Two further suites look for the cases nobody thought of, and for slow drift:
- Invariant tests run long random sequences of real transactions against fresh contracts and compare every outcome, and the full chain state, with a small reference model written in plain TypeScript.
- The gas snapshot fails the build when a tracked operation becomes more than 10 percent more expensive, and in CI also when it becomes much cheaper without the snapshot being refreshed.
Both were introduced in step P1-S13 (ADR-0059). The plan named Echidna or Foundry; the project chose fast-check inside the Hardhat suite instead, which is recorded as deviation DEV-002.
The model-based invariant suite
Pieces
| File | Role |
|---|---|
blockchain/test/invariants/model.ts | A pure reference model of the v0.3 rules. step(state, action) returns the expected outcome (success or the exact error name) and the next state, with no chain involved |
blockchain/test/invariants/actions.ts | fast-check generators for random actions, with weights |
blockchain/test/invariants/executor.ts | Runs an action as a real transaction on Hardhat and reports success or the decoded error name |
blockchain/test/invariants/observe.ts | Reads the chain after each action and checks every invariant against the model |
blockchain/test/invariants/Invariants.test.ts | The property: for random sequences, outcomes and state always match |
One run
When a run fails, the test prints the seed and the steps so far, and fast-check shrinks the sequence to a short counterexample.
The world and the actions
The model has three issuers plus a "stranger" issuer index, two students, two enrolment numbers, an admin, a guardian and a stranger. Constants in model.ts: ISSUER_COUNT = 3, EXPIRY_AFTER = 86400 (one day), WAIT_SECONDS = 172800 (two days), BUILT_IN_TYPE = 1, RESERVED_TYPE = 40, FIRST_CUSTOM_TYPE = 1000.
| Action | Weight | What varies |
|---|---|---|
issue | 6 | Which issuer (including an unregistered one), record type (0, 1, 31, 32, 40, 999, 1000, 1001), with or without expiry |
suspend, reinstate | 4 each | Caller (issuer, admin, guardian, stranger) and credential |
supersede | 3 | Caller, credential and replacement |
redeem | 3 | Issuer, student, enrolment, and whether the invitation is stale |
revoke, rebind, registerType, pause or unpause, suspendIssuer | 2 each | Caller and target |
setFirstTypeActive, setGuardian, addIssuer or removeIssuer, unbind or revokeInvitations, wait | 1 each | Caller and target; wait jumps two days, past every expiry |
Callers are drawn with the issuer and the admin twice as likely as the guardian or a stranger. Credential references fold onto the three most recent credentials so that status actions hit live credentials often.
What is checked after every action
From blockchain/test/invariants/observe.ts:
| Area | Invariant |
|---|---|
| Outcome | The transaction's success or error name equals the model's |
| Count | The chain and the model agree on how many credentials exist |
| Status | statusOf(id).status, adminHold and isValid(id) equal the model for every credential |
| Authorship | Every anchor's issuer is the account that issued it |
| Replacement | supersededBy equals the model's replacement, or zero |
| Immutability | An anchor's issuer and document hash never change after issuance |
| Terminal states | A credential that reached Revoked or Superseded never leaves it |
| Governance | The pause flag matches; the default admin never changes (there is no transfer action); the guardian role of the guardian and the stranger matches |
| Issuers | Registration, issuer suspension and custom type count match for each issuer |
| Bindings | holderOf and enrolmentOf match the model's bindings in both directions |
Running it
| Setting | Variable | Default | CI |
|---|---|---|---|
| Number of sequences | INVARIANT_RUNS | 10 | 500 (npm run invariants) |
| Maximum actions per sequence | INVARIANT_ACTIONS | 40 | 40 |
| Seed | INVARIANT_SEED | Random, printed at the start | Random, printed |
npm test includes the suite with 10 runs. ADR-0059 gives about 40 seconds for the 500 runs in CI. To reproduce a failure, rerun with the printed seed:
cd blockchain
INVARIANT_SEED=123456 INVARIANT_RUNS=500 npx hardhat test test/invariants/Invariants.test.ts
Seeded bugs
When the suite was built, these bugs were introduced into the contracts by hand to confirm the suite catches them (ADR-0059): revoke by the admin, a repeatable revoke, an issuer lifting an admin hold, a guardian lifting an issuer suspension, redeem ignoring the pause, issue ignoring the pause, unbind leaving the wallet link, a switched-off type still allowed, revokeInvitations not moving the epoch, and <= changed to < on expiry. The last one needed an exact timestamp, so it is caught by blockchain/test/v03/ExpiryBoundary.test.ts.
What it does not cover
- Inputs that fail field checks at issuance (zero hashes, bad signatures) and admin transfer are not in the action set; example tests cover them.
- Search is random, not coverage-guided.
- Key lineage has its own invariant test (below), and the access gate is covered by example and property tests (see Zero-knowledge tests).
Key lineage invariants
blockchain/test/invariants/KeyLineage.invariants.test.ts targets key retirement (contract version 0.5.0). It uses 11 keys and sequences of 8 to 60 steps:
| Step | Weight | Meaning |
|---|---|---|
grow | 8 | Register an unused key and retire the deepest active key to it, so chains get long |
add | 6 | Register a key |
retire | 4 | Retire a key to a successor |
issue | 4 | Try to issue from a key |
remove | 1 | Remove a key |
Every step's success or failure must match a small model (registered keys, successors and chain depth), so retiring past 8 hops must fail. After every step, for every key: currentIssuerOf equals the model's key in charge, that key has no successor, and isActiveIssuer matches; an issue attempt succeeds only from an active key. It honours INVARIANT_RUNS (default 10) and INVARIANT_SEED, and runs as part of npm test; npm run invariants runs only the main suite.
The gas snapshot
| Piece | Role |
|---|---|
blockchain/gas-snapshot.json | Committed gas figures for 24 operations (18 at first, the access gate rows were added in P2-S07) |
blockchain/test/gas/measure-operations.ts | Replays every operation on fresh contracts and records gas, calldata bytes and zero bytes |
blockchain/test/gas/GasSnapshot.test.ts | Compares the replay with the snapshot |
blockchain/scripts/lib/gas-snapshot.ts | compareSnapshots with GAS_TOLERANCE = 0.1, and the messages |
The figures themselves, with what each row measures, are on Gas and limits.
Rules
| Change | Locally | In CI |
|---|---|---|
| An operation over +10 percent | Fails | Fails |
| An operation in the snapshot but no longer measured | Fails | Fails |
| An operation cheaper by more than 10 percent | Logged as out of date | Fails: refresh the snapshot |
| A new operation not in the snapshot | Logged as out of date | Fails: refresh the snapshot |
The two-sided rule matters: if a large saving were left in the file, a later regression of the same size would pass unnoticed.
| Command | Effect |
|---|---|
npm run gas:check | Run the snapshot test alone |
npm run gas:snapshot | Rewrite gas-snapshot.json from a local replay (GAS_SNAPSHOT_WRITE=true); refused when CI is set |
npm run gas | Whole suite with hardhat-gas-reporter, output in gas-report.txt (uploaded by CI) |
The snapshot test skips itself under solidity-coverage, whose instrumentation changes gas.
Related gas tests
| File | Covers |
|---|---|
blockchain/test/gas/gas-snapshot-lib.test.ts | Parsing and comparing snapshots, the messages |
blockchain/test/gas/OperationRecords.test.ts | The replay records gas and calldata for every operation |
blockchain/test/gas/network-cost-lib.test.ts, network-fees-file.test.ts | The cost model and the dated fee file used by the network evaluation |
blockchain/test/v03/BoundedWork.test.ts | Gas of every write stays flat with 1, 100 and 1,000 stored items |
Source files
blockchain/test/invariants/model.ts,actions.ts,executor.ts,observe.tsblockchain/test/invariants/Invariants.test.ts,KeyLineage.invariants.test.tsblockchain/test/gas/,blockchain/gas-snapshot.json,blockchain/scripts/lib/gas-snapshot.tsdocs/progress/DECISIONS.md(ADR-0059),docs/progress/DEVIATIONS.md(DEV-002)