Skip to content

Add OUSD V3 and OETHb migration contracts#2909

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

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

Files with missing lines Patch % Lines
...racts/strategies/BridgedWOETHMigrationStrategy.sol 0.00% 45 Missing ⚠️
...egies/crosschainV3/adapters/SuperbridgeAdapter.sol 56.52% 30 Missing ⚠️
...s/strategies/crosschainV3/adapters/CCIPAdapter.sol 54.54% 15 Missing ⚠️
...rategies/crosschainV3/adapters/AbstractAdapter.sol 86.00% 14 Missing ⚠️
...s/strategies/crosschainV3/adapters/CCTPAdapter.sol 88.29% 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.57%   +5.94%     
==========================================
  Files         110      123      +13     
  Lines        4920     5811     +891     
  Branches     1362     1641     +279     
==========================================
+ Hits         2196     2939     +743     
- Misses       2721     2869     +148     
  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.

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],

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

Left some comments

Comment thread contracts/contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol Outdated
sparrowDom
sparrowDom previously approved these changes Jul 7, 2026

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

Minor comments otherwise LGTM

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

Proposed change of this section:

///      Drawable bound is `_drawableRemoteBalance()` = `remoteStrategyBalance +
///      min(0, bridgeAdjustment)`, NOT `remoteStrategyBalance` alone:
///        - a NEGATIVE `bridgeAdjustment` (net BRIDGE_OUT) is folded in, lowering the cap so we
///          never request more shares than Remote can actually unwrap (else it NACKs on Remote);
///        - a POSITIVE `bridgeAdjustment` is excluded here even though `checkBalance` counts it
///          (that + local bridgeAsset can report more). To realise it, `requestSettlement()`
///          first (folds bridgeAdjustment into remoteStrategyBalance) and/or use the local
///          bridgeAsset, then withdraw.

/// on a WITHDRAW_CLAIM_ACK (the bridgeAsset is still held when this is computed).
/// `oTokenAmount` is in OToken (18dp) units, matching `_viewCheckBalance`;
/// `_yieldOnlyBaseline()` is the `oTokenAmount == 0` case.
/// @dev Clamps to 0 rather than reverting on a negative. `_viewCheckBalance - bridgeAdjustment`

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.

suggested fix:

  /// @dev Clamps to 0 rather than reverting on a negative. `_viewCheckBalance - bridgeAdjustment`
  ///      is principal + yield + retained fees and is never *economically* negative **while
  ///      (w)OTokens are up-only** — the only thing that pushes it a few wei negative is the
  ///      wOToken 4626 rounding against the strategy (~1 wei per BRIDGE_IN floor / BRIDGE_OUT
  ///      ceil) once a `withdrawAll` drains it near 0. Reverting on that dust would freeze the
  ///      serialized yield channel, so we clamp (matches checkBalance-never-reverts).
  ///      CAVEAT: a real negative rebase (loss/slashing) would make this genuinely negative; the
  ///      clamp then masks it, and because Master re-adds its OWN `bridgeAdjustment`,
  ///      `checkBalance` OVER-reports by ~`bridgeAdjustment` (not 0). Out of scope while
  ///      (w)OTokens never negative-rebase; revisit (signed baseline) if that changes. See DESIGN §3.10.

leg-2 claim ack lands.

For `withdrawAll` (vault or governor sweep), `_withdrawRequest` is called with
`min(remoteStrategyBalance, inboundAdapter.maxTransferAmount())` so a sweep

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 should be

`min(_drawableRemoteBalance(), inboundAdapter.maxTransferAmount())` so a sweep

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.

proposed change:

Leg-2 NACKs on four conditions, not just an immature queue:

     else NACK — outstandingRequestId != EMPTY / amount == 0 / bridgeAssetHeld < amount / ship out of [min,max]

) {
return;
}
// Best-effort: a mid-migration cleared inbound adapter must no-op the sweep, not

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.

Proposed change:

// Best-effort no-op if the inbound adapter isn't wired yet (pre-configuration only —
// setInboundAdapter now rejects zero, so it can't be cleared mid-migration).

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.

4 participants