Skip to content

chore: deploy + upgrade scripts for non-EigenPod withdrawal credentials - #500

Merged
pankajjagtapp merged 4 commits into
pankaj/feat/non-eigenpod-withdrawal-credentialsfrom
pankaj/chore/upgrade-script-avs-dereg-treasury
Sep 2, 2026
Merged

chore: deploy + upgrade scripts for non-EigenPod withdrawal credentials#500
pankajjagtapp merged 4 commits into
pankaj/feat/non-eigenpod-withdrawal-credentialsfrom
pankaj/chore/upgrade-script-avs-dereg-treasury

Conversation

@pankajjagtapp

@pankajjagtapp pankajjagtapp commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Deploy and upgrade scripts for the non-EigenPod withdrawal credentials release. Branches off #485.

Governance proposal: 3CP-secure#662.

What's here

script/upgrades/non-eigenpod-creds/deploy.s.sol — CREATE2-deploys the four implementations PR #485 changes:

Contract Upgrade path Runtime size
EtherFiNodesManager UUPS upgradeTo 24,478 / 24,576
StakingManager UUPS upgradeTo 14,939
EtherFiAdmin UUPS upgradeTo 19,643
EtherFiNode beacon, via StakingManager.upgradeEtherFiNode 12,156

Constructor args are read off the live deployments' immutables rather than copied from an older script — the existing reaudit-fixes deploy passes five args to EtherFiNode, which now takes four.

script/upgrades/non-eigenpod-creds/transactions.s.sol — builds the UPGRADE_TIMELOCK batch (5 calls: the four upgrades plus Treasury.withdraw), prints the schedule/execute calldata, then fork-simulates schedule → +10 days → execute and asserts the end state.

script/deploys/Deployed.s.sol — adds TREASURY_LEGACY for the retired Treasury at 0x6329004E.

Notable findings while building this

  • The retired Treasury needs no upgrade. 0x6329004E is not a proxy; it's plain OZ Ownable (src/archive/Treasury.sol) with owner() == UPGRADE_TIMELOCK and an existing withdraw(uint256,address). 9.712223546514004611 ETH.
  • The timelock salt is pinned, not keccak256(..., block.number) as in CrossPodApproval. A block-derived salt changes the calldata every run, which makes the Safe tx hashes signers reproduce unstable.
  • UPGRADE_TIMELOCK.getMinDelay() is 864000 (10 days). CrossPodApproval/transactions.s.sol still hardcodes 259200, which is stale.
  • StakingManager links depositDataRootGenerator (already on mainnet at 0xa98F0A109dDC86c4A1dB974256d106C4B237171A). Its address is baked into the creation code, so reproducing the CREATE2 address requires linking against it. Verified the impl address is sender-independent.
  • forge build --sizes exits non-zero on master, from two test-only invariant handlers (FrozenRateWithdrawalHandler 28,107 and ProtocolInvariantsHandler 29,310). Neither is deployed, but it means the EIP-170 gate can't be used in CI as-is — worth a follow-up given EtherFiNodesManager has 98 bytes of headroom.
  • AVS deregistration is a separate routing. AvsOperatorManager.adminForwardCall is gated by OPERATION_MULTISIG_ROLE, held by the Operating Safe 0x2aCA71 and not by UPGRADE_TIMELOCK, so it cannot ride in the upgrade batch. It also isn't 14 AVSs — it's 14 registered operator proxies across 18 AVSs on the legacy AVSDirectory. A deregisterAvsOperators() function is being added here in a follow-up commit.

Verification

Local fork (--fork-url $MAINNET_RPC_URL): CREATE2 addresses match the constants in the calldata, deployed bytecode matches a fresh compile (ContractCodeChecker), all four implementation slots repoint, ENM gains withdrawalCredentialTarget/disablePod and drops sweepFunds(uint256), Treasury drains to 0 and the LiquidityPool is credited exactly. All assertions pass.

Tenderly VNET 5de0fdbf-8001-437d-9a88-4cf2b26d2d23: impls deployed to their predicted addresses at the expected runtime sizes, then the two real Safe txs — schedule 84,006 gas, execute 175,193 gas, both far under the EIP-7825 cap.


Note

Low Risk
No production contract changes in the diff—only deployment scripts and fork tests; operational risk is mis-encoded governance calldata if constants drift from CREATE2 deploys.

