Skip to content

Add OUSD V3 and OETHb migration contracts#2909

Open
shahthepro wants to merge 30 commits into
masterfrom
shah/ousd-v3
Open

Add OUSD V3 and OETHb migration contracts#2909
shahthepro wants to merge 30 commits into
masterfrom
shah/ousd-v3

Conversation

@shahthepro

@shahthepro shahthepro commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

OUSD V3 cross-chain strategy pair (Master/Remote) with bridge-agnostic adapter family (CCIP, CCTP V2, Superbridge), plus Sepolia ⇄ Base Sepolia testnet harness. Foundation for OETHb Phase 1 migration and future OUSD V3 L2 deployment rollouts.

Changes

  • Master + Remote strategies with separated yield channel (nonce-gated) and bridge channel (nonceless, replay-protected).
  • Adapters (CCIPAdapter, CCTPAdapter, SuperbridgeAdapter) on a shared AbstractAdapter base — multi-tenant whitelist, per-lane config, governor-settable maxTransferAmount cap.
  • CREATE3 proxies (BridgeAdapterProxy, CrossChainStrategyProxy) so paired chains share addresses (required for the transportSender == address(this) peer-parity check).
  • CCTPAdapter.relay() manually parses CCTP V2 burn body (works on V2.0 and V2.1, doesn't depend on V2.1-only auto-callback).
  • Sepolia + Base Sepolia network registration end-to-end (hardhat.config.js, helpers, addresses, scripts, fork-test.sh). Testnet deploy scripts with mock vault/token.
  • Production OETHb deploys (deploy/base/100-104_*, deploy/mainnet/210-211_*) with CREATE3 adapter proxies.
  • FLOWS.md (sequence diagrams)

@shahthepro shahthepro changed the title [WIP] Add OUSD V3 contracts Add OUSD V3 and OETHb migration contracts Jun 8, 2026
@shahthepro shahthepro marked this pull request as ready for review June 8, 2026 10:06
@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.06278% with 160 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.37%. Comparing base (44312e3) to head (4b7a891).

Files with missing lines Patch % Lines
...racts/strategies/BridgedWOETHMigrationStrategy.sol 0.00% 46 Missing ⚠️
...egies/crosschainV3/adapters/SuperbridgeAdapter.sol 55.22% 30 Missing ⚠️
...rategies/crosschainV3/adapters/AbstractAdapter.sol 83.00% 17 Missing ⚠️
...s/strategies/crosschainV3/adapters/CCIPAdapter.sol 54.54% 15 Missing ⚠️
...s/strategies/crosschainV3/adapters/CCTPAdapter.sol 88.17% 11 Missing ⚠️
.../strategies/crosschainV3/MasterWOTokenStrategy.sol 95.37% 8 Missing ⚠️
...ategies/crosschainV3/libraries/NativeFeeHelper.sol 0.00% 8 Missing ⚠️
contracts/contracts/utils/BytesHelper.sol 0.00% 8 Missing ⚠️
...gies/crosschainV3/AbstractCrossChainV3Strategy.sol 87.50% 7 Missing ⚠️
...trategies/crosschainV3/AbstractWOTokenStrategy.sol 94.31% 5 Missing ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2909      +/-   ##
==========================================
+ Coverage   44.63%   50.37%   +5.74%     
==========================================
  Files         110      124      +14     
  Lines        4920     5812     +892     
  Branches     1362     1640     +278     
==========================================
+ Hits         2196     2928     +732     
- Misses       2721     2881     +160     
  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.

@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.

my first set of comments on the design docs

participant SuperBase as SuperbridgeAdapter (Base, Master inbound)

Note over Master: state: lastYieldNonce=N
Vault->>Master: deposit(bridgeAsset, X)

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.

I think we need to show how the WETH gets to the strategy as it's not part of deposit. Can we add a step before this showing the Vault transfers the WETH to the strategy

Master->>Master: approve adapter for X
Master->>Adapter: sendMessageAndTokens(WETH, X, payload[DEPOSIT, N+1, ""])
Note over Master,Adapter: _send (userFunded=false): pool funds CCIP fee from<br/>address(this).balance. quoteFee returns (fee, native, true).
Adapter->>Adapter: pull WETH, build CCIP message

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.

changing pull WETH to transfer WETH from strategy to adapter is clearer. This can be on multiple lines

Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, msg)
Bridge-->>AdapterEth: ccipReceive (DON pushes)
AdapterEth->>AdapterEth: _validateInbound:<br/>transportSender == address(this) (peer parity)<br/>sourceChain == BASE_SELECTOR<br/>authorised[Remote] == true<br/>!cfg.paused
AdapterEth->>Remote: receiveMessage(Remote, WETH, X, payload)

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.

