Skip to content

Commit d01960c

Browse files
committed
feat: redeem report rate deviation fix
1 parent 2d0c922 commit d01960c

File tree

6 files changed

+521
-108
lines changed

6 files changed

+521
-108
lines changed

contracts/0.4.24/Lido.sol

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface IWithdrawalQueue {
3939
}
4040

4141
interface IRedeemsReserveVault {
42+
function fundReserve() external payable;
4243
function withdrawToLido(uint256 _amount) external;
4344
}
4445

@@ -195,13 +196,13 @@ contract Lido is Versioned, StETHPermit, AragonApp {
195196
bytes32 internal constant REDEEMS_RESERVE_VAULT_ETH_POSITION =
196197
0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2;
197198

198-
/// @dev Storage slot for redeems reserve replenishment share.
199+
/// @dev Storage slot for redeems reserve growth share.
199200
/// Basis points determining how shared allocation (withdrawalsReserve + unreserved) is split
200201
/// between reserve growth and WQ finalization when surplus is insufficient.
201202
/// 0 (default) = reserve grows only from surplus. 8000 = 80% to reserve, 20% to WQ.
202-
/// keccak256("lido.Lido.redeemsReserveReplenishmentShare")
203-
bytes32 internal constant REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION =
204-
0xf0700d4e4a89999085a7b5d94e2e6697101ac2553a2f01512a848b9140605dfb;
203+
/// keccak256("lido.Lido.redeemsReserveGrowthShare")
204+
bytes32 internal constant REDEEMS_RESERVE_GROWTH_SHARE_POSITION =
205+
0x165efeb2acd150f40e68b22ff2e9492cf5007021f951e4109c407f04e4e36129;
205206

206207
// Staking was paused (don't accept user's ether submits)
207208
event StakingPaused();
@@ -295,7 +296,7 @@ contract Lido is Versioned, StETHPermit, AragonApp {
295296
event RedeemsReserveTargetRatioSet(uint256 ratioBP);
296297

297298
// Emitted when redeems reserve replenishment share is set
298-
event RedeemsReserveReplenishmentShareSet(uint256 shareBP);
299+
event RedeemsReserveGrowthShareSet(uint256 shareBP);
299300

300301
// Emitted when the RedeemsReserveVault address is set
301302
event RedeemsReserveVaultSet(address vault);
@@ -741,25 +742,25 @@ contract Lido is Versioned, StETHPermit, AragonApp {
741742
}
742743

743744
/**
744-
* @notice Sets the replenishment share for redeems reserve.
745+
* @notice Sets the growth share for redeems reserve.
745746
* When unreserved surplus is insufficient to fill the reserve, this share (in BP)
746747
* determines what fraction of the shared allocation (withdrawalsReserve + unreserved)
747748
* goes to reserve growth vs WQ finalization.
748749
* 0 (default) = reserve grows only from genuine surplus. 10000 = 100% to reserve.
749-
* @param _shareBP Replenishment share in basis points [0-10000]
750+
* @param _shareBP Growth share in basis points [0-10000]
750751
*/
751-
function setRedeemsReserveReplenishmentShare(uint256 _shareBP) external {
752+
function setRedeemsReserveGrowthShare(uint256 _shareBP) external {
752753
_auth(BUFFER_RESERVE_MANAGER_ROLE);
753754
require(_shareBP <= TOTAL_BASIS_POINTS, "INVALID_SHARE");
754-
REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION.setStorageUint256(_shareBP);
755-
emit RedeemsReserveReplenishmentShareSet(_shareBP);
755+
REDEEMS_RESERVE_GROWTH_SHARE_POSITION.setStorageUint256(_shareBP);
756+
emit RedeemsReserveGrowthShareSet(_shareBP);
756757
}
757758

758759
/**
759-
* @return the redeems reserve replenishment share in basis points
760+
* @return the redeems reserve growth share in basis points
760761
*/
761-
function getRedeemsReserveReplenishmentShare() external view returns (uint256) {
762-
return REDEEMS_RESERVE_REPLENISHMENT_SHARE_POSITION.getStorageUint256();
762+
function getRedeemsReserveGrowthShare() external view returns (uint256) {
763+
return REDEEMS_RESERVE_GROWTH_SHARE_POSITION.getStorageUint256();
763764
}
764765

765766
/**
@@ -825,7 +826,7 @@ contract Lido is Versioned, StETHPermit, AragonApp {
825826
REDEEMS_RESERVE_VAULT_ETH_POSITION.getStorageUint256().add(_amount)
826827
);
827828

828-
vault.transfer(_amount);
829+
IRedeemsReserveVault(vault).fundReserve.value(_amount)();
829830
emit RedeemsReserveVaultFunded(_amount);
830831
}
831832

contracts/0.8.9/Accounting.sol

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import {WithdrawalQueue} from "./WithdrawalQueue.sol";
1717

1818
interface IRedeemsReserveVault {
1919
function withdrawToLido(uint256 _amount) external;
20+
function getRedeemedShares() external view returns (uint256);
21+
function getRedeemedEther() external view returns (uint256);
22+
function flushSharesToBurner() external;
23+
function resetRedeemedEther() external;
2024
}
2125

2226
interface IStakingRouter {
@@ -145,9 +149,9 @@ contract Accounting {
145149
Contracts memory contracts = _loadOracleReportContracts();
146150
if (msg.sender != contracts.accountingOracle) revert NotAuthorized("handleOracleReport", msg.sender);
147151

148-
// Do NOT reconcile vault before snapshot. The stale tracked vault ETH keeps
149-
// preInternalEther overcounted, and the vault delta is passed to smoothenTokenRebase
150-
// as an explicit ether decrease — creating headroom for the paired burn.
152+
// Vault redemption counters are read on-chain during _simulateOracleReport.
153+
// redeemedEther is subtracted from the smoothenTokenRebase base,
154+
// and redeemedShares are added outside the rebase limiter.
151155

152156
PreReportState memory pre = _snapshotPreReportState(contracts, false);
153157
CalculatedValues memory update = _simulateOracleReport(contracts, pre, _report);
@@ -189,41 +193,41 @@ contract Accounting {
189193
// Principal CL balance is sum of previous balances and new deposits
190194
update.principalClBalance = _pre.clValidatorsBalance + _pre.clPendingBalance + _pre.depositedBalance;
191195

192-
// Limit the rebase to avoid oracle frontrunning
193-
// by leaving some ether to sit in EL rewards vault or withdrawals vault
194-
// and/or leaving some shares unburnt on Burner to be processed on future reports
195-
//
196-
// NOTE: vaultDelta is merged with etherToFinalizeWQ into a single _etherToDecrease param
197-
// due to stack-too-deep in smoothenTokenRebase (0.8.9 without via-IR).
198-
// Spec describes them as separate decreaseEther calls; mathematically equivalent.
199-
uint256 vaultDelta = _getRedeemsReserveVaultDelta();
196+
// Read redemption counters from vault (on-chain, includes all redemptions)
197+
(uint256 redeemedShares, uint256 redeemedEther) = _getRedeemedCounters();
198+
199+
// Limit the rebase to avoid oracle frontrunning.
200+
// The base is reduced by redeemedEther so the limiter sees the actual protocol size.
200201
(
201202
update.withdrawalsVaultTransfer,
202203
update.elRewardsVaultTransfer,
203204
update.sharesToBurnForWithdrawals,
204-
update.totalSharesToBurn // shares to burn from Burner balance
205+
update.totalSharesToBurn // shares to burn from Burner balance (WQ + cover)
205206
) = _contracts.oracleReportSanityChecker.smoothenTokenRebase(
206-
_pre.totalPooledEther - _pre.externalEther,
207+
_pre.totalPooledEther - _pre.externalEther - redeemedEther,
207208
_pre.totalShares - _pre.externalShares,
208209
update.principalClBalance,
209210
_report.clValidatorsBalance + _report.clPendingBalance,
210211
_report.withdrawalVaultBalance,
211212
_report.elRewardsVaultBalance,
212213
_report.sharesRequestedToBurn,
213-
update.etherToFinalizeWQ + vaultDelta, // paired decreases: WQ finalization + vault redemptions
214+
update.etherToFinalizeWQ,
214215
update.sharesToFinalizeWQ
215216
);
216217

218+
// Add redemption shares outside the limiter — rate-neutral, must all burn on this report
219+
update.totalSharesToBurn += redeemedShares;
220+
217221
uint256 postInternalSharesBeforeFees = _pre.totalShares -
218222
_pre.externalShares - // internal shares before
219-
update.totalSharesToBurn; // shares to be burned for withdrawals and cover
223+
update.totalSharesToBurn; // shares to be burned (WQ + cover + redemptions)
220224

221225
update.postInternalEther =
222-
_pre.totalPooledEther - _pre.externalEther // internal ether before (includes stale vault ETH)
226+
_pre.totalPooledEther - _pre.externalEther
223227
+ _report.clValidatorsBalance + _report.clPendingBalance + update.withdrawalsVaultTransfer - update.principalClBalance
224228
+ update.elRewardsVaultTransfer
225229
- update.etherToFinalizeWQ
226-
- vaultDelta; // subtract vault delta: stale vault ETH was overcounted in preInternalEther
230+
- redeemedEther;
227231

228232
// Pre-calculate total amount of protocol fees as the amount of shares that will be minted to pay it
229233
(update.sharesToMintAsFees, update.feeDistribution) = _calculateProtocolFees(
@@ -382,12 +386,14 @@ contract Accounting {
382386
LIDO.internalizeExternalBadDebt(_pre.badDebtToInternalize);
383387
}
384388

389+
// Flush accumulated redemption shares from vault to Burner before commit
390+
_flushVaultSharesToBurner();
391+
385392
if (_update.totalSharesToBurn > 0) {
386393
_contracts.burner.commitSharesToBurn(_update.totalSharesToBurn);
387394
}
388395

389-
// Reconcile RedeemsReserveVault AFTER burns — tracked vault ETH updated to actual.
390-
// Must happen after commitSharesToBurn so the paired burn + reconcile are both applied.
396+
// Reconcile RedeemsReserveVault — tracked vault ETH updated to actual.
391397
_reconcileRedeemsReserveVault();
392398

393399
LIDO.collectRewardsAndProcessWithdrawals(
@@ -431,31 +437,38 @@ contract Accounting {
431437
);
432438
}
433439

434-
/// @dev Computes how much ETH left the RedeemsReserveVault since the last report.
435-
/// Returns 0 if no vault is configured or vault balance increased (e.g. donation).
436-
/// Used to pass the delta to smoothenTokenRebase as an ether decrease.
437-
function _getRedeemsReserveVaultDelta() internal view returns (uint256) {
440+
/// @dev Reads redemption counters from the vault. Returns (0, 0) if no vault configured.
441+
function _getRedeemedCounters() internal view returns (uint256 redeemedShares, uint256 redeemedEther) {
438442
address vault = LIDO.getRedeemsReserveVault();
439-
if (vault == address(0)) return 0;
443+
if (vault == address(0)) return (0, 0);
440444

441-
uint256 tracked = LIDO.getRedeemsReserveVaultEth();
442-
uint256 actual = vault.balance;
443-
return tracked > actual ? tracked - actual : 0;
445+
redeemedShares = IRedeemsReserveVault(vault).getRedeemedShares();
446+
redeemedEther = IRedeemsReserveVault(vault).getRedeemedEther();
447+
}
448+
449+
/// @dev Flushes accumulated redemption shares from vault to Burner.
450+
/// Called before commitSharesToBurn so redeemed shares are included in the burn.
451+
function _flushVaultSharesToBurner() internal {
452+
address vault = LIDO.getRedeemsReserveVault();
453+
if (vault == address(0)) return;
454+
455+
IRedeemsReserveVault(vault).flushSharesToBurner();
444456
}
445457

446-
/// @dev Reconciles the RedeemsReserveVault tracked ETH to the actual vault balance.
447-
/// Called AFTER commitSharesToBurn so the paired burn + reconcile are applied together.
458+
/// @dev Reconciles the RedeemsReserveVault tracked ETH to the actual vault balance
459+
/// and resets the redeemed ether counter.
448460
function _reconcileRedeemsReserveVault() internal {
449461
address vault = LIDO.getRedeemsReserveVault();
450462
if (vault == address(0)) return;
451463

452464
LIDO.reconcileRedeemsReserveVault(vault.balance);
465+
IRedeemsReserveVault(vault).resetRedeemedEther();
453466
}
454467

455468
/// @dev Replenishes or drains the RedeemsReserveVault to match the reserve target.
456469
/// Called after collectRewardsAndProcessWithdrawals when the buffer is finalized.
457470
/// Fills to target from unreserved surplus first. When surplus is insufficient,
458-
/// splits the shared allocation (withdrawalsReserve + unreserved) by replenishmentShareBP.
471+
/// splits the shared allocation (withdrawalsReserve + unreserved) by growthShareBP.
459472
function _replenishRedeemsReserveVault() internal {
460473
address vault = LIDO.getRedeemsReserveVault();
461474
if (vault == address(0)) return;
@@ -465,23 +478,20 @@ contract Accounting {
465478

466479
if (target > actual) {
467480
uint256 deficit = target - actual;
468-
uint256 unreserved = LIDO.getDepositableEther();
481+
uint256 depositableEther = LIDO.getDepositableEther();
469482
uint256 toPush;
470483

471-
if (unreserved >= deficit) {
472-
// Surplus fully covers the deficit — no impact on WQ
484+
if (depositableEther >= deficit) {
473485
toPush = deficit;
474486
} else {
475-
// Surplus insufficient — split shared allocation by replenishment share
476-
uint256 shareBP = LIDO.getRedeemsReserveReplenishmentShare();
487+
uint256 shareBP = LIDO.getRedeemsReserveGrowthShare();
477488
if (shareBP == 0) {
478-
// No forced growth — reserve grows only from genuine surplus
479-
toPush = unreserved;
489+
toPush = depositableEther;
480490
} else {
481491
uint256 withdrawalsReserve = LIDO.getWithdrawalsReserve();
482-
uint256 sharedAllocation = withdrawalsReserve + unreserved;
492+
uint256 sharedAllocation = withdrawalsReserve + depositableEther;
483493
uint256 reserveShare = sharedAllocation * shareBP / TOTAL_BASIS_POINTS;
484-
toPush = reserveShare > unreserved ? reserveShare : unreserved;
494+
toPush = reserveShare > depositableEther ? reserveShare : depositableEther;
485495
if (toPush > deficit) toPush = deficit;
486496
}
487497
}

0 commit comments

Comments
 (0)