Overview
Adds Forge upgrade tooling for the non-EigenPod withdrawal-credentials release (PR #485): CREATE2 deployment of four implementations (EtherFiNodesManager, StakingManager, EtherFiAdmin, EtherFiNode), a UPGRADE_TIMELOCK batch builder that upgrades those proxies/beacon and sweeps the legacy Treasury into the LiquidityPool, plus fork simulation (schedule → 10-day delay → execute) with bytecode, immutables, and post-upgrade smoke tests (pod-less validator, EL exit, EigenLayer withdrawal).

OracleReportCycle.s.sol applies the same upgrades directly from the timelock on a fork and runs a full oracle path (three live committee members → consensus → executeTasks), asserting TVL/rebase and cursor advancement on upgraded EtherFiAdmin.

Documents OP_RPC_URL and SCROLL_RPC_URL in .example.env for fork-dependent tests.

Test hardening for drifting mainnet fork state: integration tests force oracle committee/quorum via storage instead of add/remove members; redemption low-watermark test derives blocking bps from live restaker stETH vs TVL.

Reviewed by Cursor Bugbot for commit 93ae801. Bugbot is set up for automated code reviews on this repo. Configure here.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Deploys the four implementations changed by PR #485 (EtherFiNodesManager,
EtherFiNode, StakingManager, EtherFiAdmin) via CREATE2, and builds the
UPGRADE_TIMELOCK batch that repoints them and drains the retired Treasury
into the LiquidityPool.

Constructor args are sourced from the live deployments' immutables. The
timelock salt is pinned rather than block-derived so the Safe tx hashes
signers reproduce are stable.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Comment thread script/upgrades/non-eigenpod-creds/transactions.s.sol
@pankajjagtapp pankajjagtapp self-assigned this Aug 31, 2026
Moves TREASURY_LEGACY into the script instead of Deployed.s.sol, trims the
comments, and adds:

- immutable snapshots for ENM/StakingManager/EtherFiAdmin taken before the
  batch and re-verified after, plus verifyNotReinitializable on each
- fork tests exercising the upgraded contracts: a pod-less validator
  spin-up, an EL-triggered exit on that pod-less node through the EIP-7002
  predeploy, and the classic EigenLayer queue/complete withdrawal path

Roles for the fork tests are granted by pranking the UPGRADE_TIMELOCK
(RoleRegistry owner) outside the batch, so the proposal calldata and the
Safe tx hashes in 3CP-secure#662 are unchanged.

require(LIQUIDITY_POOL.balance > lpBefore, "liquidity pool received no withdrawal proceeds");
console2.log("[OK] EigenLayer withdrawal completed. LP delta:", LIQUIDITY_POOL.balance - lpBefore);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Legacy node fails EigenLayer fork test

Medium Severity

forkTestEigenLayerWithdrawal resolves legacy validator 10270 via linkLegacyValidatorIds, then calls getEigenPod, queueETHWithdrawal, and completeQueuedETHWithdrawals. Those manager methods all run _validateNode, which reverts UnknownNode unless the node is in deployedEtherFiNodes. Early IDs are the ones the upgraded contracts themselves treat as not backfilled, so run() can revert after the upgrade batch and skip the rest of verification.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2ebe53. Configure here.

@ethanai-ethfi ethanai-ethfi Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correction after re-checking live head f2ebe53: I do not think this is valid as an UnknownNode issue.

Evidence:

  • validator 10270 maps to node 0xb94ad...6f73.
  • StakingManager.deployedEtherFiNodes(node) is true, and node.etherFiNodesManager() points back to ENM.
  • getEigenPod(node) returns nonzero pod 0xf547...d980, so this fixture should pass _validateNode for the cited calls.

Smallest next step: no script change for this fixture unless a fork run shows a different revert.

@pankajjagtapp

Copy link
Copy Markdown
Contributor Author

Test suite baseline

forge test --fork-url $MAINNET_RPC_URL --no-match-contract "Invariant|Handler", run on this branch and on master for comparison:

Passed Failed Total
this branch 1458 128 1586
master 1183 128 1311

Comparing the failing test names as sets: 121 unique failures on each, 0 introduced, 0 fixed. All 128 failures are pre-existing on master and unrelated to #485 or these scripts. The branch adds 275 tests, all passing.

The pre-existing failures are the fork-fragility this repo's CLAUDE.md already documents — 6 from a missing OP_RPC_URL, the rest assertions against live mainnet balances and oracle slot/block state that drift because the suite forks at latest rather than a pinned block (e.g. assertion failed: 577021548053172 != 0, WrongBlockTo(), CheckpointAlreadyActive()).

Worth fixing separately: a 128-failure baseline means the suite can't gate anything, and it masks real regressions.

Verifying the impl pointer and immutables does not prove the oracle still
works, and EtherFiAdmin is the oracle entry point. This applies the upgrade
and runs a real cycle: the three live committee members submit at the real
quorum of 3, consensus is reached, then executeTasks runs after the
postReportWaitTimeInSlots delay.

Asserts the rebase applies exactly (TVL +50 ETH), the eETH exchange rate
rises, and both report cursors advance.

