Skip to content

freeze(v8-v10): zero claim rewards & no-op random sampling#458

Merged
branarakic merged 9 commits into
mainfrom
release/v8-v10-freeze-from-main
May 8, 2026
Merged

freeze(v8-v10): zero claim rewards & no-op random sampling#458
branarakic merged 9 commits into
mainfrom
release/v8-v10-freeze-from-main

Conversation

@branarakic

@branarakic branarakic commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Time-critical patch to stop the V8 → V10 transition reward leak before the next epoch boundary. Epoch reward pools on EpochStorage are no longer backed by escrowed TRAC (the V10 migration moved that backing out of contracts), so any non-zero reward emission on the live Staking / RandomSampling contracts would be paid out of delegators' staked principal.

This PR ships a freeze build that:

  • Staking.claimDelegatorRewards is patched to always emit a zero reward and to never apply previously accumulated delegatorRollingRewards to stakeBase / nodeStake / totalStake. All surrounding bookkeeping (lastClaimedEpoch, hasDelegatorClaimedEpochRewards, isOperatorFeeClaimedForEpoch, netNodeEpochRewards = 0) keeps advancing so that _validateDelegatorEpochClaims does not block legitimate requestWithdrawal / cancelWithdrawal / redelegate flows. Pre-existing delegatorRollingRewards values are preserved untouched in storage so V10 can reconcile them later.
  • RandomSampling.createChallenge and RandomSampling.submitProof become silent no-ops. They keep their profileExists / nodeExistsInShardingTable modifiers so unauthorized callers still get an early revert, but successful calls do not write to RandomSamplingStorage. This avoids client-side error spam for V8 nodes that are still polling the proofing loop.
  • Version bumps: Staking._VERSION → "1.0.2-freeze", RandomSampling._VERSION → "1.0.1-freeze".
  • deployments/{neuroweb,gnosis,base}_mainnet_contracts.json: Staking.deployed and RandomSampling.deployed flipped to false so hardhat-deploy redeploys both contracts on npm run deploy:<network> and atomically rotates them via Hub.setAndReinitializeContracts.

After Hub rotation the old Staking / RandomSampling addresses are removed from Hub.contractSet. Their bytecode remains on-chain (immutable), but every state-mutating call from them into StakingStorage / RandomSamplingStorage / DelegatorsInfo / EpochStorage reverts at the onlyContracts modifier (which checks Hub.isContract(msg.sender)). The old contracts are effectively neutralised — see HubRotationBricking.test.ts.

Mainnet rollout — DONE

Chain Staking (1.0.2-freeze) RandomSampling (1.0.1-freeze) verify_freeze.ts
Base mainnet 0xDaa40BEb1D73bC43E437cDC3188abE565119619d 0xA32780d6A89542462271ca6d2d78373889F1C2d9 17/17 ✓
NeuroWeb mainnet 0xDaa40BEb1D73bC43E437cDC3188abE565119619d 0xA32780d6A89542462271ca6d2d78373889F1C2d9 17/17 ✓
Gnosis mainnet 0xDaa40BEb1D73bC43E437cDC3188abE565119619d 0xA32780d6A89542462271ca6d2d78373889F1C2d9 17/17 ✓

Deployer EOA has been removed from all 3 chains' Gnosis Safes after rollout — zero privileged access remains on the temporary deployer key.

Files

Area File Change
contract contracts/Staking.sol Freeze claimDelegatorRewards + version bump
contract contracts/RandomSampling.sol No-op createChallenge / submitProof + version bump
deploy deployments/{neuroweb,gnosis,base}_mainnet_contracts.json Post-rollout addresses + deployed: true for new freeze contracts
deploy hardhat.config.ts, deployments/parameters.json Operational fixes for neuroweb_mainnet (gasPrice to clear baseFee, stakeWithdrawalDelay override pinned to current on-chain value)
script scripts/verify_freeze.ts Post-deploy read-only verification across all 3 chains (17 invariants per chain)
script scripts/audit_hub_registry.mjs Full Hub registry audit per chain
script scripts/replace_hub_slot.ts Generic single-slot Hub registry update (env-driven, dry-run by default)
script scripts/replace_stale_slots_{base,neuroweb,gnosis}_mainnet.ts Per-chain operational batch (dry-run by default; REGISTRY_WRITE=1 to execute)
script scripts/finalize_staking_swap_neuroweb_mainnet.ts One-off Staking swap to recover from a hardhat-deploy partial-resume edge case
script scripts/simulate_freeze_claim_base_mainnet.ts Live-chain claimDelegatorRewards dry-run against a forked mainnet
script scripts/registry_state_check_base_mainnet.mjs On-chain verification that previously-registered addresses are no longer authorised by the Hub
test (new) test/integration/StakingRewardsFreeze.test.ts 7 tests — Staking freeze invariants
test (new) test/unit/RandomSamplingFreeze.test.ts 4 tests — RandomSampling no-op invariants
test (new) test/integration/HubRotationBricking.test.ts 12 tests — Hub registry state + OLD-contract reverts
test test/integration/{Staking,StakingRewards,RandomSampling}.test.ts, test/unit/{Staking,RandomSampling}.test.ts Selected describe.skip of pre-freeze reward / score-accumulation suites with explanatory comments

