Try   HackMD

Withdrawals. Protocol safety nets for oracle reports

⚠️ THE DOCUMENT IS UNDER CONSTRUCTION ⚠️


DISCLAIMER

All of the magic numbers in the doc are DAO-controlled.
They are fixed here as a reasonable defaults for initial proposal.

Required reading

Considerations

  • Need to check oracle-reported data for sanity
  • 3rd-parties can top-up the protocol vaults to block oracle reports (if caps are too strict)
  • Too large rebases are subject of oracle report sandwiching (above 27% APR)
  • Want to re-use/upgrade the currently existing sanity checks
  • Prevent exiting all Lido validators by the misbehaving oracle committee
  • Check that old validators exit before new ones

Glossary

  • Hard caps: lead to reverts if not met
  • Soft limits: saturation values, don't revert if exceeded, though emit events to warn

Accounting oracle

Solidity structure definition:

struct Report {
    // Consensus info
    uint256 _slotId;
    // CL values
    uint256 _clValidators;
    uint64 _clBalanceGwei;
    // Staking router
    address[] _stakingModules;
    uint256[] _nodeOperatorsWithExitedValidators;
    uint256[] _exitedValidatorsNumbers;
    // EL values
    uint256 _withdrawalVaultBalance;
    // decision
    uint256 _requestIdToFinalizeUpTo;
    bool _bunkerModeFlag; // todo: to be utilized later
}

Lido:

    function handleOracleReport(
        // Oracle report timing
        uint256 _timeElapsed,
        // CL values
        uint256 _clValidators,
        uint256 _clBalance,
        // EL values
        uint256 _withdrawalVaultBalance,
        uint256 _elRewardsVaultBalance,
        // Decision about withdrawals processing
        uint256 _requestIdToFinalizeUpTo,
        uint256 _finalizationShareRate
    ) external returns
  • appearedValidator (Lido)

StakingRouter:

    // Staking router
    address[] _stakingModules;
    uint256[] _nodeOperatorsWithExitedValidators;
    uint256[] _exitedValidatorsNumbers;

WithdrawalQueue:

    uint256 _previousReportTimestamp,
    bool isBunkerMode

Hard caps

Cap 1. Withdrawals vault one-off reported balance

Can't exceed the balance at the current block.

if (_withdrawalVaultBalance > WithdrawalsVault.balance) {
    revert IncorrectWithdrawalsVaultBalance();
}
  • in Lido
Cap 2. Consensus Layer one-off balances decrease

Prohibit >5% one-off decrease.

Have to account for withdrawals vault (why: the first week of the Shapella-enabled reports with massive skimmed rewards, leading to the lowered consensus layer balance)

_unifiedPostCLBalance = _postCLBalance + _withdrawalVaultBalance;

if ((_preCLBalance - _unifiedPostCLBalance) > 5%) { 
    revert IncorrectCLBalanceDecrease();
}
  • in Lido

NOTE: was for the whole TVL before.

Cap 3. Consensus Layer annual balances increase

Prohibit >10% annual increase.

Must not account for withdrawals vault (why: suppose all validators had less than 30 ETH, i.e. unable to been skimmed)


reportBalanceDiff = (_postCLBalance - _preCLBalance);
annualBalanceDiff = reportBalanceDiff * 365 days / _timeElapsed;

if (annualBalanceDiff > 10%) { 
    revert IncorrectBeaconBalanceIncrease();
}
  • Lido

NOTE: was for the whole TVL before

Cap 4. Activation & exit churn limit

Networks allows changing a validator set only within a certain churn limit.

55 validators per epoch correspond to the state if roughly 95% of total Ether supply is staked.

churnLimit = 55 * _timeElapsed / 1 epoch;

if (_appearedValidators > churnLimit) {
    revert IncorrectAppearedValidators();
}
if (_exitedValidators > churnLimit) {
    revert IncorrectExitedValidators();
} 
  • AccountingOracle itself?
  • Only Lido currently knows appeared
  • Though, Lido doesn't know exited
Cap 5. No finalized id up to newer than the allowed report margin

1h before the reported slot or below DAO defined delay (set temporary)

lastRequestTimestamp = withdrawalRequest(_requestIdToFinalizeUpTo).requestBlock;
timeDiff = max(refReportTimestamp - 1h) - lastRequestTimestamp;

if (timeDiff < 0) {
    revert IncorrectRequestFinalization();
}
  • Bunker mode can change the margin (?)
  • 1h
  • WithdrawalQueue?
Cap 6. shareRate calculated off-chain is consistent with the on-chain one

  • maybe not in the contract
  • share rate should be sane (like TVL/total shares currently: getTotalPooledEther() / getTotalShares()) - tolerance?

Soft limits

Positive rebase limiter

No more than 27% apr increase per single report.

// initial value
limiterVal = 27% of TVL

// account for `beaconBalanceDiff`
// NB: can either increase or decrease `limiterVal`
limiterVal -= beaconBalanceDiff

// saturate `_reportedWCBalance`
_reportedWCBalance = min(
    limiterVal, 
    _reportedWCBalance
);
limiterVal -= _reportedWCBalance;

// saturate `_ELRewardsVaultBalance`
_ELRewardsVaultBalance = min(
    limiterVal,
    _ELRewardsVaultBalance
)
limiterVal -= _ELRewardsVaultBalance;

// saturate coverage
applyCoverage(limiterVal)

ValidatorExitBus oracle

Solidity structure definition:

struct Report {
    uint256 _slotId,
    address[] _stakingModules,
    uint256[] _nodeOperatorIds,
    uint256[] _validatorIds,
    bytes[] _validatorPubkeys
}

Hard caps

Rate limit

TODO (tooling?)

Ordering requirements

For each particular node operator validatorId can't be less than previously reported (i.e., The old validators are have to be exited before the new ones).