Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
241 changes: 220 additions & 21 deletions contracts/0.4.24/Lido.sol

Large diffs are not rendered by default.

63 changes: 53 additions & 10 deletions contracts/0.8.9/Accounting.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {IPostTokenRebaseReceiver} from "./interfaces/IPostTokenRebaseReceiver.so

import {WithdrawalQueue} from "./WithdrawalQueue.sol";

interface IRedeemsBuffer {
function getRedeemedEther() external view returns (uint256);
function withdrawUnredeemed() external;
}

interface IStakingRouter {
function getStakingRewardsDistribution()
external
Expand All @@ -36,6 +41,8 @@ interface IStakingRouter {
/// calculating all the state changes that is required to apply the report
/// and distributing calculated values to relevant parts of the protocol
contract Accounting {
uint256 internal constant TOTAL_BASIS_POINTS = 10000;

struct Contracts {
address accountingOracle;
IOracleReportSanityChecker oracleReportSanityChecker;
Expand Down Expand Up @@ -92,6 +99,10 @@ contract Accounting {
uint256 postTotalShares;
/// @notice amount of ether under the protocol after the report is applied
uint256 postTotalPooledEther;
/// @notice number of redeem shares to burn (outside the rebase limiter, rate-neutral)
uint256 redeemSharesToBurn;
/// @notice amount of ether redeemed since last report (from RedeemsBuffer)
uint256 redeemedEther;
}

/// @notice precalculated numbers of shares that should be minted as fee to NO
Expand Down Expand Up @@ -139,6 +150,10 @@ contract Accounting {
Contracts memory contracts = _loadOracleReportContracts();
if (msg.sender != contracts.accountingOracle) revert NotAuthorized("handleOracleReport", msg.sender);

// Vault redemption counters are read on-chain during _simulateOracleReport.
// redeemedEther is subtracted from the smoothenTokenRebase base,
// and redeemedShares are added outside the rebase limiter.

PreReportState memory pre = _snapshotPreReportState(contracts, false);
CalculatedValues memory update = _simulateOracleReport(contracts, pre, _report);
_applyOracleReportContext(contracts, _report, pre, update);
Expand Down Expand Up @@ -179,17 +194,20 @@ contract Accounting {
// Principal CL balance is sum of previous balances and new deposits
update.principalClBalance = _pre.clValidatorsBalance + _pre.clPendingBalance + _pre.depositedBalance;

// Limit the rebase to avoid oracle frontrunning
// by leaving some ether to sit in EL rewards vault or withdrawals vault
// and/or leaving some shares unburnt on Burner to be processed on future reports
// Read redemption counters from buffer (on-chain, includes all redemptions)
(uint256 redeemedShares, uint256 redeemedEther) = _getRedeemedCounters();
update.redeemedEther = redeemedEther;

// Limit the rebase to avoid oracle frontrunning.
// The base is reduced by redeemedEther so the limiter sees the actual protocol size.
(
update.withdrawalsVaultTransfer,
update.elRewardsVaultTransfer,
update.sharesToBurnForWithdrawals,
update.totalSharesToBurn // shares to burn from Burner balance
update.totalSharesToBurn // shares to burn from Burner balance (WQ + cover)
) = _contracts.oracleReportSanityChecker.smoothenTokenRebase(
_pre.totalPooledEther - _pre.externalEther, // we need to change the base as shareRate is now calculated on
_pre.totalShares - _pre.externalShares, // internal ether and shares, but inside it's still total
_pre.totalPooledEther - _pre.externalEther - redeemedEther,
_pre.totalShares - _pre.externalShares,
update.principalClBalance,
_report.clValidatorsBalance + _report.clPendingBalance,
_report.withdrawalVaultBalance,
Expand All @@ -199,15 +217,20 @@ contract Accounting {
update.sharesToFinalizeWQ
);

// Redeem shares are burned via a separate commit (outside the rebase limiter, rate-neutral)
update.redeemSharesToBurn = redeemedShares;

uint256 postInternalSharesBeforeFees = _pre.totalShares -
_pre.externalShares - // internal shares before
update.totalSharesToBurn; // shares to be burned for withdrawals and cover
update.totalSharesToBurn - // shares to be burned (WQ + cover)
update.redeemSharesToBurn; // redeem shares burned separately

update.postInternalEther =
_pre.totalPooledEther - _pre.externalEther // internal ether before
_pre.totalPooledEther - _pre.externalEther
+ _report.clValidatorsBalance + _report.clPendingBalance + update.withdrawalsVaultTransfer - update.principalClBalance
+ update.elRewardsVaultTransfer
- update.etherToFinalizeWQ;
- update.etherToFinalizeWQ
- redeemedEther;

// Pre-calculate total amount of protocol fees as the amount of shares that will be minted to pay it
(update.sharesToMintAsFees, update.feeDistribution) = _calculateProtocolFees(
Expand Down Expand Up @@ -366,10 +389,19 @@ contract Accounting {
LIDO.internalizeExternalBadDebt(_pre.badDebtToInternalize);
}

// Burn all redeem shares (rate-neutral, outside limiter)
// Shares are already on Burner — sent during each redeem() call
if (_update.redeemSharesToBurn > 0) {
_contracts.burner.commitRedeemSharesToBurn();
}

// Burn limiter-constrained cover/nonCover shares
if (_update.totalSharesToBurn > 0) {
_contracts.burner.commitSharesToBurn(_update.totalSharesToBurn);
}

// collectRewardsAndProcessWithdrawals handles ETH round-trip internally:
// withdraw unredeemed → reconcile → standard flow → grow reserve → push
LIDO.collectRewardsAndProcessWithdrawals(
_report.timestamp,
_report.clValidatorsBalance + _report.clPendingBalance,
Expand All @@ -378,7 +410,8 @@ contract Accounting {
_update.elRewardsVaultTransfer,
lastWithdrawalRequestToFinalize,
_report.simulatedShareRate,
_update.etherToFinalizeWQ
_update.etherToFinalizeWQ,
_update.redeemedEther
);

if (_update.sharesToMintAsFees > 0) {
Expand Down Expand Up @@ -408,6 +441,16 @@ contract Accounting {
);
}

/// @dev Reads redemption counters. Shares from Burner redeem track, ether from buffer.
/// Returns (0, 0) if no buffer configured.
function _getRedeemedCounters() internal view returns (uint256 redeemedShares, uint256 redeemedEther) {
address buffer = LIDO.getRedeemsBuffer();
if (buffer == address(0)) return (0, 0);

redeemedShares = IBurner(LIDO_LOCATOR.burner()).getRedeemSharesRequestedToBurn();
redeemedEther = IRedeemsBuffer(buffer).getRedeemedEther();
}

/// @dev checks the provided oracle data internally and against the sanity checker contract
/// reverts if a check fails
function _sanityChecks(
Expand Down
88 changes: 85 additions & 3 deletions contracts/0.8.9/Burner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {

uint256 totalCoverSharesBurnt;
uint256 totalNonCoverSharesBurnt;

uint256 redeemSharesBurnRequested;
uint256 totalRedeemSharesBurnt;
}

/// @custom:storage-location erc7201:Lido.Core.Burner.IsMigrationAllowed-v3Upgrade
Expand Down Expand Up @@ -116,6 +119,16 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
*/
event StETHBurnt(bool indexed isCover, uint256 amountOfStETH, uint256 amountOfShares);

/**
* Emitted when a new redeem burn request is added.
*/
event RedeemStETHBurnRequested(address indexed requestedBy, uint256 amountOfStETH, uint256 amountOfShares);

/**
* Emitted when redeem shares are burnt.
*/
event RedeemStETHBurnt(uint256 amountOfStETH, uint256 amountOfShares);

/**
* Emitted when the excessive stETH `amount` (corresponding to `amountOfShares` shares) recovered (i.e. transferred)
* to the Lido treasure address by `requestedBy` sender.
Expand Down Expand Up @@ -182,7 +195,7 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
Storage storage $ = _storage();
$.totalCoverSharesBurnt = oldBurner.getCoverSharesBurnt();
$.totalNonCoverSharesBurnt = oldBurner.getNonCoverSharesBurnt();
(uint256 coverShares, uint256 nonCoverShares) = oldBurner.getSharesRequestedToBurn();
(uint256 coverShares, uint256 nonCoverShares,) = oldBurner.getSharesRequestedToBurn();
$.coverSharesBurnRequested = coverShares;
$.nonCoverSharesBurnRequested = nonCoverShares;
}
Expand Down Expand Up @@ -280,6 +293,29 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
_requestBurn(_sharesAmountToBurn, stETHAmount, false /* _isCover */);
}

/**
* @notice BE CAREFUL, the provided stETH shares will be burnt permanently.
*
* Transfers `_sharesAmountToBurn` stETH shares from `_from` and irreversibly locks these
* on the burner contract address. Marks the shares amount for burning on the isolated
* redeem track (burned outside the rebase limiter).
*
* @param _from address to transfer shares from
* @param _sharesAmountToBurn stETH shares to burn
*/
function requestBurnSharesForRedeem(
address _from,
uint256 _sharesAmountToBurn
) external onlyRole(REQUEST_BURN_SHARES_ROLE) {
if (_sharesAmountToBurn == 0) revert ZeroBurnAmount();

uint256 stETHAmount = LIDO.transferSharesFrom(_from, address(this), _sharesAmountToBurn);

emit RedeemStETHBurnRequested(msg.sender, stETHAmount, _sharesAmountToBurn);

_storage().redeemSharesBurnRequested += _sharesAmountToBurn;
}

/**
* Transfers the excess stETH amount (e.g. belonging to the burner contract address
* but not marked for burning) to the Lido treasury address set upon the
Expand Down Expand Up @@ -392,6 +428,35 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
assert(sharesToBurnNow == _sharesToBurn);
}

/**
* Commit all pending redeem shares to burn. Burns everything — no budget argument.
*
* Redeem shares are burned outside the rebase limiter (rate-neutral),
* so they are always fully burned on each oracle report.
*
* Increments `totalRedeemSharesBurnt` counter.
* Resets `redeemSharesBurnRequested` counter.
* Does nothing if zero shares are pending.
*/
function commitRedeemSharesToBurn() external virtual override {
if (msg.sender != LOCATOR.accounting()) revert AppAuthFailed();

Storage storage $ = _storage();
uint256 redeemShares = $.redeemSharesBurnRequested;

if (redeemShares == 0) {
return;
}

$.redeemSharesBurnRequested = 0;
$.totalRedeemSharesBurnt += redeemShares;

uint256 stETHAmount = LIDO.getPooledEthByShares(redeemShares);
emit RedeemStETHBurnt(stETHAmount, redeemShares);

LIDO.burnShares(redeemShares);
}

/**
* Returns the current amount of shares locked on the contract to be burnt.
*/
Expand All @@ -400,11 +465,12 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
view
virtual
override
returns (uint256 coverShares, uint256 nonCoverShares)
returns (uint256 coverShares, uint256 nonCoverShares, uint256 redeemShares)
{
Storage storage $ = _storage();
coverShares = $.coverSharesBurnRequested;
nonCoverShares = $.nonCoverSharesBurnRequested;
redeemShares = $.redeemSharesBurnRequested;
}

/**
Expand All @@ -421,6 +487,20 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {
return _storage().totalNonCoverSharesBurnt;
}

/**
* Returns the current amount of redeem shares locked on the contract to be burnt.
*/
function getRedeemSharesRequestedToBurn() external view virtual override returns (uint256) {
return _storage().redeemSharesBurnRequested;
}

/**
* Returns the total redeem shares ever burnt.
*/
function getRedeemSharesBurnt() external view virtual override returns (uint256) {
return _storage().totalRedeemSharesBurnt;
}

/**
* Returns the stETH amount belonging to the burner contract address but not marked for burning.
*/
Expand All @@ -430,7 +510,9 @@ contract Burner is IBurner, AccessControlEnumerable, Versioned {

function _getExcessStETHShares() internal view returns (uint256) {
Storage storage $ = _storage();
uint256 sharesBurnRequested = ($.coverSharesBurnRequested + $.nonCoverSharesBurnRequested);
uint256 sharesBurnRequested = $.coverSharesBurnRequested
+ $.nonCoverSharesBurnRequested
+ $.redeemSharesBurnRequested;
uint256 totalShares = LIDO.sharesOf(address(this));

// sanity check, don't revert
Expand Down
Loading
Loading