scripts/verify_freeze.ts confirms, on a live RPC:

  • Hub.getContractAddress("Staking" / "RandomSampling") returns the new addresses.
  • The new contracts report the 1.0.2-freeze / 1.0.1-freeze versions.
  • Hub.isContract(<old address>) is false for both Staking and RandomSampling pre-freeze addresses.
  • An eth_call from the OLD Staking / RandomSampling addresses to a sensitive setter on StakingStorage / RandomSamplingStorage reverts with UnauthorizedAccess("Only Contracts in Hub") — confirming the onlyContracts gate 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.ts23 passing.
  • npx hardhat test test/unit/Staking.test.ts test/unit/RandomSampling.test.ts — 71 passing, 28 pending (skipped freeze-incompatible blocks).
  • Base Sepolia rehearsal — deploy + verify_freeze.ts 13/13 PASS.
  • Per-chain npm run deploy:<network> + verify_freeze.ts — 17/17 PASS on Base, NeuroWeb, Gnosis mainnet.

Made with Cursor

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>
@cursor

cursor Bot commented May 8, 2026

Copy link
Copy Markdown

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 branarakic left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. scripts/verify_freeze.ts appears to hard-code old RandomSampling addresses that do not match the committed mainnet deployment JSONs. The verifier snapshots 0xB440... / 0xe59c... / 0x2E44... at scripts/verify_freeze.ts:42-51, but the PR deployment JSONs still show the current RandomSampling address as 0x4B20D581695fb96db8FE78D8b12994fa43946eFA for 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.

  2. The old-contract probes in scripts/verify_freeze.ts can pass without proving the storage gate is what rejected the old code. oldStakingProbe.claimDelegatorRewards.staticCall(1, 0, 0x...) at scripts/verify_freeze.ts:186 accepts any revert, but those arbitrary inputs can fail before reaching an onlyContracts storage write. The RandomSampling probe at scripts/verify_freeze.ts:213-230 sets 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 specific UnauthorizedAccess("Only Contracts in Hub") failure for old contracts.

  3. npm run lint:ts currently fails on PR-added code. In particular scripts/verify_freeze.ts:69 uses a forbidden require() import, and the new freeze tests have Prettier errors (test/integration/StakingRewardsFreeze.test.ts and test/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 passing
  • npm run compile -> passed with “Nothing to compile”
  • npm run lint:ts -> failed as described above
  • npm 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.

Branimir Rakic and others added 2 commits May 8, 2026 14:33
- 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>
@branarakic

Copy link
Copy Markdown
Contributor Author

Thanks for the review — findings 1 and 2 were both real bugs in verify_freeze.ts. Pushed fixes in 896c00d1.

(1) Wrong RandomSampling addresses — confirmed and fixed.
The actual current mainnet RandomSampling is 0x4B20D581695fb96db8FE78D8b12994fa43946eFA on all three chains (deterministic CREATE2 from RFC-26-update commit 90b2d522). The previous values I had hardcoded (0xB44086…, 0xe59cA2…, 0x2E44b0…) were not on chain at all, so the bricking probes would have been verifying empty addresses and the actual prior contract would have gone unchecked. Cross-checked against deployments/{neuroweb,gnosis,base}_mainnet_contracts.json. Also added testnet entries (neuroweb_testnet, gnosis_chiado_test, base_sepolia_test) for the dress-rehearsal deploy.

(2) Unsound old-contract probes — replaced with deterministic gate probes.
You were right on both halves: the Staking.claimDelegatorRewards.staticCall(1, 0, 0x…01) probe could revert at _validateDelegatorEpochClaims (Epoch must follow last claimed, etc.) long before reaching a storage write, and the RandomSampling.updateAndGetActiveProofPeriodStartBlock probe set ok = true in both the success and catch paths so it could literally never fail.

The replacement probes call the storage setters directly via eth_call with from = oldContractAddress:

  • StakingStorage.setDelegatorStakeBase(1, bytes32(1), 0) from from: oldStaking
  • RandomSamplingStorage.setActiveProofPeriodStartBlock(1) from from: oldRandomSampling

This exercises the HubDependent.onlyContracts modifier deterministically. PASS only if the revert message contains 'Only Contracts in Hub' or 'UnauthorizedAccess' (the gate's own custom error from HubLib). A successful call is now an explicit LEAK! — not a silent pass. Any other revert is reported as INCONCLUSIVE, which still fails the script (the authoritative Hub.isContract(old) === false check above remains the primary safety net).

Also added a sanity check that PRE_FREEZE_ADDRESSES.{Staking,RandomSampling} differs from the current Hub registration — catches “no rotation happened” or “stale snapshot” cases before the rest of the verification runs.

(3) Lint failures — already fixed in eadff089 (one commit before your review, you saw 5f23aa8c). require('hardhat')import hre from 'hardhat' and prettier --write on the two new freeze test files. Current npm run lint && npm run format && npm run test all pass; CI is green.

Diff for the verify-script fix is +109 / -48. Happy to revisit if you want a different probe shape.

Branimir Rakic and others added 2 commits May 8, 2026 15:30
…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>
@branarakic

Copy link
Copy Markdown
Contributor Author

Testnet rehearsal — Base Sepolia ✅

Did the full freeze rotation on base_sepolia_test (chain 84532) end-to-end via npm run deploy:base_sepolia_test. Hub.owner Safe (0xD9B816D9...) already has the deployer EOA in its owner list, so Hub._isMultiSigOwner accepted the rotation without any extra Safe choreography — exactly the path mainnet will take after we add the deployer to each chain's Safe for the deploy window.

Rotated:

  • Staking 0x42b5938C...3BE50x7fFaCf01...Db2b (1.0.2-freeze)
  • RandomSampling 0x95CF1b97...AF5A30xA2DA5A7C...DB7b (1.0.1-freeze)

scripts/verify_freeze.ts on base_sepolia_test: 13/13 PASS, including the storage-gate probes that prove StakingStorage.setDelegatorStakeBase and RandomSamplingStorage.setActiveProofPeriodStartBlock reject the OLD logic addresses with the exact UnauthorizedAccess("Only Contracts in Hub") custom error.

Two defensive fixes the rehearsal forced out (commit 41117ad3):

  1. utils/helpers.ts — wrapped the post-deploy version() probe in a bounded backoff retry. Base Sepolia (and likely Base mainnet, same Optimism stack) sometimes lags a few seconds between mining a deploy tx and serving an eth_call against the new address, which made hardhat-deploy crash mid-script. Without this fix, the first two Base Sepolia deploys produced orphaned Staking contracts (visible in the explorer, never registered in the Hub).

  2. scripts/verify_freeze.ts — added decodeRevert() that pulls err.data (and the L2-nested err.info.error.data fallback) and parses it through the storage contract's interface. The previous string-match path was correctly catching the bricking on Neuroweb/Gnosis but fell back to INCONCLUSIVE on Base because Ethers reports custom errors as "execution reverted (unknown custom error)" when the calling interface doesn't declare them. Verified the new probe passes on Base Sepolia after deploy.

Mainnet rollout is unblocked. Plan stays the same:

  1. Add the deployer EOA to each chain's Safe (NeuroWeb, Gnosis, Base) — single Safe tx per chain, well under the threshold time.
  2. npm run deploy:neuroweb_mainnetverify_freeze.ts --network neuroweb_mainnet
  3. Same for gnosis_mainnet and base_mainnet.
  4. Remove the deployer EOA from each Safe.
  5. Commit the post-deploy mainnet JSONs.

Epoch ends ~02:00 CEST tomorrow on all three mainnets, so the comfortable window is the next ~10h.

Branimir Rakic and others added 2 commits May 8, 2026 15:53
…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>
@branarakic

Copy link
Copy Markdown
Contributor Author

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:

Chain Live `Hub.RandomSampling`
neuroweb_mainnet `0xB44086bb163ebaf65e2927A3e34b904050324BEa`
gnosis_mainnet `0xe59cA2ABB8020D828aeDA7d2de4bEFD5EB49FC1f`
base_mainnet `0x2E44b083096a96A39340A25B96893192eEac1fB5`

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:

  • PRE-DEPLOY mode (PRE matches Hub for both contracts) → assert that's true, exit clean. This is now a mandatory pre-flight gate before each mainnet deploy — if the snapshot is stale, the script refuses to greenlight.
  • POST-DEPLOY mode (PRE differs from Hub for both) → full rotation/bricking/storage-gate suite, plus two new structural checks per OLD address: (a) `eth_getCode` non-empty, (b) `version()` returns a non-freeze string (proves it's the prior logic, not the new build).
  • Mixed mode (matches one but not the other) → hard fail, points at the offending entry. This would have caught the bug if I'd only gotten one of the two wrong.

Verified across all reachable chains:

Chain Mode Result
`base_sepolia_test` POST-DEPLOY 17/17 PASS (incl. structural checks: OLD Staking 44160 bytes / version="1.0.1", OLD RS 24916 bytes / version="1.0.0")
`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 ✓

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):

```
pre: stakeBase=107880310405325380343 nodeStake=170776442769310354765582 totalStake=22608584477289867580970654
rollingRewards=0 hasClaimed[480]=false lastClaimedEpoch=479
→ tx mined in fork, gas used: 222936
post: stakeBase=107880310405325380343 nodeStake=170776442769310354765582 totalStake=22608584477289867580970654
rollingRewards=0 hasClaimed[480]=true lastClaimedEpoch=480

✓ delegator stakeBase UNCHANGED (no payout to stake) delta=0
✓ node stake UNCHANGED (no node-level reward applied) delta=0
✓ total stake UNCHANGED (no system-wide TRAC inflation)
✓ delegator rollingRewards PRESERVED (not paid into stake)
✓ hasDelegatorClaimedEpochRewards advanced to TRUE (UX bookkeeping kept)
✓ lastClaimedEpoch advanced by exactly 1 pre=479 post=480

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.

Branimir Rakic and others added 2 commits May 8, 2026 21:01
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>
@branarakic
branarakic merged commit 165d4a8 into main May 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant