Skip to main content

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​

FileRole
blockchain/test/invariants/model.tsA 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.tsfast-check generators for random actions, with weights
blockchain/test/invariants/executor.tsRuns an action as a real transaction on Hardhat and reports success or the decoded error name
blockchain/test/invariants/observe.tsReads the chain after each action and checks every invariant against the model
blockchain/test/invariants/Invariants.test.tsThe 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.

ActionWeightWhat varies
issue6Which issuer (including an unregistered one), record type (0, 1, 31, 32, 40, 999, 1000, 1001), with or without expiry
suspend, reinstate4 eachCaller (issuer, admin, guardian, stranger) and credential
supersede3Caller, credential and replacement
redeem3Issuer, student, enrolment, and whether the invitation is stale
revoke, rebind, registerType, pause or unpause, suspendIssuer2 eachCaller and target
setFirstTypeActive, setGuardian, addIssuer or removeIssuer, unbind or revokeInvitations, wait1 eachCaller 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:

AreaInvariant
OutcomeThe transaction's success or error name equals the model's
CountThe chain and the model agree on how many credentials exist
StatusstatusOf(id).status, adminHold and isValid(id) equal the model for every credential
AuthorshipEvery anchor's issuer is the account that issued it
ReplacementsupersededBy equals the model's replacement, or zero
ImmutabilityAn anchor's issuer and document hash never change after issuance
Terminal statesA credential that reached Revoked or Superseded never leaves it
GovernanceThe pause flag matches; the default admin never changes (there is no transfer action); the guardian role of the guardian and the stranger matches
IssuersRegistration, issuer suspension and custom type count match for each issuer
BindingsholderOf and enrolmentOf match the model's bindings in both directions

Running it​

SettingVariableDefaultCI
Number of sequencesINVARIANT_RUNS10500 (npm run invariants)
Maximum actions per sequenceINVARIANT_ACTIONS4040
SeedINVARIANT_SEEDRandom, printed at the startRandom, 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:

StepWeightMeaning
grow8Register an unused key and retire the deepest active key to it, so chains get long
add6Register a key
retire4Retire a key to a successor
issue4Try to issue from a key
remove1Remove 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​

PieceRole
blockchain/gas-snapshot.jsonCommitted gas figures for 24 operations (18 at first, the access gate rows were added in P2-S07)
blockchain/test/gas/measure-operations.tsReplays every operation on fresh contracts and records gas, calldata bytes and zero bytes
blockchain/test/gas/GasSnapshot.test.tsCompares the replay with the snapshot
blockchain/scripts/lib/gas-snapshot.tscompareSnapshots with GAS_TOLERANCE = 0.1, and the messages

The figures themselves, with what each row measures, are on Gas and limits.

Rules​

ChangeLocallyIn CI
An operation over +10 percentFailsFails
An operation in the snapshot but no longer measuredFailsFails
An operation cheaper by more than 10 percentLogged as out of dateFails: refresh the snapshot
A new operation not in the snapshotLogged as out of dateFails: 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.

CommandEffect
npm run gas:checkRun the snapshot test alone
npm run gas:snapshotRewrite gas-snapshot.json from a local replay (GAS_SNAPSHOT_WRITE=true); refused when CI is set
npm run gasWhole 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.

FileCovers
blockchain/test/gas/gas-snapshot-lib.test.tsParsing and comparing snapshots, the messages
blockchain/test/gas/OperationRecords.test.tsThe replay records gas and calldata for every operation
blockchain/test/gas/network-cost-lib.test.ts, network-fees-file.test.tsThe cost model and the dated fee file used by the network evaluation
blockchain/test/v03/BoundedWork.test.tsGas 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.ts
  • blockchain/test/invariants/Invariants.test.ts, KeyLineage.invariants.test.ts
  • blockchain/test/gas/, blockchain/gas-snapshot.json, blockchain/scripts/lib/gas-snapshot.ts
  • docs/progress/DECISIONS.md (ADR-0059), docs/progress/DEVIATIONS.md (DEV-002)