Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0feffcb
feat: add sunset wind-down controls (custody sweep + deposit pause)
blockgroot May 28, 2026
6d50bf5
test(mocks): add assetCustodied() view to sunset-touched mocks
blockgroot May 28, 2026
8febc82
feat: implement sunset custody features and asset management tests
blockgroot May 28, 2026
2e5a73f
feat: add sunset-runoff test suite and implement deposit management l…
blockgroot May 28, 2026
572c888
chore: rename fork tests file
blockgroot May 28, 2026
4e97342
feat: implement asset custody checks in SDUtilityPool and related tests
blockgroot May 29, 2026
bf556df
test: rename failure tests to indicate expected reverts
blockgroot Jun 2, 2026
aeff7a0
feat: implement asset custody management in mocks and tests
blockgroot Jun 2, 2026
6ec7554
test: add assumptions to operator reward address tests for edge cases
blockgroot Jun 2, 2026
586a540
test(sunset): add mainnet fork test suite for ethX runoff
blockgroot Jun 5, 2026
c5abbc3
feat: update foundry and package configurations for sunset fork tests
blockgroot Jun 5, 2026
3309fe4
test(fork): enforce EIP-7825 gas cap on ghost batch settlement
blockgroot Jun 6, 2026
9b49813
feat: add AllOperatorsValidatorExitTest and supporting scripts for op…
blockgroot Jun 6, 2026
8a7070b
feat: add tests for operator non-terminal keys management and update …
blockgroot Jun 8, 2026
638fe18
fix: update Dockerfile to use npm install instead of ci and adjust su…
blockgroot Jun 8, 2026
25afe32
feat: update Foundry version to stable and modify npm install command…
blockgroot Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ out/
cache/
cache_forge/
artifacts/
lib/
node_modules/
.git/modules/
4 changes: 2 additions & 2 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
version: stable
- name: npm install
run: |
npm ci --frozen-lockfile --production
npm install --no-save --no-audit --no-fund --omit=dev
- name: Run Forge coverage
run: |
forge coverage --report lcov
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ jobs:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
version: stable
- name: Set up node
uses: actions/setup-node@v1
with:
node-version: 18
- name: Install dependencies
run: npm ci --frozen-lockfile
run: npm install --no-save --no-audit --no-fund
- name: solidity unit tests
run: forge test -v
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ cache_forge/
out/
broadcast



# Sunset fork test outputs
test/fork/sunset/snapshots/
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ COPY --chown=foundry:foundry package.json .
COPY --chown=foundry:foundry package-lock.json .
COPY --chown=foundry:foundry tsconfig.json .

RUN npm ci --frozen-lockfile
RUN npm install --no-save --no-audit --no-fund

COPY --chown=foundry:foundry . .

