Skip to content

feat(redemption-rewards): phase 2 — raise the redeem payout cap over - #676

Draft
iamsahu wants to merge 3 commits into
feat/redemption-rewards-p1-stopped-earning-reportfrom
feat/redemption-rewards-p2-rate-marks
Draft

feat(redemption-rewards): phase 2 — raise the redeem payout cap over #676
iamsahu wants to merge 3 commits into
feat/redemption-rewards-p1-stopped-earning-reportfrom
feat/redemption-rewards-p2-rate-marks

Conversation

@iamsahu

@iamsahu iamsahu commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

…stopped-earning demand

Description

This pull request introduces a new "stopped earning" mechanism to the redeem manager, enabling the protocol to mark portions of redeem demand as having stopped accruing rewards at a fixed rate when their backing principal exits the consensus layer. This is achieved by tracking "rate marks" and "request anchors," which together allow for more accurate and fair payout calculations, especially in the presence of delayed withdrawals and protocol upgrades. The changes also ensure backward compatibility for requests made before this upgrade.

The most important changes are:

Stopped Earning and Rate Mark Mechanism:

  • Added the reportStoppedEarning function to RedeemManagerV1, which marks redeem demand whose backing principal stopped earning, records a "rate mark" with the pre-report ETH/LsETH rate, and emits relevant events. [1] [2] [3]
  • Introduced RateMarkStack and RedeemRequestAnchor imports, storage, and accessors, along with new getter functions for retrieving rate mark and anchor details. [1] [2] [3] [4]

Payout Calculation Logic:

  • Implemented internal helpers (_rateMarkCursor, _settledHeight, _findRateMarkAtOrBefore, _sliceCap) to calculate the ETH payout cap for redeem requests, taking into account any rate marks that may raise the cap above the original request-time value.
  • Updated the redeem request claim logic to use the new anchor and rate mark data for post-upgrade requests, while maintaining legacy behavior for pre-upgrade requests. [1] [2] [3]

Oracle Reporting Integration:

  • Modified LibOracleReporting to value the stopped-earning principal at the pre-report rate, pass this data to the redeem manager, and ensure the stopped-earning report is always called before withdrawal settlement. [1] [2] [3]

These changes collectively add a robust mechanism for marking and handling stopped-earning principal, improving fairness and transparency for redeemers in the protocol.

Notice

  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?
  • Have you assigned this PR to yourself?
  • Have you added at least 1 reviewer?
  • Have you updated the official documentation?
  • Have you added sufficient documentation in your code?
  • Have you added relevant tests to the official test suite?

Pull Request Type

  • 💫 New Feature (Breaking Change)
  • 💫 New Feature (Non-breaking Change)
  • 🛠️ Bug fix (Non-breaking Change: Fixes an issue)
  • 🕹️ Chore (Non-breaking Change: Doc updates, pkg upgrades, typos, etc..)

Breaking changes (if applicable)

Testing

  • Have you tested this code with the official test suite?
  • Have you tested this code manually?

Manual tests (if applicable)

Additional comments

…stopped-earning demand

Consumes the phase 1 report field. Redeemers now keep the yield their stake earned while it
was still in the exit queue.

Mechanism. Payout stays min(settlement value, cap) — the clamp against the withdrawal
event's actual ETH at RedeemManager.1.sol:430 is untouched. What changes is the cap: it is
the request-time value of the matched slice, RAISED over any sub-range whose backing
principal has been reported as having crossed exit_epoch, to the pool rate of the report
that observed it. Three PRD requirements fall out of that shape rather than needing
separate code:

- FR1/AC2: a fill involving no exit produces no mark, so the slice sits in a mark gap and
  is capped at rate_at_request exactly as today. No on-chain "was this an exit?"
  classification is needed — which matters, because exit ETH and buffer ETH are fungible in
  BalanceToRedeem and the distinction is not recoverable at fill time.
- FR2/AC1+AC2: the cap is a ceiling, never a floor, so a redeemer still absorbs a loss
  between mark and settlement on the same terms as a holder who stayed.
- Section 6 non-goals: the cap stops rising at exit_epoch, so nothing accrues through the
  ~27h withdrawability delay or the 0-8d sweep tail.

RateMarkStack, not a loop over requests. Crediting each affected request during the report
would be a protocol halt vector: _requestRedeem enforces only amount != 0, so dust requests
let a later mark range span an unbounded loop inside the oracle report, and OOG there stops
reporting, which stops _reportWithdrawToRedeemManager, which halts every redemption. One
push per report is O(1) on the report path; the walk is deferred to claim time where the
claimant pays for their own span and cannot be charged for anyone else's.

