Add OUSD V3 and OETHb migration contracts#2909
Conversation
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
naddison36
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
this looks weird. Why not send a CCIP message using the CCIPAdapter?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
It'd be nice to have a OUSD v3 sequence diagram rather than just a list of differences.
| L2 OETHb vault │ | ||
| │ │ | ||
| ▼ │ | ||
| ┌─────────────┐ CCIPAdapter outbound ┌─────────────┐ |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
nit: I'd call it Remove Strategy
| autonumber | ||
| participant Vault as L2 Vault | ||
| participant Master | ||
| participant Op as Operator |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
It'd be nice if there was a separate sequence diagram for OUSD v3
There was a problem hiding this comment.
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)
| internal | ||
| { | ||
| uint256 amount = CrossChainV3Helper.decodeUint256(payload); | ||
| require(amount > 0, "Remote: zero withdraw"); |
There was a problem hiding this comment.
🔴 if this ever reverts the strategy's yield channel is bricked.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
🔴 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:202—amount = _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.
There was a problem hiding this comment.
This is a good catch, will change it
| uint256 srcTimestamp = CrossChainV3Helper.decodeUint256(payload); | ||
| bytes memory ackPayload = CrossChainV3Helper | ||
| .encodeBalanceCheckResponsePayload( | ||
| _yieldOnlyBaseline(), |
There was a problem hiding this comment.
🟠 the _yieldOnlyBaseline can revert and potentially brick yield channel
There was a problem hiding this comment.
in theory, yes it could. But it'd only be a problem if Master ever requests more than it can withdraw
There was a problem hiding this comment.
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;Bvalues viapreviewRedeem(floor), andpreviewRedeem(previewDeposit(x)) ≤ x. - BRIDGE_OUT (
:500-513): burnspreviewWithdrawshares (ceil,:505) to ship exactlyamount, soBdrops slightly more thanA.
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.
There was a problem hiding this comment.
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
naddison36
left a comment
There was a problem hiding this comment.
some nit comments on storage.
naddison36
left a comment
There was a problem hiding this comment.
Next round of comments
| function bridgeToRemote(uint256 _amount) | ||
| external | ||
| payable | ||
| onlyOperatorGovernorOrStrategist |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Why would changing bridgeToRemote to onlyGovernorOrStrategist need capital while using onlyOperatorGovernorOrStrategist does not need capital?
There was a problem hiding this comment.
Ah, sorry, I misread your comment. If we make this Guardian/strategist only, Talos relayer can't call this directly. So, we would either need another Safe module or trigger this call manually every hour or so
| address(bridgedWOETH), | ||
| _amount, | ||
| "", | ||
| master, |
There was a problem hiding this comment.
Isn't this meant to be the remove strategy on Ethereum?
There was a problem hiding this comment.
Both Remote and Master strategy are deployed using Create2 or Create3, so they should be on the same address on both chains
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yep, added that comment yesterday in this commit: 8c529f2#diff-fd7d4905625ed21ee02a7cd4136ad7548b1d971c24d074129d912bffa359ae0c
| return localValueWETH; | ||
| } | ||
|
|
||
| uint256 bridgedValueWETH = (totalBridged * lastOraclePrice) / 1 ether; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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