Reference: Model A loss-sharing for ARM withdrawal queue#219
Closed
clement-ux wants to merge 6 commits into
Closed
Reference: Model A loss-sharing for ARM withdrawal queue#219clement-ux wants to merge 6 commits into
clement-ux wants to merge 6 commits into
Conversation
Reference implementation of pro-rata loss sharing as discussed in the "ARM: How to handle losses" doc. Demonstrates an alternative to the current hybrid (which is broken — see audit finding on the FIFO deadlock after losses). Storage layout is preserved but the semantics of three fields change from asset-denominated to share-denominated: withdrawsQueued, withdrawsClaimed, and WithdrawalRequest.queued. An upgrade would only be safe with a fully drained queue. - requestRedeem escrows shares (_transfer to the contract) instead of burning them. Queued shares stay in totalSupply() so the share price reflects all outstanding claims. - claimRedeem pays convertToAssets(request.shares) at claim time, burns the escrowed shares, and increments withdrawsClaimed by shares. Gains and losses between request and claim are absorbed pro-rata. - FIFO gate compares convertToAssets(pendingShares) to claimable(), keeping the gate and the payout in the same unit and at the same price. Removes the phantom-liquidity deadlock. - claimable() becomes a snapshot (balance + maxWithdraw), without the cumulative withdrawsClaimed term. - _availableAssets() is now gross; the queued shares are already in totalSupply, so no subtraction. - _requireLiquidityAvailable, _ensureLiquidityAvailableForSwap, getReserves, _allocate compute reserved liquidity dynamically as convertToAssets(outstandingShares). Tests are not updated in this commit — many will fail (queue unit and event semantics changed, payout behavior differs).
In the previous Model A draft, escrowed shares stayed in totalSupply()
during the wait, which gave the redeemer full upside until they
chose to claim. That created a free timing option: the redeemer could
park their request indefinitely and claim at the most favorable share
price.
Cap the payout at request.assets (snapshotted at request time) while
still allowing the loss-adjusted current value to flow through when it
is lower. Net effect on the redeemer:
- Loss case: payout = convertToAssets(shares) at claim time. Loss is
absorbed pro-rata exactly like before.
- Gain case: payout = request.assets. Upside accrued during the wait
is redistributed to the remaining LPs (early-bird-style penalty).
The FIFO gate still uses convertToAssets(pendingShares) and is now
conservative on the gain side: it overestimates the liquidity needed
(actual payouts are capped) but never causes a deadlock.
The previous Model A draft made every buy-side swap call convertToAssets()
which on a Morpho-backed ARM cascades into Morpho.convertToAssets() — an
expensive market-wide read that defeated the cheap-swap value prop.
Add a parallel pair of cumulative counters in assets units:
- withdrawsQueuedAssets += request.assets at requestRedeem
- withdrawsClaimedAssets += request.assets at claimRedeem
(incremented by request.assets, NOT by the actual capped payout, so
that the difference stays an upper bound on the queue's liability)
The swap-side reservation (_ensureLiquidityAvailableForSwap,
_requireLiquidityAvailable, getReserves, _allocate) now uses the cheap
direct subtraction:
reservedAssets = withdrawsQueuedAssets - withdrawsClaimedAssets
This is identical in cost to Model B (no external calls beyond balanceOf)
and correct because each payout is min(request.assets, convertToAssets(shares))
≤ request.assets by construction. The reservation is a true upper bound,
slightly conservative in loss scenarios but never under-reserves.
The FIFO gate at claim time still uses convertToAssets(pendingShares)
(shares-denominated) to keep the audit deadlock fixed. That call only
runs on the rare claim path, not on every swap.
Storage cost: 1 extra slot (two uint128 packed). _gap reduced from 35 to 34.
… ones Naming alignment with the original deployed contract: withdrawsQueued and withdrawsClaimed keep their original asset-denominated semantics (the upper-bound used by the cheap swap-side reservation). The shares-denominated counters added for the FIFO gate get the explicit suffix: withdrawsQueuedShares, withdrawsClaimedShares. Storage order also swapped so the original two slots stay at their deployed positions (assets), with the new shares slots placed after feesAccrued. Makes the upgrade path simpler: the legacy slots keep their meaning on a deployed contract, only the new slots and the WithdrawalRequest.queued reinterpretation require queue drainage. No behavior change.
Roll back rewordings, doc-comment expansions, and variable renames (outstandingWithdrawals, etc.) that didn't reflect any behavior change. Storage slots, function bodies, and the model semantics are unchanged. The diff vs the parent branch is now restricted to the actual changes required by the loss-sharing model: - new withdrawsQueuedShares / withdrawsClaimedShares storage - WithdrawalRequest.queued reinterpretation (assets -> shares) - requestRedeem escrows instead of burning - claimRedeem FIFO gate uses convertToAssets(pendingShares) - claimRedeem caps payout at request.assets - claimable() drops the cumulative withdrawsClaimed term - _availableAssets() returns gross (no subtraction for the queue) - _allocate() subtracts outstandingWithdrawals locally for the buffer No behavior change vs the previous commit.
naddison36
reviewed
May 6, 2026
|
|
||
| // The amount of liquidity assets, eg WETH, that is still to be claimed in the withdrawal queue | ||
| // The amount of liquidity assets, eg WETH, reserved for the withdrawal queue (upper bound) | ||
| outstandingWithdrawals = withdrawsQueued - withdrawsClaimed; |
Collaborator
There was a problem hiding this comment.
I think we can also remove outstandingWithdrawals from _availableAssets's return params.
outstandingWithdrawals can be calculated in _allocate instead
Contributor
Author
There was a problem hiding this comment.
Yes, completely agree with you 💯
| /// Compared in `claimRedeem` against `convertToAssets(pendingShares)`. | ||
| function claimable() public view returns (uint256 claimableAmount) { | ||
| claimableAmount = withdrawsClaimed + IERC20(liquidityAsset).balanceOf(address(this)); | ||
| claimableAmount = IERC20(liquidityAsset).balanceOf(address(this)); |
Collaborator
There was a problem hiding this comment.
this is a functional change to how claimable works. it was a way to work out if a request could be claimed.
…ment/model-a-loss-sharing
naddison36
reviewed
May 8, 2026
| uint256 public crossPrice; | ||
|
|
||
| /// @notice Cumulative total of all withdrawal requests including the ones that have already been claimed. | ||
| uint128 public withdrawsQueued; |
Collaborator
There was a problem hiding this comment.
I think withdrawsQueued and withdrawsClaimed could be collapsed into reservedWithdrawLiquidity with the addition of withdrawsQueuedShares and withdrawsClaimedShares
Contributor
Author
There was a problem hiding this comment.
I like the idea yes, but I don't know how to manage this:
// Cumulative assets queued (upper bound on liability), used for the swap-side reservation
withdrawsQueued = SafeCast.toUint128(withdrawsQueued + assets);
Collaborator
|
Closing in preference for #223 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
A recent audit finding ([OZ AI audit](/33e84d46f53c80b49635fb2609928432?pvs=25#34584d46f53c808a83acf270cb3ed2b4)) shows that the ARM withdrawal queue can become permanently unclaimable after a loss, because the FIFO gate is denominated in pre-loss assets while the payout is reduced by min(request.assets, convertToAssets(request.shares)). Before patching the bug, we need to step back and decide which loss-socialization model the ARM should follow. There are exactly two coherent options.
Model A — Pro-rata loss sharing
totalSupply().min(request.assets, convertToAssets(shares)): capped at the request-time snapshot, reduced if a loss happened.Model B — Senior debt queue
requestRedeemlocks in a fixed asset amount (request.assets).requestRedeem(bank run). Latecomers eat the loss in full.The current ARM withdrawal queue uses a hybrid loss-handling model that mixes Model B accounting (burn at request, lock in
request.assets) with a Model A-style payout reduction (min(request.assets, convertToAssets(request.shares))). This combination is the root cause of the audit finding "Withdrawal queue can become unclaimable after losses": the FIFO gate is denominated in pre-loss assets while the payout has been reduced, andwithdrawsClaimedadvances by the pre-loss amount — leaving a phantom liquidity gap that can permanently deadlock the queue.Full discussion: https://www.notion.so/originprotocol/ARM-How-to-handle-losses-35784d46f53c80e18d70d78cab6c1d52
What this PR is
This PR is a reference implementation of Model A (pro-rata loss sharing via escrow + claim-time pricing). It is meant to make the alternative concrete so the team can compare it side by side with Model B before deciding.
Model B is the recommended fix (see the doc). This PR is not the proposed merge path — it exists to ground the discussion.
Behavioral changes
request.assets— that field becomes a request-time estimate only.Code changes (single file:
src/contracts/AbstractARM.sol)withdrawsQueued,withdrawsClaimed, andWithdrawalRequest.queuedare re-denominated from assets to shares. Storage layout preserved; meaning changed. ⚠ Upgrading a live deployment requires draining the queue first (non-zero pending values would be reinterpreted incorrectly).requestRedeemescrows shares via_transfer(msg.sender, address(this), shares)instead of_burn. Shares stay intotalSupply()so the share price reflects all outstanding claims.claimRedeempaysconvertToAssets(request.shares)at claim time, burns the escrowed shares right after, and incrementswithdrawsClaimedbyrequest.shares.convertToAssets(pendingShares) <= claimable(). Same unit, same price, no phantom liquidity gap.claimable()becomes a liquidity snapshot:balance + maxWithdraw(activeMarket). The cumulativewithdrawsClaimedterm is gone._availableAssets()returns gross assets (no subtraction for outstanding withdrawals). Outstanding shares are now part oftotalSupply(), so the share price already accounts for them._requireLiquidityAvailable,_ensureLiquidityAvailableForSwap,getReserves,_allocatecompute reserved liquidity asconvertToAssets(outstandingShares)at the current share price.Trade-off vs Model B
The main reason not to merge this PR is gas:
_ensureLiquidityAvailableForSwapnow callsconvertToAssets→totalAssets()→_availableAssets()→ on a Morpho-backed ARM,IERC4626(morpho).convertToAssets(allShares).convertToAssetsis expensive (full market state read, interest accrual, several storage slots).balanceOf.For an ARM whose value proposition is cheap swaps, this is a meaningful recurring overhead.
Tests
Tests are intentionally not updated. Many will fail because:
withdrawsQueued/withdrawsClaimed/request.queuedchange unit (asset → share)RedeemRequestedevent'squeuedfield changes unitmin)If we ever take this direction, tests would be updated in a follow-up.
Status
Reference / discussion. Do not merge. The proposed fix for the audit finding is the minimal Model B change (drop the
min(...)inclaimRedeemso the FIFO gate and the payout become consistent again).