Crownridge Testing
Status honesty note. This test suite is a necessary condition for mainnet, not a sufficient one. Crownridge has not undergone an external security audit and is not deployed to mainnet. Spec §88 makes an independent smart-contract audit and an external economic/risk review non-negotiable pre-mainnet gates; nothing in this document substitutes for them.
Toolchain: Foundry (forge), Solidity 0.8.30 (pinned), via_ir + optimizer (200 runs),
EVM cancun, OpenZeppelin Contracts v5 (pinned). Indexer tests: Vitest + PGlite.
1. Test inventory
All Solidity suites live in contracts/test/. Counts below are per test function, read from the
source (each fuzz function additionally runs hundreds of sampled cases — see §3).
| File | Contract(s) | Tests | Kind |
|---|---|---|---|
Token.t.sol |
TokenTest |
9 | Unit — mint gating, burn, permit, transfer, supply |
Treasury.t.sol |
TreasuryTest |
15 | Unit — recognition, conservation, founder withdraw, write-down, sweep, rescue |
Genesis.t.sol |
GenesisTest |
13 | 12 unit + 1 fuzz — deposit/mint math, cap, window, wallet cap, pause, dust |
Buyback.t.sol |
BuybackTest |
14 | 13 unit + 1 fuzz — full risk engine + economic-attack regressions |
FounderController.t.sol |
FounderControllerTest |
7 | Unit — UNCAPPED + all CAPPED limits |
MintAndAccounting.t.sol |
MintControllerTest, AccountingLensTest |
5 + 4 | Unit + differential (lens vs Treasury) |
Timelock.t.sol |
TimelockTest |
3 | Integration — 48h delay, guardian veto (C1 fix) |
Deploy.t.sol |
DeployTest |
1 | Integration — production deploy script end-to-end |
invariant/Invariants.t.sol (+ invariant/Handler.sol) |
InvariantsTest |
8 | Stateful invariant properties |
fork/ForkRobinhood.t.sol |
ForkRobinhoodTest |
3 | Fork — live Robinhood Chain mainnet |
Total: 74 test functions + 8 invariant properties = 82 checks. Under the default profile each invariant property is additionally exercised across 16,384 handler calls per suite run (§4).
┌────────────────────────────┐
│ fork/ForkRobinhood.t.sol │ live mainnet: real USDG,
│ (3 tests, RH_RPC_URL) │ real Uniswap v3 bytecode
├────────────────────────────┤
│ invariant/Invariants │ 8 properties × 16,384
│ + Handler (stateful fuzz) │ randomized calls per run
├────────────────────────────┤
│ Deploy.t.sol Timelock.t │ wiring, hand-off, veto
├────────────────────────────┤
│ unit + bounded fuzz │ 63 tests over the full
│ (Token/Treasury/Genesis/ │ stack, deployed exactly
│ Buyback/Founder/Mint/Acct)│ as production wires it
└────────────────────────────┘
Shared fixture
Base.t.sol (CrownridgeBase) deploys and wires the entire stack — token, MintController
(address-predicted so the token's immutable minter is exact), Treasury, Genesis, Buyback,
LiquidityManager, FounderTreasuryController, Accounting lens — with the same roles and
protocol-holder set the production Deploy.s.sol establishes. admin stands in for the
TimelockController in logic tests; the timelock's own delay/cancel behavior is covered separately
in Timelock.t.sol. Mocks:
mocks/MockUSDG.sol— mintable 6-decimal ERC-20 standing in for USDG.mocks/MockUniV3.sol—MockUniV3Pool(settable tick, TWAP slope, cardinality, liquidity; implementsslot0/observe) andMockSwapRouter(delivers CRWN at a settable price), so buyback tests are deterministic. Real Uniswap behavior is validated by the fork suite, not mocked away.
The fixture derives pool ticks via binary search over the real forward quote function
(UniV3TwapLib.getQuoteAtTick), so unit tests exercise the true price derivation rather than a
parallel reimplementation.
2. Unit tests
Every externally callable function has positive and negative (revert) coverage, asserting the exact custom error where one exists. Representative guarantees, each mapped to the core invariants (CLAUDE.md §3):
| Guarantee | Test(s) |
|---|---|
Only the MintController can mint; even admin cannot (OnlyMinter) |
test_revert_directMintByAnyone, test_revert_directMintByAdmin, test_revert_issueByAdmin_withoutIssuerRole |
Recognition cannot exceed real balance (ConservationViolated) |
test_revert_recognizeBeyondBalance, test_conservationHoldsAfterDeposit |
Founder withdrawal reduces recognizedReserve + NAV, never CRWN supply |
test_founderWithdrawReducesNavNotSupply |
| Guardian pauses founder withdrawal immediately (P2 review C2) | test_guardianPausesFounderWithdraw_immediately |
writeDownRecognized can only decrease (CannotIncrease) |
test_writeDownOnlyDecreases, test_revert_writeDownCannotIncrease |
rescueToken refuses USDG/CRWN; only foreign tokens |
test_revert_rescueUsdgOrCrwn, test_rescueForeignToken |
| Donations are excess until swept, never auto-recognized (spec §138) | test_sweepExcessRecognizesDonation |
| Genesis mints net-of-fee at fixed rate; gross recognized; fee raises navPerToken | test_deposit_mintsCorrectRateNetOfFee, test_deposit_feeRaisesNavPerToken |
| Cap, window, min/max-per-wallet, pause, zero/dust deposits all enforced | test_revert_capExceeded, test_capExhaustionExact, test_revert_beforeWindow, test_revert_walletCapExceeded, test_revert_whenPaused, test_revert_dustRoundsToZeroCrwn |
| CAPPED founder mode: max-per-withdrawal, cooldown, treasury floor, rolling limit (spec §119) | test_capped_maxPerWithdrawal, test_capped_cooldown, test_capped_minTreasuryFloor, test_capped_rollingLimit |
Timelock: guardian CANCELLER_ROLE vetoes a malicious queued role grant; deployer retains no authority |
test_guardianCanVetoMaliciousRoleGrant, test_deployerHasNoTimelockAuthority, test_legitimateChangeExecutesAfterDelay |
| Burned CRWN cannot return; supply == totalIssued | test_burnedCrwnCannotReturn, test_supplyEqualsIssued |
navPerToken returns 0 (never div-by-zero) at zero circulating |
test_navPerTokenZeroWhenNoCirculating |
Decimal correctness (USDG 6 ↔ CRWN 18) is asserted throughout with explicit raw-unit expectations
(e.g. a 10,000e6 USDG deposit at rate 1e18 and 50 bps fee must mint exactly 9_950e18 CRWN and
produce navPerToken ≈ 1.005025e18).
3. Fuzz tests (bounded)
Two property-style fuzz tests run inside the standard suite; both bound their inputs to the reachable domain rather than rejecting samples:
GenesisTest.testFuzz_depositBackingInvariant(uint256 amount)— amount bounded to[minDeposit, cap]. Asserts restated Invariant 2 after every deposit:circulatingSupply ≤ recognizedReserve × 1e12 × rate / 1e18, plusconservationHolds().BuybackTest.test_accretionAlwaysHolds_fuzz(uint256 marketUsd, uint256 spend)— market price bounded to[0.30, 0.90]USD/CRWN, spend to[100, 10_000]USDG. IfexecuteBuybacksucceeds it must be accretive (navPerTokennever decreases); a revert (fail-closed) is always acceptable. This is the executable form of the accretion theorem indocs/ECONOMICS.md.
Configured in foundry.toml: 512 runs per fuzz test by default, 2,000 runs under the ci
profile, max_test_rejects = 200000.
4. Invariant suite — 8 properties over a randomized handler
invariant/Handler.sol drives randomized sequences against the fully wired stack with three actor
addresses and five actions: deposit (Genesis), founderWithdraw, burn, transfer
(occasionally into a protocol holder, to jiggle circulating-supply math), and writeDown.
Reverting actions are swallowed (fail_on_revert = false) — a rejected action is not a
violation, so the fuzzer keeps exploring the reachable state space. The handler tracks two ghost
totals, cumulativeGrossDeposited and totalBurned, that the properties check against.
Default profile: 256 runs × depth 64 = 16,384 randomized calls checked against every property per suite run. CI profile: 512 × 128 = 65,536.
Property (Invariants.t.sol) |
Asserts | Core invariant (CLAUDE.md §3) |
|---|---|---|
invariant_conservation |
recognizedReserve ≤ usdgBalance and conservationHolds() |
3, 9 |
invariant_navDeterministic |
nav() == recognizedReserve × 1e12 exactly |
7 |
invariant_supplyAccounting |
totalSupply + totalBurned == mint.totalIssued() |
5 |
invariant_noNakedMint |
totalIssued ≤ CrownridgeMath.crwnForDeposit(cumulativeGrossDeposited, rate, 6) |
1, 2 |
invariant_genesisCap |
genesis.totalDeposited() ≤ genesis.cap() |
6 |
invariant_minterImmutable |
crwn.minter() == address(mint) forever |
1 (structural) |
invariant_supplyOnlyViaIssueBurn |
supply moves only via issue/burn — founder withdrawals never touch it | 10 |
invariant_callSummary |
ghost sanity: cumulative gross deposits never exceed the cap | — |
5. Differential / accounting checks
The design rule is a single anchored accounting convention (CrownridgeMath, used identically
by Treasury, Buyback, and the replaceable Accounting lens). The tests enforce that no two surfaces
can disagree:
AccountingLensTest.test_viewsMatchTreasury— every lens view (recognizedUSDG,recognizedAssets,usdGBalance,grossAssets,netAssetValue,circulatingSupply,navPerToken,navPerCRWN,liabilities,conservationHolds) equals the Treasury's own primitives, value for value.test_backingPerTokenEqualsNav,test_protocolHeldSupply— derived views tie back to primitives.GenesisTest.test_quoteMatchesDeposit— the off-chain-facingquote()equals the actual on-chain mint result.TokenTest.test_supplyEqualsIssuedandinvariant_supplyAccounting— the token, the MintController's ledger, and burn history reconcile exactly.- Buyback accounting is checked by measured deltas, mirroring the contract's own logic:
recognizedReservedrops by exactly the USDG spent, supply by exactly the CRWN burned, andnavPerTokenstrictly increases (test_executesBelowThreshold_andBurns).
A TypeScript-vs-Solidity differential NAV recomputation is a planned P4 follow-up (CLAUDE.md §6);
by design the SDK and website read navPerToken on-chain rather than recomputing it, so the
on-chain convention is the only convention.
6. Fork suite — live Robinhood Chain mainnet
fork/ForkRobinhood.t.sol runs against live Robinhood Chain mainnet (chainId 4663) — real
USDG bytecode, real Uniswap v3 deployments, no mocks (spec §133/§163/§164). It gates itself on
RH_RPC_URL: when the variable is unset, setUp logs a skip notice and the three tests no-op, so
the offline suite stays green.
test_chainAndUsdgAreReal—block.chainid == 4663; USDG at0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168has bytecode and reportsdecimals() == 6; the Treasury queries the same.test_uniswapContractsAreReal— factory0x1f7d…2EfA, SwapRouter020xCaf6…5cb2, QuoterV2 and NFPM0x7399…E0D3all have bytecode;getPoolresponds without reverting.test_realGenesisDepositWithRealUsdg— deploys the protocol on the fork, funds a user with real USDG via Foundry'sdealstorage cheat (no faucet or counterparty needed), executes a genuinegenesis.deposit(10_000e6)through the canonical token, and asserts exact mint output (9_950e18), treasury balance, recognition, conservation, andnavPerToken ≈ 1.005025e18.
This proves the verified addresses in CLAUDE.md §2 are real and that the deposit path works end-to-end against the canonical reserve asset — it does not constitute a mainnet deployment.
7. Deploy-path testing
Deploy.t.sol executes the production script/Deploy.s.sol end-to-end (env-driven, against a mock
USDG), so the deployment procedure itself is under test, including the script's internal
post-deploy assertions. It re-checks the headline guarantees independently: the CREATE-nonce
address prediction held (crwn.minter() == mint), the deployer renounced Treasury admin, the
timelock holds DEFAULT_ADMIN_ROLE, the security multisig holds the timelock's CANCELLER_ROLE,
the buyback is disabled at launch, Genesis holds ISSUER_ROLE, and the founder controller is
in CAPPED mode. script/VerifyDeployment.s.sol re-verifies a live deployment from its manifest.
8. Economic-attack coverage
Mapped to the economic attack review required by spec §88/§164:
| Attack / failure mode | Defense under test | Test |
|---|---|---|
| Dilutive buyback (overpaying vs NAV) | measured-delta accretion check; revert if navPerToken would fall |
test_accretionAlwaysHolds_fuzz, test_executesBelowThreshold_andBurns |
| Buying without a real discount | minDiscountBps vs TWAP (NotDiscounted) |
test_revert_notDiscounted, test_exactThreshold_boundary |
| Spot manipulation before a buyback | spot-vs-TWAP deviation bound (DeviationTooHigh) |
test_revert_deviationTooHigh |
| Thin/young oracle (stale TWAP) | minCardinality fail-closed (CardinalityTooLow) |
test_revert_cardinalityTooLow |
| Treasury drain via oversized buybacks | per-tx cap, daily cap, rolling maxTreasuryBps vs reserve, cooldown |
test_revert_exceedsMaxSpendPerTx, test_revert_treasuryPercentCap, test_revert_cooldown |
| Donation-brick (P5 security-review regression): permissionless USDG transfer to the Buyback underflowing its window accounting | balance-delta accounting excludes donations; sweepStrayUsdg() recovers them to the Treasury |
test_donationDoesNotBrickBuyback |
| Donation inflating NAV | donations are excess, not backing, until a timelocked sweepExcess() |
test_sweepExcessRecognizesDonation |
| Over-recognition (phantom backing) | ConservationViolated; conservation invariant |
test_revert_recognizeBeyondBalance, invariant_conservation |
| Rounding/dust extraction at mint | floor rounding favors the Treasury; zero-mint deposits revert (MintsZero) |
test_revert_dustRoundsToZeroCrwn, exact-value unit tests |
| Reserve inflation via write-down path | writeDownRecognized can only decrease |
test_revert_writeDownCannotIncrease, handler writeDown action |
| Governance capture via queued role grant | 48h delay + guardian CANCELLER_ROLE veto |
test_guardianCanVetoMaliciousRoleGrant |
| Founder-key compromise | guardian's immediate founderWithdrawPaused brake |
test_guardianPausesFounderWithdraw_immediately |
The founder-withdrawal tests deserve emphasis: test_uncapped_withdrawsUpToReserve demonstrates —
deliberately — that in UNCAPPED mode the founder can withdraw the entire recognized reserve,
driving NAV to zero without touching CRWN supply. The suite verifies the mechanism behaves exactly
as disclosed; it does not (and cannot) remove the trust assumption. See docs/RISK.md.
9. Running the tests
cd contracts
# Everything local: unit + fuzz + invariants (fork tests self-skip without RH_RPC_URL)
forge test
# One suite / one test
forge test --match-path test/Buyback.t.sol
forge test --match-test test_donationDoesNotBrickBuyback -vvv
# Fork suite against live Robinhood Chain mainnet (public RPC is rate-limited; prefer a
# dedicated provider endpoint)
RH_RPC_URL=https://rpc.mainnet.chain.robinhood.com forge test --match-path 'test/fork/*'
# Heavier runs: fuzz 2,000 runs; invariants 512 runs × depth 128 (profile in foundry.toml)
FOUNDRY_PROFILE=ci forge test
# Coverage (informational)
forge coverage
Profile (foundry.toml) |
Fuzz runs | Invariant runs × depth | Calls per property |
|---|---|---|---|
default |
512 | 256 × 64 | 16,384 |
ci |
2,000 | 512 × 128 | 65,536 |
The repo-level runner executes the contract suite and the indexer tests as part of the full check:
npm run verify (or npm run verify -- --part contracts).
10. Indexer tests (PGlite)
indexer/test/db.test.ts (5 tests, cd indexer && npm test → Vitest) validates
src/db/schema.sql and the exact SQL the server issues against a real Postgres engine —
PGlite, Postgres compiled to WASM — so real SQL semantics are exercised with no Docker dependency:
- schema applies idempotently (double-apply must not throw; all four tables exist),
- token amounts are stored as raw integer strings without precision loss (1e24 round-trips,
beyond
Number.MAX_SAFE_INTEGER— the same no-floats rule the contracts follow), - event ingestion dedupes on
(tx_hash, log_index)(ON CONFLICT DO NOTHING), making re-indexing safe, - the summary aggregates the API serves compute correctly over
NUMERICamounts, - alerts (e.g.
conservation_breach) are recorded and readable.
Running the same suite against a full Postgres service instance is part of the CI phase (P18).
11. What formal / property verification would add (spec §89)
The invariant suite samples 16,384 execution paths per property per run; formal/property verification (e.g. Certora, Halmos, or SMTChecker) would turn the strongest of these from "unfalsified under fuzzing" into "proven for all inputs". Spec §89 identifies the candidates, most of which already exist here as executable properties:
| §89 property | Today (fuzzed/tested) | Formal target |
|---|---|---|
| Supply accounting | invariant_supplyAccounting, invariant_supplyOnlyViaIssueBurn |
totalSupply == totalIssued − totalBurned for all traces |
| Mint authorization | invariant_minterImmutable, direct-mint revert tests |
no state exists where a non-controller mint succeeds |
| Treasury conservation | invariant_conservation |
recognizedReserve ≤ balanceOf(Treasury) for all traces |
| Buyback spending limits | cap/cooldown unit tests, maxSpendNow |
spend in any window provably ≤ all three caps |
| Role separation | role revert tests, Deploy.t.sol hand-off assertions |
reachability analysis over the role graph |
| Cap enforcement | invariant_genesisCap |
totalDeposited ≤ cap for all traces |
| Bridge supply reconciliation / strategy exposure limits | — (no bridge or strategies in V1) | n/a until such modules exist |
Additional candidates from this codebase: the accretion theorem (executeBuyback succeeds ⟹
navPerToken non-decreasing) and monotonicity of writeDownRecognized. This work is planned,
not done — it belongs alongside the external audit engagement (founder action list, CLAUDE.md
§11, item 9).
12. Known gaps
Recorded so they are worked, not forgotten:
- No external audit; no testnet or mainnet deployment has occurred yet.
- TS-vs-Solidity differential NAV recomputation not yet implemented (planned, §5).
- Formal verification not yet performed (§11).
- CI wiring (contracts + indexer + Postgres service) lands in phase P18.
- Fork tests exercise deposits against live USDG; a fork-level buyback against a live CRWN/USDG
pool requires a seeded pool and is part of the local anvil-fork stack (
npm run stack:up) rather than the forge fork suite.