Skip to content

Add OUSD V3 and OETHb migration contracts#2909

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

Add OUSD V3 and OETHb migration contracts#2909
shahthepro wants to merge 56 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.97632% with 151 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.49%. Comparing base (c52692d) to head (d8331cf).

Files with missing lines Patch % Lines
...racts/strategies/BridgedWOETHMigrationStrategy.sol 0.00% 45 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.42% 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 ⚠️
.../strategies/crosschainV3/RemoteWOTokenStrategy.sol 97.10% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2909      +/-   ##
==========================================
+ Coverage   44.63%   50.49%   +5.85%     
==========================================
  Files         110      123      +13     
  Lines        4920     5807     +887     
  Branches     1362     1637     +275     
==========================================
+ Hits         2196     2932     +736     
- Misses       2721     2872     +151     
  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.

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

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.

Done: d8331cf

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?

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.

Thanks, cleaned it up in this commit: 82f0179

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

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.

There's a new DepositSkipped event being emitted now: 82f0179

}

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

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.

If for someone the CCIP message relaying fails, anyone can manually trigger it again: https://docs.chain.link/ccip/concepts/manual-execution

I'll add it as a comment though

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.

Have added that comment: 82f0179

}

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.

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.

Done: 82f0179

);

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.

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.

Done: 82f0179

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;

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.

Done: 82f0179

`CCTPAdapter._quoteFee` calls `tokenMessenger.getMinFeeAmount(amount)`,
which is V2.1-only. If a chain has only V2.0 deployed, `quoteFee(amount > 0)`
reverts. Current deploys (OETHb) don't use CCTP at all, so this is a
non-issue. OUSD V3 spoke chains must be on V2.1 — check before deploying.

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.

A link to where to check what CCTP version is deployed on each chain would be useful

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.

Done in this commit: 82f0179

*
* Reverts when the chosen source doesn't cover `fee`.
*/
library NativeFeeHelper {

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: There's only one function in this library. Feels like it'd be simpler if consume was moved to the BridgedWOETHMigrationStrategy strategy as an internal function.

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.

Removed it and made it an internal function: 82f0179

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.

it still feels like migrating over 15m USD worth of ETH should have some operational checks. Setting up an automated Talos Action without human checks doesn't feel right.
Unless there are some risks with partially migrated funds. Does migrating the funds ASAP mean less risk of an accounting error?

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.

The Operator is set to the Strategist in the deploy script so lets just use onlyGovernorOrStrategist.

        // 3. Authorise the multichain strategist as the operator for `bridgeToRemote`.
        {
          contract: cMigration,
          signature: "setOperator(address)",
          args: [addresses.multichainStrategist],

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