Its not clear how the WETH ends up in the Remote Strategy. Does receiveMessage do a transfer of WETH to the Remote Strategy?

sequenceDiagram
autonumber
participant Vault as L2 Vault
participant Master

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.

I'm make it clear this is the Master Strategy

participant Adapter as CCIPAdapter (Base, Master outbound)
participant Bridge as CCIP DON
participant AdapterEth as CCIPAdapter (Eth, Remote inbound)
participant Remote

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.

I'm make it clear this is the Remote Strategy

AdapterEth->>AdapterEth: _validateInbound:<br/>transportSender == address(this) (peer parity)<br/>sourceChain == BASE_SELECTOR<br/>authorised[Remote] == true<br/>!cfg.paused
AdapterEth->>Remote: receiveMessage(Remote, WETH, X, payload)
Remote->>Remote: unpackPayload → (DEPOSIT, N+1, "")
Remote->>OEV: mint(X) [pulls WETH]

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.

I'd make it clear where the WETH is being pulled from.
I'd add a comment or step to the OETH Vault (Ethereum) lifeline "transfer WETH from Remote Strategy".

wOETH-->>Remote: shares minted
Remote->>Remote: yieldBaseline = _viewCheckBalance()
Remote->>SuperEth: sendMessage(payload[DEPOSIT_ACK, N+1, abi.encode(yieldBaseline)])
Note over Remote,SuperEth: Remote's outbound = SuperbridgeAdapter on Eth.<br/>Message-only path goes via CCIP under the hood<br/>(no canonical leg). Pool funds the fee.

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.

this looks weird. Why not send a CCIP message using the CCIPAdapter?

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.

We have two Adapter for OETHb. CCIPAdapter and SuperBridgeAdapter. CCIPAdapter uses CCIP for both tokens and messages. SuperBridgeAdapter is a hybrid one, it uses SuperBridge for tokens and CCIP for messages, it uses the split-delivery mechanism. Not atomic delivery like CCIPAdapter.

One of the design decision was to have the adapters be composable and easy to change/replace. If we feel the SuperBridge adapter is too complicated and it's not worth saving the CCIP fee, we could just change outbound/inbound adapters to CCIP and it'll just work (but with a fee). So each Adapter knows how to send messages and tokens and how to handle them on the receiving side.

I'll update the note so that this is more clear.

- `pendingDepositAmount: 0 → X` (counts in `checkBalance` so vault doesn't see backing
disappear during the bridge round trip)
- WETH allowance to `outboundAdapter`: `0 → X`
- `Master.WETH balance: X → 0` (pulled by adapter)

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.

I'd add a separate line showing the CCIPAdaptor's WETH balance has increased

mid-flight = X (pendingDepositAmount) + B (stale remoteStrategyBalance), post-ack =
yieldBaseline ≈ B + X.

### OUSD V3 differences

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.

It'd be nice to have a OUSD v3 sequence diagram rather than just a list of differences.

L2 OETHb vault │
│ │
▼ │
┌─────────────┐ CCIPAdapter outbound ┌─────────────┐

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.

I find this diagram confusing as the Base side has the inbound and output Adapters as arrows and the Ethereum side has an Adatper as a box.

SuperBase->>SuperBase: processStoredMessage if needed (split fin.)
SuperBase->>Master: receiveMessage(Master, WETH, claimed, payload)
Master->>Master: _processWithdrawClaimAck success:<br/>_markYieldNonceProcessed(N+2)<br/>pendingWithdrawalAmount = 0<br/>remoteStrategyBalance = yieldBaseline
Master->>Vault: transfer(WETH, claimed)

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.

I assume this is show WETH is transferred to the Vault rather than the Master Strategy calling transfer(WETH, claimed) on the Vault.
You can use a comment to avoid showing the transfer call to the WETH contract.

What's the claimed param?

