CROWNRIDGE
--:--:--Z
CROWNRIDGE/DOCS/TESTING
[TEST]Testing← ALL DOCS

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.solMockUniV3Pool (settable tick, TWAP slope, cardinality, liquidity; implements slot0/observe) and MockSwapRouter (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, plus conservationHolds().
  • BuybackTest.test_accretionAlwaysHolds_fuzz(uint256 marketUsd, uint256 spend) — market price bounded to [0.30, 0.90] USD/CRWN, spend to [100, 10_000] USDG. If executeBuyback succeeds it must be accretive (navPerToken never decreases); a revert (fail-closed) is always acceptable. This is the executable form of the accretion theorem in docs/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-facing quote() equals the actual on-chain mint result.
  • TokenTest.test_supplyEqualsIssued and invariant_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: recognizedReserve drops by exactly the USDG spent, supply by exactly the CRWN burned, and navPerToken strictly 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_chainAndUsdgAreRealblock.chainid == 4663; USDG at 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 has bytecode and reports decimals() == 6; the Treasury queries the same.
  • test_uniswapContractsAreReal — factory 0x1f7d…2EfA, SwapRouter02 0xCaf6…5cb2, QuoterV2 and NFPM 0x7399…E0D3 all have bytecode; getPool responds without reverting.
  • test_realGenesisDepositWithRealUsdg — deploys the protocol on the fork, funds a user with real USDG via Foundry's deal storage cheat (no faucet or counterparty needed), executes a genuine genesis.deposit(10_000e6) through the canonical token, and asserts exact mint output (9_950e18), treasury balance, recognition, conservation, and navPerToken ≈ 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 NUMERIC amounts,
  • 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.
F2TREASURYF3TRANSPARENCYF4DOCSF5WHITEPAPERF6STATUS