Skip to content

test(invariants): totalSupply tracks net mints across mint/burn cycles (closes #25)#39

Merged
abhicris merged 1 commit into
mainfrom
kcolb/invariant-stablecoin-supply
May 25, 2026
Merged

test(invariants): totalSupply tracks net mints across mint/burn cycles (closes #25)#39
abhicris merged 1 commit into
mainfrom
kcolb/invariant-stablecoin-supply

Conversation

@abhicris
Copy link
Copy Markdown
Contributor

Summary

Adds the first invariant-style test for Stablecoin.sol. A Handler contract bounds fuzz inputs to legal sequences of mint / burn / burnFrom against three pre-seeded actors, and ghost-tracks cumulative mints and burns. The invariant then asserts that on-chain totalSupply always equals the net ghost mints minus burns.

Closes #25.

Why invariants here

Unit tests in Stablecoin.t.sol already cover mint/burn at known endpoints. What they miss: any regression that causes totalSupply to drift from the sum of balances across random sequences. Examples this catches that unit tests don't:

  • A _mint that emits the right event but skips the balance write
  • A burnFrom path that decrements balance twice in some edge case
  • An admin role mistake that lets non-MINTER mint into ghost-only state

What's asserted

Invariant Why
totalSupply() == ghostMinted - ghostBurned The core ledger consistency
sum of per-actor balances == totalSupply No silent balance leaks into unreachable addresses
invariant_callSummary (visibility) Surfaces per-action call counts at -vv so a run that minted-but-never-burned is detectable in CI

Handler design

  • targetContract(address(handler)) + explicit targetSelector list — restricts fuzzing to handler_mint / handler_burn / handler_burnFrom. Without that, the fuzzer wanders into pause(), grantRole(), etc. and most calls revert in setUp, eating the budget.
  • Three actors pre-seeded (0xB1, 0xB2, 0xB3) so burn paths fire from call 1.
  • Mint amount bounded to [0, 1e30] — generous headroom (1e24 INV at 6 decimals) while preventing uint256 overflow at the high tail of the run × depth budget.
  • Burn paths gracefully no-op when the chosen actor has zero balance — so fuzzer cycles aren't wasted.

Verifying it catches bugs

Per acceptance criteria, the test must fail when accounting is intentionally broken. To verify locally:

  1. In contracts/Stablecoin.sol, change _mint(to, amount) in mint() to _mint(to, amount + 1) (introduce drift).
  2. Run forge test --mt invariant_totalSupplyMatchesNetMints -vvv.
  3. Expected: invariant fails with Stablecoin accounting drift: totalSupply != net mints.
  4. Revert the change.

Test plan

  • CI: existing forge test -vvv job passes (forge-test/invariants is under FOUNDRY_TEST already)
  • Manual: invariant fails when accounting is intentionally corrupted (see above)
  • Manual: run summary at -vv shows non-zero counts in all three call types (mint / burn / burnFrom)

closes #25)

Adds forge-test/invariants/StablecoinInvariants.t.sol with a Handler that
bounds fuzz inputs to legal mint/burn/burnFrom sequences against the
Stablecoin and ghost-tracks cumulative mint/burn. The handler seeds three
actors so burn paths exercise from call 1; mint caps at 1e30 wei to avoid
uint overflow over the default invariant budget.

Asserts two invariants:
  1. coin.totalSupply() == ghostMinted - ghostBurned    (the core ledger)
  2. sum of per-actor balances == totalSupply           (no balance leaks)

A third trivial invariant exposes per-action call counts at -vv so a run
that minted but never burned is detectable in CI output.

Catches the accounting drift class: e.g. a regression where _mint emits
the right event but skips the balance write (totalSupply increases but
sum of balances does not), which unit tests miss because they always
mint+burn at known endpoints, not random sequences.
@abhicris abhicris merged commit a38bcbe into main May 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[good first issue] Add invariant test for total supply across mint/burn cycles

1 participant