Kept separate from transactions.s.sol to avoid colliding with concurrent
work on that file. The oracle config is left untouched: quorum cannot be
lowered to 1 because _checkQuorum requires numActiveCommitteeMembers <
2 * quorumSize, so the real members are pranked instead.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1726f3b. Configure here.

}
require(
oracle.isConsensusReached(oracle.generateReportHash(report)), "consensus not reached"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pending report blocks cycle script

Medium Severity

The readiness loop only calls verifyReport, which does not require the last published report to have been handled. submitReport then reverts LastReportNotHandled whenever a report is sitting between consensus and executeTasks, a routine mainnet window. The upgrade can be fine and the script still never exercises the upgraded EtherFiAdmin.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1726f3b. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree this is valid on live head 1726f3b.

Evidence: OracleReportCycle only gates readiness with verifyReport, but submitReport first runs shouldSubmitReport, which reverts LastReportNotHandled when lastPublishedReportRefSlot != EtherFiAdmin.lastHandledReportRefSlot().

Smallest next step: preflight/sync the pending report state before submitting the new fork report, then rerun the oracle cycle script.

vm.roll(block.number + 32);
}
}
require(ready, "could not reach a finalized report window");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Report window loop misses last check

Low Severity

Each failed verifyReport warps 32 slots, but the loop can exit after that warp without retrying. With a 40-iteration cap this advances at most 1248 slots before a successful check, short of one 1280-slot period plus the two-epoch finality buffer. A fork taken early in the period can revert even when one more check would pass.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1726f3b. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Valid on live head 1726f3b. The loop verifies before each 32-slot warp, so the last verification happens after 39 warps (=1248 slots); a 40th failure warps to 1280 and exits without rechecking. Since verifyReport still requires the report epoch finality buffer, forks early in the period can fail here. Smallest next step: retry after the final warp or raise the bound to cover one full report period plus finality. Triage-only, no changes pushed.

Three genuine failures on master, plus three that are local config.

- Withdraw.t.sol, Validator-Flows.t.sol: _syncOracleReportState removed and
  re-added AVS_OPERATOR_1/2 as committee members, but both were rotated off
  mainnet, so removeCommitteeMember reverts NotRegistered. Write the
  committee state and quorum directly instead, so setup no longer depends on
  who is registered today. setQuorumSize cannot be used because _checkQuorum
  enforces strict majority against the live member count.

- EtherFiRedemptionManager.t.sol: test_end_to_end_redeem_stETH assumed a 20%
  watermark exceeds the restaker's stETH. It no longer does (456,305 stETH
  against 2,141,454 TVL = 21.3%), so the redeem succeeded where the test
  expected a revert. Derive the watermark from live state and assert it stays
  under maxLowWatermarkInBpsOfTvl so future drift fails loudly.

- .example.env: document OP_RPC_URL and SCROLL_RPC_URL, which several suites
  need and CI supplies as secrets.

forge test --no-match-contract 'Invariant|Handler':
  master 1305 passed / 6 failed -> branch 1594 passed / 3 failed.
The 3 remaining are OP_RPC_URL not being set locally; they pass in CI.
@pankajjagtapp

Copy link
Copy Markdown
Contributor Author

Correction to the baseline above, and the actual fix

My earlier comment was wrong. The "128 failures on both branch and master" was an artifact of the command I ran, not the state of the suite. I passed --fork-url $MAINNET_RPC_URL, which forces every suite onto a mainnet fork — including unit tests that assume a fresh deployment. CI runs plain forge test (.github/workflows/run-forge-tests.yaml:38); fork tests call vm.createFork themselves and only need MAINNET_RPC_URL present in the env. So "the suite has a 128-failure baseline and can't gate anything" was not true, and the identical count on master was the same bad command producing the same artifact on both sides.

With the correct command, forge test --no-match-contract "Invariant|Handler":

Passed Failed
master 1305 6
this branch, after the fixes below 1594 3

The 6 on master were 3 genuine test bugs and 3 local config.

Fixed

  • Withdraw.t.sol, Validator-Flows.t.sol_syncOracleReportState removed and re-added AVS_OPERATOR_1/2 as oracle committee members, but both have been rotated off mainnet, so removeCommitteeMember reverts NotRegistered() in setUp. Now writes committee state and quorum directly, so setup doesn't depend on who is registered today. setQuorumSize isn't usable here because _checkQuorum enforces strict majority against the live member count.
  • EtherFiRedemptionManager.t.soltest_end_to_end_redeem_stETH assumed a 20% TVL watermark exceeds the restaker's stETH. It no longer does: 456,305 stETH against 2,141,454 TVL is 21.3%, so the redeem succeeded where the test expected a revert. The watermark is now derived from live state, with an assertion against maxLowWatermarkInBpsOfTvl so future drift fails loudly instead of silently inverting the test.
  • .example.env — documents OP_RPC_URL and SCROLL_RPC_URL.

Remaining

3 × LiquidRefer{Btc,Eth,UsdPermit}OPTest::setUpOP_RPC_URL not set locally. Config, not code; green in CI, which injects the secret. Not skipped or stubbed.

Fork block pinning turned out not to be needed — no test failed on block drift once the command was right.

@pankajjagtapp
pankajjagtapp merged commit 0548f92 into pankaj/feat/non-eigenpod-withdrawal-credentials Sep 2, 2026
2 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.

2 participants