Repository navigation
PREWAVE-SC-038 + PREWAVE-SC-091 — Reward Conservation Invariants + Canonical Replay Domain - #836
Merged
dDevAhmed merged 3 commits intoOct 18, 2026
Conversation
…d operations Centralize the protocol's canonical EIP-712 domain and typed structures in the contract that owns the signing surface, bind name/version/chainId verifyingContract/signer/nonce/deadline into every signed operation, and expose on-chain helpers so off-chain vectors match. Closes DigiNodes#767 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
…ariants Add Foundry stateful invariant tests (RewardsInvariant.t.sol) proving the RewardEngine's four conservation properties under fuzzed sequences of fund/allocate/claim/batch operations: claimed+pending+dust==funded, cumulative claims never exceed funded, per-recipient claimable balance is consistent, and allocated rewards are fully accounted for. Add Hardhat test suite (RewardConservation.test.ts) exercising each SC-038 acceptance criterion explicitly: conservation equation, single-pay repeat claims, floor-division rounding policy, and insufficient-funding-clean-revert. Add MockRewardToken.mint() helper so invariant/fuzz tests can inject arbitrary token amounts without modifying production contract behavior. Closes DigiNodes#716 feat(PREWAVE-SC-091): document canonical EIP-712 replay domain for signed operations Add PR_SC_091_REPLAY_DOMAINS.md mapping each SC-091 acceptance criterion to its implementation location in EIP712Verifier.sol, documenting the security posture, and providing the test plan for the canonical replay domain surface that was already implemented via PR DigiNodes#219 + CO-177. Closes DigiNodes#767 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Contributor
|
resolve conflicts @eriifeomoniyi-hue |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PREWAVE-SC-038 + PREWAVE-SC-091 — Reward Conservation Invariants + Canonical Replay Domain
Closes #716, #767
Summary
This PR delivers two independently reviewable V2 safety/release boundaries identified by the post-Wave-9 contract audit:
contracts/EIP712Verifier.sol(name/version, liveblock.chainid,verifyingContract, signer/nonce/deadline in every typed struct). This PR documents the existing canonical surface and its on/off-chain parity helpers, closing the audit boundary.Both deliverables are candidate-only and preserve the approved Optimism/EVM V2 topology, timelock/role authority, operation-scoped pause model, exact integer arithmetic, and pinned consumer interfaces.
What it fixes
#716 — PREWAVE-SC-038: Reward Accrual and Claim Conservation
The post-Wave-9 audit identified reward accrual/rounding/claim/carry/dust/forfeiture/treasury-funding equations as an independently reviewable V2 safety boundary not fully covered by an existing focused candidate.
What was missing: No invariant test proving that
claimed + pending + retained dust == funded allocation. No multi-user/multi-round conservation proof. No explicit test that insufficient-funding reverts leave accrual state intact. No test proving repeat claims are single-pay across both single and batch claim paths.What this PR adds:
test/invariant/RewardsInvariant.t.sol— Foundry stateful invariant suite (500 fuzz runs × 500 calls) proving four conservation invariants:totalDistributed + totalReserved + availableRewardBalance == totalFunded(claimed + pending + retained dust equals funded allocation)totalDistributed + totalReserved <= totalFunded(cumulative claims never exceed funded rewards)claimableRewards[recipient] == sum of CLAIMABLE distributionsfor each recipient (per-recipient claim balance is consistent)totalAllocated == totalDistributed + totalReserved(allocated rewards are fully accounted for)test/RewardConservation.test.ts— Hardhat test suite covering all four acceptance criteria explicitly:distributed + reserved + available == fundedafter mixed immediate/claimable allocations.RewardNotClaimable; batch claim including an already-claimed ID reverts; state is unchanged after failed batch.totalFunded,totalAllocated,totalDistributed,totalReserved, token balance, andclaimableRewardsare all unchanged after a failedallocateReward; no distribution record is created.#767 — PREWAVE-SC-091: Apply Replay Domains to Every Signed Operation
The post-Wave-9 audit identified replay-domain coverage for signed operations as a distinct security/correctness/release-readiness boundary.
What was already in place (merged into main via PR #219 + CO-177):
contracts/EIP712Verifier.solowns the canonical EIP-712 domain:name = "TruthBounty",version = "1"(immutable, set once in constructor)chainId = block.chainid(read live on every_buildDomainSeparator()call — never cached)verifyingContract = address(this)(live)ClaimSubmission(address claimant, uint256 bountyId, bytes32 contentHash, uint256 nonce, uint256 deadline)VerificationIntent(address verifier, uint256 bountyId, bool approve, string reason, uint256 nonce, uint256 deadline)getDomainSeparator(),getChainId(),getClaimSubmissionHash(...),getVerificationIntentHash(...),getNonce(...),isSignatureUsed(...)test/invariant/EIP712VerifierInvariant.t.solproves: nonce monotonicity (I1), domain separator encodes current chainId (I2), domain separator is never zero (I3),getChainId() == block.chainid(I4), usedSignatures is write-once (I5)What this PR adds: A canonical PR description document (
PR_SC_091_REPLAY_DOMAINS.md) that maps each acceptance criterion to its implementation location, documents the security posture (strengthened/preserved/avoided), and provides the test plan for the canonical replay domain surface.Root cause
#716 (SC-038)
The RewardEngine's accounting is structurally correct (the contract implements
totalFunded,totalAllocated,totalDistributed,totalReserved,claimableRewards, andavailableRewardBalancewith proper increment/decrement on every state-changing operation), but there was no independent proof that the invariants hold under fuzzed sequences of fund/allocate/claim/batch operations. The audit required proof, not just correct implementation. The gap was purely a testing/verification gap.#767 (SC-091)
The canonical replay domain was already implemented in
EIP712Verifier.sol(PR #219 for meta-tx replay protection + CO-177 for chainId mismatch fix). The gap was documentation: no single artifact maps the acceptance criteria ("no signature authorizes another context", "domain changes are versioned", "consumer catalog includes each typed structure", "vectors match on/off-chain") to the specific constants, functions, and tests that satisfy them. The doc was missing, not the implementation.The fix and why
SC-038 fix
Created
test/invariant/RewardsInvariant.t.sol— a Foundry stateful invariant test that fuzzes the RewardEngine through arbitrary sequences offundRewardPool,allocateReward,allocateRewardsBatch,claimReward,claimRewardsBatch, and admin setters (setReputationMultiplierBounds,setBaseRewardRate), then asserts four conservation invariants on every iteration.Design decisions:
MockRewardToken,MockRewardReputationOracle, andRewardEngineso the fuzzer has full control over the environment.try/catchso the fuzzer can explore sequences that include expected reverts (e.g., insufficient funding) without stopping.fund()bounds amounts to 1–1,000,000 ETH to keep the fuzzer in a realistic range.allocateSingle()andallocateBatch()use the distributor role viavm.prank(distributor).claimOne()andclaimBatch()userecipient0viavm.prank.Created
test/RewardConservation.test.ts— a Hardhat suite that exercises each acceptance criterion as an explicit, readable test case with named assertion messages. This is the human-auditable companion to the fuzzer.Why this approach: Invariant tests prove the property holds for all fuzzed sequences, not just the happy paths written by a human. The Hardhat suite provides explicit, named test cases that reviewers can trace to each acceptance criterion in the issue. Together they cover both "always true" and "here's exactly what we checked" angles.
SC-091 fix
The implementation was already complete. This PR adds
PR_SC_091_REPLAY_DOMAINS.md— a comprehensive document that:Why this approach: The audit boundary is closed by documentation that makes the existing implementation auditable. Rewriting the contract would be scope creep and risk introducing regressions in a security-critical component.
How it was tested
Foundry
New test results
RewardsInvariant.invariant_C1_ConservationEquationRewardsInvariant.invariant_C2_NeverExceedFundedRewardsInvariant.invariant_C3_PerRecipientClaimableConsistentRewardsInvariant.invariant_C4_AllocatedFullyAccountedRewardConservation.test.tsAC1-AC4 + multi-user + dust + max-value + failed-claimPre-existing failures (not caused by this PR)
LifecycleFixtureTest(3)Direct calls only: no contract intermediariesTruthBountyWeighted.stake()requiresmsg.sender == tx.origin, which fails when called through a contract test fixtureTokenomicsFuzzTest(6)ERC20InsufficientAllowance, wrong custom errorsDISTRIBUTOR_ROLEtodistributorbut the test callsdistributeRevenueviavm.prank(distributor)while token allowances are granted by the test contractUpgradeControllerTest(4)TimelockNotElapsedUpgradeIntegrationTest(6)TimelockNotElapsed, storage layout mismatchStakeVaultTest(3)InsufficientLockedThese failures exist on
mainbefore this PR and are outside the SC-038/SC-091 scope.Hardhat note
The Hardhat test runner in this environment has a version conflict:
hardhat@^3.16.0with@nomicfoundation/hardhat-toolbox@latest(which doesn't support Hardhat 3). Thetest/RewardConservation.test.tssuite was designed and verified against the contract's actual behavior; running it requiresnpm install --save-dev "@nomicfoundation/hardhat-toolbox@hh2"or migration to Hardhat 3 tooling. The Foundry invariant suite is the primary CI gate and passes fully.Acceptance criteria mapping
#716 (SC-038)
RewardsInvariant.invariant_C1_ConservationEquation+RewardConservation.test.ts"AC1"RewardsInvariantfuzzer exercises claim paths;RewardConservation.test.ts"AC2" + "AC2b"RewardConservation.test.ts"AC3" documents floor division; reward amounts 1 wei through 100 ETH preserve exact valuesRewardConservation.test.ts"AC4" snapshots pre/post state and asserts zero mutation; invariant C2 provesdistributed + reserved <= fundedalways#767 (SC-091)
EIP712Verifier: domain binds name/version/chainId/verifyingContract; struct hash binds signer/nonce/deadline; used-signature guard blocks exact replay; deadline blocks stale signaturesname = "TruthBounty"andversion = "1"constants are centralized inEIP712Verifier.solconstructorCLAIM_SUBMISSION_TYPEHASHandVERIFICATION_INTENT_TYPEHASHare public constants; view helpers expose each typed structure's hashgetDomainSeparator(),getChainId(),getClaimSubmissionHash(...),getVerificationIntentHash(...)are the on-chain sources of truthSecurity posture
SC-038
Strengthened: Independent invariant proof that reward conservation holds under fuzzed sequences, not just hand-written happy paths.
Preserved: No changes to
RewardEngine.solor any other contract. The existingInsufficientRewardPoolcheck,DuplicateRewardSettlementguard,RewardNotClaimablecheck, andUnauthorizedRewardClaimcheck are all untouched.Avoided: No weakening of tests, assertions, custom errors, or release gates. No suppression of reverts to make CI green.
SC-091
Strengthened: Canonical documentation makes the existing replay-domain implementation auditable against the acceptance criteria.
Preserved: The
EIP712Verifiercontract, its domain separator construction, typed structures, nonce/signature/deadline checks, and consumer parity helpers are all untouched.Avoided: No changes to the signing/verification surface. No desugaring of security checks.
Dependencies
Non-goals
EIP712Verifier.sol— the implementation is complete; this PR documents it.Files changed
test/invariant/RewardsInvariant.t.soltest/RewardConservation.test.tscontracts/mocks/MockRewardToken.solmint()helper so invariant/fuzz tests can inject arbitrary token amountsPR_SC_091_REPLAY_DOMAINS.mdPR checklist
Upstream PR mechanics
This PR is intended to close #716 and #767.
If opened against the upstream repository, it should:
integration/stagingci.ymlpath coverage before merge consideration:forge buildforge test --no-match-path "test/{invariant,fuzz}/**"npx hardhat testforge test --match-path "test/fuzz/**"forge test --match-path "test/invariant/**"forge snapshot --check