The stack is deliberately NOT contiguous. Settlement can outrun marking, which advances the
settled height past the mark cursor and leaves a permanent gap — and gaps are meaningful,
they are exactly the LsETH paid at the request rate. So _performDichotomicResolution's
contiguity assumption does not apply and _findRateMarkAtOrBefore is a separate predecessor
search.

RedeemRequestAnchor is a side-car mapping, not new fields on RedeemRequest: the queue is a
raw-slot array with stride 5, so widening would shift every one of the 178 live mainnet
elements and break the ABI of getRedeemRequestDetails. It stores the exact
(lsETHAtRequest, ethAtRequest) pair rather than a rate because maxRedeemableEth is a
decrementing budget whose implied rate drifts upward after a partial claim below the request
rate (pinned in phase 0). A zero anchor means a pre-upgrade request and selects the original
path, which is also the launch cutover.

maxRedeemableEth's decrement is now saturating. It is checked arithmetic, and a marked
payout may legitimately exceed the request-time budget, so an unguarded subtraction would
revert the whole claimRedeemRequests call with Panic(0x11).

Call ordering: reportStoppedEarning runs after the rebase and fee mint (so the rate is final)
and BEFORE _reportWithdrawToRedeemManager (so demand settled in the same report is marked
before settlement burns its shares). It is not gated on slashing containment — containment
lengthens a queued redeemer's wait while leaving settlement priced at the depressed rate, so
suspending accrual there would penalise them twice — and it must not be skipped at all,
since the cumulative field is already persisted by then.

Cost: RedeemManagerV1 9,167 -> 11,108 runtime bytes (13,468 free). RiverV1 unchanged at
23,068, since the new call sits in the delegatecalled library.

Tests: 10 new, covering unmarked/fully-marked/partially-marked/blended, accrual capped at
the mark rate, downside passthrough, marks accumulating across reports, clamping to markable
demand, marks skipping settled demand, a slice positioned before the first mark, and the
legacy no-anchor path. Full suite 1070 pass; heavy fuzzing (exact exceeding-buffer deltas
and total-supply reconstruction) clean.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3736d712-361f-4ad5-97c6-9c97a2d61af7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

…port rate (#678)

reportStoppedEarning derived the locked rate from live callbacks into River
(sharesFromUnderlyingBalance / underlyingBalanceFromShares), and LibOracleReporting
calls it after LastConsensusLayerReport.set and after the _onEarnings fee mint. The
mark therefore captured the closing rate of the interval, crediting the redeemer with
one interval of rewards more than intended — the very interval during which their
backing principal stopped earning.

River now values the delta at its pre-report snapshot, taken one line before
_pullCLFunds, where the conversion views still return the previous interval's answer.
Past that point the asset balance is transiently double counted: _pullCLFunds credits
exited and skimmed eth to the buffers while the stored report still counts the same eth
in validatorsBalance.

The valuation travels to the redeem manager as data rather than being read there, so the
anchor cannot silently drift if setConsensusLayerData is reordered. reportStoppedEarning
takes the amount in both eth and LsETH; their ratio is the rate the mark locks. (eth,
LsETH) rather than a scaled rate keeps the shape a RateMark already stores, matching how
RedeemRequestAnchor holds (ethAtRequest, lsETHAtRequest), and avoids the extra rounding a
1e18-scaled scalar would compound. Marking the whole reported amount now needs no
conversion at all; only the clamped case divides, scaling the eth leg in the same
proportion so the locked rate survives the clamp.

The call site keeps its position: running before _reportWithdrawToRedeemManager, so that
demand settled in the same report is marked before its shares are burned, is unchanged
and still load bearing.

Tests: the existing redeem manager call sites are untouched — the mock helper derives the
LsETH leg from its own rate, reproducing the previous math exactly. Four new tests pin the
behaviour and all four fail against the old live-rate implementation: two at the redeem
manager (the pair overrides the live rate; the clamp preserves the reported rate) and two
end to end on River, with and without slashing containment.
@iamsahu iamsahu changed the title feat(redemption-rewards): phase 2 — raise the redeem payout cap over … feat(redemption-rewards): phase 2 — raise the redeem payout cap over Aug 17, 2026
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.

1 participant