RUN yamlfmt -lint .github/workflows/*.yml

RUN forge install
RUN git submodule update --init --recursive
RUN npm run prettier:check
# RUN slither .
RUN npm run lint
Expand Down
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- "test/fork/**"
68 changes: 68 additions & 0 deletions contracts/OperatorRewardsCollector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ pragma solidity 0.8.16;

import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";

import { UtilLib } from "./library/UtilLib.sol";

Expand All @@ -19,12 +21,17 @@ import { IStaderOracle } from "../contracts/interfaces/IStaderOracle.sol";
import { IPoolUtils } from "../contracts/interfaces/IPoolUtils.sol";

contract OperatorRewardsCollector is IOperatorRewardsCollector, AccessControlUpgradeable {
using SafeERC20Upgradeable for IERC20Upgradeable;

IStaderConfig public staderConfig;

mapping(address => uint256) public balances;

IWETH public weth;

bool public assetCustodied;
uint256 public sweepToCustodyTimestamp;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
Expand Down Expand Up @@ -53,6 +60,7 @@ contract OperatorRewardsCollector is IOperatorRewardsCollector, AccessControlUpg
* @dev This function first checks for any unpaid liquidations for the operator and repays them if necessary. Then, it transfers any remaining balance to the operator's reward address.
*/
function claim() external {
if (assetCustodied) revert AssetCustodied();
uint256 amount;
if (_isPermissionlessCaller(msg.sender)) {
claimLiquidation(msg.sender);
Expand All @@ -72,6 +80,7 @@ contract OperatorRewardsCollector is IOperatorRewardsCollector, AccessControlUpg
* @param _amount amount of ETH to claim
*/
function claimWithAmount(uint256 _amount) external {
if (assetCustodied) revert AssetCustodied();
if (_isPermissionlessCaller(msg.sender)) {
claimLiquidation(msg.sender);
uint256 maxWithdrawableInEth = withdrawableInEth(msg.sender);
Expand All @@ -82,6 +91,7 @@ contract OperatorRewardsCollector is IOperatorRewardsCollector, AccessControlUpg
}

function claimLiquidation(address operator) public override {
if (assetCustodied) revert AssetCustodied();
_transferBackUtilizedSD(operator);
_completeLiquidationIfExists(operator);
}
Expand Down Expand Up @@ -226,4 +236,62 @@ contract OperatorRewardsCollector is IOperatorRewardsCollector, AccessControlUpg
// transfer back the operator's utilized SD balance to SD Utility Pool
sdCollateral.transferBackUtilizedSD(operator);
}

/// @notice admin-driven settle: pay liquidator, treasury covers SD interest (when keys terminal), push remaining ETH to operator
/// @dev Manager-only. Used during sunset wind-down for delinquent/unreachable operators.
/// @param op operator to settle
function adminSettleOperator(address op) external {
UtilLib.onlyManagerRole(msg.sender, staderConfig);

claimLiquidation(op);

(, , uint256 nonTerminalKeys) = ISDCollateral(staderConfig.getSDCollateral()).getOperatorInfo(op);
UserData memory ud = ISDUtilityPool(staderConfig.getSDUtilityPool()).getUserData(op);
if (nonTerminalKeys == 0 && ud.totalInterestSD > 0) {
IERC20Upgradeable sd = IERC20Upgradeable(staderConfig.getStaderToken());
address treasury = staderConfig.getStaderTreasury();
address sdUtilityPool = staderConfig.getSDUtilityPool();
sd.safeTransferFrom(treasury, address(this), ud.totalInterestSD);
sd.safeApprove(sdUtilityPool, 0);
sd.safeApprove(sdUtilityPool, ud.totalInterestSD);
ISDUtilityPool(sdUtilityPool).repayOnBehalf(op, ud.totalInterestSD);
uint256 sdPriceInEth = IStaderOracle(staderConfig.getStaderOracle()).getSDPriceInETH();
uint256 interestInEth = (ud.totalInterestSD * sdPriceInEth) / staderConfig.getDecimals();
uint256 ethToTreasury = Math.min(balances[op], interestInEth);
if (ethToTreasury > 0) {
balances[op] -= ethToTreasury;
UtilLib.sendValue(treasury, ethToTreasury);
}
}
if (balances[op] > 0) _claim(op, balances[op]);
emit AdminSettledOperator(op);
}

/// @notice arm custody sweep timer; sweepToCustody allowed only after delay elapses
/// @dev Admin (timelock) only
function setCustodyDelay(uint256 _custodyDelay) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_custodyDelay == 0) revert ZeroCustodyDelay();
sweepToCustodyTimestamp = block.timestamp + _custodyDelay;
emit SetCustodyDelay(sweepToCustodyTimestamp);
}

/// @notice sweep full ETH or ERC20 balance to custody; sticky-flips assetCustodied
/// @dev Admin (timelock) only; requires armed setCustodyDelay() with elapsed delay
function sweepToCustody(address _asset, address _custody) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_custody == address(0)) revert ZeroAddress();
if (sweepToCustodyTimestamp == 0 || block.timestamp < sweepToCustodyTimestamp) revert CustodyDelayNotElapsed();
assetCustodied = true;
uint256 bal;
if (_asset == address(0)) {
bal = address(this).balance;
if (bal == 0) revert ZeroAmount();
(bool success, ) = payable(_custody).call{ value: bal }("");
if (!success) revert TransferFailed();
} else {
bal = IERC20Upgradeable(_asset).balanceOf(address(this));
if (bal == 0) revert ZeroAmount();
IERC20Upgradeable(_asset).safeTransfer(_custody, bal);
}
emit SweptToCustody(_asset, _custody, bal);
}
}
46 changes: 46 additions & 0 deletions contracts/PermissionlessPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pragma solidity 0.8.16;
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";

