Skip to content

Add OUSD V3 and OETHb migration contracts#2909

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

Add OUSD V3 and OETHb migration contracts#2909
shahthepro wants to merge 52 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.12291% with 160 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.40%. Comparing base (44312e3) to head (4e57495).
⚠️ Report is 2 commits behind head on master.

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.93% 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.40%   +5.76%     
==========================================
  Files         110      124      +14     
  Lines        4920     5815     +895     
  Branches     1362     1642     +280     
==========================================
+ Hits         2196     2931     +735     
- 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.

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

Comment thread contracts/contracts/strategies/BridgedWOETHMigrationStrategy.sol Outdated
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?

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.

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,

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.

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, added that comment yesterday in this commit: 8c529f2#diff-fd7d4905625ed21ee02a7cd4136ad7548b1d971c24d074129d912bffa359ae0c

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.

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

Thanks for providing the fixes for previous comments. Some more comments inline:

uint32 msgType,
uint256 amount,
bytes memory body
) internal nonReentrant {

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.

🟡 we don't have a test for Reentrency. This would be pretty convenient to have.

// the fee; the pool is NOT consulted. Security gate: stops an
// attacker draining the operator-funded pool by spamming bridge
// in/out with msg.value = 0.
// userFunded = false → operator/protocol-funded sends (yield deposits/withdraws/claims

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.

⚪ Would be cool to add a monitoring section to this PR where we should probably add a capability to Talos to observe the balances of fee assets on contracts. The remote contract sometimes needs to pay the bridging fee to convey the message. It can be blocked if it fails

/// vault, which would be indistinguishable from "no request" if stored verbatim;
/// the +1 offset keeps `0` meaning "no outstanding (unclaimed) queue request" while
/// a real id of 0 is safely represented as 1. Cleared to 0 once the claim lands.
uint256 public outstandingRequestId;

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: another way to approach this would be to set a const REQUEST_ID_EMPTY = type(uint256).max;
And set it to that whenever you want to treat it as empty

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 great suggestion, will do it.

/// must equal the vault (enforced by the require below); Master always forwards the
/// received bridgeAsset to `vaultAddress` on the leg-2 ack.
///
/// Only the `remoteStrategyBalance` slice is drawable here: `_amount` must be

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: update this docs to reflect the changes where not only remoteStrategyBalance is taken into account

Remote->>wOETH: deposit(OETH balance, Remote)
Remote->>wOETH: «OETH X» (wrapper pulls OETH on deposit)
wOETH-->>Remote: «wOETH shares» minted
Remote->>Remote: yieldBaseline = _viewCheckBalance()

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.

_viewCheckBalance() − bridgeAdjustment

Remote->>wOUSD: deposit(OUSD balance, Remote)
Remote->>wOUSD: «OUSD» (wrapper pulls on deposit)
wOUSD-->>Remote: «wOUSD shares» minted
Remote->>Remote: yieldBaseline = _viewCheckBalance()

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.

_viewCheckBalance() − bridgeAdjustment

Note over Remote: outstandingRequestId = requestId<br/>outstandingRequestAmount = amount

Note over Master,Remote: ─── Phase B: Remote sends WITHDRAW_REQUEST_ACK ───
Remote->>Remote: yieldBaseline = _viewCheckBalance()

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.

_viewCheckBalance() − bridgeAdjustment

intermediate state; value lives in exactly one slot per row, and `checkBalance`
equals the total in every row.

| State | wOETH share value | OToken bal | bridgeAsset bal | queued\* | outstandingRequestId | checkBalance |

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 are 2 new states: mint-failed; unwrap-ok/queue-fail);

Master->>Master: _processWithdrawClaimAck success:<br/>_markYieldNonceProcessed(N+2)<br/>pendingWithdrawalAmount = 0<br/>remoteStrategyBalance = yieldBaseline
Master->>Vault: «WETH» transfer (forwards its full bridgeAsset balance)
Note over Master: safeTransfer(vaultAddress, balanceOf(this))<br/>emit Withdrawal(WETH, WETH, claimed)
else queue not yet matured (NACK)

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.

Can revert for multiple reasons:

  • outstandingRequestId != 0
  • amount == 0
  • bridgeAssetHeld < amount
  • shipOutOfBounds

@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 next batch review comments

}

function _opportunisticClaim() internal {
uint256 stored = outstandingRequestId;

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: its not clear what store is later in the function code. I would use outstandingRequestIdMem instead

return;
}
// `outstandingRequestId` stores the vault id verbatim.
uint256 vaultRequestId = stored;

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 create a new memory variable for what's stored in outstandingRequestId?

// auto-deposits once enough accumulates. A revert here would DoS the mint ->
// `_allocate` -> `deposit` path. Covers both `deposit` and `depositAll`.
if (_amount < IBridgeAdapter(outboundAdapter).minTransferAmount()) {
return;

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.

Feels like we should emit an event here since we are not doing the requested deposit while not failing the tx

}

/// @inheritdoc IAny2EVMMessageReceiver
function ccipReceive(Client.Any2EVMMessage calldata 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.

What guarantees, if any, does the CCIP Distributed Oracle Network (DON) provide for calling ccipReceive?
What if no DON participant calls ccipReceive? Can we retrigger the CCIP message?

From what I can tell, if the DON does not call ccipReceive then our strategy is stuck.

}

uint64 nonce = _getNextYieldNonce();
pendingDepositAmount = _amount;

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 like to make it clear when storage variables are writing back to storage. I'd add a comment like
// Store the pending deposit amount until the remote deposit is acknowledged.

);

uint64 nonce = _getNextYieldNonce();
pendingWithdrawalAmount = _amount;

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 like to make it clear when storage variables are writing back to storage. I'd add a comment like
// Store the pending withdrawal amount until the remote withdrawal is claimed.

function _processDepositAck(uint64 nonce, bytes memory payload) internal {
_markYieldNonceProcessed(nonce);
uint256 yieldBaseline = CrossChainV3Helper.decodeUint256(payload);
remoteStrategyBalance = yieldBaseline;

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 add a comment to make it clear this is a write to storage. eg

// Store the acknowledged deposit in the remote balance and clear the pending amount.
remoteStrategyBalance = yieldBaseline;
pendingDepositAmount = 0;

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