sequenceDiagram
autonumber
participant Vault as L2 Vault
participant Master

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.

nit: I'd call it Master Strategy

participant Adapter as CCIPAdapter (Base)
participant Bridge as CCIP DON
participant AdapterEth as CCIPAdapter (Eth, Master→Remote inbound)
participant Remote

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.

nit: I'd call it Remove Strategy

autonumber
participant Vault as L2 Vault
participant Master
participant Op as Operator

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.

I'd change participant to actor to show it's an EOA, not a contract

AdapterEth->>Remote: receiveMessage(...)
Remote->>Remote: _opportunisticClaim()
Remote->>OEV: claimWithdrawal(requestId)
OEV-->>Remote: bridgeAsset (claimed)

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.

nit: the return is just the bridgeAsset. There is no claimed in the return.
If you want to show claimed state is changed to true then add to the Note over Remote

- **`processStoredMessage(target)`** on the split-delivery adapter — once
both CCIP envelope and canonical ETH have landed, anyone can finalise.

### OUSD V3 differences

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.

It'd be nice if there was a separate sequence diagram for OUSD v3

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 get the idea but I don't think it'd be worth it for all 7 flows. Because for 5 of them, the only change is the adapter being used. I could add new ones for deposit and withdrawal for OUSD and that'll cover how the Atomic adapter works for OUSD (which is the primary difference here)

@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.

Left some comments

