Skip to content

OToken Vault Loss Socialization#2934

Open
shahthepro wants to merge 3 commits into
masterfrom
shah/vault-loss-socialization
Open

OToken Vault Loss Socialization#2934
shahthepro wants to merge 3 commits into
masterfrom
shah/vault-loss-socialization

Conversation

@shahthepro

@shahthepro shahthepro commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

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:

effective supply = live OToken supply + outstanding withdrawal requests
backing ratio = gross Vault assets / effective supply

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:

paid amount = requested amount × min(1, current backing ratio)

Consequently:

  • queued withdrawals remain exposed to Vault losses;
  • losses are shared proportionally with live OToken holders;
  • queued withdrawals do not receive upside when the Vault is over-backed;
  • all requests in a batch use the same backing ratio;
  • claim events report both the nominal request and actual amount paid.

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:

queue value = outstanding queue × min(1, backing ratio)
total value = gross assets − queue value

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:

mintTolerance = 1%

Mints remain 1:1 while permitted. mintForStrategy() is intentionally not subject to this gate.

Emergency circuit breaker

maxSupplyDiff is 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:

maxSupplyDiff = 20%

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:

grossAssets()
effectiveSupply()
backingRatio()
mintTolerance()
setMintTolerance(uint256)

WithdrawalClaimed now includes the asset amount actually paid:

WithdrawalClaimed(
    address indexed withdrawer,
    uint256 indexed requestId,
    uint256 nominalAmount,
    uint256 paidAmount
)

Deployment scope

The upgrade and configuration are applied to:

  • OUSD Vault on Ethereum
  • OETH Vault on Ethereum
  • superOETHb Vault on Base

Code Change Checklist

To be completed before internal review begins:

  • The contract code is complete
  • Executable deployment file
  • Fork tests that test after the deployment file runs
  • Unit tests *if needed
  • The owner has done a full checklist review of the code + tests

Internal review:

  • Two approvals by internal reviewers

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 45.15%. Comparing base (c52692d) to head (865d62f).
⚠️ Report is 5 commits behind head on master.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sparrowDom sparrowDom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let some comments inline

Comment thread contracts/contracts/vault/VaultCore.sol
Comment thread contracts/contracts/vault/VaultCore.sol Outdated

/**
* @dev Gross vault + strategy asset value, scaled to 18 decimals. Unlike
* `_checkBalance` this is not netted against the withdrawal queue and is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could also say: ... Unlike _checkBalance this ignores the withdrawal queue.

Comment thread contracts/contracts/vault/VaultCore.sol Outdated

// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should probably be some sensible bounding here:

  • require(_mintTolerance <= 1e18) is probably a must
  • require(_mintTolerance >= 0.95e18) might also make sense

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets also add a fork test for this confirming that the deploy has set the expected setting

@sparrowDom sparrowDom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 minor comment

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the gte check required? Can the ratio really only drift in 1 direction

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread contracts/contracts/vault/VaultCore.sol Outdated
* (which nets the queue) and `_grossAssets` (which does not).
*/
function _rawAssetBalance() internal view returns (uint256 balance) {
balance = IERC20(asset).balanceOf(address(this));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is probably unnecessary?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the things I have in my global Claude.md file is to not change the adjecent code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could remain a named return param

uint256 indexed _requestId,
uint256 _amount
uint256 _amount,
uint256 _paidAmount

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we will need to notify @apexearth once this is deployed that the event signatures have changed

@sparrowDom sparrowDom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@naddison36 naddison36 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
);

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.

3 participants