freeze(v8-v10): zero claim rewards & no-op random sampling#458
Conversation
Patches Staking.claimDelegatorRewards to always emit zero rewards (while
still advancing claim bookkeeping so requestWithdrawal stays unblocked),
and turns RandomSampling.createChallenge / submitProof into silent no-ops.
Stops the V8 -> V10 transition reward leak: epoch pools are no longer
backed by escrowed TRAC, so any non-zero reward emission on this contract
version would be paid out of delegators' staked principal.
Bumps Staking _VERSION to 1.0.2-freeze and RandomSampling to 1.0.1-freeze.
Flips deployed=false for Staking and RandomSampling in the three mainnet
contracts JSONs so hardhat-deploy redeploys both contracts on the next
mainnet deploy run and atomically rotates them via Hub.setAndReinitializeContracts.
Adds three freeze test suites:
- test/integration/StakingRewardsFreeze.test.ts (Staking freeze invariants)
- test/unit/RandomSamplingFreeze.test.ts (RandomSampling no-op invariants)
- test/integration/HubRotationBricking.test.ts (proves OLD contract
addresses are unregistered from Hub.contractSet after rotation, and
that OLD .stake() / requestWithdrawal revert UnauthorizedAccess at
the storage gate)
Adds scripts/verify_freeze.ts: post-deploy read-only check that the Hub
points to the new freeze contracts, that they report the correct freeze
versions, and that pre-freeze contract addresses are bricked.
Pre-existing reward / score-accumulation suites are marked .skip with
explanatory comments; they are the regression suite for V10 reactivation.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
You have used all of your free Bugbot PR reviews. To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial. |
branarakic
left a comment
There was a problem hiding this comment.
Thanks for putting this together. The contract-side freeze behavior looks aligned with the migration goal, and the focused freeze/bricking suites passed locally. I do think this needs changes before merge, mostly around the operational verifier.
Findings:
-
scripts/verify_freeze.tsappears to hard-code oldRandomSamplingaddresses that do not match the committed mainnet deployment JSONs. The verifier snapshots0xB440.../0xe59c.../0x2E44...atscripts/verify_freeze.ts:42-51, but the PR deployment JSONs still show the currentRandomSamplingaddress as0x4B20D581695fb96db8FE78D8b12994fa43946eFAfor base, gnosis, and neuroweb (deployments/base_mainnet_contracts.json:315,deployments/gnosis_mainnet_contracts.json:311,deployments/neuroweb_mainnet_contracts.json:345). If the JSONs are the source of truth before deployment, the post-deploy old-address bricking check may verify the wrong old contract and leave the actual prior address unchecked. -
The old-contract probes in
scripts/verify_freeze.tscan pass without proving the storage gate is what rejected the old code.oldStakingProbe.claimDelegatorRewards.staticCall(1, 0, 0x...)atscripts/verify_freeze.ts:186accepts any revert, but those arbitrary inputs can fail before reaching anonlyContractsstorage write. TheRandomSamplingprobe atscripts/verify_freeze.ts:213-230sets the result to ok in both the success and catch paths, so that check cannot fail. I would make these probes use known-valid state that reaches the intended write path, and assert the specificUnauthorizedAccess("Only Contracts in Hub")failure for old contracts. -
npm run lint:tscurrently fails on PR-added code. In particularscripts/verify_freeze.ts:69uses a forbiddenrequire()import, and the new freeze tests have Prettier errors (test/integration/StakingRewardsFreeze.test.tsandtest/unit/RandomSamplingFreeze.test.ts). There are some unrelated existing unresolved-import lint errors too, but the PR adds enough lint failures to block CI if the lint job runs.
Verification I ran locally:
npx hardhat test --network hardhat test/integration/StakingRewardsFreeze.test.ts test/unit/RandomSamplingFreeze.test.ts test/integration/HubRotationBricking.test.ts-> 23 passingnpm run compile-> passed with “Nothing to compile”npm run lint:ts-> failed as described abovenpm run lint:sol-> exit 0, warnings only
Overall: freeze implementation looks directionally sound, but the post-deploy safety net needs to be exact here since that is what proves the old leak path is really bricked.
- scripts/verify_freeze.ts: replace `require('hardhat')` with
`import hre from 'hardhat';` (matches the other scripts in this
repo and clears @typescript-eslint/no-require-imports).
- prettier --write the two new freeze test files
(test/integration/StakingRewardsFreeze.test.ts,
test/unit/RandomSamplingFreeze.test.ts).
- test/integration/StakingRewards.test.ts: mark four more suites
describe.skip — Proportional rewards, Withdrawal request tests,
Operator fee withdrawal tests, and Migration tests — each of which
asserts non-zero reward emission or operator-fee accumulation and
therefore breaks under the freeze. They form part of the V10
reactivation regression set.
Co-authored-by: Cursor <cursoragent@cursor.com>
…probes Addresses Codex review findings 1 and 2 on PR #458. (1) PRE_FREEZE_ADDRESSES.RandomSampling now matches the actual deployments JSONs across all three mainnets — `0x4B20D58...46eFA` (deterministic CREATE2, identical on Neuroweb / Gnosis / Base). The previous hardcoded values (0xB44086.../ 0xe59cA2.../ 0x2E44b0...) did not exist on chain and would have made the post-deploy bricking check verify the wrong contract while leaving the actual prior address unchecked. Also added pre-freeze entries for neuroweb_testnet, gnosis_chiado_test, and base_sepolia_test for the dress-rehearsal deploy. (2) Replaced the two old-contract probes that could pass for the wrong reason. The previous implementation called the OLD logic contracts' own functions (`Staking.claimDelegatorRewards`, `RandomSampling.updateAndGetActiveProofPeriodStartBlock`), which revert at input validation / modifiers / sanity checks long before reaching a storage write — a generic revert there proved nothing. The RandomSampling probe was further broken by setting `ok = true` in BOTH the success and catch paths, making the check unfailable. The new probes call the storage setters directly via `eth_call` with `from = oldContractAddress`, which exercises the `onlyContracts` gate deterministically: - StakingStorage.setDelegatorStakeBase(...) from old Staking - RandomSamplingStorage.setActiveProofPeriodStartBlock(...) from old RandomSampling PASS only if the revert message contains "Only Contracts in Hub" (the gate's own error). SUCCESS without revert is FAIL (LEAK). Any other revert is INCONCLUSIVE. Also added a sanity check that PRE_FREEZE_ADDRESSES differs from the current Hub registration — catches "no rotation happened" or "stale snapshot" failure modes before the rest of the verification runs. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks for the review — findings 1 and 2 were both real bugs in (1) Wrong RandomSampling addresses — confirmed and fixed. (2) Unsound old-contract probes — replaced with deterministic gate probes. The replacement probes call the storage setters directly via
This exercises the Also added a sanity check that (3) Lint failures — already fixed in Diff for the verify-script fix is |
…lia_test Drives the dress-rehearsal redeploy on Base Sepolia before the mainnet rollout. The deployer EOA (0xf8C7460b...) is already a Safe owner of Hub.owner (0xD9B816D9...) on this network, so Hub._isMultiSigOwner will accept the direct setAndReinitializeContracts call from the deploy script — no Safe choreography needed. Base Sepolia's Chronos has the same 02:00 CEST epoch boundary tonight as the mainnet chains, so a successful rehearsal here is a 1:1 preview of mainnet behaviour. Co-authored-by: Cursor <cursoragent@cursor.com>
What landed on Base Sepolia
- New Staking 0x7fFaCf01Afb11Bc04Db81c4D06D3FAcB22a5Db2b (1.0.2-freeze)
- New RandomSampling 0xA2DA5A7C44dCf84106e3Bb3363C8e56E130fDB7b (1.0.1-freeze)
- Hub.setAndReinitializeContracts swap completed in the same deploy
- OLD Staking 0x42b59...3BE5 — removed from Hub.contractSet (bricked)
- OLD RandomSampling 0x95CF1...AF5A3 — removed from Hub.contractSet (bricked)
- verify_freeze.ts: 13/13 PASS
Two follow-up code fixes that the rehearsal forced out:
1. utils/helpers.ts — retry version() after deploy
Base Sepolia (and likely Base mainnet) sometimes lags a few seconds
between mining a deploy tx and serving an eth_call against the new
contract address, which made hardhat-deploy crash mid-script. Added a
bounded backoff retry around the version() probe in updateDeploymentsJson.
Without this the first two Base Sepolia deploy attempts produced
orphaned contracts (visible in the explorer, never registered in Hub).
2. scripts/verify_freeze.ts — decode custom-error revert data
The storage-gate probes were correctly rejected by HubLib.UnauthorizedAccess,
but ethers' default error.message just says "execution reverted (unknown
custom error)" because the custom error isn't on the call's interface,
so the previous string-match fell back to INCONCLUSIVE on Base Sepolia.
Added decodeRevert() which pulls err.data (or err.info.error.data on
nested L2 errors), parses it via the storage contract's interface, and
asserts UnauthorizedAccess("Only Contracts in Hub") explicitly. Also
keeps the legacy revert(string) path for chains/networks that surface
the reason in err.message directly.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Testnet rehearsal — Base Sepolia ✅ Did the full freeze rotation on Rotated:
Two defensive fixes the rehearsal forced out (commit
Mainnet rollout is unblocked. Plan stays the same:
Epoch ends ~02:00 CEST tomorrow on all three mainnets, so the comfortable window is the next ~10h. |
…eploy mode
Three of the six PRE_FREEZE_ADDRESSES.RandomSampling entries were wrong.
I had assumed the prior RandomSampling was a deterministic-CREATE2 deployment
shared across all three mainnets at 0x4B20D581...46eFA (the value also stored
in deployments/{neuroweb,gnosis,base}_mainnet_contracts.json). It isn't.
Cross-checking Hub.getContractAddress("RandomSampling") on each mainnet
returned per-chain addresses:
neuroweb_mainnet 0xB44086bb163ebaf65e2927A3e34b904050324BEa
gnosis_mainnet 0xe59cA2ABB8020D828aeDA7d2de4bEFD5EB49FC1f
base_mainnet 0x2E44b083096a96A39340A25B96893192eEac1fB5
Hub.isContract(0x4B20D581...) returns false on all three mainnets, meaning
the deployment JSONs are stale (an older RandomSampling that was rotated out
via a prior Hub upgrade and the JSON was never refreshed). Only the live Hub
registration tells us which logic contract was actually being called by
nodes at freeze time.
If I'd shipped the script with the wrong addresses, the bricking probe would
have passed VACUOUSLY post-deploy: it would have checked that 0x4B20D581...
is not in Hub.contractSet (true — but only because it was already removed
years ago), while leaving the actual prior contract (0xB44086bb..., etc.)
unverified. This is exactly the failure mode Codex finding 1 was warning
about, just with a different wrong address.
Two-part fix:
1. Replace the three mainnet RandomSampling entries with the live values
queried from each chain's Hub. Add a comment explaining that the live
Hub is authoritative and the JSONs cannot be trusted for this.
2. Add a mode-detection layer to the script that prevents this entire class
of bug from shipping again:
- PRE-DEPLOY mode: if PRE_FREEZE matches Hub for BOTH contracts, the
freeze hasn't been deployed on this chain yet. Assert the snapshot is
correct and exit clean. Run this BEFORE every mainnet deploy as a gate.
- POST-DEPLOY mode: if PRE_FREEZE differs from Hub for BOTH contracts,
the rotation happened. Run the full bricking + storage-gate suite plus
two new structural checks per OLD address: (a) eth_getCode is non-empty
(real contract, not an EOA), (b) version() returns a non-freeze string
(proves it's the prior logic, not the new build).
- MIXED mode: hard fail with a loud error pointing at the stale entry.
This is what would have caught the bug if I'd made the mistake on only
one of the two contracts.
Verified after the fix:
base_sepolia_test POST-DEPLOY 17/17 PASS
neuroweb_mainnet PRE-DEPLOY snapshot matches live Hub
gnosis_mainnet PRE-DEPLOY snapshot matches live Hub
base_mainnet PRE-DEPLOY snapshot matches live Hub
gnosis_chiado_test PRE-DEPLOY snapshot matches live Hub
neuroweb_testnet not verified — RPC unreachable from my machine
Co-authored-by: Cursor <cursoragent@cursor.com>
Strongest possible pre-mainnet proof short of waiting for the actual epoch boundary. The script forks Base Sepolia at HEAD via hardhat_reset, confirms the on-chain Staking is the freeze build (1.0.2-freeze), fast-forwards EVM time past Chronos.timeUntilNextEpoch() to make the prior current epoch finalized, impersonates a real delegator with non-zero stake, and calls Staking.claimDelegatorRewards(...) — the exact path that would otherwise emit unbacked rewards on V8 mainnet at the 02:00 CEST boundary. It then asserts six freeze invariants on the actual on-chain state: - delegator stakeBase UNCHANGED - node stake UNCHANGED - total stake UNCHANGED - rollingRewards PRESERVED (untouched for V10 reconciliation) - hasDelegatorClaimedEpochRewards advanced to TRUE - lastClaimedEpoch advanced by exactly 1 Verified end-to-end: identityId=1 delegator=0xe94470a4BEaB39c497f8fE88D70F3a537bba55a2 pre stakeBase=107880310405325380343 lastClaimed=479 post stakeBase=107880310405325380343 lastClaimed=480 hasClaimed[480]=true → 6/6 invariants PASS (claim mined, gas=222936, no payout) Plumbing change: hardhat.node.config.ts adds hardforkHistory entries for chainId 84532 (Base Sepolia) and 8453 (Base mainnet) so that hardhat_reset against those forks doesn't error with "No known hardfork for execution on historical block …". Both chains are on Cancun; cancun: 0 means "the entire forkable history is on Cancun", which is true for our use case (post-deploy simulation, not archive replay of pre-Cancun blocks). Co-authored-by: Cursor <cursoragent@cursor.com>
|
Two follow-up findings + fixes since the last comment: 1. PRE_FREEZE_ADDRESSES had wrong mainnet RandomSampling values (commit `e4042bc7`)Cross-checking against `Hub.getContractAddress("RandomSampling")` on each mainnet RPC revealed that all three hardcoded mainnet RandomSampling addresses (`0x4B20D581...46eFA`) were stale — `Hub.isContract()` returns `false` for that address on every mainnet. The deployment JSONs that I treated as authoritative were stale (older snapshot, never refreshed after a prior Hub upgrade). The live, currently-bricking-target addresses are per-chain:
If we'd shipped mainnet with the old constant, the bricking probe would have passed vacuously — checking that an address that was already removed years ago is still removed, while leaving the actual prior contract unverified. Same failure shape as Codex finding 1, just with a different wrong address. My fault. Structural fix in the same commit: `verify_freeze.ts` now has a mode-detection layer that prevents this entire class of bug:
Verified across all reachable chains:
2. Live-chain claim simulation against forked Base Sepolia (commit `c05a10ce`)Branimir flagged that the rehearsal so far hadn't actually exercised a post-epoch-end `claimDelegatorRewards` call. Built a script that does exactly that without waiting for the boundary: `scripts/simulate_freeze_claim.ts` forks Base Sepolia at HEAD, fast-forwards EVM time past the next epoch boundary so the prior current epoch becomes finalized, impersonates a real delegator with non-zero stake, and calls `Staking.claimDelegatorRewards(...)` on the deployed freeze build. It then asserts the six freeze invariants on the actual on-chain state. Result on Base Sepolia, identityId=1, delegator=`0xe94470a4BEaB39c497f8fE88D70F3a537bba55a2` (stakeBase 107.88 TRAC): ``` ✓ delegator stakeBase UNCHANGED (no payout to stake) delta=0 Live-chain freeze claim: ✓ PASS This is the strongest live-chain proof of the freeze short of waiting for the actual mainnet epoch boundary — real Chronos timing, real DelegatorsInfo / StakingStorage state, real RandomSamplingStorage scores, and the full `_validateDelegatorEpochClaims` path against the bytecode actually deployed at the Hub-registered address. Reusable for post-deploy verification on the other forkable chains (chain config + delegator selection are the only knobs). Mainnet rollout is now unblocked, with belt-and-braces. |
Deploys the v8->v10 freeze on all 3 mainnets. Hub-rotated contracts
(uniform across chains):
Staking 1.0.2-freeze 0xDaa40BEb1D73bC43E437cDC3188abE565119619d
RandomSampling 1.0.1-freeze 0xA32780d6A89542462271ca6d2d78373889F1C2d9
verify_freeze.ts: 17/17 PASS on all 3 mainnets.
Operational fixes captured here:
- hardhat.config.ts: bump neuroweb_mainnet gasPrice from 100 to 15_000_000
wei. The previous value was 1600x below baseFeePerGas and was rejected
with 'gas price less than block base fee'. The slightly unusual value
(vs round 10_000_000) also helps invalidate previously-rejected tx
hashes still cached by the parachain RPC's mempool dedup.
- deployments/parameters.json: pin neuroweb_mainnet.stakeWithdrawalDelay
override to '300' (intentional per-network value) so the standard
policy diff doesn't auto-bump it on the next deploy.
New operational scripts:
- audit_hub_registry.mjs full Hub registry audit per chain
- replace_hub_slot.ts generic single-slot Hub registry update
(env-driven, dry-run by default)
- replace_stale_slots_*.ts per-chain canonical batch (dry-run by
default; REGISTRY_WRITE=1 to execute)
- finalize_staking_swap_*.ts one-off Staking swap (recovered the
NeuroWeb deploy after a hardhat-deploy
partial-resume edge case)
- simulate_freeze_claim_*.ts live-chain claimDelegatorRewards
dry-run against a forked Base mainnet
- registry_state_check_*.mjs on-chain verification that
previously-registered addresses are
no longer authorised by the Hub
Co-authored-by: Cursor <cursoragent@cursor.com>
…onal scripts - Apply prettier --write to deployments JSONs and operational scripts that were committed without prior formatter run. - Add file-level `eslint-disable @typescript-eslint/no-explicit-any` to the scripts that pass the loose Hub Contract / Provider runtime objects between helpers; full strict typing is overkill for one-off operational scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Time-critical patch to stop the V8 → V10 transition reward leak before the next epoch boundary. Epoch reward pools on
EpochStorageare no longer backed by escrowed TRAC (the V10 migration moved that backing out of contracts), so any non-zero reward emission on the liveStaking/RandomSamplingcontracts would be paid out of delegators' staked principal.This PR ships a freeze build that:
Staking.claimDelegatorRewardsis patched to always emit a zero reward and to never apply previously accumulateddelegatorRollingRewardstostakeBase/nodeStake/totalStake. All surrounding bookkeeping (lastClaimedEpoch,hasDelegatorClaimedEpochRewards,isOperatorFeeClaimedForEpoch,netNodeEpochRewards = 0) keeps advancing so that_validateDelegatorEpochClaimsdoes not block legitimaterequestWithdrawal/cancelWithdrawal/redelegateflows. Pre-existingdelegatorRollingRewardsvalues are preserved untouched in storage so V10 can reconcile them later.RandomSampling.createChallengeandRandomSampling.submitProofbecome silent no-ops. They keep theirprofileExists/nodeExistsInShardingTablemodifiers so unauthorized callers still get an early revert, but successful calls do not write toRandomSamplingStorage. This avoids client-side error spam for V8 nodes that are still polling the proofing loop.Staking._VERSION → "1.0.2-freeze",RandomSampling._VERSION → "1.0.1-freeze".deployments/{neuroweb,gnosis,base}_mainnet_contracts.json:Staking.deployedandRandomSampling.deployedflipped tofalsesohardhat-deployredeploys both contracts onnpm run deploy:<network>and atomically rotates them viaHub.setAndReinitializeContracts.After Hub rotation the old
Staking/RandomSamplingaddresses are removed fromHub.contractSet. Their bytecode remains on-chain (immutable), but every state-mutating call from them intoStakingStorage/RandomSamplingStorage/DelegatorsInfo/EpochStoragereverts at theonlyContractsmodifier (which checksHub.isContract(msg.sender)). The old contracts are effectively neutralised — seeHubRotationBricking.test.ts.Mainnet rollout — DONE
Staking(1.0.2-freeze)RandomSampling(1.0.1-freeze)verify_freeze.ts0xDaa40BEb1D73bC43E437cDC3188abE565119619d0xA32780d6A89542462271ca6d2d78373889F1C2d90xDaa40BEb1D73bC43E437cDC3188abE565119619d0xA32780d6A89542462271ca6d2d78373889F1C2d90xDaa40BEb1D73bC43E437cDC3188abE565119619d0xA32780d6A89542462271ca6d2d78373889F1C2d9Deployer EOA has been removed from all 3 chains' Gnosis Safes after rollout — zero privileged access remains on the temporary deployer key.
Files
contracts/Staking.solclaimDelegatorRewards+ version bumpcontracts/RandomSampling.solcreateChallenge/submitProof+ version bumpdeployments/{neuroweb,gnosis,base}_mainnet_contracts.jsondeployed: truefor new freeze contractshardhat.config.ts,deployments/parameters.jsonneuroweb_mainnet(gasPriceto clear baseFee,stakeWithdrawalDelayoverride pinned to current on-chain value)scripts/verify_freeze.tsscripts/audit_hub_registry.mjsscripts/replace_hub_slot.tsscripts/replace_stale_slots_{base,neuroweb,gnosis}_mainnet.tsREGISTRY_WRITE=1to execute)scripts/finalize_staking_swap_neuroweb_mainnet.tsscripts/simulate_freeze_claim_base_mainnet.tsclaimDelegatorRewardsdry-run against a forked mainnetscripts/registry_state_check_base_mainnet.mjstest/integration/StakingRewardsFreeze.test.tstest/unit/RandomSamplingFreeze.test.tstest/integration/HubRotationBricking.test.tstest/integration/{Staking,StakingRewards,RandomSampling}.test.ts,test/unit/{Staking,RandomSampling}.test.tsdescribe.skipof pre-freeze reward / score-accumulation suites with explanatory commentsscripts/verify_freeze.tsconfirms, on a live RPC:Hub.getContractAddress("Staking" / "RandomSampling")returns the new addresses.1.0.2-freeze/1.0.1-freezeversions.Hub.isContract(<old address>)isfalsefor both Staking and RandomSampling pre-freeze addresses.eth_callfrom the OLD Staking / RandomSampling addresses to a sensitive setter onStakingStorage/RandomSamplingStoragereverts withUnauthorizedAccess("Only Contracts in Hub")— confirming theonlyContractsgate now refuses them.Test plan
npx hardhat compile— clean build for both contracts.npx hardhat test test/integration/StakingRewardsFreeze.test.ts test/unit/RandomSamplingFreeze.test.ts test/integration/HubRotationBricking.test.ts— 23 passing.npx hardhat test test/unit/Staking.test.ts test/unit/RandomSampling.test.ts— 71 passing, 28 pending (skipped freeze-incompatible blocks).verify_freeze.ts13/13 PASS.npm run deploy:<network>+verify_freeze.ts— 17/17 PASS on Base, NeuroWeb, Gnosis mainnet.Made with Cursor