Comment thread contracts/contracts/strategies/crosschainV3/README.md
Comment thread contracts/contracts/strategies/crosschainV3/DESIGN.md
Comment thread contracts/contracts/strategies/crosschainV3/FLOWS.md
Comment thread contracts/contracts/strategies/crosschainV3/FLOWS.md
internal
{
uint256 amount = CrossChainV3Helper.decodeUint256(payload);
require(amount > 0, "Remote: zero withdraw");

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.

🔴 if this ever reverts the strategy's yield channel is bricked.

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.

That's a good point but we already have these checks in Master strategy as well. So any request for zero amount would fail at the master and wouldn't increase the nonce. The checks here just replicate what's already in master to be consistent. It'd only be an issue if we change Master strategy code but leave these checks on Remote strategy.

Also, withdrawAll does a no-op return if nothing can be withdrawn (so, it won't revert either)

uint256 sharesNeeded = IERC4626(woToken).previewWithdraw(oTokenAmount);
require(
IERC20(woToken).balanceOf(address(this)) >= sharesNeeded,
"Remote: insufficient shares"

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.

🔴 withdrawAll/withdraw on the MasterWOTokenStrategy gate ignores bridgeAdjustment, can over-request Remote's shares and brick the yield channel

withdrawAll() itself doesn't revert — it dispatches a WITHDRAW_REQUEST that reverts later on Remote during delivery.

Root cause: the amount/gate use remoteStrategyBalance alone, but Remote's actually-unwrappable shares are worth remoteStrategyBalance + bridgeAdjustment. checkBalance and availableBridgeLiquidity both correctly add bridgeAdjustment; these two paths don't:

  • MasterWOTokenStrategy.sol:202amount = _toAsset(remoteStrategyBalance)
  • MasterWOTokenStrategy.sol:365-368 — gate _amount <= _toAsset(remoteStrategyBalance)

After any net BRIDGE_OUT, bridgeAdjustment < 0 and Remote holds fewer shares than remoteStrategyBalance implies A user can set this up via permissionless bridgeOTokenToPeer.

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 catch, will change it

uint256 srcTimestamp = CrossChainV3Helper.decodeUint256(payload);
bytes memory ackPayload = CrossChainV3Helper
.encodeBalanceCheckResponsePayload(
_yieldOnlyBaseline(),

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.

🟠 the _yieldOnlyBaseline can revert and potentially brick yield channel

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.

in theory, yes it could. But it'd only be a problem if Master ever requests more than it can withdraw

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.

Claude helped find a case where a withdrawAll followed by BRIDGE_IN/BRIDGE_OUT could cause this function to revert and block yield channel:

Let B = _viewCheckBalance(), A = bridgeAdjustment. The invariant B − A ≥ 0 holds because each bridge op moves both by net — but the 4626 rounds against the strategy, so each op loses ~1 wei:

  • BRIDGE_IN (:493-497): deposits the full _amount (floor shares), A += net; B values via previewRedeem (floor), and previewRedeem(previewDeposit(x)) ≤ x.
  • BRIDGE_OUT (:500-513): burns previewWithdraw shares (ceil, :505) to ship exactly amount, so B drops slightly more than A.

The only cushion is yield headroom D (deposited principal + bridgeFeeBps fees; default bridgeFeeBps = 0 → no cushion). Trigger: withdrawAll drives remoteStrategyBalance (= B − A) to ~0, then any subsequent bridge op pushes B − A a few wei negative and the next balance-check / deposit / settlement calls _yieldOnlyBaseline() and reverts → the yield channel halts (self-inflicted freeze).

Not caused by: withdrawals (gated at B − A, :365-368, and remoteStrategyBalance only lags low); settlement (symmetric subtract converges); donations (the wrapper ignores OToken transfers); cross-chain ordering (A and B move atomically on Remote).

What if we return 0 an not revert within _yieldOnlyBaseline.

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 ran some tests with Claude. Turns out this can happen due to wei rounding. But still seems like an extreme edge case. I'll change it to clamp to zero instead of reverting, that should be simple and fix this

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.

Here's the changeset: 4b7a891

@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.

some nit comments on storage.

Comment thread contracts/contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol Outdated
Comment thread contracts/contracts/strategies/crosschainV3/AbstractWOTokenStrategy.sol Outdated
Comment thread contracts/contracts/strategies/crosschainV3/adapters/AbstractAdapter.sol Outdated

@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.

Next round of comments

* Base. Adds the ability to ship wOETH to the V3 Master/Remote pair via CCIP, while
* retaining V1's local deposit/withdraw + oracle pipeline (inherited unchanged).
*
* Storage carries forward V1's two slot-0 fields (lastOraclePrice, maxPriceDiffBps)

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.

lastOraclePrice and maxPriceDiffBps are in slot 157, not 0, but that doesn't matter. The storage slots don't change as only new fields are added

function bridgeToRemote(uint256 _amount)
external
payable
onlyOperatorGovernorOrStrategist

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.

this feels like something that only the Governor or Strategist should be doing.

I guess it depends on how much will be bridged each time. With 9,608 currently in the strategy, if the max is 1,000 ETH, then that's only 10 bridges which is reasonable to do with the Strategist.
If the max was 500 or less then I'd agree an operator EOA would be needed.

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.

The problem with having Governor or Strategist do it is that we would need enough capital and time to do it. We will have to borrow at least 1000 wOETH to bridge this in 9 cycles. With this upgrade to the strategy, we won't be needing that capital and we can just automate this with a script

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.

Why would changing bridgeToRemote to onlyGovernorOrStrategist need capital while using onlyOperatorGovernorOrStrategist does not need capital?

address(bridgedWOETH),
_amount,
"",
master,

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.

Isn't this meant to be the remove strategy on Ethereum?

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.

Both Remote and Master strategy are deployed using Create2 or Create3, so they should be on the same address on both chains

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.

ok, and we only have the immutable master variable in the contract.

Maybe add a comment that this is sending to the remote strategy which has the same address as the master strategy.

return localValueWETH;
}

uint256 bridgedValueWETH = (totalBridged * lastOraclePrice) / 1 ether;

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.

checkBalance subtracts master.checkBalance(WETH) from the WETH value of all totalBridged. That assumes every WETH reported by Master corresponds to wOETH migrated out of this old strategy. If Master has any other balance component during the migration, such as local WETH, pending deposits, bridge adjustment, or value from another flow, the old strategy will underreport because that unrelated Master balance reduces this strategy’s in-flight amount. Is the migration guaranteeing no other Master activity/balance until the old strategy is removed?

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.

Yep, that's a good point. We discussed this and decided that we won't do any operation (deposit/withdrawal) while the migration is in progress. Realistically with a script to call the bridgeToRemote function every hour, it should take us roughly 9 hours to complete the migration. So it should be fine to pause all operations during that time

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.

That seems reasonable. We don't want to over engineer something that is only used once.

I was worried about a donation attacked of WETH but that doesn't seem to be an issue. Donating to BridgedWOETHMigrationStrategy doesn't change bridgedValueWETH and donating to the master strategy is also safe.

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