import { UtilLib } from "./library/UtilLib.sol";

Expand All @@ -16,6 +18,7 @@ import { IPermissionlessNodeRegistry } from "./interfaces/IPermissionlessNodeReg

contract PermissionlessPool is IStaderPoolBase, AccessControlUpgradeable, ReentrancyGuardUpgradeable {
using Math for uint256;
using SafeERC20Upgradeable for IERC20Upgradeable;
IStaderConfig public staderConfig;

uint256 public constant DEPOSIT_NODE_BOND = 3 ether;
Expand All @@ -28,6 +31,19 @@ contract PermissionlessPool is IStaderPoolBase, AccessControlUpgradeable, Reentr

uint256 public constant MAX_COMMISSION_LIMIT_BIPS = 1500;

uint256 public sweepToCustodyTimestamp;
bool public assetCustodied;

error AssetCustodied();
error ZeroCustodyDelay();
error ZeroAddress();
error ZeroAmount();
error CustodyDelayNotElapsed();
error TransferFailed();

event SetCustodyDelay(uint256 sweepToCustodyTimestamp);
event SweptToCustody(address asset, address custody, uint256 amount);

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
Expand Down Expand Up @@ -86,6 +102,7 @@ contract PermissionlessPool is IStaderPoolBase, AccessControlUpgradeable, Reentr
uint256 _operatorId,
uint256 _operatorTotalKeys
) external payable nonReentrant {
if (assetCustodied) revert AssetCustodied();
UtilLib.onlyStaderContract(msg.sender, staderConfig, staderConfig.PERMISSIONLESS_NODE_REGISTRY());
address vaultFactory = staderConfig.getVaultFactory();
uint256 pubkeyCount = _pubkey.length;
Expand Down Expand Up @@ -122,6 +139,7 @@ contract PermissionlessPool is IStaderPoolBase, AccessControlUpgradeable, Reentr
* @dev deposit validator taking care of pool capacity
*/
function stakeUserETHToBeaconChain() external payable override nonReentrant {
if (assetCustodied) revert AssetCustodied();
UtilLib.onlyStaderContract(msg.sender, staderConfig, staderConfig.STAKE_POOL_MANAGER());
uint256 requiredValidators = msg.value / (staderConfig.getFullDepositSize() - DEPOSIT_NODE_BOND);
address nodeRegistryAddress = staderConfig.getPermissionlessNodeRegistry();
Expand Down Expand Up @@ -267,6 +285,34 @@ contract PermissionlessPool is IStaderPoolBase, AccessControlUpgradeable, Reentr
emit ValidatorDepositedOnBeaconChain(_validatorId, pubkey);
}

/// @notice arm custody sweep timer; sweepToCustody allowed only after delay elapses
/// @dev Admin (timelock) only
function setCustodyDelay(uint256 _custodyDelay) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (_custodyDelay == 0) revert ZeroCustodyDelay();
sweepToCustodyTimestamp = block.timestamp + _custodyDelay;
emit SetCustodyDelay(sweepToCustodyTimestamp);
}

/// @notice sweep full ETH or ERC20 balance to custody; sticky-flips assetCustodied
/// @dev Admin (timelock) only; requires armed setCustodyDelay() with elapsed delay
function sweepToCustody(address _asset, address _custody) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) {
if (_custody == address(0)) revert ZeroAddress();
if (sweepToCustodyTimestamp == 0 || block.timestamp < sweepToCustodyTimestamp) revert CustodyDelayNotElapsed();
assetCustodied = true;
uint256 bal;
if (_asset == address(0)) {
bal = address(this).balance;
if (bal == 0) revert ZeroAmount();
(bool success, ) = payable(_custody).call{ value: bal }("");
if (!success) revert TransferFailed();
} else {
bal = IERC20Upgradeable(_asset).balanceOf(address(this));
if (bal == 0) revert ZeroAmount();
IERC20Upgradeable(_asset).safeTransfer(_custody, bal);
}
emit SweptToCustody(_asset, _custody, bal);
}

//ethereum deposit contract function to get amount into little_endian_64
function to_little_endian_64(uint256 _depositAmount) internal pure returns (bytes memory ret) {
uint64 value = uint64(_depositAmount / 1 gwei);
Expand Down
Loading
Loading