OToken Vault Loss Socialization#2934
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2934 +/- ##
==========================================
+ Coverage 44.63% 45.15% +0.52%
==========================================
Files 110 110
Lines 4920 4956 +36
Branches 1362 1366 +4
==========================================
+ Hits 2196 2238 +42
+ Misses 2721 2715 -6
Partials 3 3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
|
||
| /** | ||
| * @dev Gross vault + strategy asset value, scaled to 18 decimals. Unlike | ||
| * `_checkBalance` this is not netted against the withdrawal queue and is |
There was a problem hiding this comment.
nit: Could also say: ... Unlike _checkBalance this ignores the withdrawal queue.
|
|
||
| // Nominal amount is emitted (the full size of the settled request); the | ||
| // asset actually transferred is the haircut `amount` return value. | ||
| emit WithdrawalClaimed(msg.sender, requestId, request.amount); |
There was a problem hiding this comment.
Might make sense to add another field to WithdrawalClaimed that indicates the actual paid out amount - which in case of socialized loss would be different from the request.amount
| "Claim delay not met" | ||
| ); | ||
| // If there isn't enough reserved liquidity in the queue to claim | ||
| require(request.queued <= queue.claimable, "Queue pending liquidity"); |
There was a problem hiding this comment.
🔴 request.queued is in nominal units while the queue.claimable is in actual balances -> which in case of socialization losses is smaller than queued. In case users would decide to empty the whole protocol, this would trigger preventing the full withdrawals.
Would probably also be cool to have a test for this:
- 3 users with 10 WETH in the vault
- Vault experiences a loss cutting the ratio to 0.9
- see if all 3 can claim the withdrawal
There was a problem hiding this comment.
This is a good find. This is actually a byproduct of one of the decision decisions: https://app.notion.com/p/originprotocol/OToken-Vault-Loss-Socialization-39784d46f53c8020bea8ef588ffde2b9?source=copy_link#39784d46f53c80028347c6aa8b95722b
Let me brainstorm with Claude to see if it can come up with a better solution without complicating much
| * still permits user mints. 18 decimals. eg 0.01e18 = 1%. | ||
| */ | ||
| function setMintTolerance(uint256 _mintTolerance) external onlyGovernor { | ||
| mintTolerance = _mintTolerance; |
There was a problem hiding this comment.
There should probably be some sensible bounding here:
require(_mintTolerance <= 1e18)is probably a mustrequire(_mintTolerance >= 0.95e18)might also make sense
There was a problem hiding this comment.
Could add bounds here. But I'm not sure if that'd end up causing issues for us in the future. If we set it to 5% max and if for any reason we need to allow a higher tolerance, that'll start causing issues for us. I can keep a higher upper bound though (something like 50%, either way this is onlyGovernor action, so it already goes through a proposal). Lower bound seems unnecessary since 0% tolerance is the best case
There was a problem hiding this comment.
Agreed, the upper bound might be the only one sensible
| * @notice Sets the maximum shortfall in the backing ratio below 1.0 that | ||
| * still permits user mints. 18 decimals. eg 0.01e18 = 1%. | ||
| */ | ||
| function setMintTolerance(uint256 _mintTolerance) external onlyGovernor { |
There was a problem hiding this comment.
Lets also add a fork test for this confirming that the deploy has set the expected setting
| const queueAfter = await vault.withdrawalQueueMetadata(); | ||
| expect(queueAfter.claimed).to.equal(queueBefore.claimed.add(nominal6)); | ||
| // ratio is ~invariant; a tiny upward drift from 6-dp rounding favours the vault | ||
| expect(await vault.backingRatio()).to.be.gte(ratio); |
There was a problem hiding this comment.
is the gte check required? Can the ratio really only drift in 1 direction
There was a problem hiding this comment.
Interestingly, it's always one directional for 18 decimal. But for OUSD which has USDC w/ 6 decimals, there seems to be an edge where it can lower by 1 wei or so. Will update it
| * (which nets the queue) and `_grossAssets` (which does not). | ||
| */ | ||
| function _rawAssetBalance() internal view returns (uint256 balance) { | ||
| balance = IERC20(asset).balanceOf(address(this)); |
There was a problem hiding this comment.
This change is probably unnecessary?
There was a problem hiding this comment.
One of the things I have in my global Claude.md file is to not change the adjecent code.
There was a problem hiding this comment.
I explicitly asked Claude to do this change (and in other functions that this PR added) because as a security precaution we decided not to use named return variables
| function _claimWithdrawal(uint256 requestId, uint256 ratio) | ||
| internal | ||
| returns (uint256 amount) | ||
| returns (uint256) |
There was a problem hiding this comment.
nit: this could remain a named return param
| uint256 indexed _requestId, | ||
| uint256 _amount | ||
| uint256 _amount, | ||
| uint256 _paidAmount |
There was a problem hiding this comment.
nit: we will need to notify @apexearth once this is deployed that the event signatures have changed
naddison36
left a comment
There was a problem hiding this comment.
The mintTolerance feels like an over complication to me. New minters can still have significant losses with a 1% mint tolerance.
Here's a contrived example with $10 million in assets and a 0.991 backing ratio:
Effective supply: $10.09m
Existing deficit: ~$90.8k
A user can deposit $10 million because the Vault is within the 1% mintTolerance. They receive 10 million OTokens at 1:1, but after minting those tokens are worth only about $9.955 million.
Immediate minter loss: ~$45.2k
That value is transferred to existing holders and queued withdrawals. A simpler rule—blocking all mints whenever backingRatio < 1—avoids this value transfer and removes the need for mintTolerance.
I'd prefer to go with a simpler mint gate of
require(
_grossAssets() >= _effectiveSupply(),
"Vault under-backed"
);
Functional Changes
This PR introduces proportional loss socialization across live OToken holders and queued withdrawal requests.
Effective supply and backing ratio
Vault solvency is now measured using:
This prevents burned OTokens in the withdrawal queue from disappearing from the Vault’s solvency calculations.
Withdrawal loss socialization
Withdrawal requests continue to record a nominal OToken amount. At claim time, the amount paid is:
Consequently:
Queue-liquidity accounting now reserves only the haircut-adjusted asset amount while continuing to advance the FIFO frontier in nominal units.
Socialized Vault value
When the Vault is impaired, outstanding withdrawals are deducted from gross assets at their socialized value rather than their original fixed claim:
This avoids treating queued withdrawals as senior, fixed-value liabilities.
Mint protection
User mints are rejected when the pre-mint backing shortfall exceeds the configurable
mintTolerance.The initial proposed configuration is:
Mints remain 1:1 while permitted.
mintForStrategy()is intentionally not subject to this gate.Emergency circuit breaker
maxSupplyDiffis repurposed as a symmetric backing-ratio circuit breaker. Redemption and withdrawal operations revert when the backing ratio differs from 1.0 by more than the configured threshold.The initial proposed configuration is:
This is emergency protection; proportional claim haircuts perform the underlying loss accounting.
New Vault interfaces
The PR adds the following external views and governance configuration:
WithdrawalClaimednow includes the asset amount actually paid:Deployment scope
The upgrade and configuration are applied to:
Code Change Checklist
To be completed before internal review begins:
Internal review: