diff --git a/.claude/skills/add-network/SKILL.md b/.claude/skills/add-network/SKILL.md new file mode 100644 index 0000000000..ffc408c3b5 --- /dev/null +++ b/.claude/skills/add-network/SKILL.md @@ -0,0 +1,317 @@ +--- +name: add-network +description: | + Checklist for adding a new EVM network (mainnet or testnet) to the + origin-dollar repo. Walks through every file that needs touching: + hardhat config, package.json scripts, utils/{addresses,hardhat-helpers}.js, + fork-test.sh, deploy/{network}/ directory, and (optionally) CI lanes. Use + when the user wants to add support for a new chain — e.g. "register + Sepolia", "add Hyperliquid", "wire up Mantle." References PR #2485 (Plume) + and PR #2839 (HyperEVM) as worked examples. +when-to-use: | + - User asks to add support for a new chain in this repo. + - User wants to register a testnet for staging contracts before mainnet. + - User asks "what files do I need to change to add {chain}?" + - User refers to "the add-network skill" or invokes it directly. +--- + +# Add a network to origin-dollar + +Adding a network in this codebase is mechanical but spread across ~12–15 +files. Follow this checklist top to bottom. Skip the **(optional)** items if +you don't need fork tests / CI / Defender automation for the new network. + +> **Working dir convention.** All file paths below are relative to the repo +> root `/Volumes/origin/origin-dollar/`. Run all commands from `contracts/`. + +## 0. Decide network classification + +Up front, classify the network. This drives which files actually need touching: + +| Dimension | Options | Affects | +|---|---|---| +| **Mainnet or testnet** | mainnet, testnet | Whether you bother with CI lanes; whether the network appears in top-level README's "Deployed on …" list. | +| **L1 / L2 / sidechain** | L1, OP Stack rollup, ZK rollup, sidechain | Whether you need L1StandardBridge addresses, finality assumptions, canonical-bridge support. | +| **Native asset** | ETH, custom token | Affects gas / fee plumbing in adapters and strategies. | +| **Primary bridge protocols** | CCIP, CCTP, LayerZero, OP canonical, custom | Drives which adapters get deployed + what address constants you'll need. | +| **EVM-compatible explorer** | Etherscan family, Blockscout, custom | Drives `etherscan.customChains` config. | + +Pick the answers before editing any files — they affect which sections below apply. + +## 1. Gather constants + +You'll need these before touching any file. Keep them in a scratch note: + +- `chainId` (e.g., Sepolia = 11155111) +- `providerURL` env var name (e.g., `SEPOLIA_PROVIDER_URL`) +- CCIP chain selector (if using CCIP — look up at https://docs.chain.link/ccip/directory) +- Canonical bridge addresses (if L2 — L1StandardBridge, L2StandardBridge) +- Explorer URL + API key (or Blockscout-style endpoint) +- Deployer / governor / strategist EOAs or multisigs +- WETH / USDC / etc. token addresses (mainnet equivalents on the new chain) +- Per-network address registry contents the strategies will need + +## 2. `contracts/utils/hardhat-helpers.js` + +Add the network-detection flags and provider URL. Mirror the existing pattern +for similar networks (e.g., if adding an L2 testnet, copy how `holesky` / +`hoodi` look). + +```js +const isSepolia = process.env.NETWORK === "sepolia"; +const isSepoliaFork = process.env.FORK_NETWORK_NAME === "sepolia"; +const isSepoliaForkTest = isSepoliaFork && isForkTest; +const isSepoliaUnitTest = isSepolia && process.env.IS_TEST === "true"; +// ... + +const sepoliaProviderUrl = process.env.SEPOLIA_PROVIDER_URL || ""; +``` + +Also branch in `adjustTheForkBlockNumber()` for the new network if you use +`BLOCK_NUMBER` fork pinning. + +Export everything at the bottom of the file so `hardhat.config.js` can import. + +## 3. `contracts/hardhat.config.js` + +Three sections to update: + +**(a) Imports** — add the new flags + providerUrl to the destructure at the top: + +```js +const { + // ... existing imports + isSepolia, + isSepoliaFork, + isSepoliaForkTest, + sepoliaProviderUrl, +} = require("./utils/hardhat-helpers.js"); +``` + +**(b) `networks.` entry** — within the `networks` config: + +```js +sepolia: { + url: sepoliaProviderUrl, + accounts: [ + process.env.DEPLOYER_PK || privateKeyPlaceholder, + process.env.GOVERNOR_PK || privateKeyPlaceholder, + ], + chainId: 11155111, + tags: ["sepolia"], + live: true, + saveDeployments: true, +}, +``` + +**(c) `namedAccounts`** — add per-network deployer/governor/strategist indexes: + +```js +namedAccounts: { + deployer: { default: 0, sepolia: 0, baseSepolia: 0, ... }, + governorAddr: { default: 1, sepolia: 1, baseSepolia: 1, ... }, + strategistAddr: { default: 2, sepolia: 2, baseSepolia: 2, ... }, +}, +``` + +**(d) Etherscan verification** — within `etherscan` config: + +```js +etherscan: { + apiKey: { + sepolia: process.env.ETHERSCAN_API_KEY, + baseSepolia: process.env.ETHERSCAN_API_KEY, // Etherscan V2 multichain key + // ... + }, + customChains: [ + // Add an entry if the network isn't built into hardhat-verify. + { + network: "baseSepolia", + chainId: 84532, + urls: { + apiURL: "https://api-sepolia.basescan.org/api", + browserURL: "https://sepolia.basescan.org", + }, + }, + ], +}, +``` + +## 4. `contracts/package.json` + +Add per-network scripts. Minimum set: + +```json +{ + "scripts": { + "deploy:sepolia": "hardhat deploy --network sepolia", + "node:sepolia": "FORK=true FORK_NETWORK_NAME=sepolia hardhat node", + "test:sepolia-fork": "FORK_NETWORK_NAME=sepolia bash fork-test.sh" + } +} +``` + +If you'll have coverage runs or Anvil-based local nodes: + +```json +{ + "test:coverage:sepolia-fork": "REPORT_COVERAGE=true FORK_NETWORK_NAME=sepolia bash fork-test.sh", + "node:anvil:sepolia": "anvil --fork-url $SEPOLIA_PROVIDER_URL" +} +``` + +## 5. `contracts/utils/addresses.js` + +Add the new network and populate it with what the strategies need: + +```js +addresses.sepolia = {}; +addresses.sepolia.WETH = "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"; +addresses.sepolia.CCIPRouter = "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59"; +// ... +``` + +For L2s, document **both** sides of the lane in the L1's entry (e.g., the L1 +side stores both Sepolia-side endpoints AND L1StandardBridge for the L2's +rollup). Otherwise deploy scripts have to cross-reference and that gets +brittle. + +## 6. `contracts/utils/deploy.js` + +Usually no changes. Check for any `isMainnet*` / `isBase*` style helper +predicates inside `withConfirmation` or `deployWithConfirmation` that gate on +network — mirror them if your new network needs the same behaviour. + +## 7. `contracts/fork-test.sh` + +Add an `elif` branch in the network-mapping switch (~line 30–60): + +```bash +elif [[ $FORK_NETWORK_NAME == "sepolia" ]]; then + PROVIDER_URL=$SEPOLIA_PROVIDER_URL; + BLOCK_NUMBER=$SEPOLIA_BLOCK_NUMBER; +elif [[ $FORK_NETWORK_NAME == "baseSepolia" ]]; then + PROVIDER_URL=$BASE_SEPOLIA_PROVIDER_URL; + BLOCK_NUMBER=$BASE_SEPOLIA_BLOCK_NUMBER; +``` + +## 8. `contracts/dev.env` + +Document the new env vars (this file is copied to `.env` for local dev): + +``` +SEPOLIA_PROVIDER_URL= +SEPOLIA_BLOCK_NUMBER= +BASE_SEPOLIA_PROVIDER_URL= +BASE_SEPOLIA_BLOCK_NUMBER= +``` + +## 9. `contracts/deploy/{network}/` + +Create the directory and add numbered deploy scripts. Pattern from +PR #2485 (Plume) and PR #2839 (HyperEVM): + +- `001_mock_*.js` — mocks if testnet (Mock vault, Mock OToken, etc.) +- `002_*.js` … `00N_*.js` — actual strategy / contract deploys +- Final script — wiring (authorise adapters, set caps, whitelist on vault) + +Each script: +1. Exports a `deployFunction` async with `(hre)` signature. +2. Tags: `{module.exports.tags = ["network-name", "specific-tag"]}`. +3. Uses helpers from `utils/deploy.js`: + - `withConfirmation(promise)` — awaits and logs. + - `deployWithConfirmation(name, args, contractName)` — deploys with verify support. + - `deployProxyWithCreateX(name, args, salt, contractName)` — CREATE3 for peer-parity addresses. + +## 10. `contracts/deployments/{network}/` + +This directory is auto-created by `hardhat-deploy` on first run. To make it +explicit and discoverable, commit a stub `.chainId` file with the chain ID: + +``` +$ echo "11155111" > contracts/deployments/sepolia/.chainId +``` + +## 11. `contracts/test/helpers.js` (only if fork/unit tests follow) + +Add to the `network detection` flags block (~line 314–328): + +```js +const isSepolia = hre.network.name === "sepolia"; +const isSepoliaFork = process.env.FORK_NETWORK_NAME === "sepolia"; +const isSepoliaOrFork = isSepolia || isSepoliaFork; +// ... +module.exports = { isSepolia, isSepoliaFork, isSepoliaOrFork, ... } +``` + +Also branch `getAssetAddresses(hre)` if the new network has different token addresses than mainnet. + +## 12. `contracts/test/_fixture-{network}.js` (optional) + +Only if you'll have unit or fork tests for the network. Copy +`test/_fixture-base.js` as a template and adapt for the new network's +fixtures. + +## 13. `.github/workflows/defi.yml` (optional) + +Add a `contracts-{network}-forktest` job mirroring the existing pattern +(~line 173–228). Requires CI secrets configured for `{NETWORK}_PROVIDER_URL`. +Skip if you don't need CI-level fork tests for the network. + +## 14. `contracts/scripts/defender-actions/` (optional) + +Only relevant if cross-chain relay automation or scheduled jobs need to run +for the new network. Mirror existing scripts (e.g., +`scripts/defender-actions/crossChainRelay.js`). + +## 15. Top-level `README.md` + +Add the network to the "Deployed on Ethereum Mainnet, Base, Arbitrum, Sonic, +Plume, Holesky, Hoodi, …" list at the top. + +## 16. Verify + +```bash +cd contracts +pnpm hardhat compile # compiles +pnpm prettier:sol && pnpm prettier:js # format +pnpm lint:sol && pnpm lint:js # lint +pnpm hardhat console --network {network} # provider resolves (will error on connect if URL is empty, that's OK) +pnpm hardhat deploy --network {network} --dry-run # deploy wiring sanity +``` + +## 17. Smoke test on the new network + +After actual deploy: +- Read back a key contract's address via `hardhat console`. +- Read a public view function to confirm the deployment actually accepted calls. +- For CREATE3 deploys: confirm peer-chain addresses are byte-identical. + +--- + +## Edge cases & gotchas + +- **Sonic** added `isSonicForkTest` as a separate flag from `isSonicFork`. Some networks need both depending on how tests are wired. Look at existing usage in `test/helpers.js` to decide. +- **HyperEVM** has no Etherscan-family verifier; uses `customChains` with the + HyperEVM block explorer endpoint. +- **Plume Explorer** is Blockscout-compatible at `explorer.plume.org/api`; no + API key needed. +- **Base / Base Sepolia**: basescan.org's V1 API is deprecated. Use the + Etherscan V2 multichain API key with a `customChains` entry pointing at + Etherscan's per-chain endpoint. +- **L2s**: register both directions in `addresses.js` — the L2-side bridge + components AND the L1-side companion components on the L1's address entry + (L1StandardBridge for the L2's rollup, etc.). +- **`accounts`** field in `networks.`: do NOT use `defaultAccounts` + blindly. Specify deployer + governor PK env vars so devs can override + per-environment. + +## Reference PRs + +- **PR #2485** (Plume) — https://github.com/OriginProtocol/origin-dollar/pull/2485 + L2 network with full token + vault deploy, LayerZero integration. +- **PR #2839** (HyperEVM) — https://github.com/OriginProtocol/origin-dollar/pull/2839 + Sidechain with strategy proxies + cross-chain relay scripts. + +When in doubt, look at how the most-similar existing network is wired and +mirror that pattern. Don't invent new conventions. diff --git a/.husky/pre-commit b/.husky/pre-commit index c3766bff17..9607484514 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,3 @@ #!/bin/sh - cd contracts pnpm run lint:js diff --git a/contracts/abi/createx.json b/contracts/abi/createx.json index 84904ff09a..64e9137026 100644 --- a/contracts/abi/createx.json +++ b/contracts/abi/createx.json @@ -97,5 +97,29 @@ ], "stateMutability": "payable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "initCodeHash", + "type": "bytes32" + } + ], + "name": "computeCreate2Address", + "outputs": [ + { + "internalType": "address", + "name": "computedAddress", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" } ] \ No newline at end of file diff --git a/contracts/contracts/interfaces/crosschainV3/IBridgeAdapter.sol b/contracts/contracts/interfaces/crosschainV3/IBridgeAdapter.sol new file mode 100644 index 0000000000..405ba5ceba --- /dev/null +++ b/contracts/contracts/interfaces/crosschainV3/IBridgeAdapter.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title IBridgeAdapter + * @author Origin Protocol Inc + * + * @notice Bridge-agnostic adapter interface used by Master / Remote strategies. The adapter + * encapsulates one bridge transport (CCIP, CCTP, OP Stack canonical bridge) so the + * strategy stays bridge-ignorant. The adapter owns the envelope shape; the strategy + * only ever passes its own opaque `payload` bytes. + * + * A single adapter deployment serves all authorised strategies on its chain, with + * per-sender lane configuration held inside the adapter. Each adapter is bound to + * one peer chain through the lane configuration. + */ +interface IBridgeAdapter { + /** + * @notice Send a message-only payload to the configured peer. + * @dev Strategy passes opaque `payload`; the adapter wraps `(msg.sender, payload)` into + * its transport envelope. For bridges that require a native fee, caller must + * supply `msg.value >= quoteFee.fee` (the adapter checks). No refund of excess — + * overpayment stays on the adapter and can be recovered by governor via + * `transferToken(address(0), ...)`. + */ + function sendMessage(bytes calldata payload) external payable; + + /** + * @notice Send a token transfer alongside a message to the configured peer. + * @dev Adapter pulls `amount` of `token` from `msg.sender` via `safeTransferFrom`, + * then forwards via its bridge transport together with the wrapped envelope. + * Same `msg.value` semantics as `sendMessage`. + */ + function sendMessageAndTokens( + address token, + uint256 amount, + bytes calldata payload + ) external payable; + + /** + * @notice Quote the fee for the operation described by `(token, amount, payload)`. + * + * @return fee The fee amount, denominated in `feeToken`. When `feeToken == address(0)` + * this is a native (ETH) fee. When non-zero this is an ERC20 fee in that + * token. When the bridge protocol auto-deducts from the bridged amount + * (e.g., CCTP V2 fast-finality), `fee` is informational only — the actual + * deduction happens transparently inside the bridge, NOT on the caller. + * @return feeToken The token the fee is denominated in. `address(0)` means native. + * @return requiresExternalPayment True if the caller must supply `fee` of `feeToken` + * alongside the send call (e.g., via `msg.value` for native). False if the + * bridge protocol handles the fee transparently (e.g., deducts from the + * bridged token amount). The strategy reads this flag to decide whether to + * enforce a `msg.value` check; if false, the caller can ignore `fee` + * entirely. + * + * The three-value return separates two orthogonal concerns: + * 1. Pre-send caller action (do I need to pay anything separately?) + * 2. Post-send accounting (the actual deduction is surfaced on the adapter's + * `MessageDelivered(target, token, amountReceived, feePaid)` event on the + * receiving side — it is NOT passed to `receiveMessage`, which no strategy + * reads it from; the strategy accounts on `amountReceived`). + */ + function quoteFee( + address token, + uint256 amount, + bytes calldata payload + ) + external + view + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ); + + /** + * @notice Per-tx maximum token amount this adapter accepts on outbound, and the implied + * maximum it can deliver on inbound (since outbound and inbound are configured + * as mirror sides of the same protocol lane). + * + * Strategies use this in two places: + * - Master.depositAll caps the locally-staged balance by + * `outboundAdapter.maxTransferAmount()` before sending. + * - Master.withdrawAll caps the requested amount by + * `inboundAdapter.maxTransferAmount()`, because Master can't query Remote's + * outbound across chains — the symmetric inbound adapter holds the same + * protocol-level cap. + * + * `0` means "no enforcement" (unlimited). Concrete adapters layer additional + * protocol-level constants on top (e.g., CCTPAdapter enforces a hard 10M USDC + * cap regardless of the configured value). + */ + function maxTransferAmount() external view returns (uint256); + + /** + * @notice Per-tx minimum token amount (dust floor) this adapter enforces on outbound, and + * the implied floor on inbound (mirror-lane convention, like `maxTransferAmount`). + * `0` means "no floor". Strategies quote `[minTransferAmount(), maxTransferAmount()]` + * to avoid initiating a transfer the adapter would reject. + */ + function minTransferAmount() external view returns (uint256); +} diff --git a/contracts/contracts/interfaces/crosschainV3/IBridgeReceiver.sol b/contracts/contracts/interfaces/crosschainV3/IBridgeReceiver.sol new file mode 100644 index 0000000000..fcb0c19c93 --- /dev/null +++ b/contracts/contracts/interfaces/crosschainV3/IBridgeReceiver.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title IBridgeReceiver + * @author Origin Protocol Inc + * + * @notice Receiver hook implemented by Master and Remote strategies. The configured inbound + * adapter forwards incoming bridge deliveries through this single entry point. + * + * The adapter MUST have transferred any inbound tokens to the strategy before + * invoking this function. The `amountReceived` argument carries the actual landed + * amount (post any transport-side fee deduction); the strategy accounts on + * `amountReceived`. Any transport-side fee is emitted by the adapter's + * `MessageDelivered` event for off-chain consumers — it is not forwarded here because + * no strategy reads it. + */ +interface IBridgeReceiver { + /** + * @notice Called by the authorised inbound adapter when a message lands. + * @param sender Strategy address on the source chain — under CREATE3 parity, the + * same address as the destination strategy on this chain. + * @param token Token delivered alongside the message; `address(0)` for + * message-only deliveries. + * @param amountReceived Actual amount of `token` transferred to this strategy by the + * adapter immediately before this call (already received). + * @param payload Strategy-owned opaque bytes from the source envelope. + */ + function receiveMessage( + address sender, + address token, + uint256 amountReceived, + bytes calldata payload + ) external; +} diff --git a/contracts/contracts/interfaces/crosschainV3/ISplitInboundAdapter.sol b/contracts/contracts/interfaces/crosschainV3/ISplitInboundAdapter.sol new file mode 100644 index 0000000000..0a6ca0e635 --- /dev/null +++ b/contracts/contracts/interfaces/crosschainV3/ISplitInboundAdapter.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title ISplitInboundAdapter + * @author Origin Protocol Inc + * @dev Interface for split-delivery inbound bridge adapters — those where the message and + * its companion token leg arrive in separate transactions (e.g., OP Stack canonical + * bridge for the tokens + a separate message bridge for the envelope). + * + * Atomic adapters (CCIP, CCTP V2 with combined token + message) do NOT implement this + * interface — they deliver in a single transaction and have no pending-slot lifecycle. + * + * Split-delivery adapters are multi-tenant: each pending slot is keyed by the destination + * strategy's address on this chain (which equals the source sender by CREATE3 parity), + * so callers pass that address when querying or finalising. + */ +interface ISplitInboundAdapter { + /** + * @notice Whether the adapter currently has a stored message for `_target` waiting for + * its companion token leg. + */ + function hasPendingMessage(address _target) external view returns (bool); + + /** + * @notice Permissionless finaliser: if both message and tokens have arrived for + * `_target`, forward to it and clear that target's pending slot. Reverts when + * nothing is pending or the token leg hasn't landed yet, so off-chain automation + * can retry. + */ + function processStoredMessage(address _target) external; +} diff --git a/contracts/contracts/mocks/MockERC4626Vault.sol b/contracts/contracts/mocks/MockERC4626Vault.sol index 02b4672c2d..a94a6558fd 100644 --- a/contracts/contracts/mocks/MockERC4626Vault.sol +++ b/contracts/contracts/mocks/MockERC4626Vault.sol @@ -74,6 +74,13 @@ contract MockERC4626Vault is IERC4626, ERC20 { return IERC20(asset).balanceOf(address(this)); } + /// @notice TEST-ONLY: simulate a loss of underlying backing so each share is worth less + /// (previewRedeem drops). Moves `amount` of the underlying out of the wrapper — the + /// inverse of the "airdrop OToken to inflate previewRedeem" yield-accrual mock. + function simulateLoss(uint256 amount) external { + IERC20(asset).safeTransfer(address(0xdead), amount); + } + function convertToShares(uint256 assets) public view diff --git a/contracts/contracts/mocks/crosschainV3/MockBridgeAdapter.sol b/contracts/contracts/mocks/crosschainV3/MockBridgeAdapter.sol new file mode 100644 index 0000000000..37c2e2698f --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockBridgeAdapter.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IBridgeAdapter } from "../../interfaces/crosschainV3/IBridgeAdapter.sol"; +import { IBridgeReceiver } from "../../interfaces/crosschainV3/IBridgeReceiver.sol"; + +/** + * @title MockBridgeAdapter + * @author Origin Protocol Inc + * + * @notice TEST-ONLY synchronous loopback adapter for the V3 strategy pair. Plays the role + * of both the outbound adapter on the source side and the receiver adapter on the + * destination side — it calls peer.receiveMessage() in the same transaction. + * + * The new design has the wire envelope owned by the adapter, but the strategy + * passes opaque `payload` bytes that already encode `(msgType, nonce, body)` via + * `CrossChainV3Helper.packPayload`. This mock simply forwards `payload` through. + */ +contract MockBridgeAdapter is IBridgeAdapter { + using SafeERC20 for IERC20; + + /// @notice Authorised sender on the local side (the strategy we adapt for). + address public sender; + /// @notice Peer receiver on the destination side (the other strategy). + address public peer; + + /// @notice When false, send* are no-ops on the peer side. Useful for simulating + /// in-flight delays in tests; calls still consume tokens. + bool public deliveryEnabled = true; + + // Inspection slots + bytes public lastMessageSent; + uint256 public lastAmountSent; + address public lastTokenSent; + + event PeerConfigured(address peer); + event SenderConfigured(address sender); + event DeliveryToggled(bool enabled); + event MessageDelivered(address token, uint256 amount, bytes payload); + + function setPeer(address _peer) external { + peer = _peer; + emit PeerConfigured(_peer); + } + + function setSender(address _sender) external { + sender = _sender; + emit SenderConfigured(_sender); + } + + function setDeliveryEnabled(bool _enabled) external { + deliveryEnabled = _enabled; + emit DeliveryToggled(_enabled); + } + + /// @inheritdoc IBridgeAdapter + function sendMessage(bytes calldata payload) external payable override { + _requireAuthorised(); + lastMessageSent = payload; + lastAmountSent = 0; + lastTokenSent = address(0); + + if (!deliveryEnabled || peer == address(0)) { + return; + } + _dispatch(address(0), 0, payload); + } + + /// @inheritdoc IBridgeAdapter + function sendMessageAndTokens( + address token, + uint256 amount, + bytes calldata payload + ) external payable override { + _requireAuthorised(); + lastMessageSent = payload; + lastAmountSent = amount; + lastTokenSent = token; + + // Pull tokens from the local strategy. + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + + if (!deliveryEnabled || peer == address(0)) { + return; + } + // Optional simulated underdelivery: consume `underdeliveryForNext` of `amount` + // before forwarding. Lets tests assert claim-ack behaviour when CCTP fast-finality + // (or similar protocol-side fees) reduces delivered amount below `ackAmount`. + uint256 deliver = amount; + if (underdeliveryForNext > 0 && underdeliveryForNext <= amount) { + deliver = amount - underdeliveryForNext; + underdeliveryForNext = 0; + } + // Forward tokens to peer and call its receiver hook synchronously. + IERC20(token).safeTransfer(peer, deliver); + _dispatch(token, deliver, payload); + } + + /// @inheritdoc IBridgeAdapter + function quoteFee( + address, + uint256, + bytes calldata + ) + external + pure + override + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) + { + // Test mock: zero fee, no external payment required. Lets unit tests exercise + // `_send` for both the user-funded path (fee=0, msg.value>=0 trivially) and the + // pool-funded path (fee=0, balance>=0 trivially) without ETH plumbing in fixtures. + return (0, address(0), false); + } + + /// @notice Configurable per-tx cap for testing Master's clamp paths. Default + /// `type(uint256).max` means "no clamp" so existing tests stay unaffected. + uint256 public maxTransferOverride = type(uint256).max; + + function setMaxTransferAmountOverride(uint256 _amount) external { + maxTransferOverride = _amount; + } + + /// @notice Configurable per-tx floor for testing the bounds pre-check / NACK paths. + /// Default 0 = no floor so existing tests stay unaffected. + uint256 public minTransferOverride; + + function setMinTransferAmountOverride(uint256 _amount) external { + minTransferOverride = _amount; + } + + /// @notice One-shot simulated under-delivery for the next `sendMessageAndTokens`. + /// Resets to 0 after consumption. Used to exercise the `amount < ackAmount` + /// path on the receiving strategy (CCTP fast-finality fee scenario). + uint256 public underdeliveryForNext; + + function setUnderdeliveryForNextMessage(uint256 _amount) external { + underdeliveryForNext = _amount; + } + + /// @inheritdoc IBridgeAdapter + function maxTransferAmount() external view override returns (uint256) { + return maxTransferOverride; + } + + /// @inheritdoc IBridgeAdapter + function minTransferAmount() external view override returns (uint256) { + return minTransferOverride; + } + + /** + * @dev Manually flush a previously-stored undelivered message to the peer. + * Useful in tests that toggled deliveryEnabled off to inspect in-flight state. + */ + function flushPendingDelivery() external { + require(deliveryEnabled, "Delivery still disabled"); + require(lastMessageSent.length > 0, "Nothing to flush"); + + if (lastAmountSent > 0 && lastTokenSent != address(0)) { + IERC20(lastTokenSent).safeTransfer(peer, lastAmountSent); + } + _dispatch(lastTokenSent, lastAmountSent, lastMessageSent); + + delete lastMessageSent; + lastAmountSent = 0; + lastTokenSent = address(0); + } + + function _requireAuthorised() internal view { + require( + sender == address(0) || msg.sender == sender, + "MockBridgeAdapter: unauthorised sender" + ); + } + + function _dispatch( + address token, + uint256 amount, + bytes memory payload + ) internal { + emit MessageDelivered(token, amount, payload); + IBridgeReceiver(peer).receiveMessage(sender, token, amount, payload); + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockBridgeCallTarget.sol b/contracts/contracts/mocks/crosschainV3/MockBridgeCallTarget.sol new file mode 100644 index 0000000000..85d1fcd502 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockBridgeCallTarget.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title MockBridgeCallTarget + * @notice TEST-ONLY recipient contract used to exercise the optional `callData` post-delivery + * hook on BRIDGE_IN / BRIDGE_OUT. Records successful invocations, can be flipped to + * always-revert, and exposes a gas-burning helper. + */ +contract MockBridgeCallTarget { + bool public alwaysRevert; + uint256 public callCount; + bytes32 public lastBridgeId; + address public lastCaller; + uint256 public lastValueObserved; + bytes public lastData; + + event Pinged( + bytes32 indexed bridgeId, + address indexed caller, + uint256 token + ); + + function setAlwaysRevert(bool _r) external { + alwaysRevert = _r; + } + + /// @dev Match the kind of post-mint hook a real composing contract would expose. + function onBridgeDelivered(bytes32 _bridgeId, uint256 _tokenAmount) + external + { + if (alwaysRevert) revert("MockTarget: intentional revert"); + callCount += 1; + lastBridgeId = _bridgeId; + lastCaller = msg.sender; + lastValueObserved = _tokenAmount; + emit Pinged(_bridgeId, msg.sender, _tokenAmount); + } + + /// @dev Spin-loop until gas exhaustion. Used to exercise out-of-gas in the post-call hook. + // solhint-disable-next-line no-empty-blocks + function burnGas() external { + while (true) {} + } + + /// @dev Fallback used by tests that simply want to assert "any call landed". + fallback() external payable { + if (alwaysRevert) revert("MockTarget: intentional revert"); + callCount += 1; + lastCaller = msg.sender; + lastValueObserved = msg.value; + lastData = msg.data; + } + + receive() external payable { + if (alwaysRevert) revert("MockTarget: intentional revert"); + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockBridgeReceiver.sol b/contracts/contracts/mocks/crosschainV3/MockBridgeReceiver.sol new file mode 100644 index 0000000000..d897b7ac4a --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockBridgeReceiver.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IBridgeReceiver } from "../../interfaces/crosschainV3/IBridgeReceiver.sol"; + +/** + * @title MockBridgeReceiver + * @notice TEST-ONLY recorder for `receiveMessage` calls. Used to assert what an + * inbound adapter forwarded after split-delivery store-and-process. + */ +contract MockBridgeReceiver is IBridgeReceiver { + address public lastSender; + address public lastToken; + uint256 public lastAmount; + bytes public lastPayload; + uint256 public callCount; + + function receiveMessage( + address sender, + address token, + uint256 amountReceived, + bytes calldata payload + ) external override { + lastSender = sender; + lastToken = token; + lastAmount = amountReceived; + lastPayload = payload; + callCount += 1; + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockCCIPRouter.sol b/contracts/contracts/mocks/crosschainV3/MockCCIPRouter.sol new file mode 100644 index 0000000000..61fb574aab --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockCCIPRouter.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IRouterClient } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +/** + * @title MockCCIPRouter + * @notice TEST-ONLY CCIP router stub for fork tests. Records the most recent ccipSend call + * so the test can assert encoding, token amounts, and destination. No actual cross- + * chain delivery happens; the destination is mocked out separately on the same fork. + */ +contract MockCCIPRouter is IRouterClient { + using SafeERC20 for IERC20; + + uint256 public sentMessagesLength; + + /// @notice Native fee getFee() reports; ccipSend pulls this in `msg.value`. + uint256 public mockFee; + + function setFee(uint256 _fee) external { + mockFee = _fee; + } + + event MockCcipSend( + uint64 destinationChainSelector, + bytes receiver, + address token, + uint256 amount, + uint256 valueReceived + ); + + function ccipSend( + uint64 destinationChainSelector, + Client.EVM2AnyMessage memory message + ) external payable override returns (bytes32 messageId) { + // Pull each token in the message from the caller — that's what the real router does. + for (uint256 i = 0; i < message.tokenAmounts.length; i++) { + IERC20(message.tokenAmounts[i].token).safeTransferFrom( + msg.sender, + address(this), + message.tokenAmounts[i].amount + ); + emit MockCcipSend( + destinationChainSelector, + message.receiver, + message.tokenAmounts[i].token, + message.tokenAmounts[i].amount, + msg.value + ); + } + if (message.tokenAmounts.length == 0) { + emit MockCcipSend( + destinationChainSelector, + message.receiver, + address(0), + 0, + msg.value + ); + } + + sentMessagesLength += 1; + messageId = keccak256( + abi.encode( + destinationChainSelector, + message.receiver, + sentMessagesLength + ) + ); + } + + function getFee(uint64, Client.EVM2AnyMessage memory) + external + view + override + returns (uint256) + { + return mockFee; + } + + function getSupportedTokens(uint64) + external + pure + override + returns (address[] memory) + { + return new address[](0); + } + + function isChainSupported(uint64) external pure override returns (bool) { + return true; + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockCCTPRelayTransmitter.sol b/contracts/contracts/mocks/crosschainV3/MockCCTPRelayTransmitter.sol new file mode 100644 index 0000000000..def89b9372 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockCCTPRelayTransmitter.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { ICCTPMessageTransmitter, IMessageHandlerV2 } from "../../interfaces/cctp/ICCTP.sol"; +import { BytesHelper } from "../../utils/BytesHelper.sol"; + +interface IMintableUSDC { + function mintTo(address to, uint256 amount) external; +} + +/** + * @title MockCCTPRelayTransmitter + * @author Origin Protocol Inc + * + * @notice TEST-ONLY minimal mock of `ICCTPMessageTransmitter` focused on the relay path. + * Supports two modes: + * + * 1. **Pure-message** — when the transport `sender` is NOT the registered + * token messenger, the mock decodes the transport header and calls back into + * the recipient adapter's `handleReceiveFinalizedMessage` with the body as + * messageBody. Simulates `MessageTransmitter.sendMessage`. + * + * 2. **Burn-and-hook** — when the transport `sender` IS the registered token + * messenger, the mock decodes the burn body, mints USDC to the `mintRecipient` + * field, and returns success WITHOUT invoking any hook callback. This mirrors + * CCTP V2.0 behaviour (no auto-callback for burn messages). The + * `CCTPAdapter.relay()` is expected to parse the burn body itself and + * dispatch. + */ +contract MockCCTPRelayTransmitter is ICCTPMessageTransmitter { + using BytesHelper for bytes; + + // Transport header offsets (must match CCTPMessageHelper). + uint256 private constant SOURCE_DOMAIN_INDEX = 4; + uint256 private constant SENDER_INDEX = 44; + uint256 private constant RECIPIENT_INDEX = 76; + uint256 private constant MESSAGE_BODY_INDEX = 148; + + // Burn-body offsets (must match CCTPMessageHelper). + uint256 private constant BURN_BODY_MINT_RECIPIENT_INDEX = 36; + uint256 private constant BURN_BODY_AMOUNT_INDEX = 68; + uint256 private constant BURN_BODY_FEE_EXECUTED_INDEX = 164; + + /// @notice When `false`, `receiveMessage` returns `false` without forwarding. + bool public shouldSucceed = true; + + /// @notice When non-zero, transport `sender == tokenMessenger` triggers the burn path. + address public tokenMessenger; + + /// @notice USDC mock to mint from (must support `mint(to, amount)`). + address public usdcToMint; + + /// @notice Spy on the last `sendMessage` call (outbound side, not tested here). + bytes public lastSentMessage; + + event MessageForwarded( + address indexed recipient, + uint32 sourceDomain, + address sender + ); + event BurnMessageMinted( + address indexed mintRecipient, + uint256 amount, + uint256 feeExecuted + ); + + function setShouldSucceed(bool _ok) external { + shouldSucceed = _ok; + } + + function setBurnConfig(address _tokenMessenger, address _usdc) external { + tokenMessenger = _tokenMessenger; + usdcToMint = _usdc; + } + + function sendMessage( + uint32, // destinationDomain + bytes32, // recipient + bytes32, // destinationCaller + uint32, // minFinalityThreshold + bytes memory messageBody + ) external override { + lastSentMessage = messageBody; + } + + function receiveMessage( + bytes calldata message, + bytes calldata /* attestation */ + ) external override returns (bool) { + if (!shouldSucceed) { + return false; + } + + uint32 sourceDomain = message.extractUint32(SOURCE_DOMAIN_INDEX); + address sender = message.extractAddress(SENDER_INDEX); + address recipient = message.extractAddress(RECIPIENT_INDEX); + bytes memory body = message.extractSlice( + MESSAGE_BODY_INDEX, + message.length + ); + + // Burn-message path: mint USDC to the burn body's mintRecipient. NO hook callback — + // the destination CCTPAdapter is expected to parse the burn body itself. + if (sender == tokenMessenger && tokenMessenger != address(0)) { + address mintRecipient = body.extractAddress( + BURN_BODY_MINT_RECIPIENT_INDEX + ); + uint256 amount = body.extractUint256(BURN_BODY_AMOUNT_INDEX); + uint256 feeExecuted = body.extractUint256( + BURN_BODY_FEE_EXECUTED_INDEX + ); + require(amount >= feeExecuted, "Mock: bad fee"); + uint256 minted = amount - feeExecuted; + if (minted > 0) { + IMintableUSDC(usdcToMint).mintTo(mintRecipient, minted); + } + emit BurnMessageMinted(mintRecipient, amount, feeExecuted); + return true; + } + + // Pure-message path: call the recipient's IMessageHandlerV2 hook with the body. + IMessageHandlerV2(recipient).handleReceiveFinalizedMessage( + sourceDomain, + bytes32(uint256(uint160(sender))), + 2000, + body + ); + emit MessageForwarded(recipient, sourceDomain, sender); + return true; + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockCrossChainV3HelperHarness.sol b/contracts/contracts/mocks/crosschainV3/MockCrossChainV3HelperHarness.sol new file mode 100644 index 0000000000..9ba9ec13b0 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockCrossChainV3HelperHarness.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { CrossChainV3Helper } from "../../strategies/crosschainV3/CrossChainV3Helper.sol"; + +/** + * @title MockCrossChainV3HelperHarness + * @notice TEST-ONLY harness exposing CrossChainV3Helper's internal functions externally + * so the JS test suite can validate the codec. + */ +contract MockCrossChainV3HelperHarness { + function packPayload( + uint32 msgType, + uint64 nonce, + bytes calldata body + ) external pure returns (bytes memory) { + return CrossChainV3Helper.packPayload(msgType, nonce, body); + } + + function unpackPayload(bytes calldata payload) + external + pure + returns ( + uint32, + uint64, + bytes memory + ) + { + return CrossChainV3Helper.unpackPayload(payload); + } + + function encodeNewBalancePayload(uint256 newBalance) + external + pure + returns (bytes memory) + { + return CrossChainV3Helper.encodeUint256(newBalance); + } + + function decodeNewBalancePayload(bytes calldata payload) + external + pure + returns (uint256) + { + return CrossChainV3Helper.decodeUint256(payload); + } + + function encodeAmountPayload(uint256 amount) + external + pure + returns (bytes memory) + { + return CrossChainV3Helper.encodeUint256(amount); + } + + function decodeAmountPayload(bytes calldata payload) + external + pure + returns (uint256) + { + return CrossChainV3Helper.decodeUint256(payload); + } + + function encodeWithdrawClaimAckPayload( + uint256 newBalance, + bool success, + uint256 amount + ) external pure returns (bytes memory) { + return + CrossChainV3Helper.encodeWithdrawClaimAckPayload( + newBalance, + success, + amount + ); + } + + function decodeWithdrawClaimAckPayload(bytes calldata payload) + external + pure + returns ( + uint256, + bool, + uint256 + ) + { + return CrossChainV3Helper.decodeWithdrawClaimAckPayload(payload); + } + + function encodeBalanceCheckRequestPayload(uint256 timestamp) + external + pure + returns (bytes memory) + { + return CrossChainV3Helper.encodeUint256(timestamp); + } + + function decodeBalanceCheckRequestPayload(bytes calldata payload) + external + pure + returns (uint256) + { + return CrossChainV3Helper.decodeUint256(payload); + } + + function encodeBalanceCheckResponsePayload( + uint256 balance, + uint256 timestamp + ) external pure returns (bytes memory) { + return + CrossChainV3Helper.encodeBalanceCheckResponsePayload( + balance, + timestamp + ); + } + + function decodeBalanceCheckResponsePayload(bytes calldata payload) + external + pure + returns (uint256, uint256) + { + return CrossChainV3Helper.decodeBalanceCheckResponsePayload(payload); + } + + function encodeBridgeUserPayload( + bytes32 bridgeId, + uint256 amount, + address recipient, + bytes calldata callData, + uint32 callGasLimit + ) external pure returns (bytes memory) { + CrossChainV3Helper.BridgeUserPayload memory p = CrossChainV3Helper + .BridgeUserPayload({ + bridgeId: bridgeId, + amount: amount, + recipient: recipient, + callData: callData, + callGasLimit: callGasLimit + }); + return CrossChainV3Helper.encodeBridgeUserPayload(p); + } + + function decodeBridgeUserPayload(bytes calldata payload) + external + pure + returns ( + bytes32, + uint256, + address, + bytes memory, + uint32 + ) + { + CrossChainV3Helper.BridgeUserPayload memory p = CrossChainV3Helper + .decodeBridgeUserPayload(payload); + return (p.bridgeId, p.amount, p.recipient, p.callData, p.callGasLimit); + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockEthOTokenVault.sol b/contracts/contracts/mocks/crosschainV3/MockEthOTokenVault.sol new file mode 100644 index 0000000000..bbf463ee27 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockEthOTokenVault.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { MockMintableBurnableOToken } from "./MockMintableBurnableOToken.sol"; +import { IBasicToken } from "../../interfaces/IBasicToken.sol"; +import { StableMath } from "../../utils/StableMath.sol"; + +/** + * @title MockEthOTokenVault + * @notice TEST-ONLY Ethereum-side OToken vault stand-in for the V3 RemoteWOTokenStrategy tests. + * + * Mirrors the OUSD VaultCore surface Remote uses, INCLUDING the decimal scaling the + * real vault applies (`scaleBy(18, assetDecimals)` on mint; `scaleBy(assetDecimals, 18)` + * on the withdrawal queue), so a 6dp-asset / 18dp-oToken pair is exercised end-to-end. + * When the asset and OToken share decimals (e.g. WETH/OETH 18/18) every scale is the + * identity, matching production for the OETHb deployment. + * - mint(assetAmount): pulls bridgeAsset, mints scaled OToken to caller (instant, 1:1 value). + * - redeem(oTokenAmount, minAsset): burns OToken, returns scaled bridgeAsset (instant). + * - requestWithdrawal(oTokenAmount) / claimWithdrawal: async queue; the claim pays the + * asset-scaled amount after a configurable delay. + */ +contract MockEthOTokenVault { + using SafeERC20 for IERC20; + using StableMath for uint256; + + address public immutable bridgeAsset; + MockMintableBurnableOToken public immutable oToken; + uint8 public immutable assetDecimals; + uint8 public immutable oTokenDecimals; + + /// @notice Optional delay applied to async withdrawal claims (seconds). Default 0 = instant. + uint256 public withdrawalClaimDelay; + + /// @notice TEST-ONLY: when true, `mint` reverts — mirrors a paused / disabled vault so the + /// Remote deposit handler's revert-free path can be exercised. + bool public revertOnMint; + /// @notice TEST-ONLY: when true, `requestWithdrawal` reverts — mirrors a paused withdrawal + /// queue so the Remote withdraw-request handler's failure path can be exercised. + bool public revertOnRequestWithdrawal; + + struct WithdrawalRequest { + address owner; + uint256 amount; // asset-decimals payout + uint256 claimableAt; + bool claimed; + } + + mapping(uint256 => WithdrawalRequest) public withdrawalRequests; + uint256 public nextRequestId = 1; + + event WithdrawalRequested( + uint256 indexed id, + address indexed owner, + uint256 amount + ); + event WithdrawalClaimed( + uint256 indexed id, + address indexed owner, + uint256 amount + ); + + constructor(address _bridgeAsset, MockMintableBurnableOToken _oToken) { + bridgeAsset = _bridgeAsset; + oToken = _oToken; + assetDecimals = IBasicToken(_bridgeAsset).decimals(); + oTokenDecimals = _oToken.decimals(); + } + + function setWithdrawalClaimDelay(uint256 _delay) external { + withdrawalClaimDelay = _delay; + } + + /// @notice TEST-ONLY: toggle whether `mint` reverts. + function setRevertOnMint(bool _revert) external { + revertOnMint = _revert; + } + + /// @notice TEST-ONLY: toggle whether `requestWithdrawal` reverts. + function setRevertOnRequestWithdrawal(bool _revert) external { + revertOnRequestWithdrawal = _revert; + } + + /// @notice TEST-ONLY: seed the next requestId. Set to 0 to mimic a fresh vault whose + /// first-ever withdrawal returns requestId 0 (exercises the Remote offset-by-one). + function setNextRequestId(uint256 _id) external { + nextRequestId = _id; + } + + // --- Instant mint / redeem --------------------------------------------- + + /// @param _amount Amount of bridgeAsset deposited (asset decimals). Mints scaled OToken. + function mint(uint256 _amount) external { + require(!revertOnMint, "MockEthVault: mint disabled"); + IERC20(bridgeAsset).safeTransferFrom( + msg.sender, + address(this), + _amount + ); + oToken.mint(msg.sender, _amount.scaleBy(oTokenDecimals, assetDecimals)); + } + + /// @param _oTokenAmount OToken to burn (18dp). Returns the asset-scaled bridgeAsset. + /// @dev TEST-ONLY: not exercised by the current suite; retained as a production-surface + /// mirror of the OToken vault's instant-redeem path Remote may use. + function redeem(uint256 _oTokenAmount, uint256 _minAsset) external { + uint256 assetAmount = _oTokenAmount.scaleBy( + assetDecimals, + oTokenDecimals + ); + require(assetAmount >= _minAsset, "MockEthVault: below min"); + oToken.burn(msg.sender, _oTokenAmount); + IERC20(bridgeAsset).safeTransfer(msg.sender, assetAmount); + } + + // --- Async withdrawal queue (used by the OETH/OUSD withdraw path) ------ + + /// @param _oTokenAmount OToken to burn (18dp). The queued payout is in asset decimals. + function requestWithdrawal(uint256 _oTokenAmount) + external + returns (uint256 id, uint256 queued) + { + require( + !revertOnRequestWithdrawal, + "MockEthVault: withdrawals disabled" + ); + // Burn the OToken upfront, mirroring the real vault flow. + oToken.burn(msg.sender, _oTokenAmount); + uint256 assetAmount = _oTokenAmount.scaleBy( + assetDecimals, + oTokenDecimals + ); + id = nextRequestId++; + withdrawalRequests[id] = WithdrawalRequest({ + owner: msg.sender, + amount: assetAmount, + claimableAt: block.timestamp + withdrawalClaimDelay, + claimed: false + }); + queued = assetAmount; + emit WithdrawalRequested(id, msg.sender, assetAmount); + } + + function claimWithdrawal(uint256 _id) external returns (uint256 amount) { + WithdrawalRequest storage r = withdrawalRequests[_id]; + require(r.owner == msg.sender, "MockEthVault: not owner"); + require(!r.claimed, "MockEthVault: already claimed"); + require(block.timestamp >= r.claimableAt, "MockEthVault: queue delay"); + r.claimed = true; + amount = r.amount; // asset decimals + IERC20(bridgeAsset).safeTransfer(msg.sender, amount); + emit WithdrawalClaimed(_id, msg.sender, amount); + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockMintableBurnableOToken.sol b/contracts/contracts/mocks/crosschainV3/MockMintableBurnableOToken.sol new file mode 100644 index 0000000000..dad8be37d4 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockMintableBurnableOToken.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title MockMintableBurnableOToken + * @notice TEST-ONLY OToken stand-in for the V3 strategy unit tests. Mirrors the + * vault-restricted mint / burn surface of the real OUSD / OETH tokens + * without any rebasing or share-credit machinery. + */ +contract MockMintableBurnableOToken is ERC20 { + address public immutable vaultAddress; + + constructor( + string memory name_, + string memory symbol_, + address _vault + ) ERC20(name_, symbol_) { + require(_vault != address(0), "MockOToken: vault required"); + vaultAddress = _vault; + } + + modifier onlyVault() { + require(msg.sender == vaultAddress, "MockOToken: only vault"); + _; + } + + function mint(address _to, uint256 _amount) external onlyVault { + _mint(_to, _amount); + } + + function burn(address _from, uint256 _amount) external onlyVault { + _burn(_from, _amount); + } +} diff --git a/contracts/contracts/mocks/crosschainV3/MockOTokenVault.sol b/contracts/contracts/mocks/crosschainV3/MockOTokenVault.sol new file mode 100644 index 0000000000..a30dc715ce --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockOTokenVault.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { MockMintableBurnableOToken } from "./MockMintableBurnableOToken.sol"; + +interface IStrategyForMock { + function deposit(address asset, uint256 amount) external; + + function depositAll() external; + + function withdraw( + address recipient, + address asset, + uint256 amount + ) external; + + function withdrawAll() external; +} + +/** + * @title MockOTokenVault + * @notice TEST-ONLY minimal vault that exposes `mintForStrategy` / `burnForStrategy` to + * whitelisted strategies for the V3 strategy unit tests. Skips all the real Vault + * surface area (assets registry, allocate, redeem queue, rebase, etc.). + */ +contract MockOTokenVault { + MockMintableBurnableOToken public oToken; + mapping(address => bool) public isMintWhitelistedStrategy; + address public strategistAddr; + + event StrategyWhitelisted(address strategy); + + function setOToken(MockMintableBurnableOToken _oToken) external { + oToken = _oToken; + } + + function setStrategistAddr(address _strategist) external { + strategistAddr = _strategist; + } + + /// @notice TEST-ONLY: mint OToken to an arbitrary holder, mirroring a real user deposit + /// (the vault minting OToken against collateral). Lets a test give a user OToken + /// that did NOT come from a bridge-in, so a BRIDGE_OUT can drive `bridgeAdjustment` + /// negative. + function mintOTokenTo(address _to, uint256 _amount) external { + oToken.mint(_to, _amount); + } + + function whitelistStrategy(address _strategy) external { + isMintWhitelistedStrategy[_strategy] = true; + emit StrategyWhitelisted(_strategy); + } + + function mintForStrategy(uint256 _amount) external { + require( + isMintWhitelistedStrategy[msg.sender], + "MockVault: not whitelisted" + ); + oToken.mint(msg.sender, _amount); + } + + function burnForStrategy(uint256 _amount) external { + require( + isMintWhitelistedStrategy[msg.sender], + "MockVault: not whitelisted" + ); + oToken.burn(msg.sender, _amount); + } + + // --- Test driver helpers ------------------------------------------------- + // These let tests drive `onlyVault`-gated strategy entry points without + // having to impersonate the vault via hardhat helpers (which trips up + // ethers v5 arg-parsing when the impersonated signer is involved). + + function callDeposit( + address _strategy, + address _asset, + uint256 _amount + ) external { + IStrategyForMock(_strategy).deposit(_asset, _amount); + } + + function callDepositAll(address _strategy) external { + IStrategyForMock(_strategy).depositAll(); + } + + function callWithdraw( + address _strategy, + address _recipient, + address _asset, + uint256 _amount + ) external { + IStrategyForMock(_strategy).withdraw(_recipient, _asset, _amount); + } + + function callWithdrawAll(address _strategy) external { + IStrategyForMock(_strategy).withdrawAll(); + } +} diff --git a/contracts/contracts/proxies/create2/BridgeAdapterProxy.sol b/contracts/contracts/proxies/create2/BridgeAdapterProxy.sol new file mode 100644 index 0000000000..eae5e3f20e --- /dev/null +++ b/contracts/contracts/proxies/create2/BridgeAdapterProxy.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { InitializeGovernedUpgradeabilityProxy2 } from "../InitializeGovernedUpgradeabilityProxy2.sol"; + +// ******************************************************** +// ******************************************************** +// IMPORTANT: DO NOT CHANGE ANYTHING IN THIS FILE. +// Any changes to this file (even whitespaces) will +// affect the create2 address of the proxy +// ******************************************************** +// ******************************************************** + +/** + * @notice BridgeAdapterProxy delegates calls to a concrete + * crosschainV3 adapter implementation (CCIPAdapter, CCTPAdapter, + * SuperbridgeAdapter). + * + * Deployed via CREATE3 with a coordinated salt across chains so + * the peer adapter on the destination chain shares this contract's + * own address — the adapter family relies on that parity to validate + * inbound `transportSender == address(this)`. + */ +contract BridgeAdapterProxy is InitializeGovernedUpgradeabilityProxy2 { + constructor(address governor) + InitializeGovernedUpgradeabilityProxy2(governor) + {} +} diff --git a/contracts/contracts/strategies/BridgedWOETHMigrationStrategy.sol b/contracts/contracts/strategies/BridgedWOETHMigrationStrategy.sol new file mode 100644 index 0000000000..1f63284cd4 --- /dev/null +++ b/contracts/contracts/strategies/BridgedWOETHMigrationStrategy.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IRouterClient } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +import { BridgedWOETHStrategy } from "./BridgedWOETHStrategy.sol"; +import { IStrategy } from "../interfaces/IStrategy.sol"; +import { IVault } from "../interfaces/IVault.sol"; +import { NativeFeeHelper } from "./crosschainV3/libraries/NativeFeeHelper.sol"; +import { CCIPMessageBuilder } from "./crosschainV3/libraries/CCIPMessageBuilder.sol"; + +/** + * @title BridgedWOETHMigrationStrategy + * @author Origin Protocol Inc + * + * @notice One-shot migration impl that upgrades the existing BridgedWOETHStrategy proxy on + * 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 existing fields (lastOraclePrice, maxPriceDiffBps) + * and appends three new ones (totalBridged, maxPerBridge, operator) plus an upgrade + * gap. All cross-chain configuration that doesn't change between deploys lives in + * immutables: `master` is both the local Master strategy on Base (read for + * in-flight reconciliation) and the cross-chain CCIP recipient on Ethereum (same + * address by CreateX-driven parity). + * + * Access pattern: + * - `bridgeToRemote` callable by operator, governor, or strategist. + * - `setMaxPerBridge` callable by governor or strategist. + * - `setOperator` callable by governor only. + * - V1's `setMaxPriceDiffBps` (governor-only) and depositBridgedWOETH / + * withdrawBridgedWOETH (governor or strategist) are inherited unchanged. + */ +contract BridgedWOETHMigrationStrategy is BridgedWOETHStrategy { + using SafeERC20 for IERC20; + + // --- Immutables ------------------------------------------------------- + + /// @notice Local Master strategy address on Base. Same address on Ethereum (CreateX + /// parity) points at the Remote strategy — used as the CCIP recipient. + address public immutable master; + + /// @notice Chainlink CCIP Router on this chain (Base). + IRouterClient public immutable ccipRouter; + + /// @notice CCIP chain selector for Ethereum mainnet. + uint64 public immutable ccipChainSelectorMainnet; + + // --- Storage (appended after V1's existing fields) -------------------- + + /// @notice Cumulative wOETH bridged out to the V3 Remote on Ethereum. Used to compute + /// the in-flight component of `checkBalance` until Master reports it. + uint256 public totalBridged; + + /// @notice Per-call cap on `bridgeToRemote`, configurable by governor or strategist. + uint256 public maxPerBridge; + + /// @notice Automation EOA permitted to drive `bridgeToRemote` calls. + address public operator; + + uint256[47] private __gap; + + // --- Events ----------------------------------------------------------- + + event MaxPerBridgeSet(uint256 maxPerBridge); + event OperatorUpdated(address oldOperator, address newOperator); + event WOETHBridgedToRemote(uint256 amount, uint256 totalBridged); + + // --- Errors ----------------------------------------------------------- + + // (none — using require strings for parity with the rest of the codebase) + + // --- Constructor ------------------------------------------------------ + + constructor( + BaseStrategyConfig memory _stratConfig, + address _weth, + address _bridgedWOETH, + address _oethb, + address _oracle, + address _master, + address _ccipRouter, + uint64 _ccipChainSelectorMainnet + ) + BridgedWOETHStrategy( + _stratConfig, + _weth, + _bridgedWOETH, + _oethb, + _oracle + ) + { + require(_master != address(0), "BWM: zero master"); + require(_ccipRouter != address(0), "BWM: zero router"); + master = _master; + ccipRouter = IRouterClient(_ccipRouter); + ccipChainSelectorMainnet = _ccipChainSelectorMainnet; + } + + // --- Access control --------------------------------------------------- + + modifier onlyOperatorGovernorOrStrategist() { + require( + msg.sender == operator || + isGovernor() || + msg.sender == IVault(vaultAddress).strategistAddr(), + "BWM: not authorised" + ); + _; + } + + // --- Operator / cap configuration ------------------------------------ + + function setOperator(address _operator) external onlyGovernor { + emit OperatorUpdated(operator, _operator); + operator = _operator; + } + + function setMaxPerBridge(uint256 _maxPerBridge) + external + onlyGovernorOrStrategist + { + _setMaxPerBridge(_maxPerBridge); + } + + function _setMaxPerBridge(uint256 _maxPerBridge) internal { + require(_maxPerBridge > 0, "BWM: zero max"); + maxPerBridge = _maxPerBridge; + emit MaxPerBridgeSet(_maxPerBridge); + } + + // --- Bridge to Remote ------------------------------------------------- + + /** + * @notice Ship `_amount` of wOETH to the Remote strategy on Ethereum via CCIP. The fee + * is paid in native (either pre-funded on this contract or supplied as + * `msg.value`; any surplus refunds to the caller). + * + * CCIP is invoked with `extraArgs.gasLimit = 0`, which CCIP interprets as + * "token transfer only, no destination callback". The Remote strategy on + * Ethereum receives the wOETH balance directly; no `ccipReceive` runs. + */ + function bridgeToRemote(uint256 _amount) + external + payable + onlyOperatorGovernorOrStrategist + nonReentrant + { + require(_amount > 0 && _amount <= maxPerBridge, "BWM: bad amount"); + require( + bridgedWOETH.balanceOf(address(this)) >= _amount, + "BWM: insufficient wOETH" + ); + + // Same shape (single token amount, native fee, V1 extraArgs) the V3 CCIPAdapter + // builds — `require(_amount > 0)` above guarantees the token-amount branch. + // The CCIP recipient `master` is the peer strategy on Ethereum (the V3 Remote, which + // custodies the wOETH); Master and Remote share an address via CREATE3 deployment. + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + address(bridgedWOETH), + _amount, + "", + master, + 0 + ); + + uint256 fee = ccipRouter.getFee(ccipChainSelectorMainnet, ccipMessage); + NativeFeeHelper.consume(fee); + + IERC20(address(bridgedWOETH)).safeApprove(address(ccipRouter), _amount); + ccipRouter.ccipSend{ value: fee }( + ccipChainSelectorMainnet, + ccipMessage + ); + + totalBridged += _amount; + emit WOETHBridgedToRemote(_amount, totalBridged); + } + + receive() external payable {} + + // --- checkBalance override (WETH-only accounting) -------------------- + + /** + * @notice Returns the strategy's contribution to the OETHb vault in WETH terms. + * @dev Stays entirely in WETH on this side (the V2 design converted Master's WETH to + * wOETH using a stale stored price, which over-counted in-flight whenever Master + * reported value before our oracle ticked). New design: + * - `localValueWETH = bridgedWOETH.balanceOf(self) * lastOraclePrice / 1e18` + * - `bridgedValueWETH = totalBridged * lastOraclePrice / 1e18` + * - in-flight = max(0, bridgedValueWETH - master.checkBalance(weth)) + * Once Master has reported at least the bridged-out value, the in-flight component + * collapses to zero — no negative subtraction needed. + */ + function checkBalance(address _asset) + external + view + override + returns (uint256) + { + require(_asset == address(weth), "BWM: unsupported asset"); + if (lastOraclePrice == 0) return 0; + + uint256 localValueWETH = (bridgedWOETH.balanceOf(address(this)) * + lastOraclePrice) / 1 ether; + + if (totalBridged == 0) { + return localValueWETH; + } + + uint256 bridgedValueWETH = (totalBridged * lastOraclePrice) / 1 ether; + uint256 masterValueWETH = IStrategy(master).checkBalance(address(weth)); + + uint256 inFlight = masterValueWETH >= bridgedValueWETH + ? 0 + : bridgedValueWETH - masterValueWETH; + + return localValueWETH + inFlight; + } +} diff --git a/contracts/contracts/strategies/BridgedWOETHStrategy.sol b/contracts/contracts/strategies/BridgedWOETHStrategy.sol index 37c029acb6..21045a6b60 100644 --- a/contracts/contracts/strategies/BridgedWOETHStrategy.sol +++ b/contracts/contracts/strategies/BridgedWOETHStrategy.sol @@ -216,6 +216,7 @@ contract BridgedWOETHStrategy is InitializableAbstractStrategy { function checkBalance(address _asset) external view + virtual override returns (uint256 balance) { diff --git a/contracts/contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol b/contracts/contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol new file mode 100644 index 0000000000..b1db449596 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Governable } from "../../governance/Governable.sol"; +import { IBridgeAdapter } from "../../interfaces/crosschainV3/IBridgeAdapter.sol"; +import { IBridgeReceiver } from "../../interfaces/crosschainV3/IBridgeReceiver.sol"; +import { CrossChainV3Helper } from "./CrossChainV3Helper.sol"; + +/** + * @title AbstractCrossChainV3Strategy + * @author Origin Protocol Inc + * + * @notice Shared base for OUSD V3 Master (L2) and Remote (Ethereum) strategies. Provides: + * - Bridge-agnostic outbound / inbound adapter wiring. + * - Yield-channel nonce machinery (one yield op in flight at a time). + * - Inbound `receiveMessage` entry point with adapter-only access control, + * dispatching to a single hook the concrete strategy implements. + * - A single outbound `_send` helper that packs `(msgType, nonce, body)` into the + * strategy-owned payload, quotes the adapter fee, and forwards exact native via + * `msg.value`. Excess is NOT refunded — overpayment joins the fee pool (recover + * via `transferNative`). + * + * The abstract does NOT itself inherit `InitializableAbstractStrategy` — it stays + * small and composable. Concrete Master / Remote contracts mix in + * `InitializableAbstractStrategy` separately. + */ +abstract contract AbstractCrossChainV3Strategy is Governable, IBridgeReceiver { + // --- Events ------------------------------------------------------------- + + event OutboundAdapterUpdated(address oldAdapter, address newAdapter); + event InboundAdapterUpdated(address oldAdapter, address newAdapter); + event OperatorUpdated(address oldOperator, address newOperator); + event YieldNonceAdvanced(uint64 nonce); + event YieldNonceProcessed(uint64 nonce); + + // --- Storage (all new slots; nothing relocated from any parent) --------- + + /// @notice Adapter used to send outbound messages and tokens to the peer chain. + address public outboundAdapter; + + /// @notice Adapter authorised to call `receiveMessage` on this strategy. + /// For atomic bridges the outbound and inbound adapters can be the same address; + /// for split-delivery they're typically different. + address public inboundAdapter; + + /// @notice Account allowed to drive periodic, permissioned operations + /// (balance check, settlement, claim trigger). Set by governor. + address public operator; + + /// @notice Highest yield-channel nonce ever assigned. + uint64 public lastYieldNonce; + + /// @notice Marks each yield-channel nonce as processed (true) once its + /// message round-trip completes. + mapping(uint64 => bool) public nonceProcessed; + + /// @notice Timestamp echoed back from the most-recently-accepted balance check ack. + /// Used by `_processBalanceCheckResponse` to enforce strict monotonic ordering + /// when multiple balance checks are in flight at the same yield-nonce window + /// and responses can arrive out of order (CCIP delivery isn't FIFO). + /// @dev Written/read only on the Master leg (which initiates balance checks); inert on + /// the Remote leg, which echoes the nonce back without recording a timestamp. + // slither-disable-next-line constable-states + uint256 public lastBalanceCheckTimestamp; + + /// @dev Reserved for future expansion of this abstract layer. + uint256[50] private __gap; + + // --- Modifiers ---------------------------------------------------------- + + modifier onlyInboundAdapter() { + require( + inboundAdapter != address(0) && msg.sender == inboundAdapter, + "V3: only inbound adapter" + ); + _; + } + + // --- Adapter / operator configuration (governor) ------------------------ + + function setOutboundAdapter(address _outboundAdapter) + external + onlyGovernor + { + _setOutboundAdapter(_outboundAdapter); + } + + /** + * @dev Hook for concrete strategies that need to perform token-allowance swaps when + * the outbound adapter changes (e.g., revoke an old adapter's bridgeAsset + * allowance, grant the new one max). Default implementation just rotates the + * stored address; override to add side effects. + */ + function _setOutboundAdapter(address _outboundAdapter) internal virtual { + emit OutboundAdapterUpdated(outboundAdapter, _outboundAdapter); + outboundAdapter = _outboundAdapter; + } + + function setInboundAdapter(address _inboundAdapter) external onlyGovernor { + emit InboundAdapterUpdated(inboundAdapter, _inboundAdapter); + inboundAdapter = _inboundAdapter; + } + + function setOperator(address _operator) external onlyGovernor { + emit OperatorUpdated(operator, _operator); + operator = _operator; + } + + // --- Yield-channel nonce machinery -------------------------------------- + + /** + * @dev True when a yield-channel operation has been initiated but its ack + * has not yet been processed. + */ + function isYieldOpInFlight() public view returns (bool) { + uint64 n = lastYieldNonce; + if (n == 0) return false; + return !nonceProcessed[n]; + } + + function _getNextYieldNonce() internal returns (uint64) { + require(!isYieldOpInFlight(), "V3: yield op already in flight"); + lastYieldNonce += 1; + emit YieldNonceAdvanced(lastYieldNonce); + return lastYieldNonce; + } + + /** + * @dev Called by the initiating side (typically Master) when an ack lands. Requires the + * nonce to match the most recently issued one and not yet be marked processed. + */ + function _markYieldNonceProcessed(uint64 nonce) internal { + require(nonce == lastYieldNonce, "V3: stale or unknown nonce"); + require(!nonceProcessed[nonce], "V3: nonce already processed"); + nonceProcessed[nonce] = true; + emit YieldNonceProcessed(nonce); + } + + /** + * @dev Called by the receiving side (typically Remote) when an inbound yield-channel + * message arrives. The receiver doesn't issue nonces of its own; it adopts the + * sender's nonce, enforcing strict monotonicity and one-time processing. + */ + function _acceptYieldNonce(uint64 nonce) internal { + require(nonce > lastYieldNonce, "V3: nonce not monotonic"); + require(!nonceProcessed[nonce], "V3: nonce already processed"); + lastYieldNonce = nonce; + nonceProcessed[nonce] = true; + emit YieldNonceProcessed(nonce); + } + + // --- Inbound dispatch --------------------------------------------------- + + /** + * @inheritdoc IBridgeReceiver + * @dev Single ingress for all inbound bridge deliveries. Validates the caller is the + * configured inbound adapter, decodes the strategy-owned `(msgType, nonce, body)` + * from `payload`, and forwards to the concrete strategy's hook. The reentrancy guard + * lives on the bridge-channel inbound (`_handleInboundBridgeMessage`) — the only + * inbound path that makes an UNTRUSTED external call (the optional post-delivery + * callback). Yield acks touch only trusted vault / wrapper contracts, so guarding + * them is unnecessary; keeping the guard off `receiveMessage` also lets a synchronous + * (same-tx) yield round-trip complete without a self-reentrancy false trip (relevant + * only to tests — production bridge delivery is always a separate tx). + */ + function receiveMessage( + address, // sender — redundant with onlyInboundAdapter + CREATE3 peer parity + address, // token — inbound paths that need the delivered token read balanceOf directly + uint256 amountReceived, + bytes calldata payload + ) external override onlyInboundAdapter { + (uint32 msgType, uint64 nonce, bytes memory body) = CrossChainV3Helper + .unpackPayload(payload); + _handleBridgeMessage(amountReceived, msgType, nonce, body); + } + + /** + * @dev Concrete strategies (Master / Remote) override this to dispatch by `msgType` and + * implement the per-message logic. `body` is the message-specific payload (e.g., + * `abi.encode(yieldBaseline)` for DEPOSIT_ACK). + */ + function _handleBridgeMessage( + uint256 amountReceived, + uint32 msgType, + uint64 nonce, + bytes memory body + ) internal virtual; + + // --- Outbound helper ---------------------------------------------------- + // + // One send path, parameterised by `userFunded`: + // userFunded = true → user-initiated sends (bridgeOTokenToPeer). msg.value MUST cover + // 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 + // and the acks Remote sends back). Fee paid from + // `address(this).balance`, which already absorbs any attached + // msg.value via `receive()`. + // + // `token == address(0)` selects the message-only path; otherwise tokens ride along. + // Excess msg.value is NEVER refunded — overpayment joins the strategy's pool (recover via + // `transferNative`, governor only). Callers quote exactly via `IBridgeAdapter.quoteFee`. + function _send( + address token, + uint256 amount, + uint32 msgType, + uint64 nonce, + bytes memory body, + bool userFunded + ) internal { + bytes memory payload = CrossChainV3Helper.packPayload( + msgType, + nonce, + body + ); + address adapter = outboundAdapter; + ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) = IBridgeAdapter(adapter).quoteFee(token, amount, payload); + // Native to forward: the quoted `fee` when the bridge needs external payment, else 0 + // (CCTP-style auto-deduct path). `{ value: 0 }` is a no-op, so a single dispatch + // serves both fee modes. + uint256 payValue = 0; + if (requiresExternalPayment) { + // Only native fee supported today. ERC20 fee tokens (e.g., LINK-mode CCIP) + // would need explicit allowance handling; not implemented here. + require(feeToken == address(0), "V3: only native fee supported"); + require( + (userFunded ? msg.value : address(this).balance) >= fee, + userFunded ? "V3: insufficient user fee" : "V3: pool unfunded" + ); + payValue = fee; + } + // `adapter` is the governor-set outbound adapter, not arbitrary user input. + // slither-disable-start arbitrary-send-eth + if (token == address(0)) { + IBridgeAdapter(adapter).sendMessage{ value: payValue }(payload); + } else { + IBridgeAdapter(adapter).sendMessageAndTokens{ value: payValue }( + token, + amount, + payload + ); + } + // slither-disable-end arbitrary-send-eth + } + + /// @notice Sweep native ETH out of the strategy to governor. Used to drain the fee + /// pool (operator rotation, decommission) or recover stray donations (a user + /// that overpaid msg.value when calling `bridgeOTokenToPeer`). + function transferNative(uint256 amount) external onlyGovernor { + // slither-disable-next-line low-level-calls + (bool ok, ) = governor().call{ value: amount }(""); + require(ok, "V3: native sweep failed"); + } + + /// @dev Strategy accepts native ETH unconditionally. Lands in `address(this).balance` + /// and serves as the fee pool. NEVER counted toward `checkBalance` — that function + /// only sums bridge-asset-denominated slots, so ETH on this contract is naturally + /// invisible to the L2 vault's accounting. (No explicit "exclude ETH" code needed.) + receive() external payable virtual {} +} diff --git a/contracts/contracts/strategies/crosschainV3/AbstractWOTokenStrategy.sol b/contracts/contracts/strategies/crosschainV3/AbstractWOTokenStrategy.sol new file mode 100644 index 0000000000..80303f795e --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/AbstractWOTokenStrategy.sol @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20, SafeERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { IVault } from "../../interfaces/IVault.sol"; +import { IBasicToken } from "../../interfaces/IBasicToken.sol"; +import { StableMath } from "../../utils/StableMath.sol"; + +import { AbstractCrossChainV3Strategy } from "./AbstractCrossChainV3Strategy.sol"; +import { CrossChainV3Helper } from "./CrossChainV3Helper.sol"; + +/** + * @title AbstractWOTokenStrategy + * @author Origin Protocol Inc + * + * @notice Shared base for the wOToken cross-chain strategy pair (Master on L2, Remote on + * Ethereum). Lifts everything that's duplicated between Master and Remote: + * + * - Constants + immutables (bridgeAsset, oToken, MAX_BRIDGE_CALL_GAS). + * - Bridge-channel state (bridgeAdjustment, consumedBridgeIds, bridgeIdCounter). + * - Generic bridge-channel mechanics: outbound send (`bridgeOTokenToPeer`), + * inbound dispatch (`_handleInboundBridgeMessage`), replay protection, signed + * `bridgeAdjustment` bookkeeping, optional post-delivery callback. + * - `_abstractSetPToken` and `collectRewardTokens` no-op stubs (Strategy base + * requires them; neither strategy uses them). + * - `onlyOperatorGovernorOrStrategist` modifier (operator OR strategist OR governor). + * + * Concrete strategies implement four hooks for the small middle of each bridge op + * that differs between the two sides: + * + * - `_bridgeOutboundMsgType()` — Master: BRIDGE_OUT, Remote: BRIDGE_IN. + * - `availableBridgeLiquidity()` — Master: deliverable wOToken ceiling, Remote: unbounded. + * - `_consumeOTokenForBridge(amount)` — Master: burn via vault, Remote: wrap to wOToken. + * - `_deliverOTokenForBridge(amount, recipient)` — Master: mint+transfer, Remote: unwrap+transfer. + */ +abstract contract AbstractWOTokenStrategy is + AbstractCrossChainV3Strategy, + InitializableAbstractStrategy +{ + using SafeERC20 for IERC20; + using StableMath for uint256; + + // --- Constants & immutables -------------------------------------------- + + /// @notice Maximum gas forwarded to the optional post-delivery `callData` call on + /// the bridge channel. Caps griefing surface; users can request lower per call. + uint32 public constant MAX_BRIDGE_CALL_GAS = 500000; + + /// @notice Maximum protocol fee on the bridge channel (10% in basis points). + uint256 public constant MAX_BRIDGE_FEE_BPS = 1000; + + /// @dev Basis-points denominator (100% = 10000) for the bridge-fee calc. + uint256 internal constant BPS_DENOMINATOR = 10000; + + /// @notice Asset that bridges between Master and Remote (USDC for OUSD V3, WETH for OETHb). + address public immutable bridgeAsset; + + /// @notice OToken on this chain (the rebasing OToken — OUSD, OETH, OETHb, etc.). + address public immutable oToken; + + /// @notice Decimals of `bridgeAsset` (6 for USDC, 18 for WETH). Cached at construction. + uint8 public immutable bridgeAssetDecimals; + + /// @notice Decimals of `oToken` (18 for OUSD / OETH). Cached at construction. + uint8 public immutable oTokenDecimals; + + // --- Storage (all new slots) ------------------------------------------- + + /// @notice Signed net delta from bridge-channel activity since the last settlement. + /// BRIDGE_IN (mint locally / wrap locally) → increases. + /// BRIDGE_OUT (burn locally / unwrap locally) → decreases. + int256 public bridgeAdjustment; + + /// @notice Replay protection for the nonceless bridge channel. + mapping(bytes32 => bool) public consumedBridgeIds; + + /// @notice Monotonic counter used to generate fresh bridgeIds for outbound BRIDGE_IN + /// / BRIDGE_OUT operations. NOT globally unique on its own — under CREATE3 parity + /// Master and Remote share `address(this)`, so the same counter yields the same id + /// on both. Replay safety instead comes from `consumedBridgeIds` being per-chain: + /// each side only ever consumes the PEER's ids (Master consumes Remote's BRIDGE_INs, + /// Remote consumes Master's BRIDGE_OUTs), and the peer's counter is monotonic. + uint256 public bridgeIdCounter; + + /// @notice Protocol fee on the bridge channel in basis points (1 bp = 0.01%). Default + /// 0; capped at `MAX_BRIDGE_FEE_BPS`. When > 0, the source side consumes the + /// full `_amount` of OToken while the envelope carries `net = _amount - fee`, + /// so the peer only delivers `net`. The retained `fee` worth of backing flows + /// through the next `BALANCE_CHECK` and lifts the vault's rebase by the + /// fee. + uint256 public bridgeFeeBps; + + /// @dev Reserved for future expansion of this abstract layer. + uint256[50] private __gap; + + // --- Events ------------------------------------------------------------- + + event BridgeRequested( + bytes32 indexed bridgeId, + address indexed sender, + address indexed recipient, + uint256 amount, + uint256 fee, + bytes callData, + uint32 callGasLimit + ); + event BridgeDelivered( + bytes32 indexed bridgeId, + address indexed recipient, + uint256 amount + ); + event BridgeFeeBpsUpdated(uint256 oldBps, uint256 newBps); + event BridgeCallSucceeded( + bytes32 indexed bridgeId, + address indexed recipient, + uint256 amount + ); + event BridgeCallFailed( + bytes32 indexed bridgeId, + address indexed recipient, + uint256 amount, + bytes returnData + ); + + // --- Construction ------------------------------------------------------- + + constructor( + BaseStrategyConfig memory _stratConfig, + address _bridgeAsset, + address _oToken + ) InitializableAbstractStrategy(_stratConfig) { + require(_bridgeAsset != address(0), "WOT: bridge asset required"); + require(_oToken != address(0), "WOT: oToken required"); + bridgeAsset = _bridgeAsset; + oToken = _oToken; + bridgeAssetDecimals = IBasicToken(_bridgeAsset).decimals(); + oTokenDecimals = IBasicToken(_oToken).decimals(); + } + + /// @dev Shared `initialize` body: no reward tokens, `[bridgeAsset]` as the supported + /// asset, and `[pToken]` as the platform token for the strategy registry. Master + /// passes `bridgeAsset` (it has no real platform); Remote passes `woToken`. + function _initWithPToken(address pToken) internal { + address[] memory rewardTokens = new address[](0); + address[] memory assets = new address[](1); + address[] memory pTokens = new address[](1); + assets[0] = bridgeAsset; + pTokens[0] = pToken; + InitializableAbstractStrategy._initialize( + rewardTokens, + assets, + pTokens + ); + } + + // --- Modifiers ---------------------------------------------------------- + + /// @notice Permits the operator, strategist, or governor. + modifier onlyOperatorGovernorOrStrategist() { + require( + msg.sender == operator || + isGovernor() || + msg.sender == IVault(vaultAddress).strategistAddr(), + "WOT: not authorised" + ); + _; + } + + // --- Strategy-base shims (no-op) --------------------------------------- + + /// @inheritdoc InitializableAbstractStrategy + function supportsAsset(address _asset) public view override returns (bool) { + return _asset == bridgeAsset; + } + + /// @inheritdoc InitializableAbstractStrategy + function _abstractSetPToken(address, address) internal override {} + + /// @inheritdoc InitializableAbstractStrategy + function collectRewardTokens() + external + override + onlyHarvesterOrStrategist + nonReentrant + {} + + /** + * @inheritdoc AbstractCrossChainV3Strategy + * @dev Rotates the bridgeAsset allowance from the old outbound adapter to the new one + * (old → 0, new → max) so the per-op send path never needs a per-call approve. + * Shared by Master and Remote — both only ever push bridgeAsset through the adapter. + */ + function _setOutboundAdapter(address _outboundAdapter) + internal + virtual + override + { + address old = outboundAdapter; + if (old != address(0) && old != _outboundAdapter) { + IERC20(bridgeAsset).safeApprove(old, 0); + } + // slither-disable-next-line reentrancy-no-eth + super._setOutboundAdapter(_outboundAdapter); + if (_outboundAdapter != address(0) && old != _outboundAdapter) { + IERC20(bridgeAsset).safeApprove( + _outboundAdapter, + type(uint256).max + ); + } + } + + // --- Decimal scaling ---------------------------------------------------- + // + // The OToken domain (wOToken shares, OToken, `bridgeAdjustment`, + // `remoteStrategyBalance`, the OToken bridge channel) is denominated in + // `oTokenDecimals` (18). The vault / physical domain (deposit / withdraw amounts, + // `pendingDepositAmount`, `pendingWithdrawalAmount`, physical bridge transfers, and + // `checkBalance`'s return value) is denominated in `bridgeAssetDecimals`. These two + // helpers convert between the domains; both are the identity when the decimals match + // (e.g. WETH / OETH 18/18), so the matched-decimal deployment is unaffected. + + /// @dev bridgeAsset units → OToken units. + function _toOToken(uint256 assetAmount) internal view returns (uint256) { + return assetAmount.scaleBy(oTokenDecimals, bridgeAssetDecimals); + } + + /// @dev OToken units → bridgeAsset units. + function _toAsset(uint256 oTokenAmount) internal view returns (uint256) { + return oTokenAmount.scaleBy(bridgeAssetDecimals, oTokenDecimals); + } + + // --- Bridge channel: outbound ------------------------------------------- + + /** + * @notice User-initiated bridge: burn (Master) or wrap (Remote) `_amount` of OToken + * locally and instruct the peer chain to deliver the equivalent amount. + * @param _amount OToken amount to bridge. + * @param _recipient Destination on the peer chain. `address(0)` defaults to msg.sender. + * @param _callData Optional calldata invoked on `_recipient` after token delivery on + * the destination side. Empty for plain bridge. + * @param _callGasLimit Per-call gas cap; must be ≤ MAX_BRIDGE_CALL_GAS. + */ + function bridgeOTokenToPeer( + uint256 _amount, + address _recipient, + bytes calldata _callData, + uint32 _callGasLimit + ) external payable nonReentrant { + require(_amount > 0, "WOT: zero bridge"); + require(outboundAdapter != address(0), "WOT: outbound not set"); + require( + _callGasLimit <= MAX_BRIDGE_CALL_GAS, + "WOT: callGasLimit too high" + ); + require( + _callData.length == 0 || _callGasLimit > 0, + "WOT: callData needs gas" + ); + _bridgeOutboundExec(_amount, _recipient, _callData, _callGasLimit); + } + + /// @dev Split from `bridgeOTokenToPeer` to keep the public function's stack within + /// limits — the burn-full/deliver-net + envelope build chain pushes several + /// locals that crowd the verifier. + function _bridgeOutboundExec( + uint256 _amount, + address _recipient, + bytes calldata _callData, + uint32 _callGasLimit + ) private { + // Burn-full / deliver-net: protocol fee is consumed on the source (full `_amount`) + // but only `net` flows across the bridge; the difference is the retained backing + // that lifts the next rebase. + uint256 fee = (_amount * bridgeFeeBps) / BPS_DENOMINATOR; + uint256 net = _amount - fee; + require(net > 0, "WOT: net zero after fee"); + + // Liquidity gate against `net` (what the peer must produce). Quote the same value + // off-chain via `availableBridgeLiquidity()` first to avoid a revert. + require( + net <= availableBridgeLiquidity(), + "WOT: insufficient bridge liquidity" + ); + + address recipient = _recipient == address(0) ? msg.sender : _recipient; + + // Master burns FULL `_amount`; Remote wraps FULL `_amount`. The fee portion stays + // as backing on the wOToken side and accrues to yield via the next BALANCE_CHECK. + _consumeOTokenForBridge(_amount); + + uint32 msgType = _bridgeOutboundMsgType(); + // Accounting captures the obligation that's actually leaving — `net`. + _applyBridgeAdjustment(msgType, net); + + bytes32 bridgeId = _nextBridgeId(); + bytes memory body = CrossChainV3Helper.encodeBridgeUserPayload( + CrossChainV3Helper.BridgeUserPayload({ + bridgeId: bridgeId, + amount: net, + recipient: recipient, + callData: _callData, + callGasLimit: _callGasLimit + }) + ); + _send(address(0), 0, msgType, 0, body, true); + + emit BridgeRequested( + bridgeId, + msg.sender, + recipient, + net, + fee, + _callData, + _callGasLimit + ); + } + + // --- Governance -------------------------------------------------------- + + function setBridgeFeeBps(uint256 _bps) external onlyGovernor { + require(_bps <= MAX_BRIDGE_FEE_BPS, "WOT: fee too high"); + emit BridgeFeeBpsUpdated(bridgeFeeBps, _bps); + bridgeFeeBps = _bps; + } + + // --- Bridge channel: inbound ------------------------------------------- + + /** + * @dev Called by concrete strategies from `_handleBridgeMessage` when an inbound + * BRIDGE_IN / BRIDGE_OUT envelope arrives. Replay-checked, applies signed + * `bridgeAdjustment`, invokes the side-specific delivery hook, runs the optional + * post-delivery callback. + * + * `nonReentrant` (Governable's shared fixed-slot lock — the same one + * `bridgeOTokenToPeer` / `deposit` acquire) is held through `_postDeliveryCall`'s + * untrusted `recipient.call`, so the callback can't re-enter any state-mutating + * entrypoint. This is the only inbound path with an external callback. + */ + function _handleInboundBridgeMessage( + uint32 msgType, + uint256 amount, + bytes memory body + ) internal nonReentrant { + CrossChainV3Helper.BridgeUserPayload memory p = CrossChainV3Helper + .decodeBridgeUserPayload(body); + + require(!consumedBridgeIds[p.bridgeId], "WOT: bridgeId replayed"); + // Bridge-channel messages are message-only by design; tokens never ride along. + require(amount == 0, "WOT: bridge-in tokens not expected"); + require( + p.callGasLimit <= MAX_BRIDGE_CALL_GAS, + "WOT: callGasLimit too high" + ); + // Defense-in-depth: the trusted CREATE3 peer always sets a non-zero recipient + // (outbound defaults it to msg.sender), but reject a zero recipient explicitly so a + // malformed payload can't consume the bridgeId and then revert in delivery. + require(p.recipient != address(0), "WOT: zero recipient"); + + // CEI: mark consumed, update accounting, deliver tokens, optional call. + consumedBridgeIds[p.bridgeId] = true; + _applyBridgeAdjustment(msgType, p.amount); + + // Side-specific delivery (Master: mint + transfer; Remote: unwrap + transfer). + _deliverOTokenForBridge(p.amount, p.recipient); + + emit BridgeDelivered(p.bridgeId, p.recipient, p.amount); + + if (p.callData.length == 0) { + return; + } + + _postDeliveryCall(p); + } + + /** + * @dev Best-effort post-delivery call on the recipient. Never reverts; tokens have + * already been delivered before this runs. No msg.value forwarded; gas bounded + * by `p.callGasLimit` (already capped above by MAX_BRIDGE_CALL_GAS). + */ + function _postDeliveryCall(CrossChainV3Helper.BridgeUserPayload memory p) + private + { + // slither-disable-next-line low-level-calls,unchecked-lowlevel + (bool ok, bytes memory ret) = p.recipient.call{ + value: 0, + gas: p.callGasLimit + }(p.callData); + if (ok) { + emit BridgeCallSucceeded(p.bridgeId, p.recipient, p.amount); + } else { + emit BridgeCallFailed(p.bridgeId, p.recipient, p.amount, ret); + } + } + + /** + * @dev Apply the signed delta to `bridgeAdjustment` based on the message type. Both + * Master and Remote use the same convention: BRIDGE_IN increases (mint/wrap), + * BRIDGE_OUT decreases (burn/unwrap). The sign is determined by the message type + * alone — no per-side configuration needed. + */ + function _applyBridgeAdjustment(uint32 msgType, uint256 amount) internal { + if (msgType == CrossChainV3Helper.BRIDGE_IN) { + bridgeAdjustment += int256(amount); + } else { + // BRIDGE_OUT (only other valid bridge-channel type; caller enforces this). + bridgeAdjustment -= int256(amount); + } + } + + function _nextBridgeId() internal returns (bytes32) { + bridgeIdCounter += 1; + return keccak256(abi.encode(address(this), bridgeIdCounter)); + } + + // --- Hooks (concrete strategies implement) ----------------------------- + + /// @notice Outbound bridge-channel message type. Master: BRIDGE_OUT, Remote: BRIDGE_IN. + function _bridgeOutboundMsgType() internal pure virtual returns (uint32); + + /** + * @notice Max OToken (18dp) amount currently bridgeable from this chain to the peer — what + * the peer can actually deliver right now. Quote against this before + * `bridgeOTokenToPeer` to avoid a revert. The per-side formula lives in each + * concrete override (Master: bounded by Remote's deliverable shares; Remote: + * unbounded — `type(uint256).max` — since bridging out wraps the user's own OToken). + */ + function availableBridgeLiquidity() public view virtual returns (uint256); + + /** + * @notice Pull OToken from `msg.sender` and consume it on this chain. + * Master: burn via the OToken vault. Remote: wrap to wOToken via the ERC-4626. + */ + function _consumeOTokenForBridge(uint256 amount) internal virtual; + + /** + * @notice Produce OToken on this chain and deliver it to `recipient`. + * Master: mint via the OToken vault, then transfer. Remote: unwrap wOToken to + * OToken, then transfer. + */ + function _deliverOTokenForBridge(uint256 amount, address recipient) + internal + virtual; +} diff --git a/contracts/contracts/strategies/crosschainV3/CrossChainV3Helper.sol b/contracts/contracts/strategies/crosschainV3/CrossChainV3Helper.sol new file mode 100644 index 0000000000..c17aaf288f --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/CrossChainV3Helper.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title CrossChainV3Helper + * @author Origin Protocol Inc + * + * @dev Strategy-level message-type constants and payload codecs for OUSD V3 + * cross-chain messages. The wire envelope (sender + intendedAmount + payload) is + * bridge-adapter-internal; strategies only encode and decode the per-message-type + * payloads below, with the message type discriminator embedded inside the payload + * itself. + */ +library CrossChainV3Helper { + // --- Message type discriminators --------------------------------------- + + // Yield channel (nonce-gated, one operation in flight at a time) + + /// @notice Master → Remote: deposit `amount` of bridgeAsset (carried by the adapter). + uint32 internal constant DEPOSIT = 1; + /// @notice Remote → Master: deposit acknowledgement with Remote's yield-only baseline (OToken 18dp). + uint32 internal constant DEPOSIT_ACK = 2; + /// @notice Master → Remote: leg-1 withdrawal request for `amount` of bridgeAsset. + uint32 internal constant WITHDRAW_REQUEST = 3; + /// @notice Remote → Master: leg-1 acknowledgement with Remote's yield-only baseline (OToken 18dp). + uint32 internal constant WITHDRAW_REQUEST_ACK = 4; + /// @notice Master → Remote: leg-2 trigger to ship the previously-queued amount. + uint32 internal constant WITHDRAW_CLAIM = 5; + /// @notice Remote → Master: leg-2 ack carrying bridgeAsset on success. + uint32 internal constant WITHDRAW_CLAIM_ACK = 6; + /// @notice Master → Remote: read Remote's balance snapshot at a given timestamp. + uint32 internal constant BALANCE_CHECK_REQUEST = 7; + /// @notice Remote → Master: balance response (balance + originating timestamp). + uint32 internal constant BALANCE_CHECK_RESPONSE = 8; + /// @notice Master → Remote: clear the bridge-adjustment accounting on both sides. + uint32 internal constant SETTLE_BRIDGE_ACCOUNTING = 9; + /// @notice Remote → Master: settlement acknowledgement with Remote's yield-only baseline (OToken 18dp). + uint32 internal constant SETTLE_BRIDGE_ACCOUNTING_ACK = 10; + + // Bridge channel (nonceless, multiple operations in flight) + + /// @notice Remote → Master: user-driven bridge of OToken from Ethereum onto the L2. + uint32 internal constant BRIDGE_IN = 11; + /// @notice Master → Remote: user-driven bridge of OToken from L2 back to Ethereum. + uint32 internal constant BRIDGE_OUT = 12; + + // --- Bridge user payload (BRIDGE_IN / BRIDGE_OUT) ----------------------- + + /** + * @dev User-supplied payload for the bridge channel. Encoded inside the + * envelope body. The destination strategy uses `bridgeId` for replay + * protection (see Master / Remote `consumedBridgeIds` mapping) and + * validates `callGasLimit` against its adapter-configured maximum + * before issuing the optional post-delivery call. + */ + struct BridgeUserPayload { + bytes32 bridgeId; + uint256 amount; + address recipient; + bytes callData; + uint32 callGasLimit; + } + + // --- Strategy-level envelope (msgType + nonce + body) ------------------- + // + // Strategies wrap their per-op body bytes inside a small strategy-owned envelope so a + // single `payload` field can carry message-type discrimination and a yield-channel + // nonce without leaking those concerns into the bridge adapter. The adapter sees the + // strategy envelope as opaque bytes. + + /** + * @notice Build the strategy-level envelope: `abi.encode(msgType, nonce, body)`. + */ + function packPayload( + uint32 msgType, + uint64 nonce, + bytes memory body + ) internal pure returns (bytes memory) { + return abi.encode(msgType, nonce, body); + } + + /** + * @notice Decode the strategy-level envelope. + */ + function unpackPayload(bytes memory payload) + internal + pure + returns ( + uint32 msgType, + uint64 nonce, + bytes memory body + ) + { + (msgType, nonce, body) = abi.decode(payload, (uint32, uint64, bytes)); + } + + // --- Per-message payload encoders / decoders ---------------------------- + // + // DEPOSIT : payload empty; amount is carried by the adapter + // DEPOSIT_ACK : payload = abi.encode(yieldBaseline) + // WITHDRAW_REQUEST : payload = abi.encode(amount) + // WITHDRAW_REQUEST_ACK : payload = abi.encode(yieldBaseline, success) + // WITHDRAW_CLAIM : payload empty + // WITHDRAW_CLAIM_ACK : payload = abi.encode(yieldBaseline, success, amount) + // BALANCE_CHECK_REQUEST : payload = abi.encode(timestamp) + // BALANCE_CHECK_RESPONSE : payload = abi.encode(balance, timestamp) + // SETTLE_BRIDGE_ACCOUNTING : payload = abi.encode(int256 snapshot) + // SETTLE_BRIDGE_ACCOUNTING_ACK : payload = abi.encode(yieldBaseline) + // BRIDGE_IN / BRIDGE_OUT : payload = abi.encode(BridgeUserPayload) + + /** + * @notice Encode a single-`uint256` payload — shared by every message whose body is one + * uint256: DEPOSIT_ACK / SETTLE_BRIDGE_ACCOUNTING_ACK (a balance), + * WITHDRAW_REQUEST (an amount), BALANCE_CHECK_REQUEST (a timestamp). + */ + function encodeUint256(uint256 value) internal pure returns (bytes memory) { + return abi.encode(value); + } + + /// @notice Decode the single-`uint256` payload above. + function decodeUint256(bytes memory payload) + internal + pure + returns (uint256) + { + return abi.decode(payload, (uint256)); + } + + /** + * @notice Encode the WITHDRAW_CLAIM_ACK payload. The only R→M yield message that + * carries tokens — `amount` pins the exact bridgeAsset bundled with the + * message (0 on NACK or message-only) so split-delivery receivers can set + * `expectedAmount` without inspecting the bridge transport. + * @param yieldBaseline Remote's yield-only baseline (OToken 18dp) after the claim leg. + * @param success `true` if the claim shipped tokens, `false` if leg-2 NACK'd. + * @param amount bridgeAsset units bundled with this ack; 0 when `success` is false. + */ + function encodeWithdrawClaimAckPayload( + uint256 yieldBaseline, + bool success, + uint256 amount + ) internal pure returns (bytes memory) { + return abi.encode(yieldBaseline, success, amount); + } + + /// @notice Decode the WITHDRAW_CLAIM_ACK 3-tuple payload. + function decodeWithdrawClaimAckPayload(bytes memory payload) + internal + pure + returns ( + uint256 yieldBaseline, + bool success, + uint256 amount + ) + { + return abi.decode(payload, (uint256, bool, uint256)); + } + + /** + * @notice Encode the WITHDRAW_REQUEST_ACK payload. + * @param yieldBaseline Remote's yield-only baseline (OToken 18dp) after the leg-1 request. + * @param success `true` if Remote queued the withdrawal; `false` if the unwrap/queue failed + * (Remote queued nothing, so Master must clear its pending withdrawal and the + * two-leg flow does not proceed to a claim). + */ + function encodeWithdrawRequestAckPayload( + uint256 yieldBaseline, + bool success + ) internal pure returns (bytes memory) { + return abi.encode(yieldBaseline, success); + } + + /// @notice Decode the WITHDRAW_REQUEST_ACK 2-tuple payload. + function decodeWithdrawRequestAckPayload(bytes memory payload) + internal + pure + returns (uint256 yieldBaseline, bool success) + { + return abi.decode(payload, (uint256, bool)); + } + + /// @notice Encode the BALANCE_CHECK_RESPONSE payload (balance + originating ts). + function encodeBalanceCheckResponsePayload( + uint256 balance, + uint256 timestamp + ) internal pure returns (bytes memory) { + return abi.encode(balance, timestamp); + } + + /// @notice Decode the BALANCE_CHECK_RESPONSE 2-tuple payload. + function decodeBalanceCheckResponsePayload(bytes memory payload) + internal + pure + returns (uint256 balance, uint256 timestamp) + { + return abi.decode(payload, (uint256, uint256)); + } + + /** + * @notice Encode the BRIDGE_IN / BRIDGE_OUT payload — packs the 5 user-supplied + * fields the receiving strategy needs to deliver tokens and run the + * optional post-delivery call. + */ + function encodeBridgeUserPayload(BridgeUserPayload memory p) + internal + pure + returns (bytes memory) + { + // Field-by-field, NOT `abi.encode(p)`: this struct has a dynamic member + // (`callData`), so `abi.encode(struct)` would prepend an extra offset word and + // diverge from this established wire layout (and from the JS test encoders / + // already-deployed peers). Keep the flat tuple. + return + abi.encode( + p.bridgeId, + p.amount, + p.recipient, + p.callData, + p.callGasLimit + ); + } + + /// @notice Decode the BRIDGE_IN / BRIDGE_OUT payload into a `BridgeUserPayload`. + function decodeBridgeUserPayload(bytes memory payload) + internal + pure + returns (BridgeUserPayload memory) + { + ( + bytes32 bridgeId, + uint256 amount, + address recipient, + bytes memory callData, + uint32 callGasLimit + ) = abi.decode(payload, (bytes32, uint256, address, bytes, uint32)); + return + BridgeUserPayload({ + bridgeId: bridgeId, + amount: amount, + recipient: recipient, + callData: callData, + callGasLimit: callGasLimit + }); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/DESIGN.md b/contracts/contracts/strategies/crosschainV3/DESIGN.md new file mode 100644 index 0000000000..1edb1056d7 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/DESIGN.md @@ -0,0 +1,582 @@ +# OUSD V3 Cross-Chain Strategy — Design Notes + +This document captures the **why** behind the V3 cross-chain strategy: what +this work delivers, how the pieces fit together, the non-obvious design +decisions and their rationale, and the operational caveats an integrator or +on-call engineer should know. + +For the **what** (file map, message envelope, state-transition table, +adapter knobs), see [`README.md`](./README.md). +For end-to-end flow walkthroughs with sequence diagrams, see +[`FLOWS.md`](./FLOWS.md). + +--- + +## 1. Scope of this work + +This PR introduces the bridge-agnostic cross-chain strategy pair and the +adapter family that drives it. Concretely: + +- **`MasterWOTokenStrategy`** + **`RemoteWOTokenStrategy`** (with abstract bases + `AbstractCrossChainV3Strategy` and `AbstractWOTokenStrategy`). Two channels: + a nonce-gated **yield channel** (deposit / withdraw / balance check / + settlement) and a nonceless **bridge channel** (BRIDGE_IN / BRIDGE_OUT with + user-driven `bridgeOTokenToPeer`). +- **Adapter family** on a shared `AbstractAdapter` base: `CCIPAdapter`, + `CCTPAdapter`, `SuperbridgeAdapter`. Each carries a multi-tenant whitelist, + per-lane config, and a governor-settable `maxTransferAmount` cap. +- **CREATE3 proxies** (`BridgeAdapterProxy`, `CrossChainStrategyProxy`) so the + proxy address is byte-identical on paired chains. Adapter impls are + deployed plain — only the proxy address matters for the + `transportSender == address(this)` peer-parity check. +- **CCTPAdapter.relay()** manually parses the CCTP V2 burn body via + `CCTPMessageHelper.decodeBurnBody`, dispatches the strategy directly with + authoritative `amount`, `feeExecuted`, and `hookData` — works on both V2.0 + and V2.1 chains (no dependency on the V2.1-only auto-callback). +- **Transfer caps** at every adapter (`maxTransferAmount`), plus + `MAX_TRANSFER_AMOUNT = 10M USDC` constant on `CCTPAdapter` (Circle's V2 + per-burn ceiling). +- **`Master.depositAll` / `withdrawAll`** clamp by the relevant adapter's + `maxTransferAmount` view so a vault sweep larger than the bridge per-tx + limit becomes a partial fill rather than reverting. +- **Fast-finality tolerance.** `Master._processWithdrawClaimAck` now accepts + `amount <= ackAmount` so CCTP V2 fast-finality fee deductions don't reject + legitimate withdrawals. +- **Testnet harness** (Sepolia ⇄ Base Sepolia): hardhat config, helpers, + addresses, scripts, mock-vault deploys. End-to-end deploy-able for + rehearsal. +- **Production OETHb deploys** at `deploy/base/100-104_*` and + `deploy/mainnet/210-211_*`. Master/Remote proxies via CREATE3; adapters + behind `BridgeAdapterProxy` (also CREATE3) for paired-chain address + matching. +- **Docs** — `FLOWS.md` (sequence diagrams), `README.md` refresh, + `.claude/skills/add-network/SKILL.md`. +- **116 unit tests** + mainnet/Base fork tests. + +--- + +## 2. Architecture in one page + +Two strategy contracts, one bridge-agnostic adapter API: + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ chain A (vault side) │ │ chain B (yield side) │ +│ │ │ │ +│ OToken vault │ │ OToken vault │ +│ │ │ │ │ │ +│ ▼ │ │ ▼ │ +│ MasterWOTokenStrategy │ ◀── yield ch ──▶ │ RemoteWOTokenStrategy │ +│ │ ▲ │ ◀── bridge ch ─▶ │ │ ▲ │ +│ ▼ │ │ │ ▼ │ │ +│ outbound inbound │ │ outbound inbound │ +│ adapter adapter │ │ adapter adapter │ +│ (proxy) (proxy) │ │ (proxy) (proxy) │ +└─────────────────────────┘ └─────────────────────────┘ + ▲ ▲ + └──── byte-identical via CREATE3 + (peer-parity precondition) +``` + +**Roles:** +- **Master** lives on the chain hosting the rebasing OToken vault. It's the + strategy that vault registers. Vault calls `deposit` / `withdraw`. Master + doesn't hold yield-earning shares; it tracks `remoteStrategyBalance` (last + known Remote balance) + a signed `bridgeAdjustment` (unsettled bridge + channel delta). +- **Remote** lives on the chain hosting the wOToken (ERC-4626 yield wrapper). + Custodian for shares held on behalf of the L2 vault. Runs the + bridgeAsset ↔ OToken ↔ wOToken pipeline using the local OToken vault for + mint/redeem. + +**Two channels:** +- **Yield channel.** DEPOSIT / WITHDRAW_REQUEST / WITHDRAW_CLAIM / + BALANCE_CHECK / SETTLE and their ACKs. Each message has a yield nonce. + Master gates concurrent yield ops via `pendingDepositAmount == 0 && + pendingWithdrawalAmount == 0`. The balance check is the only non-blocking + yield op (nonce-echo, no advance). +- **Bridge channel.** BRIDGE_IN / BRIDGE_OUT. Nonceless. User-driven via + `bridgeOTokenToPeer`. Replay protection via `consumedBridgeIds[bridgeId]`. + Fire-and-forget (no ack). `bridgeAdjustment` accumulates per-op deltas + until settlement clears them. + +**Three adapters, one interface.** The strategy talks to adapters via +`IBridgeAdapter` (outbound) + `IBridgeReceiver` (inbound). Each adapter +encapsulates one bridge transport: +- **CCIPAdapter** — Chainlink CCIP, atomic token + message. +- **CCTPAdapter** — Circle CCTP V2. Burn messages parsed manually in `relay()`; + pure messages go through the V2 hook callback. +- **SuperbridgeAdapter** — split delivery. CCIP for messages; OP Stack + L1StandardBridge for canonical ETH leg. Pending-slot lifecycle in + `pendingFor` mapping. + +See [`FLOWS.md`](./FLOWS.md) for sequence diagrams of each flow. + +--- + +## 3. Design decisions & rationale + +### 3.1 Two channels (yield-gated + bridge-nonceless) + +**Decision.** Strategy operations split into two distinct channels with +different ordering semantics. + +**Why.** Yield ops change protocol-level accounting (`remoteStrategyBalance`, +`pendingDepositAmount`, `pendingWithdrawalAmount`) so they must be serialised — out-of-order +delivery would corrupt state. User-driven bridge ops are independent (each +has its own `bridgeId`) and can run concurrently; gating them on a single +nonce would create a DOS vector (one user could front-run others by +spamming bridge ops). Splitting the channels lets the operator handle the +two cadences independently. + +**How.** +- Yield channel: `_acceptYieldNonce` + `_markYieldNonceProcessed` enforce + monotonic advance. Sender gate `pendingDepositAmount == 0 && + pendingWithdrawalAmount == 0` blocks concurrent yield sends. +- Bridge channel: no nonce, no global gate. Replay protection is + per-message via `consumedBridgeIds[bridgeId]` on the destination side. + +### 3.2 Non-blocking balance check (nonce-echo + three guards on response) + +**Decision.** `requestBalanceCheck` doesn't advance the yield nonce; it +echoes the current value. The response is accepted only when three +independent guards pass. + +**Why.** Balance check is an oracle-update operation that runs on a cadence +(every ~2h). Blocking it on yield-nonce serialisation would force the +operator to choose between fresh balance reads and other yield ops in +flight. Instead: nonce-echo means a balance check can be in flight +concurrently with a deposit/withdraw without locking either out. + +The three guards on the response (`MasterWOTokenStrategy._processBalanceCheckResponse`): +1. `isYieldOpInFlight()` — if a deposit/withdraw started after the balance + check fired, ignore the now-stale reading. +2. `nonce == lastYieldNonce` — if the nonce advanced between request and + response, ignore (a yield op landed in the middle). +3. `respTimestamp > lastBalanceCheckTimestamp` — out-of-order CCIP delivery + of two balance checks in the same nonce window: keep the latest only. + +**Trade-off.** Cost: slight extra storage (`lastBalanceCheckTimestamp`). +Benefit: balance check never blocks the yield channel, and operationally is +the simplest cadence to automate (run on a cron, ignore failures). + +### 3.3 Manual CCTP V2 burn-body parsing in `relay()` + +**Decision.** `CCTPAdapter.relay()` decodes the inner burn body itself +(`CCTPMessageHelper.decodeBurnBody`) and dispatches via `_deliver` directly, +rather than relying on CCTP V2.1's auto-callback to `mintRecipient`. + +**Why.** Not all chains run CCTP V2.1. The V2.0 deployment does not auto-call +the `mintRecipient` after a burn-with-hook; the message is delivered to the +TokenMessenger which then needs an explicit relay. The older +`AbstractCCTPIntegrator` already used manual parsing for the same reason. +By parsing the burn body ourselves, the V3 adapter works identically on +V2.0 and V2.1 deployments — no chain-specific code paths. + +**How.** +- `CCTPMessageHelper.decodeBurnBody` extracts `(burnToken, amount, msgSender, + feeExecuted, hookData)` using the CCTP V2 wire-format offsets. +- `relay()` distinguishes burn vs pure messages by `transportSender == + tokenMessenger` and routes accordingly. +- Pure messages still go through `handleReceiveFinalizedMessage` / + `handleReceiveUnfinalizedMessage` hooks. Those handlers now revert if + `intendedAmount != 0` — a token-bearing message arriving through the + pure-message path is a design violation. + +**Trade-off.** Slight bytecode bloat (~150 lines of parsing logic). Worth it +for the V2.0/V2.1 portability guarantee. + +### 3.4 `amount <= ackAmount` claim tolerance (fast-finality fee) + +**Decision.** `Master._processWithdrawClaimAck` accepts when +`amount <= ackAmount` (not strict equality). The shortfall is the protocol +fee deducted on the destination side. + +**Why.** CCTP V2 fast-finality charges a per-burn fee taken from the burned +amount. The recipient mints `amount - feeExecuted`. If Master enforced +`amount == ackAmount`, fast-finality withdrawals would always revert. + +The shortfall isn't lost — it's yield drag absorbed via the next +BALANCE_CHECK (which refreshes `remoteStrategyBalance` to the new +yield-only baseline). Master ignores `feePaid` entirely; the older +`CrossChainMasterStrategy._onTokenReceived` follows the same pattern (the +`feeExecuted` argument is marked `solhint-disable-next-line +no-unused-vars`). + +**No lower bound.** Master doesn't enforce `amount >= ackAmount * (1 - X%)` +because the older design didn't either, and adding a tolerance threshold +would just create another knob to tune (and revert path to handle). If +Remote ships much less than requested, it shows up as yield drag on the +next balance check — operationally visible. + +### 3.5 CREATE3 peer parity for both proxies and adapter proxies + +**Decision.** Master, Remote, and every adapter live behind a CREATE3-deployed +proxy. Impl contracts are deployed plain (chain-specific addresses are fine). +The proxy address matches on both chains. + +**Why.** The `transportSender == address(this)` check inside `_validateInbound` +requires the source-side adapter address to equal the destination-side +adapter address. The strategy `_deliver` similarly dispatches to +`envelopeSender` (the source strategy), which must resolve to the +destination strategy on the receiving chain. Both checks need byte-identical +addresses across chains. CREATE3 gives that. + +**Why proxy + plain impl, not CREATE3 the impl directly.** Impls have +chain-specific constructor args (CCIPRouter, L1StandardBridge, USDC, +WETH, etc.) — different bytecode → different CREATE3 addresses. The proxy +has a uniform constructor (just `address governor`) so its CREATE3 address +is deterministic. The proxy delegates to the chain-specific impl. + +**See:** `BridgeAdapterProxy.sol`, `CrossChainStrategyProxy.sol`, +`deployBridgeAdapterProxy` helper in `contracts/utils/createXProxyHelper.js`. + +### 3.6 Signed `bridgeAdjustment` + settlement snapshot-subtract + +**Decision.** `bridgeAdjustment` is `int256`. Both sides accumulate signed +deltas per bridge op (BRIDGE_OUT decreases, BRIDGE_IN increases). Settlement +captures `settlementSnapshot = bridgeAdjustment` at request time on Master +and snapshot-subtracts on both sides (NOT zero). + +**Why signed.** BRIDGE_IN and BRIDGE_OUT can interleave; the net delta can +swing in either direction. Tracking sign avoids two separate counters +(in / out) plus the bookkeeping to net them. + +**Why snapshot-subtract on settlement (not `= 0`).** If a new BRIDGE_OUT +happens between `requestSettlement` and the ack, that new delta should +persist after settlement. `bridgeAdjustment -= settlementSnapshot` preserves +it; `bridgeAdjustment = 0` would erase it. The yield-only baseline in the +ack response handles the cross-side ordering: regardless of whether the new +op lands before or after the SETTLE message on Remote, both sides converge +to a consistent `(remoteStrategyBalance + bridgeAdjustment)` total. + +**Why both sides need it.** Master's `checkBalance` adds `bridgeAdjustment` to +`remoteStrategyBalance` to reconstruct true backing. Remote's +`_viewCheckBalance - bridgeAdjustment` strips bridge-channel effects to +report a yield-only baseline. Both sides must have synchronised +`bridgeAdjustment` values (in magnitude) for the math to work. + +### 3.7 `pendingWithdrawalAmount` not in `checkBalance` + +**Decision.** `Master.checkBalance` includes `bridgeAsset.balanceOf(this)` + +`pendingDepositAmount` + `remoteStrategyBalance` + `bridgeAdjustment`, but NOT +`pendingWithdrawalAmount`. + +**Why.** During an in-flight withdrawal, the value is still on Remote (in the +OToken vault's withdrawal queue) and reflected in `remoteStrategyBalance`. +Including it as `pendingWithdrawalAmount` too would double-count. +`pendingWithdrawalAmount` is purely a gate for "is there an in-flight +withdraw," not a balance component. + +**Trade-off.** If Remote's outbound ack is permanently lost (transport +failure),`pendingWithdrawalAmount` stays set forever, blocking future +withdrawals. Mitigation: governor swaps `outboundAdapter` / +`inboundAdapter` to a new adapter and re-delivers the ack via the new +adapter. Not a code change — operational only. + +### 3.8 Fee channel split — user-paid vs operator-pool, no refunds + +**Decision.** A single `_send(token, amount, msgType, nonce, body, userFunded)` +helper with two funding modes selected by `userFunded`: +- **User-paid** (`userFunded = true`): the caller supplies `msg.value` ≥ `fee`. + Used by `bridgeOTokenToPeer`. Any excess `msg.value` stays in the adapter's + balance — no refund. +- **Op-pool** (`userFunded = false`): the fee comes from `address(this).balance`. + Used by the yield channel (deposit / withdraw / balance check / settle). The + operator pre-funds the pool; any inbound refunds also accumulate there. + +**Why split.** User-driven bridge ops should pay their own way (no operator +subsidy of arbitrary user bridges). Yield ops are operator-driven and +predictable; pre-funding the pool is simpler than threading `msg.value` +through every yield call. + +**Why no refunds.** Refunds add code (per-call) for a problem the caller can +solve up front (call `quoteFee` first). Excess `msg.value` becomes adapter +balance, recoverable via `transferToken(address(0), amount)` (governor). +Trade-off: small UX rough edge for users who overpay. Mitigation: the front-end +quotes the fee. + +### 3.9 USDT is not in scope → standard `safeApprove(spender, amount)` + +**Decision.** The codebase uses `safeApprove(spender, amount)` directly, +without zeroing first. + +**Why.** OpenZeppelin's `safeApprove` reverts on a non-zero → non-zero +allowance transition (the USDT quirk). The tokens we actually bridge (USDC, +WETH, plus the OToken family) don't have this quirk. The "defensive +zero-first" pattern adds code surface and gas for a problem we don't have. + +**If USDT ever enters scope** (it won't, but hypothetically): every +per-operation `safeApprove` would need the zero-first dance. Today it's +a non-issue. + +### 3.10 `checkBalance` must never revert and never return negative + +**Decision.** `Master.checkBalance` clamps to 0 when the signed total goes +negative; the function is `view` and has no revert paths. + +**Why.** The vault treats `checkBalance` as an oracle. A reverting balance +read cascades into broken rebases and stuck deposits / redemptions. Even +a hypothetical negative `total` (which shouldn't happen because BRIDGE_OUT +preflights against available liquidity) must be reported as `0`, not as a +revert. + +```solidity +int256 total = int256(...) + bridgeAdjustment; +return total > 0 ? uint256(total) : 0; +``` + +Remote's external `checkBalance` is likewise total: it scales the +OToken-denominated `_viewCheckBalance` down to bridgeAsset units (see 3.11) and +never reverts. The internal `_yieldOnlyBaseline` (`_viewCheckBalance - +bridgeAdjustment`, used only for the R→M yield reports — never for +`checkBalance`) DELIBERATELY reverts if it would go negative: a loud halt is +safer than shipping a wrong value on the balance-bearing path, and a negative +baseline shouldn't arise under normal ops (each BRIDGE_IN/OUT moves +`_viewCheckBalance` and `bridgeAdjustment` by the same amount). Governor recovers +via an implementation upgrade if a slashing / negative rebase ever trips it. + +--- + +### 3.11 Decimal domains — OToken (18dp) internal, bridgeAsset at the vault edge + +**Decision.** The strategy keeps two unit domains and scales only at the seams: +- **OToken (18dp):** `remoteStrategyBalance`, `bridgeAdjustment`, the whole OToken + bridge channel, and Remote's `_viewCheckBalance` / `_yieldOnlyBaseline`. Remote + reports its yield baseline to Master in **18dp**. +- **bridgeAsset decimals (6dp USDC / 18dp WETH):** `pendingDepositAmount`, + `pendingWithdrawalAmount`, `outstandingRequestAmount`, the locally-held balance, + every physical bridge transfer, and the `checkBalance` return value. + +`AbstractWOTokenStrategy._toOToken` / `_toAsset` (thin `StableMath.scaleBy` +wrappers over the cached `bridgeAssetDecimals` / `oTokenDecimals` immutables) do +the conversion. Adapters never scale — they move the physical token at native +decimals. For the matched-decimal OETHb deployment (WETH/OETH 18/18) every scale +is the identity, so the deployed config is unaffected. + +**Why.** `bridgeAdjustment` is intrinsically an OToken (18dp) quantity; storing it +(or `remoteStrategyBalance`) at 6dp would truncate ~12 digits per bridge op and +drift. Keeping the OToken block at 18dp and scaling down once at the `checkBalance` +read preserves full precision; the vault interface still receives bridgeAsset +decimals like every other strategy. Mirrors `CurveAMOStrategy`. + +--- + +### 3.12 Governor is fully trusted across this subsystem + +**Decision / note.** The governor is a fully-trusted role here, on par with the +proxy-upgrade power it already holds: +- `AbstractAdapter.transferToken` can sweep ANY asset off an adapter, including + bridge tokens that rest there transiently or across blocks (e.g. Superbridge + split-delivery WETH stranded until `processStoredMessage`). It is intentionally + NOT guarded by `!supportsAsset` (unlike the strategy base) precisely so it can + recover in-flight / stranded bridge assets. +- `AbstractCrossChainV3Strategy.transferNative` sweeps the native fee pool. +- Governor sets adapters, operator, lane configs, and upgrades the proxies. + +These are expected centralized-trust surfaces, strictly weaker than the upgrade +power, and the only bounded levers (`bridgeFeeBps <= 1000` with `net > 0`; the +per-tx `maxTransferAmount` cap) constrain the operator/economic paths, not the +governor. + +### 3.13 We bridge messages + the backing asset, never the OToken or wOToken + +**Decision.** No OToken or wOToken ever crosses the bridge. What moves differs +by channel: +- **Yield channel** (operator deposit / withdraw): the strategy bridges the + **backing asset** (WETH / USDC) plus a message. Remote mints OToken from that + asset at the local OToken vault and wraps it to wOToken; on withdraw it + unwraps, redeems to the backing asset, and bridges the asset back. +- **Bridge channel** (user `bridgeOTokenToPeer`): the source **burns** the + user's OToken and sends a message only (no token transfer); the destination + **mints** `net = amount - fee` fresh OToken to the recipient. + +**Why.** Bridging the rebasing OToken directly would force every chain to track +the other's rebase, and the in-flight value would be ambiguous while a rebase +lands mid-transit. Burning + re-minting sidesteps that: the OToken supply is +authoritative per chain, and value-in-transit is carried as the backing asset +(yield channel) or as an accounting delta (`bridgeAdjustment`, bridge channel). +A side effect — by design — is that a user who bridges does **not** earn the +OToken's appreciation during transit: they receive `net`, and the retained +`fee` plus any in-flight appreciation accrues to the protocol as yield (the +burn-full / deliver-net mechanic; see §3.6 and `FLOWS.md` §6). This is the +intended behaviour, not a loss path. + +See the OUSD V3 spec for the OToken-vs-wOToken bridging design decision: +https://app.notion.com/p/originprotocol/OUSD-V3-Spec-33c84d46f53c807c80c2c187e0c6c2df + +--- + +## 4. Caveats & operational concerns + +These are NOT bugs — they're things an operator should know. + +### 4.1 Production deploy `proposalId` is empty + +`deploy/mainnet/210_oethb_v3_remote_proxy.js:14` and `211_oethb_v3_remote_impl.js:27` +both have `proposalId: ""`. Fine for fork simulation; blocks the on-chain +governance executor. **Populate the Snapshot UUID before mainnet.** + +### 4.2 OETH-vault Remote registration is undefined + +Base side registers Master via `103_oethb_v3_vault_wiring.js` (Master needs +to mint/burn OETHb for the bridge channel). Mainnet side has no equivalent +governance action touching the OETH vault. **Verify with the team:** does +the OETH vault need Remote registered as a strategy? If yes, a follow-up +governance proposal is needed. + +### 4.3 CCTP V2.0 vs V2.1 deployment uncertainty + +The manual burn-relay in `relay()` works on both V2.0 and V2.1. But +`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. + +### 4.4 Lost claim-ack stalls `pendingWithdrawalAmount` + +If Remote's outbound adapter goes pathological and a leg-2 ack is +permanently lost, Master's `pendingWithdrawalAmount` stays non-zero, +blocking future withdrawals. **Mitigation:** governor calls +`setOutboundAdapter` (Remote) / `setInboundAdapter` (Master) to swap to a +fresh adapter pair; the new pair can re-deliver the ack. No code change +needed — operational only. + +### 4.5 `bridgeAdjustment` unbounded + +No protocol-level upper bound on `|bridgeAdjustment|`. Operational mitigation +only: settlement cadence (6-12h target) bounds the magnitude. **Action item:** +formal operator runbook (pending list item #7 in README) should document the +alert threshold and recovery procedure. + +### 4.6 9-batch Phase 1 migration pacing + +OETHb Phase 1 migrates 8.7k wOETH from the existing `BridgedWOETHStrategy` to +the new Master/Remote pair via 9 × `bridgeToRemote(1000e18)`. **CCIP rate +limits this to ~1000 WETH/hour**, so the migration takes ~9 hours. No +deposits / withdrawals on the new pair during this window — the +`bridgeAdjustment` accumulates and is settled at the end. + +### 4.7 Cleanup script (`104`) is gated by `forceSkip` + +`deploy/base/104_oethb_v3_remove_old_strategy.js` has `forceSkip: true` so +it never auto-fires. **The operator must manually flip this to `false`** +after the 9-batch migration completes and `BridgedWOETHStrategy.checkBalance` +is at dust. + +### 4.8 Adapter `maxTransferAmount` is a per-tx cap, not a per-hour rate + +The CCIP lane has a per-hour rate limit on Chainlink's side (~1000 WETH/h +on the OETHb pair). The adapter's `maxTransferAmount` caps each +individual call, not cumulative time-window throughput. The operator must +still pace operations off-chain to respect the rate limit. **Why not a +time-window?** Adds state + complexity for no real protection — Chainlink +enforces the rate limit on its end anyway, so a contract-side mirror +is redundant defense. + +### 4.9 No refund on user-paid overpayment + +`bridgeOTokenToPeer` accepts any `msg.value >= fee`. Excess stays on the +adapter as donation. **Recovery:** `transferToken(address(0), amount)` +(governor only). UI / front-end should call `quoteFee` first to avoid +donations; if it doesn't, the user loses the difference. + +### 4.10 `lastBalanceCheckTimestamp` is per-Master + +The timestamp guard on balance-check responses is local state on Master. If +Master is upgraded (impl swap) and the storage layout changes, the timestamp +could be reset to 0, accepting a stale response on the next check. **Mitigation:** +storage layout is preserved across upgrades (the slot is part of +`AbstractCrossChainV3Strategy` with explicit `__gap` reservation). Verify +the storage-layout file before any upgrade. + +--- + +## 5. Pending work + +See [`README.md`](./README.md) "Open items for follow-up" for the canonical +pending list. Top of mind for the next PR: + +1. Populate production `proposalId` (4.1) — blocks mainnet deploy. +2. Decide OETH-vault Remote registration (4.2). +3. CCTP testnet harness for OUSD V3 (Iris-sandbox attestation relayer). +4. OETHb Phase 1 base fork test driving the 9-batch migration. +5. Governance proposals: deploy + wire (prop 1), post-migration cleanup + (prop 2). +6. Operator runbook (cadences, failure modes, alert thresholds). +7. OUSD V3 spoke deploys (per spoke chain). + +--- + +## 6. Cross-references + +- **[`README.md`](./README.md)** — reference doc: file map, message + envelope layout, state-transition table, authorisation surface, adapter + knobs, pending list. +- **[`FLOWS.md`](./FLOWS.md)** — narrative walkthroughs of the five core + flows (deposit, withdraw, balance check, bridge in/out, settlement) with + Mermaid sequence diagrams + fee model reference. +- **`.claude/skills/add-network/SKILL.md`** — checklist for adding a new + network to the repo (reusable for OUSD V3 spoke rollouts). +- **`contracts/utils/createXProxyHelper.js`** — shared + `deployBridgeAdapterProxy` / `initBridgeAdapterProxy` helpers for testnet + + production deploys. + +--- + +## 7. Key invariants (one-line summaries) + +For an auditor or on-call engineer reviewing the code quickly: + +- **Master.checkBalance never reverts and never returns negative.** Clamping + to 0 on hypothetical negative totals is intentional. +- **Yield ops are serialised on Master.** `pendingDepositAmount == 0 && + pendingWithdrawalAmount == 0` must hold before a new yield op fires. +- **Balance check is non-blocking** but acceptance requires all three guards + (`isYieldOpInFlight()`, nonce match, timestamp monotonic). +- **Bridge channel is replay-protected** per-`bridgeId`. Same `bridgeId` + delivered twice → second call reverts. +- **Adapter peer parity** (`transportSender == address(this)`) is enforced + on every inbound. CREATE3 deployment gives byte-identical proxy addresses + across paired chains. +- **Yield-only baseline** on Remote: `_viewCheckBalance() - bridgeAdjustment` + strips bridge-channel effects so out-of-order delivery between balance + check and bridge messages doesn't desync `remoteStrategyBalance` on + Master. +- **Settlement preserves in-flight bridge ops.** `bridgeAdjustment -= + settlementSnapshot` (not `= 0`) so a bridge op that landed between + request and ack survives the settlement round. +- **Pool drains only for op-funded sends.** User-funded sends require + `msg.value >= fee` explicitly; pool is never tapped for user paths. +- **Master forwards full local bridgeAsset to vault on claim-ack success.** + Donated bridgeAsset on Master ends up in the vault as "free deposit" — + intentional (locked policy). +- **Yield-ack handlers only call protocol-controlled contracts.** + `receiveMessage` is deliberately NOT `nonReentrant` (so a synchronous + same-tx round-trip works in tests); it is safe only because every + yield-ack handler touches trusted contracts (OToken vault, wOToken 4626, + bridgeAsset, governor-set adapter). The reentrancy guard lives solely on + `_handleInboundBridgeMessage` (the one path with an untrusted + `recipient.call`). Never add an external call to a non-protocol address in + a yield-ack handler. +- **Bridge bounds can't brick the yield channel.** A withdrawal outside the + adapter's `[minTransferAmount, maxTransferAmount]` is rejected at Master + leg 1 (pre-check against the inbound/mirror adapter) and, as defense in + depth, NACK'd — not reverted — at Remote leg 2. A sub-floor / above-cap + amount can never deadlock the one-op-in-flight channel. +- **Queue requestId is stored offset-by-one.** `outstandingRequestId = + vault.requestId + 1`, so a real requestId of 0 (first withdrawal on a fresh + vault) is distinguishable from "no request"; `outstandingRequestId != 0` + means "pending, unclaimed". +- **OToken (18dp) internal, bridgeAsset units at the vault edge** (see §3.11): + `remoteStrategyBalance` / `bridgeAdjustment` / the bridge channel are 18dp; + conversions happen only at the documented seams (identity for 18/18 OETHb). +- **CCTP token legs use the finalised threshold (fee 0).** Fast-finality + (1000–1999) deducts a non-zero burn fee with no `maxFee` headroom, so the + deploy config sets `minFinalityThreshold = 2000` for token-carrying legs. +- **Strategist-gated paths are inert on Remote.** Remote has no vault + (`vaultAddress == 0`), so the strategist branch of the shared modifiers + cannot resolve; Remote runs via governor / operator / permissionless paths. + +These invariants are the load-bearing assumptions across the codebase. If +any one breaks, downstream math goes wrong. Tests cover each one explicitly. diff --git a/contracts/contracts/strategies/crosschainV3/FLOWS.md b/contracts/contracts/strategies/crosschainV3/FLOWS.md new file mode 100644 index 0000000000..bdd0c98d4d --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/FLOWS.md @@ -0,0 +1,999 @@ +# OUSD V3 Cross-Chain Strategy — Flow Walkthroughs + +This document walks through each of the five cross-chain flows end-to-end with +sequence diagrams and prose annotations. Use `README.md` for the reference +material (file map, message envelope, authorisation surface, message-type +table); use this document for "what happens when X." + +The contracts are generic across two products: + +- **OETHb** — OETH bridged between Base (where OETHb lives) and Ethereum + (where wOETH lives and earns yield). Bridge mix: CCIP for messages, OP Stack + canonical bridge for native ETH transfers (split delivery via + `SuperbridgeAdapter`). +- **OUSD V3** — OUSD bridged between Ethereum (where OUSD lives) and L2 spoke + chains (Base, HyperEVM, etc.). Bridge mix: Circle CCTP V2 for everything, + atomic delivery in both directions. + +Walkthroughs default to OETHb for concreteness. Differences for OUSD V3 are +called out inline. + +--- + +## 1. Architecture overview + +### Master and Remote roles + +The strategy pair always has the same role split, regardless of product: + +- **Master** lives on the chain that hosts the rebasing OToken vault. It's the + strategy registered with that vault. The vault calls `Master.deposit()` / + `Master.withdraw()`. Master holds an accounting view of how much value sits + on the peer chain via `remoteStrategyBalance` + a signed `bridgeAdjustment`. + It never holds the yield-earning shares directly. +- **Remote** lives on the chain that hosts the wOToken (the yield-earning + ERC-4626 wrapper). Remote isn't registered with any vault — it's a custodian + for wOToken shares held on behalf of the L2 vault. Remote runs the + bridgeAsset ↔ OToken ↔ wOToken pipeline using the local OToken vault for + mint/redeem. + +For OETHb: Master on Base (OETHb's chain), Remote on Ethereum (wOETH's chain). +For OUSD V3: each spoke chain has a Master in its sub-OUSD vault; Remote on +Ethereum holds the wOUSD that backs that spoke. + +### Two channels + +The cross-chain protocol carries two distinct kinds of messages, gated +differently: + +- **Yield channel** — DEPOSIT, WITHDRAW_REQUEST, WITHDRAW_CLAIM, + BALANCE_CHECK_REQUEST, SETTLE_BRIDGE_ACCOUNTING and their ACK variants. + Nonce-gated (yield-channel nonce machinery in + `AbstractCrossChainV3Strategy`), serialised — one in-flight at a time — + except for balance check which is non-blocking. Drives the protocol-level + accounting between Master and Remote. **All yield-channel messages originate + at Master** (the operator/vault side); Remote only ever replies with ACKs. + +- **Bridge channel** — BRIDGE_IN and BRIDGE_OUT. Nonceless and user-facing. + Multiple can be in flight simultaneously. Replay protection via + `bridgeId = keccak256(strategy, counter)` on the destination side. No ack. + Unlike the yield channel, these originate on **either** side: BRIDGE_OUT + starts at Master, BRIDGE_IN starts at Remote (each from a user's + `bridgeOTokenToPeer`). + +No OToken or wOToken ever crosses the bridge. The yield channel moves the +**backing asset** (WETH / USDC) + a message and mints/wraps on Remote; the +bridge channel **burns** OToken on the source and **mints** `net` on the +destination (message-only). See `DESIGN.md` §3.13 for the rationale. + +### Fee model + +Two separate fee dimensions, never conflated: + +1. **Native fee** (paid in ETH/msg.value) — CCIP and Superbridge charge for + message delivery. CCTP doesn't. +2. **Token-side fee** (deducted from bridged tokens) — CCTP V2 fast-finality + takes a fee out of the burned amount. CCIP and Superbridge don't. + +Native fees come from one of two places depending on who initiated: + +- **User-initiated** (`bridgeOTokenToPeer`) → `msg.value` only. Strict + requirement; pool is not consulted. Prevents pool drain by user paths. +- **Operator-initiated** (yield channel + every Remote-side ack) → the + strategy's local ETH pool (`address(this).balance`). Operator pre-funds. + +Token-side fees are surfaced on the adapter's `MessageDelivered` event (not +forwarded to `receiveMessage`). The receiving strategy accounts on +`amountReceived`; the delta becomes implicit yield drag. + +ETH on the strategy is **never** counted in `checkBalance` — `checkBalance` +only reads bridge-asset-denominated slots. Sweep via +`transferNative(amount) onlyGovernor`. + +### Diagram conventions + +In the sequence diagrams below: + +- **Solid arrows** (`A->>B: call(...)`) are function calls or cross-chain messages. +- **Arrows tagged `«asset N»`** are ERC20 token movements (a `transfer` / `transferFrom`), + drawn from the party that gives up the asset to the party that receives it. To keep the + diagrams readable the token contract is not drawn as its own lifeline. +- **`actor`** lifelines are EOAs (operator, users); **`participant`** lifelines are contracts. + +--- + +## 2. Topology + +### OETHb (single pair) + +```mermaid +flowchart LR + subgraph BASE + L2V[L2 OETHb vault] + Master[Master Strategy] + CCIPb[CCIPAdapter
Base] + Superb[SuperbridgeAdapter
Base] + end + subgraph ETHEREUM + CCIPe[CCIPAdapter
Ethereum] + Supere[SuperbridgeAdapter
Ethereum] + Remote[Remote Strategy] + wOETH[wOETH 4626] + OEV[OETH vault] + end + + L2V --> Master + Master -->|outbound: msgs + WETH via CCIP| CCIPb + CCIPb -->|CCIP| CCIPe + CCIPe --> Remote + Remote -->|outbound: msg via CCIP,
ETH via canonical bridge| Supere + Supere -->|split delivery| Superb + Superb --> Master + Remote -->|holds| wOETH + Remote -->|mint/redeem OETH ↔ WETH| OEV +``` + +Adapters: `CCIPAdapter` (both sides) and `SuperbridgeAdapter` (both sides; L1 +side does `bridgeETHTo`, L2 side wraps incoming ETH to WETH). + +### OUSD V3 (hub-and-spoke, planned) + +Same Master/Remote pattern as OETHb — Master on the spoke chain (where the +sub-OUSD vault lives); Remote on Ethereum (where the wOUSD yield wrapper +lives). One pair per spoke. CCTPAdapter on each chain handles both directions +of that lane atomically. + +```mermaid +flowchart TB + subgraph ETHEREUM [ETHEREUM hub] + OUSDV[OUSD vault] + RB[Remote Strategy
Base] --> wB[wOUSD] + RH[Remote Strategy
Hyper] --> wH[wOUSD] + RS[Remote Strategy
Sonic] --> wS[wOUSD] + OUSDV -.->|mint/redeem| RB + OUSDV -.->|mint/redeem| RH + OUSDV -.->|mint/redeem| RS + end + subgraph SPOKES [Spoke chains] + MB[Master Strategy
Base sub-OUSD vault] + MH[Master Strategy
Hyper sub-OUSD vault] + MS[Master Strategy
Sonic sub-OUSD vault] + end + MB <-->|CCTP| RB + MH <-->|CCTP| RH + MS <-->|CCTP| RS +``` + +Each spoke gets its own (Master, Remote) pair. Remote lives on Ethereum +because that's where the OUSD vault is. CCTPAdapter on each chain handles both +directions — atomic delivery, no native fee, but every inbound message +requires an operator-driven `relay(message, attestation)` call. + +--- + +## 3. Deposit + +User-facing entry: `Vault.allocate()` (or any other path that ends up calling +`Master.deposit()`). The cross-chain machinery runs synchronously inside the +single transaction that lands tokens on Master. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + participant Vault as L2 Vault + participant Master as Master Strategy + participant Adapter as CCIPAdapter (Base, Master outbound) + participant Bridge as CCIP DON + participant AdapterEth as CCIPAdapter (Eth, Remote inbound) + participant Remote as Remote Strategy + participant OEV as OETH Vault (Ethereum) + participant wOETH as wOETH (4626) + participant SuperEth as SuperbridgeAdapter (Eth, Remote outbound) + participant SuperBase as SuperbridgeAdapter (Base, Master inbound) + + Note over Master: state: lastYieldNonce=N + Vault->>Master: «WETH X» transfer (vault funds the strategy first) + Vault->>Master: deposit(bridgeAsset, X) + Note over Master,Vault: Master.deposit is non-payable.
msg.value = 0 by construction. + Master->>Master: _getNextYieldNonce → N+1 + Master->>Master: pendingDepositAmount = X + Master->>Adapter: sendMessageAndTokens(WETH, X, payload[DEPOSIT, N+1, ""]) + Master->>Adapter: «WETH X» (adapter pulls via standing max allowance) + Note over Master,Adapter: _send (userFunded=false): pool funds CCIP fee from
address(this).balance. quoteFee returns (fee, native, true). + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, msg) [WETH bridged over CCIP] + Bridge-->>AdapterEth: ccipReceive (DON pushes) + AdapterEth->>AdapterEth: _validateInbound:
transportSender == address(this) (peer parity)
sourceChain == BASE_SELECTOR
authorised[Remote] == true
!cfg.paused + AdapterEth->>Remote: «WETH X» transfer (adapter delivers tokens first) + AdapterEth->>Remote: receiveMessage(Remote, WETH, X, payload) + Remote->>Remote: unpackPayload → (DEPOSIT, N+1, "") + Remote->>OEV: mint(X) + Remote->>OEV: «WETH X» (vault pulls WETH on mint) + OEV-->>Remote: «OETH X» minted + Remote->>wOETH: deposit(OETH balance, Remote) + Remote->>wOETH: «OETH X» (wrapper pulls OETH on deposit) + wOETH-->>Remote: «wOETH shares» minted + Remote->>Remote: yieldBaseline = _viewCheckBalance() + Remote->>SuperEth: sendMessage(payload[DEPOSIT_ACK, N+1, abi.encode(yieldBaseline)]) + Note over Remote,SuperEth: Remote's configured outbound is the SuperbridgeAdapter. A message-only
send (no tokens) rides purely its CCIP leg (_sendMessage → _sendCCIPMessage,
no canonical bridge). Adapters are swappable: pointing Remote's outbound at the
plain CCIPAdapter also works (atomic; pays the CCIP token fee on token-bearing legs).
Pool funds the fee. + Remote->>Remote: _acceptYieldNonce(N+1)
lastYieldNonce=N+1, nonceProcessed=true + SuperEth->>Bridge: ccipSend + Bridge-->>SuperBase: ccipReceive (intendedAmount=0) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Master->>Master: _processDepositAck:
_markYieldNonceProcessed(N+1)
remoteStrategyBalance = yieldBaseline
pendingDepositAmount = 0 +``` + +### State changes + +**Phase 1 — `Master.deposit(WETH, X)` (Base):** +- `lastYieldNonce: N → N+1` +- `pendingDepositAmount: 0 → X` (counts in `checkBalance` so vault doesn't see backing + disappear during the bridge round trip) +- `Master.WETH balance: X → 0` (pulled by the outbound adapter via its standing max allowance) +- `outboundAdapter.WETH balance: 0 → X → 0` (held momentarily, then handed to the CCIP router) + +**Phase 2 — `Remote._processDeposit(N+1, X)` (Ethereum):** +- WETH consumed by OETH vault mint; OETH wrapped to wOETH. +- `Remote.wOETH balance: increased by ≈X-worth of shares` +- `Remote.lastYieldNonce: → N+1`; `nonceProcessed[N+1] = true` + +**Phase 3 — `Master._processDepositAck(N+1, yieldBaseline)` (Base):** +- `remoteStrategyBalance: B → yieldBaseline` +- `pendingDepositAmount: X → 0` +- `nonceProcessed[N+1] = true` + +`Master.checkBalance(WETH)` is consistent throughout: pre-deposit = B, +mid-flight = X (pendingDepositAmount) + B (stale remoteStrategyBalance), post-ack = +yieldBaseline ≈ B + X. + +### OUSD V3 differences + +The same choreography over CCTP — atomic burn+mint instead of CCIP, and every inbound is +operator-relayed: + +```mermaid +sequenceDiagram + autonumber + participant Vault as Spoke sub-OUSD Vault + participant Master as Master Strategy + actor Op as Operator + participant Adapter as CCTPAdapter (spoke) + participant CCTP as Circle CCTP + participant AdapterEth as CCTPAdapter (Ethereum) + participant Remote as Remote Strategy + participant OUV as OUSD Vault (Ethereum) + participant wOUSD as wOUSD (4626) + + Vault->>Master: «USDC X» transfer (vault funds the strategy first) + Vault->>Master: deposit(USDC, X) + Master->>Adapter: sendMessageAndTokens(USDC, X, payload[DEPOSIT, N+1, ""]) + Master->>Adapter: «USDC X» (adapter pulls) + Note over Master,Adapter: quoteFee = (getMinFeeAmount(X), USDC, false).
Native fee 0; msg.value = 0, no pool needed. + Adapter->>CCTP: depositForBurnWithHook(X) [burns USDC; hook carries the envelope] + Note over Op: polls for Circle's attestation + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>AdapterEth: decodeBurnBody → amount, feeExecuted, envelope
messageTransmitter.receiveMessage → mints USDC to adapter + AdapterEth->>Remote: «USDC landed» transfer (landed = min(mint, amount − feeExecuted)) + AdapterEth->>Remote: receiveMessage(Remote, USDC, landed, payload) + Remote->>OUV: mint(landed) + Remote->>OUV: «USDC landed» (vault pulls on mint) + OUV-->>Remote: «OUSD» minted + Remote->>wOUSD: deposit(OUSD balance, Remote) + Remote->>wOUSD: «OUSD» (wrapper pulls on deposit) + wOUSD-->>Remote: «wOUSD shares» minted + Remote->>Remote: yieldBaseline = _viewCheckBalance() + Remote->>AdapterEth: sendMessage(payload[DEPOSIT_ACK, N+1, abi.encode(yieldBaseline)]) + Note over Remote,AdapterEth: DEPOSIT_ACK is a pure message (intendedAmount = 0):
messageTransmitter.sendMessage, no token leg. + AdapterEth->>CCTP: sendMessage (message-only) + Op->>Adapter: relay(message, attestation) [spoke side] + Adapter->>Master: receiveMessage(Master, 0, 0, payload) + Master->>Master: _processDepositAck:
remoteStrategyBalance = yieldBaseline
pendingDepositAmount = 0 +``` + +Key differences: + +- Outbound adapter: `CCTPAdapter`. `quoteFee` returns `(getMinFeeAmount(X), + USDC, false)` — native fee 0, token-side fee handled by CCTP itself. + `msg.value=0` works directly without needing a pool. +- Inbound is operator-driven: the operator calls `CCTPAdapter.relay(message, + attestation)` after Circle's attestation lands. The CCTP wire message is a + **burn-message + hook** (sourced from `TokenMessenger.depositForBurnWithHook`), + whose transport `sender` is the source-side TokenMessenger and `recipient` + is the destination TokenMessenger — NOT this adapter. Auto-dispatch via + the `handleReceiveMessage` hook on the mintRecipient is CCTP V2.1-only and + not universally available, so `relay()` does NOT rely on it. +- Manual burn parse: `relay()` decodes the burn body via + `CCTPMessageHelper.decodeBurnBody` to extract authoritative `amount`, + `feeExecuted`, `msgSender` (peer adapter under CREATE3 parity), and + `hookData` (our application envelope). It then calls + `messageTransmitter.receiveMessage` to credit USDC to this adapter, + computes `landed = min(actualMint, amount - feeExecuted)`, validates the + envelope, and calls `_deliver(envelopeSender, USDC, landed, feeExecuted, + payload)` directly. +- DEPOSIT_ACK path: a pure message (no token leg). The `handleReceiveFinalizedMessage` + hook fires, runs `_validateInbound`, and `_deliver(envelopeSender, + address(0), 0, 0, payload)`. The hook is restricted to `intendedAmount == 0` + and reverts otherwise — token-bearing messages MUST go through `relay()`'s + burn-message path. +- Token-side fee for CCTP V2 fast-finality: the strategy ignores `feePaid` + (matches older `_onTokenReceived`'s `solhint-disable-next-line` pattern); + the shortfall is yield drag absorbed via the next BALANCE_CHECK. Master's + `_processWithdrawClaimAck` uses `amount <= ackAmount` (not strict equality) + to tolerate this gap. + +--- + +## 4. Withdraw + +Async, two-leg cycle. Vault triggers leg 1 synchronously; operator triggers +leg 2 after the OToken vault's withdrawal queue has matured. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + participant Vault as L2 Vault + participant Master as Master Strategy + actor Op as Operator + participant Adapter as CCIPAdapter (Base) + participant Bridge as CCIP DON + participant AdapterEth as CCIPAdapter (Eth, Master→Remote inbound) + participant Remote as Remote Strategy + participant OEV as OETH Vault (Ethereum) + participant wOETH as wOETH (4626) + participant SuperEth as SuperbridgeAdapter (Eth, Remote outbound) + participant SuperBase as SuperbridgeAdapter (Base, Master inbound) + + Note over Master,Remote: ─── Phase A: vault.withdraw triggers leg 1 synchronously ─── + Vault->>Master: withdraw(vault, WETH, amount) + Master->>Master: require(recipient == vault)
_withdrawRequest(WETH, amount) + Master->>Master: _getNextYieldNonce → N+1
pendingWithdrawalAmount = amount
require(amount <= remoteStrategyBalance) + Master->>Adapter: sendMessage(payload[WITHDRAW_REQUEST, N+1, abi.encode(amount)]) + Note over Master,Adapter: Master.withdraw is non-payable. _send (userFunded=false) uses
pool (address(this).balance) for CCIP fee. + Adapter->>Bridge: ccipSend + Bridge-->>AdapterEth: ccipReceive + AdapterEth->>Remote: receiveMessage(...) + Remote->>wOETH: withdraw(amount, Remote, Remote) [unwrap shares to OETH] + wOETH-->>Remote: «OETH A» unwrapped + Remote->>OEV: requestWithdrawal(amount) + Remote->>OEV: «OETH A» queued for withdrawal + OEV-->>Remote: requestId + Note over Remote: outstandingRequestId = requestId
outstandingRequestAmount = amount + + Note over Master,Remote: ─── Phase B: Remote sends WITHDRAW_REQUEST_ACK ─── + Remote->>Remote: yieldBaseline = _viewCheckBalance() + Remote->>SuperEth: sendMessage(payload[WITHDRAW_REQUEST_ACK, N+1, abi.encode(yieldBaseline)]) + Note over SuperEth: Remote's outbound = SuperbridgeAdapter (Eth).
Message-only rides its CCIP leg (no canonical bridge). + SuperEth->>Bridge: ccipSend + Bridge-->>SuperBase: ccipReceive (intendedAmount=0) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Master->>Master: _processWithdrawRequestAck:
_markYieldNonceProcessed(N+1)
remoteStrategyBalance = yieldBaseline + Note over Master: pendingWithdrawalAmount stays set — gates leg-2 + + Note over Master,Remote: ─── Phase C: queue delay (minutes for OUSD, ~10d for OETH) ─── + + Note over Master,Remote: ─── Phase D: operator triggers leg 2 ─── + Op->>Master: triggerClaim{value: fee}() + Master->>Master: _getNextYieldNonce → N+2 + Master->>Adapter: sendMessage(payload[WITHDRAW_CLAIM, N+2, ""]) + Adapter->>Bridge: ccipSend + Bridge-->>AdapterEth: ccipReceive + AdapterEth->>Remote: receiveMessage(...) + Remote->>Remote: _opportunisticClaim() + Remote->>OEV: claimWithdrawal(requestId) + OEV-->>Remote: «WETH claimed» paid out + Note over Remote: claimed = the WETH the vault actually paid out
outstandingRequestId = 0
outstandingRequestAmount = claimed (refined to the payout) + alt claim succeeded and tokens are in hand + Remote->>SuperEth: sendMessageAndTokens(WETH, claimed, payload[WITHDRAW_CLAIM_ACK, N+2, ack(true)]) + Remote->>SuperEth: «WETH claimed» (adapter pulls) + Note over SuperEth: split delivery Ethereum→Base:
WETH unwrapped to ETH → L1StandardBridge
CCIP message in parallel + SuperEth-->>SuperBase: «ETH claimed» canonical bridge (receive() wraps to WETH on Base) + SuperEth-->>SuperBase: ccipReceive delivers the envelope + SuperBase->>SuperBase: processStoredMessage if needed (split fin.) + SuperBase->>Master: «WETH claimed» transfer (adapter delivers tokens first) + SuperBase->>Master: receiveMessage(Master, WETH, claimed, payload) + Master->>Master: _processWithdrawClaimAck success:
_markYieldNonceProcessed(N+2)
pendingWithdrawalAmount = 0
remoteStrategyBalance = yieldBaseline + Master->>Vault: «WETH» transfer (forwards its full bridgeAsset balance) + Note over Master: safeTransfer(vaultAddress, balanceOf(this))
emit Withdrawal(WETH, WETH, claimed) + else queue not yet matured (NACK) + Remote->>SuperEth: sendMessage(payload[WITHDRAW_CLAIM_ACK, N+2, ack(false)]) + SuperEth->>Bridge: ccipSend + Bridge-->>SuperBase: ccipReceive (intendedAmount=0) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Master->>Master: _processWithdrawClaimAck nack:
_markYieldNonceProcessed(N+2)
remoteStrategyBalance = yieldBaseline
pendingWithdrawalAmount stays set + Note over Master: operator retries triggerClaim later + end +``` + +### Phase notes + +**Phase A — `Vault.withdraw → Master.withdraw(vault, WETH, amount)`:** +synchronous. `onlyVault`, `nonReentrant`, non-payable. Calls +`_withdrawRequest` which assigns the next yield nonce, sets +`pendingWithdrawalAmount`, and ships WITHDRAW_REQUEST. The CCIP fee for the +message comes from Master's local ETH pool (`_send (userFunded=false)` uses +`address(this).balance`); operator must keep it topped up. + +`pendingWithdrawalAmount` gates concurrent ops but is NOT part of +`checkBalance` — the value is still in `remoteStrategyBalance` until the +leg-2 claim ack lands. + +For `withdrawAll` (vault or governor sweep), `_withdrawRequest` is called with +`min(remoteStrategyBalance, inboundAdapter.maxTransferAmount())` so a sweep +larger than the bridge's per-tx limit lands as a partial withdrawal rather +than reverting. + +**Phase B — Remote queues + acks:** Remote unwraps wOETH shares to OETH and +queues the OETH withdrawal on the Ethereum-side OETH vault. Replies with the +new balance. From here Remote's outbound adapter is `SuperbridgeAdapter` on +Ethereum; for message-only sends it just uses CCIP under the hood. + +**Phase C — queue delay.** OETH vault: ~10 days. OUSD vault: ~30 minutes. +During this window Master is in "withdrawal pending" state; the operator must +wait before triggering leg 2. + +**Phase D — `triggerClaim{value: fee}()`:** operator-driven, second leg. +`triggerClaim` is `payable` so the operator funds the CCIP fee for +WITHDRAW_CLAIM; pool-fallback also works. Remote runs `_opportunisticClaim`, +then ships tokens back via WITHDRAW_CLAIM_ACK if successful. NACK if the +queue delay hasn't elapsed — operator retries later. +`outstandingRequestAmount` is refined inside `_opportunisticClaim` to +whatever the vault actually paid out (rounding-safe). + +**Tokens forwarded to vault:** `_processWithdrawClaimAck` success branch +transfers received bridgeAsset to the vault before clearing +`pendingWithdrawalAmount`. Vault sees +`Withdrawal(bridgeAsset, bridgeAsset, claimed)` on Master and the funds in +its own balance. + +### State transition table (Remote) + +From `README.md`, reproduced here for completeness. Each row is a single +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 | +|---|---|---|---|---|---|---| +| Idle | X | 0 | 0 | 0 | 0 | X | +| Requested (post-leg-1) | X − A | 0 | 0 | A | nonzero | X | +| Claimed (post-`claimRemoteWithdrawal`) | X − A | 0 | A | 0 | 0 | X | +| Bridging-out (post-leg-2 send) | X − A | 0 | 0 | 0 | 0 | X − A | +| Completed | X − A | 0 | 0 | 0 | 0 | X − A | + +\* `queued` is no longer a stored slot — it's derived as +`outstandingRequestId != 0 ? outstandingRequestAmount : 0` (so it's `A` only while the queue +request is outstanding, and `0` once claimed). + +### Permissionless touchpoints + +- **`claimRemoteWithdrawal()`** on Remote — anyone can poke the queue claim + once it's matured. Idempotent; safe to spam. +- **`processStoredMessage(target)`** on the split-delivery adapter — once + both CCIP envelope and canonical ETH have landed, anyone can finalise. + +### OUSD V3 differences + +The same two-leg cycle over CCTP — message-only legs plus an atomic burn+mint claim, every +inbound operator-relayed: + +```mermaid +sequenceDiagram + autonumber + participant Vault as Spoke sub-OUSD Vault + participant Master as Master Strategy + actor Op as Operator + participant Adapter as CCTPAdapter (spoke) + participant CCTP as Circle CCTP + participant AdapterEth as CCTPAdapter (Ethereum) + participant Remote as Remote Strategy + participant OUV as OUSD Vault (Ethereum) + participant wOUSD as wOUSD (4626) + + Note over Master,Remote: ─── Leg 1: request (message-only) ─── + Vault->>Master: withdraw(vault, USDC, amount) + Master->>Adapter: sendMessage(payload[WITHDRAW_REQUEST, N+1, amount]) + Adapter->>CCTP: sendMessage (message-only, native fee 0) + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>Remote: receiveMessage(...) + Remote->>wOUSD: withdraw(amount) [unwrap to OUSD] + Remote->>OUV: requestWithdrawal(amount) → requestId + Remote->>AdapterEth: sendMessage(WITHDRAW_REQUEST_ACK, yieldBaseline) + AdapterEth->>CCTP: sendMessage (message-only) + Op->>Adapter: relay(message, attestation) [spoke side] + Adapter->>Master: receiveMessage(...) → remoteStrategyBalance = yieldBaseline + + Note over Master,Remote: ─── queue delay (~30 min for OUSD) ─── + + Note over Master,Remote: ─── Leg 2: claim (atomic burn+mint, carries tokens) ─── + Op->>Master: triggerClaim() + Master->>Adapter: sendMessage(payload[WITHDRAW_CLAIM, N+2, ""]) + Adapter->>CCTP: sendMessage (message-only) + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>Remote: receiveMessage(...) + Remote->>OUV: claimWithdrawal(requestId) + OUV-->>Remote: «USDC claimed» paid out + Remote->>AdapterEth: sendMessageAndTokens(USDC, claimed, [WITHDRAW_CLAIM_ACK, N+2, ack(true)]) + Remote->>AdapterEth: «USDC claimed» (adapter pulls) + AdapterEth->>CCTP: depositForBurnWithHook(claimed) [burns USDC + hook, atomic] + Op->>Adapter: relay(message, attestation) [spoke side] + Adapter->>Adapter: messageTransmitter.receiveMessage → mints USDC to adapter + Adapter->>Master: «USDC landed» transfer (landed = min(mint, claimed − feeExecuted)) + Adapter->>Master: receiveMessage(Master, USDC, landed, payload) + Master->>Vault: «USDC» transfer (forwards its full bridgeAsset balance) + Note over Master: pendingWithdrawalAmount = 0
emit Withdrawal(USDC, USDC, landed) +``` + +Key differences: + +- Both legs use CCTP. Leg-2 (`WITHDRAW_CLAIM_ACK` with tokens) is atomic — + CCTP burns USDC + carries the hook payload in one shot, mints on destination + on `relay`. +- Operator runs `relay(message, attestation)` on each inbound (4 relays per + full cycle: request ack, claim ack on the Master side; request, claim on the + Remote side). +- Token-side fee on the claim-ack leg (if fast-finality used) → strategy sees + `amountReceived < ackAmount`. Master's success-branch already uses + `require(amount <= ackAmount)` (a tolerance window), so the shortfall is + absorbed as yield drag and refreshed on the next BALANCE_CHECK; a finalised + (fee=0) claim leg sees `amount == ackAmount`. (The fee itself is emitted on the + adapter's `MessageDelivered` event, not forwarded to the strategy.) + +--- + +## 5. Check balance + +The operator's "heartbeat" — refreshes `remoteStrategyBalance` to pick up +yield that's accrued on Remote's wOToken shares. **Non-blocking** and +**nonce-echo** (no nonce advance) so it can run any time without blocking +other yield ops. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + actor Op as Operator + participant Master as Master Strategy + participant Adapter as Outbound (Base→ETH) + participant Bridge as CCIP DON + participant AdapterEth as Inbound (ETH side) + participant Remote as Remote Strategy + participant ReturnA as Outbound (ETH→Base) + participant ReturnB as Inbound (Base side) + + Note over Master: lastYieldNonce = N (any value)
bridgeAdjustment = B (any value) + Op->>Master: requestBalanceCheck{value: optionalTopUp}() + Master->>Adapter: sendMessage(payload[BALANCE_CHECK_REQUEST,
nonce=N, abi.encode(block.timestamp)]) + Note over Master: NONCE ECHOED, NOT ADVANCED.
lastYieldNonce stays N. + Adapter->>Bridge: ccipSend + Bridge-->>AdapterEth: ccipReceive + AdapterEth->>Remote: receiveMessage(...) + Note over Remote: yieldOnly = _viewCheckBalance() - bridgeAdjustment
(cancels bridge channel effects: see comment in code) + Remote->>Remote: require(yieldOnly >= 0) + Remote->>ReturnA: sendMessage(payload[BALANCE_CHECK_RESPONSE,
nonce=N, abi.encode(yieldOnly, srcTimestamp)]) + Note over Remote: DOES NOT call _acceptYieldNonce.
Read-only on Remote's side. + ReturnA->>Bridge: ccipSend + Bridge-->>ReturnB: ccipReceive (intendedAmount=0) + ReturnB->>Master: receiveMessage(Master, 0, 0, payload) + Master->>Master: _processBalanceCheckResponse(N, body):
guard 1: if isYieldOpInFlight() → return
guard 2: if respNonce != lastYieldNonce → return
guard 3: if respTimestamp <= lastBalanceCheckTimestamp → return + alt all guards pass + Master->>Master: lastBalanceCheckTimestamp = respTimestamp
remoteStrategyBalance = yieldBaseline + Note over Master: emit BalanceCheckResponded + else any guard fails + Note over Master: silently discard + end +``` + +### Why the three guards + +The response can arrive in three "bad" situations; each guard catches one: + +1. **`isYieldOpInFlight()`** — a deposit/withdraw was kicked off between the + request and the response. Accepting now would race with the upcoming + deposit/withdraw ack and corrupt `remoteStrategyBalance` or `pendingDepositAmount`. + Skip. + +2. **`respNonce != lastYieldNonce`** — a yield op happened and the nonce + advanced. The response is from a prior epoch and reflects pre-op state. + Skip. + +3. **`respTimestamp <= lastBalanceCheckTimestamp`** — multiple balance checks + in flight with the same nonce, but CCIP delivered them out of order. + Without the timestamp guard, an older snapshot could overwrite a newer one + (subtle wOToken-depeg edge case). Strict monotonic timestamp preserves the + latest read. + +### Yield-only baseline (why Remote subtracts `bridgeAdjustment`) + +The math: + +- For each BRIDGE_OUT processed on Remote: `_viewCheckBalance` drops by `net` + AND `bridgeAdjustment -= net`. Difference unchanged. +- For each BRIDGE_IN processed on Remote: `_viewCheckBalance` grows by `full + amount X` AND `bridgeAdjustment += net`. Difference grows by `fee` (the + retained protocol fee). +- Yield accrual on wOToken: `_viewCheckBalance` grows; `bridgeAdjustment` + unchanged. Difference grows monotonically. + +So `_viewCheckBalance - bridgeAdjustment` strips out bridge-channel effects +and reports a pure "yield-and-protocol-fee" baseline. Master adds back its own +`bridgeAdjustment` (always equal in magnitude to Remote's) to reconstruct true +backing in `checkBalance`. The reconstruction is correct regardless of +whether bridge messages have reached Remote yet — out-of-order delivery +between balance check and bridge messages doesn't desync the picture. + +### Why no `_acceptYieldNonce` on Remote + +Balance check is purely read-only on Remote. Bumping the nonce there would +desynchronise Master and Remote's nonce streams (Master's nonce didn't advance +for this op either). The nonce in the envelope is a stale-detection token, +not a state-advance trigger. + +### OUSD V3 differences + +- Both legs use CCTP message-only sends. No native fee. +- Each inbound (request on Ethereum, response on Base) needs an operator + `relay(message, attestation)` call. +- Non-blocking nature is preserved; just requires operator action on each hop. + +--- + +## 6. Bridge in / Bridge out + +User-facing OToken transfers. Independent of yield channel; nonceless; +fire-and-forget (no ack). The "burn-full / deliver-net" mechanic retains a +configurable `bridgeFeeBps` as protocol yield. + +### BRIDGE_OUT (Master burns, Remote unwraps) + +```mermaid +sequenceDiagram + autonumber + actor Alice as User (Alice) + participant Master as Master Strategy + participant L2V as L2 OETHb Vault + participant Adapter as CCIPAdapter (Base) + participant Bridge as CCIP DON + participant AdapterEth as CCIPAdapter (Ethereum) + participant Remote as Remote Strategy + participant wOETH as wOETH (4626) + actor AliceEth as Alice (Ethereum) + + Alice->>Master: approve(Master, X) [OETHb] + Alice->>Master: bridgeOTokenToPeer{value: fee}(X, alice_eth, "0x", 0) + Master->>Master: fee = X * bridgeFeeBps / 10_000
net = X - fee
require(net > 0) + Master->>Master: liquidity gate:
require(net <= availableBridgeLiquidity())
(rsb + bridgeAdjustment - pendingWithdrawalAmount) + Master->>L2V: burnForStrategy(X) + Alice-->>L2V: «OETHb X» pulled from Alice & burned + Note over Master: bridgeAdjustment -= net (NOT -= X)
bridgeIdCounter += 1
bridgeId = keccak256(strategy, counter) + Master->>Master: _send(userFunded=true):
require(msg.value >= ccipFee)
(pool NOT consulted) + Master->>Adapter: sendMessage{value: fee}(payload[BRIDGE_OUT, 0, BridgeUserPayload{
bridgeId, amount=net, recipient=alice_eth, callData, callGasLimit
}]) + Adapter->>Bridge: ccipSend + Note over Master: emit BridgeRequested(bridgeId, alice, alice_eth, net, fee, ...) + Bridge-->>AdapterEth: ccipReceive + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Remote->>Remote: unpack → BRIDGE_OUT, decode BridgeUserPayload
require(!consumedBridgeIds[bridgeId])
consumedBridgeIds[bridgeId] = true
bridgeAdjustment -= net + Remote->>wOETH: withdraw(net, Remote, Remote) [shares→OETH] + wOETH-->>Remote: «OETH net» unwrapped + Remote->>AliceEth: «OETH net» transfer + Note over Remote: emit BridgeDelivered(bridgeId, alice_eth, net) + opt callData provided + Remote->>Remote: _postDeliveryCall(p):
recipient.call{value:0, gas:p.callGasLimit}(p.callData) + Note over Remote: emit BridgeCallSucceeded / BridgeCallFailed + end +``` + +### BRIDGE_IN (Remote wraps, Master mints) — mirror image + +Same structure with the roles flipped: + +- Bob calls `Remote.bridgeOTokenToPeer{value: fee}(Y, bob_base, ...)` on + Ethereum. +- Remote wraps **full Y** OETH into wOETH shares. + - `bridgeAdjustment += net` on Remote. + - Sends BRIDGE_IN envelope to Master via `SuperbridgeAdapter` (message-only; + no canonical bridge leg needed for bridge channel). +- Master receives, decodes BRIDGE_IN, mints **only `net`** OETHb via L2 vault, + transfers to `bob_base`. + - `bridgeAdjustment += net` on Master. + +### Yield retention math + +| | Source side | Destination side | +|---|---|---| +| OToken consumed | full `X` burned (BRIDGE_OUT) or `Y` wrapped (BRIDGE_IN) | — | +| OToken produced | — | `net` delivered | +| `bridgeAdjustment` change | `-net` (BRIDGE_OUT) / `+net` (BRIDGE_IN) | `-net` / `+net` | +| Side note | full amount consumed locally | only net produced locally | + +The `fee` worth of value stays on the wOToken side (Remote retains an extra +`fee` of wOETH shares per BRIDGE_OUT; Remote wraps an extra `fee` of OToken +per BRIDGE_IN). When the next BALANCE_CHECK runs and `remoteStrategyBalance` +refreshes, that extra value shows up. L2 vault's per-OToken backing rises by +`fee` — distributed to all OToken holders on the next rebase. + +### Why no ack + +Bridge channel is fire-and-forget by design. Replay protection lives in +`consumedBridgeIds[bridgeId]` on the destination, not in a nonce that needs +acking. State delta is recorded locally on each side at op-time; +`bridgeAdjustment` accumulates and is reconciled via SETTLE_BRIDGE_ACCOUNTING +periodically. + +If CCIP fails to deliver (rare but possible), the source side has burned and +recorded the deduction in `bridgeAdjustment`, but the destination never marks +the bridgeId consumed. After the next BALANCE_CHECK, the picture self-heals +via yield-only baseline math. No permanent loss, just a temporary undercount +until settlement runs. + +### `callData` callback safety + +- Tokens delivered BEFORE the callback runs (CEI). Revert in callback doesn't + strand funds. +- `callGasLimit ≤ MAX_BRIDGE_CALL_GAS` (500_000) — caps griefing surface. +- No `msg.value` forwarded — callback is pure-data. +- `nonReentrant` on the inbound dispatcher prevents re-entering Master/Remote. + +### User pays via `msg.value` + +`_send(..., userFunded=true)` requires `msg.value >= fee`; pool is NOT consulted. +This is the security gate that prevents a bridge_in/out path from being a pool-drain +vector. Excess `msg.value` becomes pool donation (no refund); user can quote +exactly via `adapter.quoteFee` to avoid this. + +### OUSD V3 differences + +- All transit via CCTP (atomic, no native fee). User passes `msg.value = 0` — + `requiresExternalPayment == false` from `quoteFee`, no payment required. +- Each inbound needs operator `relay`. So user-initiated bridges still depend + on operator presence on the destination side, even though the user did + everything they need to do on the source. + +--- + +## 7. Settlement + +Operator-driven housekeeping. Bounds `bridgeAdjustment` magnitude and provides +a clean state for audit. With the locked design's yield-only baseline in +balance check, `Master.checkBalance` is already accurate without settlement — +settlement is no longer correctness-critical, just hygiene. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + actor Op as Operator + participant Master as Master Strategy + participant Adapter as Outbound + participant Bridge as CCIP DON + participant AdapterEth as Inbound (ETH) + participant Remote as Remote Strategy + participant ReturnA as Outbound (ETH→Base) + participant ReturnB as Inbound (Base) + + Note over Master: bridgeAdjustment = -10 (one BRIDGE_OUT for net=10 happened)
Remote.bridgeAdjustment = -10 also + Op->>Master: requestSettlement{value: fee}() + Master->>Master: nonce = _getNextYieldNonce()
settlementSnapshot = -10
(persisted for ack handler) + Master->>Adapter: sendMessage(payload[SETTLE_BRIDGE_ACCOUNTING, nonce,
abi.encode(int256(-10))]) + Note over Master: emit SettlementRequested(nonce, -10)
Master.bridgeAdjustment STILL -10 (NOT zeroed yet) + Adapter->>Bridge: ccipSend + + Note over Master,Bridge: (optional: a new BRIDGE_OUT for net=5 happens here.
Master.bridgeAdjustment becomes -15. This is the in-flight case.) + + Bridge-->>AdapterEth: ccipReceive + AdapterEth->>Remote: receiveMessage(...) + Remote->>Remote: _processSettlement(nonce, body):
snapshot = -10 (decoded)
bridgeAdjustment -= snapshot // NOT = 0
(if no in-flight: -10 - -10 = 0)
(if in-flight applied before settle: -15 - -10 = -5)
(if in-flight not yet at Remote: -10 - -10 = 0) + Remote->>Remote: yieldOnly = _viewCheckBalance() - bridgeAdjustment
(yield-only baseline preserves consistency across orderings) + Remote->>ReturnA: sendMessage(payload[SETTLE_BRIDGE_ACCOUNTING_ACK, nonce,
abi.encode(yieldOnly)]) + Remote->>Remote: _acceptYieldNonce(nonce) + ReturnA->>Bridge: ccipSend + Bridge-->>ReturnB: ccipReceive + ReturnB->>Master: receiveMessage(...) + Master->>Master: _processSettlementAck:
_markYieldNonceProcessed(nonce)
bridgeAdjustment -= settlementSnapshot // NOT = 0
(if no in-flight: -10 - -10 = 0)
(if in-flight burn: -15 - -10 = -5)
settlementSnapshot = 0
remoteStrategyBalance = yieldOnly + Note over Master: emit SettlementAcked(nonce, yieldOnly) +``` + +### Why snapshot-subtract instead of `= 0` + +If a new BRIDGE_OUT happens between `requestSettlement` and the ack: + +- Master sees the new burn, `bridgeAdjustment` moves to `-15` (was `-10`). +- If we did `bridgeAdjustment = 0` on ack, the new op would be silently erased. +- Snapshot-subtract preserves it: `-15 - (-10) = -5`, the new op stays. + +The same logic applies on Remote, regardless of whether the new BRIDGE_OUT +arrived on Remote before or after the SETTLE message: + +| Ordering on Remote | Before settle | After settle | yield-only reported | +|---|---|---|---| +| BRIDGE_OUT first, then SETTLE | bridgeAdj = -15, wOETH-value = X-4.95 | bridgeAdj -= -10 = -5 | (X-4.95) - (-5) = X+0.05 | +| SETTLE first, then BRIDGE_OUT | bridgeAdj = -10, wOETH-value = X (no unwrap yet) | bridgeAdj -= -10 = 0 → then later -= 4.95 = -4.95 (post BRIDGE_OUT) | At settle ack send-time: X - 0 = X | + +The exact reported value depends on Remote's processing order, BUT the +combination of (Master's residual bridgeAdjustment after subtract) + (the +reported yieldBaseline) is consistent and equals true backing. The yield-only +baseline construction is what makes both orderings converge. + +### When to run settlement + +- Periodic housekeeping (~weekly cadence in production). +- When `|bridgeAdjustment|` is growing uncomfortable relative to + `remoteStrategyBalance` (e.g., > 1%). +- Before any rebase that wants pure yield-based accounting without bridge + channel deltas in the picture. + +### OUSD V3 differences + +- Settlement is still nonce-gated (no change). CCTP relays add operator + intervention on each inbound; pattern is otherwise identical. + +--- + +## 8. Fee model reference + +### Two fee categories, never conflated + +| Category | Where paid | When non-zero | How surfaced | +|---|---|---|---| +| **Native** | Caller's wallet (`msg.value`) → adapter | CCIP always; Superbridge always (CCIP message leg); CCTP **never** | `quoteFee` returns `requiresExternalPayment = true`, `feeToken = address(0)`; strategy enforces `msg.value >= fee` | +| **Token-side** | Bridged token (auto-deducted by protocol) | CCTP V2 fast-finality only | Strategy operates on `amountReceived` (delta becomes yield drag); the fee is emitted on the adapter's `MessageDelivered` event, not forwarded to `receiveMessage`. | + +### One send path, two funding modes + +```solidity +// Single helper. `token == address(0)` selects message-only; userFunded selects who pays. +// userFunded=true — user-initiated bridge_in/out; msg.value MUST cover fee, pool NOT consulted. +// userFunded=false — operator yield ops + ack-triggered sends; pool (address(this).balance) +// covers fee. msg.value (if any) lands via receive() first, augmenting the pool. +function _send(token, amount, msgType, nonce, body, userFunded) internal { ... } +``` + +The split prevents pool-drain attacks: an unauthenticated user-facing path +can't siphon the operator-funded pool. Each bridge tx is paid by the actor +who originated it. + +### `quoteFee` return — what each adapter says + +| Adapter | `(fee, feeToken, requiresExternalPayment)` | Notes | +|---|---|---| +| `CCIPAdapter` | `(routerFee, address(0), true)` | LINK-mode not supported | +| `CCTPAdapter` (msg-only) | `(0, address(0), false)` | Nothing to pay | +| `CCTPAdapter` (with tokens) | `(getMinFeeAmount(amount), USDC, false)` | Informational; CCTP auto-deducts | +| `SuperbridgeAdapter` | `(ccipMessageFee, address(0), true)` | CCIP leg native; canonical bridge free | + +### Pool semantics + +- Pool = `address(this).balance` on Master and on Remote independently. +- Anyone can send ETH to either strategy (`receive() external payable`). Pool + is operationally topped up by the operator/governor. +- ETH **never** counted in `checkBalance` (only bridge-asset slots are + summed; ETH is naturally invisible). +- Sweep via `transferNative(amount) onlyGovernor` (strategy) or + `transferToken(address(0), amount) onlyGovernor` (adapter). +- No refunds anywhere — caller overpayment stays in pool; recover via sweep. + +### Operational pre-funding by product + +| Product | Master pool needs ETH? | Remote pool needs ETH? | +|---|---|---| +| **OETHb** | Yes — CCIP outbound from Base | Yes — CCIP outbound from Ethereum for acks | +| **OUSD V3** | No — CCTP everywhere, fee=0 native | No — same reason | + +--- + +## 9. Adapter knobs reference + +Governor-settable configuration on each adapter. All setters are +`onlyGovernor` and emit a corresponding `*Updated` event. + +### All adapters (via `AbstractAdapter`) + +| Knob | Type | Default | Purpose | +|---|---|---|---| +| `authorise(sender, ChainConfig)` | call | — | Adds a strategy to the lane whitelist with `(paused, chainSelector, destGasLimit)`. | +| `revoke(sender)` | call | — | Removes strategy from whitelist. | +| `setLaneConfig(sender, ChainConfig)` | call | — | Updates lane config in place (mutates routing — governance-grade). | +| `pauseLane(sender)` / `unpauseLane(sender)` | call | — | Strategist OR governor: emergency freeze of a single lane. | +| `addStrategist(addr)` / `removeStrategist(addr)` | call | — | Manage the pause/unpause role list. | +| `maxTransferAmount` | uint256 | 0 (unlimited) | Per-tx cap enforced in `sendMessageAndTokens`. Strategies on the peer chain read this as "max this adapter can deliver inbound" to size their withdrawAll requests. | +| `setMaxTransferAmount(amount)` | call | — | Governor sets the cap. `0` re-disables enforcement. | +| `transferToken(address, amount)` | call | — | Governor sweep of stuck tokens / pool ETH (use `address(0)` for native). | + +### CCTPAdapter-specific + +| Knob | Type | Default | Purpose | +|---|---|---|---| +| `MAX_TRANSFER_AMOUNT` | constant | `10_000_000 * 10**6` (10M USDC) | CCTP V2 protocol cap per burn. Hard-coded; not settable. Enforced ON TOP of the configurable `maxTransferAmount`. | +| `minTransferAmount` | uint256 | 0 | Dust floor. Reject sends below this. Governor-settable. | +| `minFinalityThreshold` | uint32 | 0 (must be set post-deploy) | CCTP V2 finality threshold for outbound sends. 2000 = finalised (zero fee, ~13 min). 1000–1999 = fast finality (non-zero token-side fee, sub-minute). `_sendMessage` / `_sendMessageAndTokens` revert with `"CCTP: threshold not set"` if unset. NOT initialised at declaration to stay proxy-safe. | +| `operator` | address | `address(0)` | The single address authorised to call `relay(message, attestation)` (the off-chain attestation poller). Required for inbound finalisation since `destinationCaller == address(this)` on every burn. | + +### Inbound dispatch paths + +CCTP V2 has two on-wire message shapes; `CCTPAdapter` handles them on different paths: + +- **Burn-message + hook** (sourced from `TokenMessenger.depositForBurnWithHook`). + Routed through `relay()`, which manually parses the burn body + (`CCTPMessageHelper.decodeBurnBody`) for authoritative `amount`, + `feeExecuted`, `msgSender`, and `hookData`. Calls + `messageTransmitter.receiveMessage` to credit USDC, then dispatches + `_deliver` with `amount - feeExecuted`. The `handleReceiveMessage` hook is + NOT used for these — that's V2.1-only behaviour and we don't rely on it. + +- **Pure message** (sourced from `MessageTransmitter.sendMessage`). + `relay()` invokes `messageTransmitter.receiveMessage` which fires the + callback hook. The hook is restricted to `intendedAmount == 0` and reverts + otherwise — token-bearing messages going through this path is a design + violation. + +### Finality handler gates + +Both `handleReceiveFinalizedMessage` and `handleReceiveUnfinalizedMessage` +accept inbound (pure-message) deliveries; the difference is the finality gate: + +- **`handleReceiveFinalizedMessage`** — fires when CCTP confirms with + `finalityThresholdExecuted >= 2000`. Always accepts (since 2000 ≥ any + configured threshold). +- **`handleReceiveUnfinalizedMessage`** — fires when CCTP confirms with + `1000 <= finalityThresholdExecuted < 2000`. Accepts only when + `finalityThresholdExecuted >= minFinalityThreshold`. This is the fast-finality + path; rejecting it (the old behaviour) broke fast-finality entirely. + +### Master `_depositToRemote` / `_withdrawRequest` interaction + +- `Master.depositAll` clamps `local bridgeAsset balance` to + `outboundAdapter.maxTransferAmount()` before sending. Vault sweep larger + than the bridge's per-tx limit becomes a partial deposit; remainder stays on + Master for the next cycle. +- `Master.withdrawAll` clamps `remoteStrategyBalance` to + `inboundAdapter.maxTransferAmount()` before sending WITHDRAW_REQUEST. Same + partial-fill rationale. Inbound adapter is used because Master can't query + Remote's outbound across chains — the symmetric inbound adapter on this + chain holds the same protocol-level cap (outbound + inbound are mirrors of + the same lane). +- `Master.deposit` and `Master.withdraw` (specific-amount, vault-driven) do + NOT clamp — they propagate the adapter's revert if amount exceeds the cap. + Operator splits via depositAll/withdrawAll or sequenced batches. + +### Suggested per-deployment values + +| Deployment | Adapter | maxTransferAmount | Other | +|---|---|---|---| +| OETHb / Base CCIPAdapter (Master outbound) | `1000 ether` | CCIP lane rate ~1000 WETH/hour | — | +| OETHb / Eth SuperbridgeAdapter (Remote outbound) | `0` (unlimited) | canonical bridge has no per-tx limit | — | +| OETHb / Base SuperbridgeAdapter (Master inbound) | match Remote outbound | mirror; `0` works | — | +| OETHb / Eth CCIPAdapter (Remote inbound) | match Master outbound (`1000 ether`) | — | — | +| OUSD V3 / Spoke CCTPAdapter | `10_000_000 * 10**6` (or less for tighter ops) | also set `minTransferAmount = 1 USDC`, `minFinalityThreshold = 2000` | — | +| OUSD V3 / Eth CCTPAdapter | same | — | — | + +--- + +## 10. Glossary + +| Term | Meaning | +|---|---| +| **Master** | Strategy on the chain that hosts the rebasing OToken vault. Registered with that vault. | +| **Remote** | Strategy on the chain that hosts the wOToken (yield-earning wrapper). Not registered with any vault — custodian for shares. | +| **wOToken** | ERC-4626 wrapper of the OToken (wOETH wraps OETH; wOUSD wraps OUSD). | +| **Yield channel** | Protocol-internal messages (deposit/withdraw/ack/balance check/settle). Nonce-gated except balance check. | +| **Bridge channel** | User-facing messages (BRIDGE_IN, BRIDGE_OUT). Nonceless. | +| **bridgeAdjustment** | Signed net delta from bridge-channel activity since last settlement. Tracked on both sides; always equal in magnitude. | +| **remoteStrategyBalance** | Master's cached snapshot of Remote's `_viewCheckBalance` minus Remote's `bridgeAdjustment` (i.e., yield-only baseline). Updated by balance check and settlement acks. | +| **pendingDepositAmount** | Master's in-flight deposit value. Counts in `checkBalance` so vault doesn't see backing dip during bridge round-trip. | +| **pendingWithdrawalAmount** | Master's in-flight withdrawal amount. Gates concurrent ops; NOT in `checkBalance` (value is already in `remoteStrategyBalance` until claim ack). | +| **claimed** | The bridgeAsset the OToken vault actually paid out on `claimWithdrawal(requestId)` (`RemoteWOTokenStrategy._opportunisticClaim`). `outstandingRequestAmount` is refined to it so leg-2 ships exactly the vault's payout, not the originally-requested amount. | +| **settlementSnapshot** | `bridgeAdjustment` value captured at request time, persisted on Master so the ack handler can subtract exactly that delta. Preserves in-flight bridge ops. | +| **lastBalanceCheckTimestamp** | Most recently accepted balance check timestamp. Enforces strict monotonic ordering across out-of-order CCIP delivery. | +| **bridgeId** | `keccak256(strategy, counter)`. Unique per user bridge op. Recorded in `consumedBridgeIds[bridgeId]` on destination for replay protection. | +| **bridgeFeeBps** | Protocol fee on the bridge channel in basis points. Default 0; capped at 1000 (10%). Burn-full / deliver-net: full `_amount` consumed locally; only `net = _amount - fee` flows to destination; difference becomes rebase yield. | +| **Yield-only baseline** | `_viewCheckBalance() - bridgeAdjustment` — strips bridge-channel effects from the reported balance. Master adds back its own `bridgeAdjustment` to reconstruct true backing. | + +--- + +For deeper rationale on any design decision, see inline `why` comments at the +relevant function in source. Each non-obvious decision (yield-only baseline, +snapshot-subtract, three-guard balance check, user-vs-op fee split, no-refunds +policy) is documented at its call site. diff --git a/contracts/contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol b/contracts/contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol new file mode 100644 index 0000000000..d90dbfe453 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20, SafeERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { IVault } from "../../interfaces/IVault.sol"; +import { IBridgeAdapter } from "../../interfaces/crosschainV3/IBridgeAdapter.sol"; + +import { AbstractWOTokenStrategy } from "./AbstractWOTokenStrategy.sol"; +import { CrossChainV3Helper } from "./CrossChainV3Helper.sol"; + +/** + * @title MasterWOTokenStrategy + * @author Origin Protocol Inc + * + * @notice Vault-facing leg of the wOToken cross-chain strategy pair. Registered with the + * OToken vault on its own chain; orchestrates deposits, withdrawals, balance + * checks, and settlement against the Remote strategy on the peer chain. Topology + * is deployment-dependent (e.g., for OETHb, Master is on Base and Remote on + * Ethereum; for OUSD V3, the topology can be inverted per spoke). + * Bridge-channel mechanics (`bridgeOTokenToPeer`, + * inbound BRIDGE_IN handling, replay protection, signed `bridgeAdjustment` + * bookkeeping) live in `AbstractWOTokenStrategy` and are wired here via four hooks. + * + * Master is intentionally dumb on the withdrawal queue. It never sees a `requestId`, + * never tracks per-withdrawal state beyond a single in-flight amount flag — Remote + * owns the queue lifecycle. See the V3 design plan for the full state-transition table. + */ +contract MasterWOTokenStrategy is AbstractWOTokenStrategy { + using SafeERC20 for IERC20; + + // --- Storage (all new slots; nothing from any parent is relocated) ----- + + /// @notice Last reported Remote yield-only baseline, denominated in **OToken (18dp)** units + /// (Remote always reports `_yieldOnlyBaseline()`, never its bridgeAsset checkBalance). + /// Scaled down to bridgeAsset units at the checkBalance / withdraw seams via + /// `_toAsset`. Updated by each yield-channel ack (deposit, withdrawal, balance check, + /// settlement). + uint256 public remoteStrategyBalance; + + /// @notice In-flight deposit amount (zero when no deposit is pending). + /// Part of `checkBalance` so that bridged-but-not-yet-acked tokens stay accounted for. + uint256 public pendingDepositAmount; + + /// @notice In-flight withdrawal amount (zero when no withdrawal is pending). Pure state flag — + /// NOT part of `checkBalance` because the value is already covered by the stale + /// `remoteStrategyBalance` until the leg-2 ack lands. + uint256 public pendingWithdrawalAmount; + + /// @notice Snapshot of `bridgeAdjustment` captured at the moment `requestSettlement` + /// fires. The ack handler subtracts exactly this value (not zero) so that any + /// bridge ops processed between request and ack are preserved on both sides. + /// See `_processSettlementAck` for rationale. + int256 public settlementSnapshot; + + /// @dev Reserved for future expansion. + uint256[41] private __gap; + + // --- Events ------------------------------------------------------------- + + event RemoteStrategyBalanceUpdated(uint256 yieldBaseline); + event DepositRequested(uint64 nonce, uint256 amount); + event DepositAcked(uint64 nonce, uint256 yieldBaseline); + event WithdrawRequested(uint64 nonce, uint256 amount); + event WithdrawRequestAcked(uint64 nonce, uint256 yieldBaseline); + event WithdrawClaimTriggered(uint64 nonce, uint256 amount); + event WithdrawClaimAcked(uint64 nonce, uint256 yieldBaseline, bool success); + event BalanceCheckRequested(uint64 nonce, uint256 timestamp); + event BalanceCheckResponded( + uint64 nonce, + uint256 yieldBaseline, + uint256 remoteTimestamp + ); + event SettlementRequested(uint64 nonce, int256 bridgeAdjustmentSnapshot); + event SettlementAcked(uint64 nonce, uint256 yieldBaseline); + + // --- Construction / initialisation ------------------------------------- + + constructor( + BaseStrategyConfig memory _stratConfig, + address _bridgeAsset, + address _oToken + ) AbstractWOTokenStrategy(_stratConfig, _bridgeAsset, _oToken) { + require( + _stratConfig.platformAddress == address(0), + "Master: platform must be zero" + ); + require( + _stratConfig.vaultAddress != address(0), + "Master: vault required" + ); + // This is an implementation contract. The governor is set in the proxy contract. + _setGovernor(address(0)); + } + + function initialize(address _operator) external onlyGovernor initializer { + operator = _operator; + // No real platform; mirror the bridgeAsset as the registry pToken. + _initWithPToken(bridgeAsset); + } + + // --- Required strategy overrides --------------------------------------- + + /// @inheritdoc InitializableAbstractStrategy + function checkBalance(address _asset) + external + view + override + returns (uint256) + { + require(_asset == bridgeAsset, "Master: unsupported asset"); + // Two domains (see AbstractWOTokenStrategy decimal-scaling note): + // - local bridgeAsset balance + in-flight deposit are in bridgeAsset units. + // - remoteStrategyBalance + bridgeAdjustment are OToken (18dp) units; the signed + // bridgeAdjustment captures unsettled bridge-channel activity. + // Clamp the OToken block to zero, scale it down to bridgeAsset units, then add the + // bridgeAsset-denominated locals. pendingWithdrawalAmount is NOT included — its value + // is still in remoteStrategyBalance until the leg-2 ack lands. + int256 remote = int256(remoteStrategyBalance) + bridgeAdjustment; + uint256 remoteInAsset = remote > 0 ? _toAsset(uint256(remote)) : 0; + return + IERC20(bridgeAsset).balanceOf(address(this)) + + pendingDepositAmount + + remoteInAsset; + } + + /// @inheritdoc InitializableAbstractStrategy + function safeApproveAllTokens() + external + override + onlyGovernor + nonReentrant + { + // No platform to approve. The bridgeAsset → outbound adapter allowance is the only + // approval Master needs, and it's (re)granted in `_setOutboundAdapter`. + } + + /// @inheritdoc InitializableAbstractStrategy + function deposit(address _asset, uint256 _amount) + external + override + onlyVault + nonReentrant + { + _depositToRemote(_asset, _amount); + } + + /// @inheritdoc InitializableAbstractStrategy + /// @dev Clamps the local balance by the outbound adapter's `maxTransferAmount` so a + /// vault sweep larger than the bridge's per-tx limit lands as a partial deposit + /// rather than reverting deep inside the bridge router. Remainder stays on Master + /// until the next `depositAll` (or operator-driven sequencing). + function depositAll() external override onlyVault nonReentrant { + uint256 bal = IERC20(bridgeAsset).balanceOf(address(this)); + if (bal == 0) return; + uint256 cap = IBridgeAdapter(outboundAdapter).maxTransferAmount(); + if (cap > 0 && bal > cap) bal = cap; + _depositToRemote(bridgeAsset, bal); + } + + /// @inheritdoc InitializableAbstractStrategy + /// @dev Withdrawals are async: this kicks off leg 1 (WITHDRAW_REQUEST). The actual tokens + /// land later when `triggerClaim()` is invoked and the leg-2 ack returns. `_recipient` + /// 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 + /// `<= remoteStrategyBalance` even though `checkBalance` can report more (local + /// bridgeAsset + positive bridgeAdjustment). To realise the remainder, the strategist + /// can `requestSettlement()` (folding bridgeAdjustment into remoteStrategyBalance) + /// and/or use the locally-held bridgeAsset, then withdraw. + function withdraw( + address _recipient, + address _asset, + uint256 _amount + ) external override onlyVault nonReentrant { + require(_recipient == vaultAddress, "Master: recipient must be vault"); + _withdrawRequest(_asset, _amount); + } + + /// @inheritdoc InitializableAbstractStrategy + /// @dev Best-effort sweep: requests withdrawal of `remoteStrategyBalance` (clamped by + /// Remote's per-tx bridge cap) if nothing else is in flight; otherwise silently + /// no-ops so the vault sweep stays safe. + /// + /// Clamping uses `inboundAdapter.maxTransferAmount()` — Master can't query + /// Remote's outbound across chains, but the symmetric inbound adapter on this + /// chain holds the same protocol-level cap (outbound and inbound on a lane + /// are mirror sides of the same bridge). For OETHb that's the Superbridge cap + /// (canonical bridge, typically 0 = unlimited); for OUSD V3 it's the CCTPAdapter + /// cap (10M USDC). + function withdrawAll() external override onlyVaultOrGovernor nonReentrant { + if ( + pendingDepositAmount != 0 || + pendingWithdrawalAmount != 0 || + isYieldOpInFlight() + ) { + return; + } + // Best-effort: a mid-migration cleared inbound adapter must no-op the sweep, not + // revert it (honors the "best-effort no-op" contract). + address inbound = inboundAdapter; + if (inbound == address(0)) return; + // remoteStrategyBalance is OToken (18dp); withdraw amounts are bridgeAsset units. + // Use the drawable balance (folds in a negative bridgeAdjustment) so a sweep can't + // over-request more shares than Remote can actually unwrap. + uint256 amount = _toAsset(_drawableRemoteBalance()); + if (amount == 0) return; + uint256 cap = IBridgeAdapter(inbound).maxTransferAmount(); + if (cap > 0 && amount > cap) amount = cap; + // Don't initiate a sub-floor sweep — leg-2 ship would be rejected by the adapter. + if (amount < IBridgeAdapter(inbound).minTransferAmount()) return; + _withdrawRequest(bridgeAsset, amount); + } + + // --- Operator entrypoints --------------------------------------------- + + /** + * @notice Operator-triggered leg 2: instructs Remote to claim from its OToken-vault queue + * (if not already done by peer-chain automation) and bridge the bridgeAsset back. + * Must be called only after a leg-1 ack has been processed (otherwise no + * pending withdrawal to claim). + */ + function triggerClaim() + external + payable + nonReentrant + onlyOperatorGovernorOrStrategist + { + require(outboundAdapter != address(0), "Master: outbound not set"); + require(pendingWithdrawalAmount > 0, "Master: no pending withdrawal"); + + // _getNextYieldNonce() enforces !isYieldOpInFlight(). + uint64 nonce = _getNextYieldNonce(); + _send( + address(0), + 0, + CrossChainV3Helper.WITHDRAW_CLAIM, + nonce, + "", + false + ); + + emit WithdrawClaimTriggered(nonce, pendingWithdrawalAmount); + } + + /** + * @notice Operator-triggered yield-channel round-trip to refresh `remoteStrategyBalance` + * off the back of Remote's `previewRedeem`. Run on a cron (~2h) in production. + * + * @dev Non-blocking: does NOT advance the yield nonce. Sends with the CURRENT + * `lastYieldNonce` as an "epoch marker" — the response is accepted only if + * that nonce still matches when the ack lands AND no other yield op is in + * flight AND the timestamp is newer than the last accepted check. See + * `_processBalanceCheckResponse` for the three-guard logic. + * + * Multiple BCs in flight at the same nonce are harmless; whichever response + * is newest wins via the timestamp guard. + */ + function requestBalanceCheck() + external + payable + nonReentrant + onlyOperatorGovernorOrStrategist + { + require(outboundAdapter != address(0), "Master: outbound not set"); + uint64 nonce = lastYieldNonce; // echo current nonce; do NOT advance it + bytes memory payload = CrossChainV3Helper.encodeUint256( + block.timestamp + ); + // Read-only on Remote's side. + _send( + address(0), + 0, + CrossChainV3Helper.BALANCE_CHECK_REQUEST, + nonce, + payload, + false + ); + emit BalanceCheckRequested(nonce, block.timestamp); + } + + /** + * @notice Operator-triggered settlement: zero out (or reduce) `bridgeAdjustment` on + * both sides. With the locked design (yield-only baseline in balance check), + * settlement is housekeeping — keeps bridgeAdjustment magnitude bounded + * rather than being correctness-critical. + * + * @dev Captures `bridgeAdjustment` as a snapshot at request time. Both sides + * subtract exactly that snapshot on their respective handlers (NOT `= 0`), + * which preserves any bridge ops that happen between request and ack. This + * avoids the desync that would occur if both sides naively zeroed while a + * new BRIDGE_OUT was mid-flight. See `_processSettlementAck` for the math. + */ + function requestSettlement() + external + payable + nonReentrant + onlyOperatorGovernorOrStrategist + { + require(outboundAdapter != address(0), "Master: outbound not set"); + require(pendingWithdrawalAmount == 0, "Master: withdrawal pending"); + // _getNextYieldNonce() enforces !isYieldOpInFlight(). + + uint64 nonce = _getNextYieldNonce(); + // Persist for the ack handler to subtract from the (possibly-evolved) bridgeAdjustment. + settlementSnapshot = bridgeAdjustment; + bytes memory payload = abi.encode(settlementSnapshot); + _send( + address(0), + 0, + CrossChainV3Helper.SETTLE_BRIDGE_ACCOUNTING, + nonce, + payload, + false + ); + emit SettlementRequested(nonce, settlementSnapshot); + } + + // --- Yield channel: deposit -------------------------------------------- + + function _depositToRemote(address _asset, uint256 _amount) internal { + require(_asset == bridgeAsset, "Master: unsupported asset"); + require(_amount > 0, "Master: zero deposit"); + require(outboundAdapter != address(0), "Master: outbound not set"); + require( + pendingDepositAmount == 0 && pendingWithdrawalAmount == 0, + "Master: deposit or withdrawal pending" + ); + // Best-effort min floor (mirror of the withdraw-side guard in `_withdrawRequest` / + // `withdrawAll`): a sub-min amount would revert deep inside the adapter on `_send`. + // No-op instead of reverting — the asset is already on the strategy (the vault + // transfers it before calling `deposit`) and stays counted in `checkBalance`, so it + // 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; + } + + uint64 nonce = _getNextYieldNonce(); + pendingDepositAmount = _amount; + + // bridgeAsset → outboundAdapter allowance is granted once in `_setOutboundAdapter`. + _send( + bridgeAsset, + _amount, + CrossChainV3Helper.DEPOSIT, + nonce, + "", + false + ); + + emit DepositRequested(nonce, _amount); + emit Deposit(bridgeAsset, bridgeAsset, _amount); + } + + // --- Yield channel: withdrawal Option 1 (leg 1) ------------------------ + + function _withdrawRequest(address _asset, uint256 _amount) internal { + require(_asset == bridgeAsset, "Master: unsupported asset"); + require(_amount > 0, "Master: zero withdraw"); + require(outboundAdapter != address(0), "Master: outbound not set"); + require( + pendingDepositAmount == 0 && pendingWithdrawalAmount == 0, + "Master: deposit or withdrawal pending" + ); + // _amount is bridgeAsset units; gate against the drawable balance in bridgeAsset units. + // _drawableRemoteBalance folds in a negative bridgeAdjustment so that after a net + // BRIDGE_OUT the gate can't over-permit a withdrawal Remote couldn't unwrap (it would + // revert on Remote). Scaling down also rounds conservatively. + require( + _amount <= _toAsset(_drawableRemoteBalance()), + "Master: amount exceeds remote balance" + ); + // Reject amounts the leg-2 ship can't satisfy, so a withdrawal never commits leg 1 + // and then bricks leg 2. The inbound adapter mirrors Remote's outbound bounds (the + // lane-mirror convention). Symmetric with the deposit floor (deposits already reject + // out-of-bounds at the adapter). Skipped if no inbound adapter is wired — the + // withdrawal can't complete then anyway. + address inbound = inboundAdapter; + if (inbound != address(0)) { + require( + _amount >= IBridgeAdapter(inbound).minTransferAmount(), + "Master: amount below bridge min" + ); + uint256 maxT = IBridgeAdapter(inbound).maxTransferAmount(); + require( + maxT == 0 || _amount <= maxT, + "Master: amount above bridge max" + ); + } + + uint64 nonce = _getNextYieldNonce(); + pendingWithdrawalAmount = _amount; + + bytes memory payload = CrossChainV3Helper.encodeUint256(_amount); + _send( + address(0), + 0, + CrossChainV3Helper.WITHDRAW_REQUEST, + nonce, + payload, + false + ); + + emit WithdrawRequested(nonce, _amount); + } + + // --- Inbound dispatch -------------------------------------------------- + + function _handleBridgeMessage( + uint256 amountReceived, + uint32 msgType, + uint64 nonce, + bytes memory body + ) internal override { + if (msgType == CrossChainV3Helper.DEPOSIT_ACK) { + _processDepositAck(nonce, body); + } else if (msgType == CrossChainV3Helper.WITHDRAW_REQUEST_ACK) { + _processWithdrawRequestAck(nonce, body); + } else if (msgType == CrossChainV3Helper.WITHDRAW_CLAIM_ACK) { + _processWithdrawClaimAck(nonce, amountReceived, body); + } else if (msgType == CrossChainV3Helper.BRIDGE_IN) { + _handleInboundBridgeMessage(msgType, amountReceived, body); + } else if (msgType == CrossChainV3Helper.BALANCE_CHECK_RESPONSE) { + _processBalanceCheckResponse(nonce, body); + } else if (msgType == CrossChainV3Helper.SETTLE_BRIDGE_ACCOUNTING_ACK) { + _processSettlementAck(nonce, body); + } else { + revert("Master: unsupported message type"); + } + } + + /// @dev Three-guard acceptance: + /// 1. `!isYieldOpInFlight()` — if a deposit/withdraw is mid-flight, the response + /// would race with its ack; ignore to avoid corrupting pendingDepositAmount / + /// remoteStrategyBalance accounting. + /// 2. `respNonce == lastYieldNonce` — the request was sent at this nonce; if + /// lastYieldNonce has since advanced, this response is from a now-stale + /// epoch. Ignore. + /// 3. `respTimestamp > lastBalanceCheckTimestamp` — out-of-order CCIP delivery + /// could land an older snapshot after a newer one. Strict monotonic order + /// preserves the latest read. + function _processBalanceCheckResponse(uint64 nonce, bytes memory payload) + internal + { + // No _markYieldNonceProcessed here — balance check did NOT advance the nonce, so + // there's nothing to mark. The 3 guards below replace nonce-advance semantics. + if (isYieldOpInFlight()) return; + if (nonce != lastYieldNonce) return; + (uint256 yieldBaseline, uint256 remoteTimestamp) = CrossChainV3Helper + .decodeBalanceCheckResponsePayload(payload); + if (remoteTimestamp <= lastBalanceCheckTimestamp) return; + lastBalanceCheckTimestamp = remoteTimestamp; + remoteStrategyBalance = yieldBaseline; + emit BalanceCheckResponded(nonce, yieldBaseline, remoteTimestamp); + emit RemoteStrategyBalanceUpdated(yieldBaseline); + } + + /// @dev Subtracts `settlementSnapshot` (NOT `= 0`). Rationale: + /// + /// Master.bridgeAdj at ack time may differ from what it was at request time if + /// new bridge ops landed in between. Zeroing would erase those new ops. By + /// subtracting only the exact snapshot we committed to settling, we preserve + /// the post-snapshot delta on both sides — Remote does the symmetric subtract + /// in `_processSettlement`, so both sides converge to the same value + /// regardless of the order in which bridge ops vs. the settle message reach + /// Remote. + /// + /// Remote's reported `yieldBaseline` is its yield-only baseline (`_viewCheckBalance + /// - bridgeAdjustment` post-subtract), which combined with Master's residual + /// bridgeAdjustment gives consistent checkBalance across all orderings. + function _processSettlementAck(uint64 nonce, bytes memory payload) + internal + { + _markYieldNonceProcessed(nonce); + uint256 yieldBaseline = CrossChainV3Helper.decodeUint256(payload); + bridgeAdjustment -= settlementSnapshot; + settlementSnapshot = 0; + remoteStrategyBalance = yieldBaseline; + emit SettlementAcked(nonce, yieldBaseline); + emit RemoteStrategyBalanceUpdated(yieldBaseline); + } + + function _processWithdrawRequestAck(uint64 nonce, bytes memory payload) + internal + { + _markYieldNonceProcessed(nonce); + (uint256 yieldBaseline, bool success) = CrossChainV3Helper + .decodeWithdrawRequestAckPayload(payload); + remoteStrategyBalance = yieldBaseline; + // On success Remote queued the withdrawal — pendingWithdrawalAmount stays set, gating + // concurrent triggerClaim() calls until the leg-2 ack lands. On failure Remote queued + // nothing, so clear the pending withdrawal to unblock the channel; it can be re-requested. + if (!success) { + pendingWithdrawalAmount = 0; + } + emit WithdrawRequestAcked(nonce, yieldBaseline); + emit RemoteStrategyBalanceUpdated(yieldBaseline); + } + + function _processWithdrawClaimAck( + uint64 nonce, + uint256 amount, + bytes memory payload + ) internal { + _markYieldNonceProcessed(nonce); + ( + uint256 yieldBaseline, + bool success, + uint256 ackAmount + ) = CrossChainV3Helper.decodeWithdrawClaimAckPayload(payload); + + if (success) { + // Tokens arrived alongside the ack. Forward what landed to the vault. + // `amount <= ackAmount` (not strict equality) so CCTP fast-finality fees + // are tolerated: the shortfall is the protocol fee, absorbed as yield drag + // and refreshed on the next BALANCE_CHECK. Mirrors the older + // `CrossChainMasterStrategy._onTokenReceived` which ignores `feeExecuted` + // entirely (marked `solhint-disable-next-line no-unused-vars`). + require(amount > 0, "Master: claim ack missing tokens"); + require(amount <= ackAmount, "Master: claim above ack"); + require( + amount <= pendingWithdrawalAmount, + "Master: claim amount above pending" + ); + pendingWithdrawalAmount = 0; + // Forward delivered bridgeAsset to the vault. + uint256 bal = IERC20(bridgeAsset).balanceOf(address(this)); + if (bal > 0) { + IERC20(bridgeAsset).safeTransfer(vaultAddress, bal); + emit Withdrawal(bridgeAsset, bridgeAsset, bal); + } + } + // Either way, update remoteStrategyBalance to Remote's current view. + remoteStrategyBalance = yieldBaseline; + emit WithdrawClaimAcked(nonce, yieldBaseline, success); + emit RemoteStrategyBalanceUpdated(yieldBaseline); + } + + function _processDepositAck(uint64 nonce, bytes memory payload) internal { + _markYieldNonceProcessed(nonce); + uint256 yieldBaseline = CrossChainV3Helper.decodeUint256(payload); + remoteStrategyBalance = yieldBaseline; + pendingDepositAmount = 0; + emit DepositAcked(nonce, yieldBaseline); + emit RemoteStrategyBalanceUpdated(yieldBaseline); + } + + // --- AbstractWOTokenStrategy hooks ------------------------------------- + + /// @inheritdoc AbstractWOTokenStrategy + function _bridgeOutboundMsgType() internal pure override returns (uint32) { + return CrossChainV3Helper.BRIDGE_OUT; + } + + /// @inheritdoc AbstractWOTokenStrategy + /// @dev Conservative: subtracts the in-flight withdrawal's claim on Remote's shares + /// (`pendingWithdrawalAmount`), which `remoteStrategyBalance` still counts until the + /// claim-ack lands. Does NOT add the in-flight `pendingDepositAmount` deposit — it isn't yet + /// shares on Remote, and a BRIDGE_OUT could race ahead of (or outlive) it, so counting + /// it would re-open a stranding window. + function availableBridgeLiquidity() public view override returns (uint256) { + // Reported in OToken (18dp) — it gates an OToken bridge (`net`). remoteStrategyBalance + // and bridgeAdjustment are already 18dp; pendingWithdrawalAmount is bridgeAsset units, + // so scale it up before subtracting. + int256 a = int256(remoteStrategyBalance) + + bridgeAdjustment - + int256(_toOToken(pendingWithdrawalAmount)); + return a > 0 ? uint256(a) : 0; + } + + /// @dev OToken (18dp) value Remote can actually unwrap right now. Remote's shares are worth + /// `remoteStrategyBalance + bridgeAdjustment`; we fold in only the NEGATIVE part of + /// `bridgeAdjustment`. After a net BRIDGE_OUT (which anyone can trigger via + /// `bridgeOTokenToPeer`) `bridgeAdjustment < 0` and Remote holds fewer shares than + /// `remoteStrategyBalance` implies, so a draw gated on `remoteStrategyBalance` alone would + /// over-request and revert on Remote. Positive `bridgeAdjustment` stays excluded here — + /// realise it with `requestSettlement()` first — preserving the conservative draw behaviour. + function _drawableRemoteBalance() internal view returns (uint256) { + int256 d = int256(remoteStrategyBalance) + + (bridgeAdjustment < 0 ? bridgeAdjustment : int256(0)); + return d > 0 ? uint256(d) : 0; + } + + /// @inheritdoc AbstractWOTokenStrategy + function _consumeOTokenForBridge(uint256 amount) internal override { + // Pull OToken from the user and burn it via the vault. + IERC20(oToken).safeTransferFrom(msg.sender, address(this), amount); + IVault(vaultAddress).burnForStrategy(amount); + } + + /// @inheritdoc AbstractWOTokenStrategy + function _deliverOTokenForBridge(uint256 amount, address recipient) + internal + override + { + IVault(vaultAddress).mintForStrategy(amount); + IERC20(oToken).safeTransfer(recipient, amount); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/README.md b/contracts/contracts/strategies/crosschainV3/README.md new file mode 100644 index 0000000000..af91c48191 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/README.md @@ -0,0 +1,198 @@ +# OUSD V3 — Bridge-Agnostic Cross-Chain Strategy + +This directory implements the V3 cross-chain strategy pair (Master + Remote) and the bridge-agnostic adapter layer they speak to. Two workstreams share the code: + +- **OUSD V3:** OUSD across multiple L2s with native cross-chain bridging, yield generated on Ethereum and reported to each L2 via a yield-channel round-trip. +- **OETHb Phase 1:** Migration of 8.7k wOETH from the existing oracle-priced `BridgedWOETHStrategy` on Base into a new Master/Remote pair built on this abstraction. + +**For narrative walkthroughs of each flow (deposit, withdraw, balance check, bridge in/out, settlement) with sequence diagrams, see [`FLOWS.md`](./FLOWS.md).** This README is the reference: file map, message envelope, state-transition table, authorisation surface, adapter knobs. + +## File map + +``` +contracts/interfaces/crosschainV3/ + IBridgeAdapter.sol — strategies talk through this for outbound sends + quoteFee + maxTransferAmount + IBridgeReceiver.sol — strategies implement `receiveMessage` for inbound delivery + ISplitInboundAdapter.sol — split-delivery adapters expose pending-slot lifecycle + +contracts/strategies/crosschainV3/ + CrossChainV3Helper.sol — strategy envelope `abi.encode(msgType, nonce, body)` + per-msgType codec + AbstractCrossChainV3Strategy.sol — adapter wiring, yield-nonce machinery, inbound dispatch, + single outbound send helper (_send, parameterised by userFunded) + AbstractWOTokenStrategy.sol — wOToken pair base: bridge-channel state + generic bridge mechanics, + `bridgeOTokenToPeer`, replay protection, signed bridgeAdjustment, + onlyOperatorGovernorOrStrategist modifier, side-specific hooks + MasterWOTokenStrategy.sol — vault-facing leg: yield-channel ACK handlers + operator entrypoints, + implements 4 hooks (burn / mint OToken via vault) + RemoteWOTokenStrategy.sol — yield-side leg: 2-step bridgeAsset↔OToken↔wOToken pipeline, + implements 4 hooks (wrap / unwrap OToken via 4626) + +contracts/strategies/crosschainV3/adapters/ + AbstractAdapter.sol — shared base: multi-tenant whitelist, per-lane config, + envelope wrap/unwrap (52-byte header: 20-byte sender + 32-byte + intendedAmount), `_validateInbound`, `_deliver`, transfer caps + CCTPAdapter.sol — Circle CCTP V2: manual burn-body parse in `relay()` (auth amount/fee/hookData); + pure messages dispatch via `handleReceiveFinalizedMessage` hook. + Hard 10M USDC `MAX_TRANSFER_AMOUNT` constant; configurable min + threshold. + CCIPAdapter.sol — Chainlink CCIP atomic token + message + SuperbridgeAdapter.sol — split delivery: OP Stack L1StandardBridge for the canonical ETH leg + CCIP + for the message. Token-bearing sends only on the L1 side; L2 side runs as + inbound only (canonical ETH wrapped to WETH via `receive()`). + +contracts/strategies/crosschainV3/libraries/ + CCTPMessageHelper.sol — CCTP V2 wire-format decoder: transport header + burn-message body + CCIPMessageBuilder.sol — shared CCIP `Client.EVM2AnyMessage` construction + NativeFeeHelper.sol — shared native-fee consumption helper + +contracts/proxies/create2/ + CrossChainStrategyProxy.sol — Master/Remote strategy proxy (CREATE3-deployable for peer parity) + BridgeAdapterProxy.sol — Adapter proxy (CREATE3-deployable for peer parity) + +contracts/strategies/ + BridgedWOETHMigrationStrategy.sol — Phase 1 upgrade impl for the existing Base proxy + +contracts/mocks/crosschainV3/ + MockBridgeAdapter, MockBridgeCallTarget, MockBridgeReceiver, MockCCIPRouter, + MockCCTPRelayTransmitter, MockCrossChainV3HelperHarness, MockEthOTokenVault, + MockMintableBurnableOToken, MockOTokenVault +``` + +## Message envelope (wire format) + +The protocol uses two nested envelopes: + +1. **Adapter envelope** (built by `AbstractAdapter._wrap`): a 52-byte header followed by the strategy's opaque payload. + + ``` + [0..20) address sender (source-side strategy) + [20..52) uint256 intendedAmount (token-leg intent; 0 for message-only) + [52..] bytes payload (the strategy envelope below) + ``` + +2. **Strategy envelope** (built by `CrossChainV3Helper.packPayload`): `abi.encode(uint32 msgType, uint64 nonce, bytes body)` — no version field. + + - `msgType` ∈ 1..12 (see table below) + - `nonce` is the yield-channel nonce for yield-channel messages, 0 for bridge-channel messages + - `body` is `abi.encode(...)` of message-specific fields (or empty) + +| ID | Type | Channel | Direction | Body | Notes | +|---|---|---|---|---|---| +| 1 | DEPOSIT | Yield | M→R | empty | tokens carried via adapter | +| 2 | DEPOSIT_ACK | Yield | R→M | `(uint256 yieldBaseline)` | | +| 3 | WITHDRAW_REQUEST | Yield | M→R | `(uint256 amount)` | leg 1 | +| 4 | WITHDRAW_REQUEST_ACK | Yield | R→M | `(uint256 yieldBaseline)` | requestId stays on Remote | +| 5 | WITHDRAW_CLAIM | Yield | M→R | empty | leg 2 trigger | +| 6 | WITHDRAW_CLAIM_ACK | Yield | R→M | `(uint256 yieldBaseline, bool success, uint256 amount)` | tokens carried on success | +| 7 | BALANCE_CHECK_REQUEST | Yield | M→R | `(uint256 timestamp)` | | +| 8 | BALANCE_CHECK_RESPONSE | Yield | R→M | `(uint256 balance, uint256 timestamp)` | | +| 9 | SETTLE_BRIDGE_ACCOUNTING | Yield | M→R | empty | clears bridgeAdjustment both sides | +| 10 | SETTLE_BRIDGE_ACCOUNTING_ACK | Yield | R→M | `(uint256 yieldBaseline)` | | +| 11 | BRIDGE_IN | Bridge | R→M | `BridgeUserPayload` | nonceless, mint on destination | +| 12 | BRIDGE_OUT | Bridge | M→R | `BridgeUserPayload` | nonceless, release on destination | + +`BridgeUserPayload` = `(bytes32 bridgeId, uint256 amount, address recipient, bytes callData, uint32 callGasLimit)`. + +## Withdrawal state-transition table (Remote) + +Authoritative summary of the Option-1 withdrawal flow with idempotent claim. Each row is a single intermediate state; the value lives in exactly one slot per row, and `checkBalance` equals the total in every row: + +| State | shares value | oToken bal | bridgeAsset bal | queued\* | outstandingRequestId | checkBalance | +|---|---|---|---|---|---|---| +| Idle | X | 0 | 0 | 0 | 0 | X | +| Requested (post-leg-1) | X − A | 0 | 0 | A | nonzero | X | +| Claimed (post-`claimRemoteWithdrawal`) | X − A | 0 | A | 0 | 0 | X | +| Bridging-out (post-leg-2 send) | X − A | 0 | 0 | 0 | 0 | X − A | +| Completed | X − A | 0 | 0 | 0 | 0 | X − A | + +\* `queued` is derived, not a stored slot: `outstandingRequestId != 0 ? outstandingRequestAmount : 0`. + +## Authorisation surface + +- **Governor**: sets adapters, operator, bridge configs, sweeps stuck tokens, upgrades. +- **Operator**: triggers permissioned yield-channel round-trips (`requestBalanceCheck`, + `requestSettlement`, `triggerClaim`). Can be a multisig or automation EOA. +- **Vault**: drives `deposit` / `withdraw` on Master (no user-facing redemption against this strategy in normal ops). +- **Receiver adapter**: the only address allowed to call `receiveMessage` on the strategy. +- **Anyone**: `claimRemoteWithdrawal` (idempotent), `processStoredMessage` (split-delivery finaliser). + +## Bridge-channel composability (`callData`) + +Both Master and Remote expose a user-facing `bridgeOTokenToPeer(amount, recipient, callData, callGasLimit)` payable function. On the destination, after the strategy mints/releases tokens to `recipient`, an optional `recipient.call{value: 0, gas: callGasLimit}(callData)` runs. Guardrails: + +- Tokens are delivered first (CEI). Reverting calldata never strands funds. +- `callGasLimit ≤ MAX_BRIDGE_CALL_GAS` (500_000). +- No `msg.value` ever forwarded. +- `nonReentrant` on the inbound entry blocks re-entering Master/Remote during the call. +- Empty calldata = no call. + +## Adapter knobs + +All adapter caps and modes are governor-settable post-deploy. See [`FLOWS.md`](./FLOWS.md#9-adapter-knobs-reference) for the full table; high points: + +- `maxTransferAmount` (all adapters) — per-tx token cap. `0` = unlimited. Strategies on the peer chain read this as "max I can deliver in one tx" via `IBridgeAdapter.maxTransferAmount()` to size their withdrawAll-style requests. +- `MAX_TRANSFER_AMOUNT` (CCTPAdapter) — hard 10M USDC constant (CCTP V2 protocol cap; never higher than this). +- `minTransferAmount` (CCTPAdapter) — dust floor. +- `minFinalityThreshold` (CCTPAdapter) — 1000–1999 = fast finality (non-zero token-side fee), 2000 = finalised. NO declaration default; governor MUST call `setMinFinalityThreshold` post-deploy or sends revert with `"CCTP: threshold not set"`. +- `operator` (CCTPAdapter) — the single address allowed to call `relay(message, attestation)`. + +`CCTPAdapter` inbound dispatch has two paths: + +- **Burn messages** (sourced from `TokenMessenger.depositForBurnWithHook`) — `relay()` manually parses the burn body (`CCTPMessageHelper.decodeBurnBody`) for authoritative `amount` / `feeExecuted` / `hookData`, calls `messageTransmitter.receiveMessage` to credit USDC, then dispatches `_deliver` with `amount - feeExecuted`. The `handleReceiveFinalizedMessage` hook is NOT used for token-bearing messages. +- **Pure messages** (sourced from `MessageTransmitter.sendMessage`) — `relay()` calls `messageTransmitter.receiveMessage` which fires the hook callback. The hook is restricted to `intendedAmount == 0` and reverts if a token leg sneaks through. + +## Tests + +``` +test/strategies/crosschainV3/ + crosschain-v3-helper.js — envelope codec + master-v3.js / remote-v3.js — per-side deposit / bridge / init / dispatch + master-remote-pair.js — paired loopback (deposit, BRIDGE_IN/OUT) + withdrawal.js — full withdrawal cycle (happy / NACK / idempotent / fast-finality) + settlement-balance-check.js — operator-driven rounds, yield-only baseline + bridge-fee.js — bridgeFeeBps burn-full / deliver-net mechanics + fee-path.js — adapter fee plumbing (msg.value, pool, refund-stays semantics) + transfer-caps.js — adapter MAX/min, Master clamp via adapter views + cctp-relay.js — CCTPAdapter pure-message relay path + auth / threshold + cctp-burn-relay.js — CCTPAdapter burn-message manual-parse path, donation isolation + split-inbound-adapter.js — SuperbridgeAdapter pending-slot lifecycle + *.fork-test.js — base / mainnet fork tests (run via the fork-test.sh harness) +``` + +Run the unit suite (the fork-test files skip when `FORK` is not set in the env, so the glob is safe to run as-is): + +``` +pnpm hardhat test test/strategies/crosschainV3/*.js +``` + +For the fork tests, set `FORK=true` and the appropriate `FORK_NETWORK_NAME` (`base`, `mainnet`, etc.) via the standard `fork-test.sh` harness: + +``` +FORK_NETWORK_NAME=mainnet pnpm test:fork test/strategies/crosschainV3/withdrawal.mainnet.fork-test.js +``` + +Current total: **111 unit tests** + the per-network `*.fork-test.js` files. + +## Operational runbook (mainnet / testnet) + +Deploy scripts (testnet at `deploy/sepolia/*` + `deploy/baseSepolia/*`, production at `deploy/base/100-104_*` + `deploy/mainnet/210-211_*`) deploy both the strategy proxies and the adapter proxies via CREATE3 (deterministic peer-parity addresses) with impls deployed plain on each chain. The contracts are deploy-ready against any chain pair given the right addresses (CCIP routers, CCTP TokenMessengers, OP Stack L1StandardBridge addresses, governance multisigs). + +Key cadences (production targets): + +- **Balance check**: every ~2 hours on a cron, operator-triggered. +- **Settlement**: every 6–12 hours, operator-triggered. Higher cadence on testnet (1h) for surfacing issues. +- **OETHb Phase 1 migration**: 9 × `bridgeToRemote(1000e18)` over ~9 hours respecting CCIP rate limits. No deposits/withdrawals on the new pair during this window. + +## Open items for follow-up + +These were intentionally not authored as part of the protocol code because they require real on-chain configuration. Items completed in earlier sessions (transfer-amount caps on adapters, FLOWS.md walkthrough doc, CCTPAdapter proxy-safe `minFinalityThreshold` + fast-finality inbound handler, `Master.depositAll/withdrawAll` clamp by adapter caps) are no longer on this list. + +| # | Item | Status | +|---|---|---| +| 1 | **Testnet registration (Sepolia + Base Sepolia)** — full network registration + mock vault/token + deploy scripts wiring `MasterWOTokenStrategy`/`RemoteWOTokenStrategy` + `CCIPAdapter` + `SuperbridgeAdapter` (all behind `BridgeAdapterProxy` via CREATE3 for peer parity). OETHb topology only — no CCTP wiring in this scope. | Done | +| 2 | **CCTP testnet path** — `CCTPAdapter` on Sepolia/Base Sepolia + Iris-sandbox attestation relayer setup for OUSD V3 testnet rehearsal. | Follow-up | +| 3 | **OETHb Phase 1 base fork test** — `oethb-phase1-migration.base.fork-test.js` driving 9 × `bridgeToRemote(1000e18)` against a Base fork, validating CCIP rate-limit pacing. | Pending | +| 4 | **Mainnet + Base production deploy scripts** — `deploy/mainnet/200-203_*` and `deploy/base/100-105_*` to wire Master/Remote pair + adapters on production. | Pending | +| 5 | **Governance proposal 1 (deploy + wire)** — mainnet proposal to deploy + wire Master/Remote and upgrade old `BridgedWOETHStrategy`. | Pending | +| 6 | **Governance proposal 2 (post-migration cleanup)** — remove old `BridgedWOETHStrategy` from vault + mint whitelist after Phase 1 migration completes. | Pending | +| 7 | **Operator runbook** — formal cadence + failure-mode runbook (balance-check ~2h, settlement 6–12h, what to do on stuck nonce, etc.); cadences exist in inline comments but no operator-facing doc. | Pending | +| 8 | **OUSD V3 spoke deploys** — once OETHb Phase 1 stabilises, deploy OUSD V3 Master/Remote pairs per spoke chain (Base, HyperEVM, etc.). | Future | diff --git a/contracts/contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol b/contracts/contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol new file mode 100644 index 0000000000..4622f1f94b --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20, SafeERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { IERC4626 } from "../../../lib/openzeppelin/interfaces/IERC4626.sol"; +import { IVault } from "../../interfaces/IVault.sol"; +import { IBridgeAdapter } from "../../interfaces/crosschainV3/IBridgeAdapter.sol"; + +import { AbstractWOTokenStrategy } from "./AbstractWOTokenStrategy.sol"; +import { CrossChainV3Helper } from "./CrossChainV3Helper.sol"; + +/** + * @title RemoteWOTokenStrategy + * @author Origin Protocol Inc + * + * @notice Yield-side leg of the wOToken cross-chain strategy pair. Holds wOToken shares + * on behalf of the peer Master. Runs the 2-step pipeline: + * + * inbound : bridgeAsset → OToken (via OToken vault `mint`) → wOToken (via 4626.deposit) + * outbound: wOToken (via 4626.withdraw) → OToken → bridgeAsset (via OToken vault redeem) + * + * Remote is NOT registered with any vault — it's a custodian for shares held on + * behalf of the peer Master. The `oTokenVault` parameter points at the local + * OToken vault on this chain (e.g. the OUSD vault on Ethereum or the OETH vault + * on Ethereum). + * + * For the full Remote state-transition table (Idle → Requested → Claimed → Bridging-out + * → Completed) see the V3 implementation plan. + */ +contract RemoteWOTokenStrategy is AbstractWOTokenStrategy { + using SafeERC20 for IERC20; + + // --- Immutables -------------------------------------------------------- + + /// @notice ERC-4626 wrapper of the OToken (wOUSD or wOETH). + address public immutable woToken; + + /// @notice Yield-side OToken vault. Used to convert bridgeAsset ↔ OToken via mint / redeem. + address public immutable oTokenVault; + + // --- Storage (all new slots; nothing from any parent is relocated) ----- + + /// @notice OToken-vault queue handle, stored **offset by one** (vault `requestId + 1`). + /// The vault's `requestId` starts at 0 for the first-ever withdrawal on a fresh + /// 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; + + /// @notice Originally-requested bridgeAsset amount for the outstanding withdrawal. + /// Set in `_processWithdrawRequest`, refined to the actually-claimed amount + /// once `_opportunisticClaim` succeeds, cleared on successful leg-2 delivery. + /// Caps the value leg-2 may ship to Master, defeating residual/donation over-send. + uint256 public outstandingRequestAmount; + + /// @dev Reserved for future expansion. + uint256[43] private __gap; + + // --- Events ------------------------------------------------------------- + + event DepositProcessed(uint64 nonce, uint256 amount, uint256 yieldBaseline); + event WithdrawRequestProcessed( + uint64 nonce, + uint256 amount, + uint256 requestId + ); + event WithdrawClaimDelivered( + uint64 nonce, + uint256 amount, + uint256 yieldBaseline + ); + event WithdrawClaimNack(uint64 nonce, uint256 yieldBaseline); + event RemoteWithdrawalClaimed(uint256 requestId, uint256 amount); + /// @dev DEPOSIT mint/wrap reverted; bridgeAsset/oToken left idle (recoverable via retryDeposit). + event DepositUnderlyingFailed(uint64 nonce, uint256 amount, bytes reason); + /// @dev WITHDRAW_REQUEST unwrap/queue reverted; nothing queued, Master told to clear pending. + event WithdrawRequestUnderlyingFailed( + uint64 nonce, + uint256 amount, + bytes reason + ); + /// @dev Operator re-ran the mint/wrap pipeline on idle bridgeAsset/oToken. + event IdleDepositRetried(uint256 mintedBridgeAsset, uint256 wrappedOToken); + + // --- Construction / initialisation ------------------------------------- + + constructor( + BaseStrategyConfig memory _stratConfig, + address _bridgeAsset, + address _oToken, + address _woToken, + address _oTokenVault + ) AbstractWOTokenStrategy(_stratConfig, _bridgeAsset, _oToken) { + // Remote has no vault and uses `woToken` as its "platform" for the strategy registry. + require( + _stratConfig.vaultAddress == address(0), + "Remote: vault must be zero" + ); + require(_woToken != address(0), "Remote: woToken required"); + require(_oTokenVault != address(0), "Remote: oTokenVault required"); + require( + _stratConfig.platformAddress == _woToken, + "Remote: platform must be woToken" + ); + woToken = _woToken; + oTokenVault = _oTokenVault; + // This is an implementation contract. The governor is set in the proxy contract. + _setGovernor(address(0)); + } + + function initialize(address _operator) external onlyGovernor initializer { + operator = _operator; + // wOToken is the registry platform token for Remote. + _initWithPToken(woToken); + } + + // --- Required strategy overrides --------------------------------------- + + /// @inheritdoc InitializableAbstractStrategy + function checkBalance(address _asset) + external + view + override + returns (uint256) + { + require(_asset == bridgeAsset, "Remote: unsupported asset"); + // _viewCheckBalance is OToken-denominated (18dp); checkBalance reports in bridgeAsset + // units like every strategy. (The R→M yield reports use the 18dp baseline directly.) + return _toAsset(_viewCheckBalance()); + } + + /// @inheritdoc InitializableAbstractStrategy + function safeApproveAllTokens() + external + override + onlyGovernor + nonReentrant + { + // Static (token, spender) pairs the strategy ever transfers through: + // bridgeAsset → oTokenVault (vault.mint pulls WETH on deposit) + // oToken → oTokenVault (vault.requestWithdrawal pulls OToken on withdraw) + // oToken → woToken (ERC-4626 deposit / withdraw of OToken shares) + // One-shot: approves to type(uint256).max so the per-op approval dance isn't needed. + // The dynamic (bridgeAsset → outboundAdapter) pair is managed by `setOutboundAdapter`. + IERC20(bridgeAsset).safeApprove(oTokenVault, type(uint256).max); + IERC20(oToken).safeApprove(oTokenVault, type(uint256).max); + IERC20(oToken).safeApprove(woToken, type(uint256).max); + } + + /// @inheritdoc InitializableAbstractStrategy + function deposit(address, uint256) + external + view + override + onlyVaultOrGovernor + { + revert("Remote: use bridge"); + } + + /// @inheritdoc InitializableAbstractStrategy + function depositAll() external view override onlyVaultOrGovernor { + revert("Remote: use bridge"); + } + + /// @inheritdoc InitializableAbstractStrategy + function withdraw( + address, + address, + uint256 + ) external view override onlyVaultOrGovernor { + revert("Remote: use bridge"); + } + + /// @inheritdoc InitializableAbstractStrategy + function withdrawAll() external view override onlyVaultOrGovernor { + revert("Remote: use bridge"); + } + + /// @inheritdoc InitializableAbstractStrategy + /// @dev Hardened recovery sweep. Remote custodies the L2 vault's backing as `woToken` + /// shares (and transiently `oToken`), so block sweeping those alongside the supported + /// `bridgeAsset`. Otherwise a `transferToken(woToken, …)` would silently lower + /// `_viewCheckBalance` -> `remoteStrategyBalance` and rebase L2 holders down. Mirrors + /// `BridgedWOETHStrategy.transferToken`. Genuinely-stuck unrelated tokens stay + /// recoverable; true custody recovery goes through the governor upgrade path. + function transferToken(address _asset, uint256 _amount) + public + override + onlyGovernor + { + require( + _asset != bridgeAsset && _asset != woToken && _asset != oToken, + "Cannot transfer custody asset" + ); + IERC20(_asset).safeTransfer(governor(), _amount); + } + + // --- Inbound dispatch -------------------------------------------------- + + function _handleBridgeMessage( + uint256 amountReceived, + uint32 msgType, + uint64 nonce, + bytes memory body + ) internal override { + if (msgType == CrossChainV3Helper.DEPOSIT) { + _processDeposit(nonce, amountReceived); + } else if (msgType == CrossChainV3Helper.WITHDRAW_REQUEST) { + _processWithdrawRequest(nonce, body); + } else if (msgType == CrossChainV3Helper.WITHDRAW_CLAIM) { + _processWithdrawClaim(nonce); + } else if (msgType == CrossChainV3Helper.BRIDGE_OUT) { + _handleInboundBridgeMessage(msgType, amountReceived, body); + } else if (msgType == CrossChainV3Helper.BALANCE_CHECK_REQUEST) { + _processBalanceCheckRequest(nonce, body); + } else if (msgType == CrossChainV3Helper.SETTLE_BRIDGE_ACCOUNTING) { + _processSettlement(nonce, body); + } else { + revert("Remote: unsupported message type"); + } + } + + /// @dev Reports the YIELD-ONLY baseline: `_viewCheckBalance() - bridgeAdjustment`. + /// This cancels bridge-channel deltas on both sides — for each BRIDGE_OUT, + /// `_viewCheckBalance` drops by `net` AND `bridgeAdjustment` drops by `net`, so + /// the difference stays constant. Bridge channel becomes invisible at this layer. + /// + /// Master combines this yield-only value with its own `bridgeAdjustment` to + /// reconstruct the true backing total via `checkBalance`. The math is consistent + /// regardless of whether bridge messages have been processed on Remote yet — + /// see the design doc for the full case analysis. + /// + /// DOES NOT call `_acceptYieldNonce`: balance check is non-blocking, read-only, + /// and the nonce is echoed back unchanged so Master can validate it's still in + /// the same yield epoch. + function _processBalanceCheckRequest(uint64 nonce, bytes memory payload) + internal + { + uint256 srcTimestamp = CrossChainV3Helper.decodeUint256(payload); + bytes memory ackPayload = CrossChainV3Helper + .encodeBalanceCheckResponsePayload( + _yieldOnlyBaseline(), + srcTimestamp + ); + _send( + address(0), + 0, + CrossChainV3Helper.BALANCE_CHECK_RESPONSE, + nonce, + ackPayload, + false + ); + } + + /// @dev Subtracts the snapshot Master sent (NOT `= 0`). Rationale: + /// + /// At Remote-processing time, Remote.bridgeAdjustment may equal Master's snapshot + /// (no in-flight ops), or differ by some delta (new bridge op has reached Remote + /// between Master sending settle and Remote processing it). By subtracting only + /// the exact snapshot, any newer delta is preserved on Remote — and Master does + /// the symmetric subtract in `_processSettlementAck`, so both sides converge. + /// + /// The reported balance is yield-only baseline (`_viewCheckBalance - bridgeAdj` + /// post-subtract), so even if a new bridge op landed in between, the report is + /// consistent with Master's reconstruction. + function _processSettlement(uint64 nonce, bytes memory body) internal { + int256 snapshot = abi.decode(body, (int256)); + bridgeAdjustment -= snapshot; + bytes memory ackPayload = CrossChainV3Helper.encodeUint256( + _yieldOnlyBaseline() + ); + _send( + address(0), + 0, + CrossChainV3Helper.SETTLE_BRIDGE_ACCOUNTING_ACK, + nonce, + ackPayload, + false + ); + _acceptYieldNonce(nonce); + } + + /** + * @dev Leg 1 of Option 1. Unwrap wOToken → OToken, request a withdrawal from the + * Ethereum OToken vault queue, reply to Master with the new view of `checkBalance`. + * Master doesn't need the `requestId` (Remote owns the queue lifecycle). + */ + function _processWithdrawRequest(uint64 nonce, bytes memory payload) + internal + { + uint256 amount = CrossChainV3Helper.decodeUint256(payload); + require(amount > 0, "Remote: zero withdraw"); + require(outstandingRequestId == 0, "Remote: queue already busy"); + + // `amount` is in bridgeAsset units (what the L2 vault asked back). The wOToken unwrap + // and the OToken-vault queue operate in OToken (18dp) units. + uint256 oTokenAmount = _toOToken(amount); + + // Unwrap + queue can revert (insufficient shares, vault queue paused, 4626 edge). A revert + // must NOT brick the serialized channel, so each external call is guarded individually + // (mirrors crosschain/CrossChainRemoteStrategy). On failure we queue nothing and tell Master + // success=false so it clears its pending withdrawal; the next op can proceed. 291's gate makes + // the insufficient-shares case unreachable in normal flow — this covers the residual reverts. + // Non-atomic: if the unwrap succeeds but the queue fails, the unwrapped OToken is left idle + // here (counted by _viewCheckBalance, re-wrappable via retryDeposit) rather than rolled back. + bool success = false; + uint256 requestId = 0; + bool unwrapped = false; + try + IERC4626(woToken).withdraw( + oTokenAmount, + address(this), + address(this) + ) + returns (uint256) { + unwrapped = true; + } catch (bytes memory reason) { + emit WithdrawRequestUnderlyingFailed(nonce, amount, reason); + } + if (unwrapped) { + try IVault(oTokenVault).requestWithdrawal(oTokenAmount) returns ( + uint256 id, + uint256 + ) { + // Store offset by one so a vault requestId of 0 (first withdrawal on a fresh vault) + // is distinguishable from the "no request" sentinel. See `outstandingRequestId` doc. + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = id + 1; + // outstandingRequestAmount tracks the bridgeAsset value leg 2 will ship back. + outstandingRequestAmount = amount; + requestId = id; + success = true; + } catch (bytes memory reason) { + emit WithdrawRequestUnderlyingFailed(nonce, amount, reason); + } + } + + // Reply to Master with the new total and whether the queue was created. + uint256 yieldBaseline = _yieldOnlyBaseline(); + bytes memory ackPayload = CrossChainV3Helper + .encodeWithdrawRequestAckPayload(yieldBaseline, success); + _send( + address(0), + 0, + CrossChainV3Helper.WITHDRAW_REQUEST_ACK, + nonce, + ackPayload, + false + ); + _acceptYieldNonce(nonce); + + emit WithdrawRequestProcessed(nonce, amount, requestId); + } + + /** + * @dev Leg 2 of Option 1. If the OToken-vault queue hasn't been claimed yet, try the + * claim opportunistically. If the bridgeAsset is in hand, bridge it back to Master. + * Otherwise reply with a NACK so Master can retry later. + */ + function _processWithdrawClaim(uint64 nonce) internal { + // Best-effort claim (idempotent — early-returns if already claimed). + _opportunisticClaim(); + + // Ship only when the queue actually paid out THIS cycle. `_opportunisticClaim` zeroes + // `outstandingRequestId` only on a successful claim, so it's the authoritative + // "claim landed" signal — gating on it (not just held balance) stops a bridgeAsset + // donation during the queue-delay window from being shipped as the proceeds and + // permanently orphaning the still-pending queue request. `outstandingRequestAmount` + // (refined to the claimed amount in `_opportunisticClaim`) caps the ship to the real + // amount, so any donation stays behind and is realised as yield on the next report. + uint256 amount = outstandingRequestAmount; + uint256 bridgeAssetHeld = IERC20(bridgeAsset).balanceOf(address(this)); + + // Defense-in-depth: if the claimed amount is outside the outbound adapter's + // [min, max], the leg-2 ship would revert inside the adapter and brick the yield + // channel (the nonce never gets accepted). NACK instead so the channel stays live and + // the claimed bridgeAsset remains counted on Remote (recoverable). Master's leg-1 + // pre-check (mirror-lane bounds) should prevent reaching this; it only fires on a + // bounds desync between Master's inbound and Remote's outbound configuration. + uint256 minT = IBridgeAdapter(outboundAdapter).minTransferAmount(); + uint256 maxT = IBridgeAdapter(outboundAdapter).maxTransferAmount(); + bool shipOutOfBounds = amount < minT || (maxT != 0 && amount > maxT); + + if ( + outstandingRequestId != 0 || + amount == 0 || + bridgeAssetHeld < amount || + shipOutOfBounds + ) { + // Claim not landed / no request / un-shippable amount: NACK so Master can retry. + uint256 currentBalance = _yieldOnlyBaseline(); + bytes memory nackPayload = CrossChainV3Helper + .encodeWithdrawClaimAckPayload(currentBalance, false, 0); + _send( + address(0), + 0, + CrossChainV3Helper.WITHDRAW_CLAIM_ACK, + nonce, + nackPayload, + false + ); + _acceptYieldNonce(nonce); + emit WithdrawClaimNack(nonce, currentBalance); + return; + } + + // Clear queue-side state (re-set if a fresh leg 1 starts) and bridge back. + // outstandingRequestId is already 0 here (the guard NACKs otherwise); cleared defensively. + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = 0; + outstandingRequestAmount = 0; + + // `amount` (bridgeAsset units) is about to leave us; subtract its OToken-equivalent + // value from the yield baseline. + uint256 yieldBaseline = _yieldOnlyBaselineAfter(_toOToken(amount)); + bytes memory ackPayload = CrossChainV3Helper + .encodeWithdrawClaimAckPayload(yieldBaseline, true, amount); + // bridgeAsset → outboundAdapter allowance is granted by `setOutboundAdapter`. + _send( + bridgeAsset, + amount, + CrossChainV3Helper.WITHDRAW_CLAIM_ACK, + nonce, + ackPayload, + false + ); + _acceptYieldNonce(nonce); + + emit WithdrawClaimDelivered(nonce, amount, yieldBaseline); + } + + /** + * @notice Permissionless, idempotent: claim the outstanding queue withdrawal if its delay + * has elapsed. Safe to call multiple times — early-returns when nothing's pending. + */ + function claimRemoteWithdrawal() external nonReentrant { + _opportunisticClaim(); + } + + function _opportunisticClaim() internal { + uint256 stored = outstandingRequestId; + if (stored == 0) { + return; + } + // `outstandingRequestId` is stored offset-by-one; the real vault id is `stored - 1`. + uint256 vaultRequestId = stored - 1; + // Hoist `claimed` outside the try so its scope is unambiguous to static + // analysers (avoids the slither uninitialized-local false-positive that + // fired when `claimed` was named only in the try-returns clause). + uint256 claimed; + // Use try/catch so a not-yet-claimable queue delay doesn't bubble up as a revert. + try IVault(oTokenVault).claimWithdrawal(vaultRequestId) returns ( + uint256 _claimed + ) { + claimed = _claimed; + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = 0; + // Refine `outstandingRequestAmount` to the vault's actually-returned asset + // amount so leg-2 ships exactly what the vault paid out. This is a defensive + // read-back of the authoritative vault value, NOT a rounding correction: the + // request->claim round-trip is exact (claimed == requested) because the vault + // stores the queued 18dp amount and returns scaleBy(amount, assetDecimals, 18), + // which is the identity when bridgeAsset and the vault's asset share decimals. + outstandingRequestAmount = claimed; + emit RemoteWithdrawalClaimed(vaultRequestId, claimed); + } catch { + // Still queued; leave state unchanged. + } + } + + function _processDeposit(uint64 nonce, uint256 amount) internal { + // bridgeAsset already arrived with the tokens-with-message delivery. + require( + IERC20(bridgeAsset).balanceOf(address(this)) >= amount, + "Remote: deposit asset missing" + ); + + // Mint OToken, then wrap to wOToken. These touch trusted contracts but can still revert + // (vault paused, 4626 edge). A revert here must NOT brick the serialized yield channel, so + // each external call is guarded individually (mirrors crosschain/CrossChainRemoteStrategy): + // on failure the bridgeAsset/oToken stays idle on this strategy — still counted by + // `_viewCheckBalance`, recoverable via `retryDeposit` — and we still ack Master below. + try IVault(oTokenVault).mint(amount) { + // OToken minted; wrapped below. + } catch (bytes memory reason) { + emit DepositUnderlyingFailed(nonce, amount, reason); + } + uint256 oTokenBalance = IERC20(oToken).balanceOf(address(this)); + if (oTokenBalance > 0) { + try + IERC4626(woToken).deposit(oTokenBalance, address(this)) + returns (uint256) { + // wOToken shares minted to this strategy. + } catch (bytes memory reason) { + emit DepositUnderlyingFailed(nonce, amount, reason); + } + } + + // Reply to Master with the new balance and mark the yield nonce processed (always — the + // baseline counts any idle bridgeAsset/oToken, so Master's accounting stays correct). + uint256 yieldBaseline = _yieldOnlyBaseline(); + bytes memory ackPayload = CrossChainV3Helper.encodeUint256( + yieldBaseline + ); + _send( + address(0), + 0, + CrossChainV3Helper.DEPOSIT_ACK, + nonce, + ackPayload, + false + ); + _acceptYieldNonce(nonce); + + emit DepositProcessed(nonce, amount, yieldBaseline); + } + + /// @dev Mint `mintAmount` of bridgeAsset into OToken via the vault (allowance pre-granted by + /// `safeApproveAllTokens`), then wrap all idle OToken into wOToken shares. Used by the + /// operator `retryDeposit`, where a revert SHOULD surface (unlike the message path). + function _mintAndWrap(uint256 mintAmount) internal { + if (mintAmount > 0) { + IVault(oTokenVault).mint(mintAmount); + } + uint256 oTokenBalance = IERC20(oToken).balanceOf(address(this)); + if (oTokenBalance > 0) { + IERC4626(woToken).deposit(oTokenBalance, address(this)); + } + } + + /** + * @notice Recover a deposit whose mint/wrap previously failed: re-runs the pipeline on any + * idle bridgeAsset (mint → OToken) and idle OToken (wrap → wOToken), returning the + * stranded value to productive wOToken. `checkBalance` already counts the idle assets, + * so this changes nothing for accounting — it just stops the value sitting unproductive. + * @dev Operator/strategist/governor; reverts loudly if the underlying still fails (unlike the + * message path, a manual retry SHOULD surface the error). + */ + function retryDeposit() + external + onlyOperatorGovernorOrStrategist + nonReentrant + { + uint256 idleBridgeAsset = IERC20(bridgeAsset).balanceOf(address(this)); + uint256 oTokenBefore = IERC20(oToken).balanceOf(address(this)); + require( + idleBridgeAsset > 0 || oTokenBefore > 0, + "Remote: nothing to retry" + ); + _mintAndWrap(idleBridgeAsset); + emit IdleDepositRetried(idleBridgeAsset, oTokenBefore); + } + + // --- AbstractWOTokenStrategy hooks ------------------------------------- + + /// @inheritdoc AbstractWOTokenStrategy + function _bridgeOutboundMsgType() internal pure override returns (uint32) { + return CrossChainV3Helper.BRIDGE_IN; + } + + /// @inheritdoc AbstractWOTokenStrategy + /// @dev Bridging out of Remote wraps the user's own OToken, so there's no Remote-side + /// liquidity ceiling — the bound is the user's balance. Report unbounded. + function availableBridgeLiquidity() public pure override returns (uint256) { + return type(uint256).max; + } + + /// @inheritdoc AbstractWOTokenStrategy + function _consumeOTokenForBridge(uint256 amount) internal override { + // Pull OToken from the user and wrap into wOToken shares held by this strategy. + IERC20(oToken).safeTransferFrom(msg.sender, address(this), amount); + IERC4626(woToken).deposit(amount, address(this)); + } + + /// @inheritdoc AbstractWOTokenStrategy + function _deliverOTokenForBridge(uint256 amount, address recipient) + internal + override + { + // Defensive: ensure we actually hold enough OToken value to satisfy this bridge-out. + uint256 sharesNeeded = IERC4626(woToken).previewWithdraw(amount); + require( + IERC20(woToken).balanceOf(address(this)) >= sharesNeeded, + "Remote: insufficient remote wOToken" + ); + + IERC4626(woToken).withdraw(amount, address(this), address(this)); + IERC20(oToken).safeTransfer(recipient, amount); + } + + // --- Helpers ----------------------------------------------------------- + + function _viewCheckBalance() internal view returns (uint256) { + // Denominated in OToken (18dp). Value lives in exactly one slot at any time per the + // state-transition table: + // - shares (4626-wrapped wOToken) — OToken units + // - oToken (unwrapped but not yet queued / redeemed) — OToken units + // - bridgeAsset (claimed / redeemed but not yet bridged back) — bridgeAsset units, + // scaled up to OToken units here + // - the OToken-vault queue — tracked by outstandingRequestAmount (bridgeAsset units, + // scaled up), counted only while the request is still outstanding + uint256 sharesBalance = IERC20(woToken).balanceOf(address(this)); + uint256 valueOfShares = sharesBalance == 0 + ? 0 + : IERC4626(woToken).previewRedeem(sharesBalance); + uint256 queued = outstandingRequestId != 0 + ? _toOToken(outstandingRequestAmount) + : 0; + return + valueOfShares + + IERC20(oToken).balanceOf(address(this)) + + _toOToken(IERC20(bridgeAsset).balanceOf(address(this))) + + queued; + } + + /// @dev Remote's yield-only baseline = full custody value minus the bridge-channel + /// delta. `Master.remoteStrategyBalance` must hold exactly this, because + /// `Master.checkBalance` re-adds its OWN `bridgeAdjustment` separately — so every + /// R→M balance report routes through here (deposit / withdraw / claim acks, not + /// just balance-check / settle). + function _yieldOnlyBaseline() internal view returns (uint256) { + return _yieldOnlyBaselineAfter(0); + } + + /// @dev Yield-only baseline as it will stand AFTER `oTokenAmount` of OToken value leaves + /// 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` + /// is principal + yield + retained fees and is never *economically* negative, but the + /// wOToken ERC-4626 rounds against the strategy by ~1 wei on each BRIDGE_IN (floor) / + /// BRIDGE_OUT (ceil), so once a `withdrawAll` drains this near 0 a later bridge op can push + /// it a few wei negative. Reverting there would freeze the whole serialized yield channel on + /// dust; clamping reports a dust-accurate 0 instead (and matches the project-wide + /// checkBalance-never-reverts convention). The accumulated drift is economically nil. + function _yieldOnlyBaselineAfter(uint256 oTokenAmount) + internal + view + returns (uint256) + { + int256 v = int256(_viewCheckBalance()) - + int256(oTokenAmount) - + bridgeAdjustment; + return v > 0 ? uint256(v) : 0; + } +} diff --git a/contracts/contracts/strategies/crosschainV3/adapters/AbstractAdapter.sol b/contracts/contracts/strategies/crosschainV3/adapters/AbstractAdapter.sol new file mode 100644 index 0000000000..8b846580a0 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/AbstractAdapter.sol @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { Governable } from "../../../governance/Governable.sol"; +import { IBridgeAdapter } from "../../../interfaces/crosschainV3/IBridgeAdapter.sol"; +import { IBridgeReceiver } from "../../../interfaces/crosschainV3/IBridgeReceiver.sol"; + +/** + * @title AbstractAdapter + * @author Origin Protocol Inc + * + * @notice Shared base for OUSD V3 bridge adapters. One adapter deployment serves a single + * (chain, bridge protocol) — multi-tenant across strategies on that chain, with per- + * sender lane configuration. Under CREATE3 cross-chain parity, the peer adapter on + * the destination chain shares this contract's own address, so outbound routing and + * inbound trust checks both reference `address(this)`. + * + * The base provides: + * - `authorised` whitelist gating both outbound (`msg.sender`) and inbound + * (`envelopeSender`); a single authorise call wires both directions. + * - `laneConfig[sender]` with destination chain selector, paused flag, and + * destination-side gas hint. Concrete adapters extend with their own per-lane + * extras as separate mappings. + * - Strategists list — accounts that can pause/unpause lanes for fast incident + * response. Governor also has these powers. + * - Outbound `sendMessage` / `sendMessageAndTokens` that wrap + * `(msg.sender, payload)` into a transport envelope and require + * `msg.value >= quote`. Excess is NOT refunded — it stays on the adapter + * (recover via `transferToken`); see `sendMessage`. + * - Inbound helpers `_validateInbound` (transport identity already verified by + * the concrete adapter) and `_deliver` (atomic delivery to the destination + * strategy). + * - A `transferToken` sweep for stuck tokens / native (governor only). + * + * Concrete adapters implement three internal hooks for the bridge-specific transport + * calls: `_sendMessage`, `_sendMessageAndTokens`, `_quoteFee`. + */ +abstract contract AbstractAdapter is IBridgeAdapter, Governable { + using SafeERC20 for IERC20; + + /// @notice Per-lane routing config. One row per authorised sender. + struct ChainConfig { + bool paused; + uint64 chainSelector; // destination chain identifier (protocol-specific encoding) + uint32 destGasLimit; // gas hint forwarded to the receive callback on the peer + } + + /// @notice Sender → authorised flag. Gates both outbound `msg.sender` and inbound + /// envelopeSender. CREATE3 parity means the same address represents the same + /// strategy on every chain it lives on. + mapping(address => bool) public authorised; + + /// @notice Sender → lane config. Mutating this changes which destination chain the + /// sender can send to / be received from; treat as governance-grade. + mapping(address => ChainConfig) public laneConfig; + + /// @notice Strategists list — actors permitted to flip the `paused` flag on a lane. + /// The governor also has these powers. + mapping(address => bool) public strategists; + + /// @notice Per-tx maximum token amount this adapter accepts on outbound. Governor-set + /// to match the bridge protocol's per-tx limit (CCIP token-lane rate, CCTP V2 + /// per-burn cap, etc.). Strategies on the peer chain treat the same value as + /// "max this adapter can deliver inbound per tx" to size their withdrawAll-style + /// requests. `0` = no enforcement at this layer (concrete adapters may still + /// apply hard protocol-level constants on top). + /// @dev Backing storage for the `maxTransferAmount()` getter, which concrete adapters may + /// override to surface a hard protocol cap (e.g. CCTPAdapter's 10M) regardless of the + /// configured value. Internal so the override is the single source of truth externally. + uint256 internal _maxTransferAmount; + + event Authorised(address indexed sender, ChainConfig cfg); + event Revoked(address indexed sender); + event LaneConfigUpdated(address indexed sender, ChainConfig cfg); + event LanePaused(address indexed sender); + event LaneUnpaused(address indexed sender); + event StrategistAdded(address indexed who); + event StrategistRemoved(address indexed who); + event MaxTransferAmountUpdated(uint256 oldAmount, uint256 newAmount); + event MessageSent( + address indexed sender, + address token, + uint256 amount, + uint256 feeCharged + ); + event MessageDelivered( + address indexed target, + address token, + uint256 amountReceived, + uint256 feePaid + ); + + /// @dev Reserved for future expansion of this abstract layer (proxy upgradeable). + uint256[50] private __gap; + + constructor() { + // For standalone deployments (tests, scratch). When behind a proxy, the proxy's + // own constructor + initialize ritual is the source of truth — this assignment is + // overwritten as soon as the proxy delegates governance through `_changeGovernor`. + _setGovernor(msg.sender); + } + + // --- Modifiers --------------------------------------------------------- + + modifier onlyAuthorised() { + require(authorised[msg.sender], "Adapter: not authorised"); + _; + } + + modifier onlyStrategistOrGovernor() { + require( + strategists[msg.sender] || isGovernor(), + "Adapter: not strategist or governor" + ); + _; + } + + // --- Governance: strategists ------------------------------------------- + + function addStrategist(address who) external onlyGovernor { + require(who != address(0), "Adapter: zero strategist"); + strategists[who] = true; + emit StrategistAdded(who); + } + + function removeStrategist(address who) external onlyGovernor { + strategists[who] = false; + emit StrategistRemoved(who); + } + + // --- Governance: authorisation + lane config --------------------------- + + /** + * @notice Authorise `sender` to use this adapter and register its lane config. + * Authorisation is bidirectional: the same `sender` is recognised both as + * outbound `msg.sender` and as inbound `envelopeSender`. + */ + function authorise(address sender, ChainConfig calldata cfg) + external + onlyGovernor + { + require(sender != address(0), "Adapter: zero sender"); + // chainSelector may be 0 — CCTP V2 domain for Ethereum/Sepolia is literally 0. + // Authorisation lookup uses the `authorised` flag, not chainSelector, so 0 + // is a valid (non-uninitialised) value here. + authorised[sender] = true; + laneConfig[sender] = cfg; + emit Authorised(sender, cfg); + } + + function revoke(address sender) external onlyGovernor { + authorised[sender] = false; + emit Revoked(sender); + } + + function setLaneConfig(address sender, ChainConfig calldata cfg) + external + onlyGovernor + { + require(authorised[sender], "Adapter: sender not authorised"); + // See note in `authorise()` — chainSelector may be 0 (CCTP Ethereum domain). + laneConfig[sender] = cfg; + emit LaneConfigUpdated(sender, cfg); + } + + /// @notice Governor sets the per-tx token amount ceiling. Set to match the bridge + /// protocol's actual per-tx limit (CCIP lane rate, CCTP burn cap, etc.). + /// `0` disables the check (e.g., canonical bridges with no per-tx limit). + function setMaxTransferAmount(uint256 _amount) external onlyGovernor { + emit MaxTransferAmountUpdated(_maxTransferAmount, _amount); + _maxTransferAmount = _amount; + } + + /// @notice Per-tx maximum token amount (see `_maxTransferAmount`). `0` = unlimited at this + /// layer. Concrete adapters override to surface a hard protocol cap. + function maxTransferAmount() public view virtual returns (uint256) { + return _maxTransferAmount; + } + + /// @notice Per-tx minimum token amount (dust floor). `0` = no floor. Concrete adapters + /// that enforce a floor (e.g. CCTPAdapter) override this; default is no floor so + /// strategies can quote `[minTransferAmount(), maxTransferAmount()]` generically. + function minTransferAmount() public view virtual returns (uint256) { + return 0; + } + + function pauseLane(address sender) external onlyStrategistOrGovernor { + require(authorised[sender], "Adapter: sender not authorised"); + laneConfig[sender].paused = true; + emit LanePaused(sender); + } + + function unpauseLane(address sender) external onlyStrategistOrGovernor { + require(authorised[sender], "Adapter: sender not authorised"); + laneConfig[sender].paused = false; + emit LaneUnpaused(sender); + } + + // --- Governance: recovery ---------------------------------------------- + + /** + * @notice Sweep a stuck asset (or native via `_asset == address(0)`) to the governor. + * Recovery only — used to rescue mistaken sends or drain stale refund balances. + */ + function transferToken(address _asset, uint256 _amount) + external + onlyGovernor + { + if (_asset == address(0)) { + // slither-disable-next-line low-level-calls + (bool ok, ) = governor().call{ value: _amount }(""); + require(ok, "Adapter: native transfer failed"); + } else { + IERC20(_asset).safeTransfer(governor(), _amount); + } + } + + // --- Outbound (IBridgeAdapter) ----------------------------------------- + + /// @inheritdoc IBridgeAdapter + /// + /// @dev No refund on excess. Overpayment stays on the adapter; recover via + /// `transferToken(address(0), amount)` (governor-only). Rationale: refunds + /// add code surface, and the strategy quotes fees itself before calling — overpay + /// should be rare. Pool-donation semantics are simpler than per-call refund logic. + function sendMessage(bytes calldata payload) + external + payable + override + onlyAuthorised + { + ChainConfig memory cfg = laneConfig[msg.sender]; + require(!cfg.paused, "Adapter: lane paused"); + bytes memory envelope = _wrap(msg.sender, 0, payload); + (uint256 fee, , bool requiresExternalPayment) = _quoteFee( + envelope, + cfg, + address(0), + 0 + ); + // requiresExternalPayment == false means the bridge handles its own fee internally + // (e.g., CCTP V2 auto-deducts from the burn amount); msg.value is not consumed. + if (requiresExternalPayment) { + require(msg.value >= fee, "Adapter: insufficient fee"); + } + _sendMessage(envelope, cfg, requiresExternalPayment ? fee : 0); + emit MessageSent(msg.sender, address(0), 0, fee); + } + + /// @inheritdoc IBridgeAdapter + function sendMessageAndTokens( + address token, + uint256 amount, + bytes calldata payload + ) external payable override onlyAuthorised { + require(token != address(0), "Adapter: zero token"); + require(amount > 0, "Adapter: zero amount"); + // Per-tx amount cap. `0` disables the check (canonical bridges, unconfigured). + // Reject cleanly here rather than letting the bridge router revert deep inside + // its own validation. Read the virtual getter (not the raw `_maxTransferAmount` + // field) so a concrete adapter's hard-cap override (e.g. CCTP's 10M) is honoured + // here even when `_maxTransferAmount` is left at 0. + uint256 cap = maxTransferAmount(); + require(cap == 0 || amount <= cap, "Adapter: amount above max"); + ChainConfig memory cfg = laneConfig[msg.sender]; + require(!cfg.paused, "Adapter: lane paused"); + bytes memory envelope = _wrap(msg.sender, amount, payload); + (uint256 fee, , bool requiresExternalPayment) = _quoteFee( + envelope, + cfg, + token, + amount + ); + if (requiresExternalPayment) { + require(msg.value >= fee, "Adapter: insufficient fee"); + } + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + _sendMessageAndTokens( + token, + amount, + envelope, + cfg, + requiresExternalPayment ? fee : 0 + ); + emit MessageSent(msg.sender, token, amount, fee); + } + + /// @inheritdoc IBridgeAdapter + function quoteFee( + address token, + uint256 amount, + bytes calldata payload + ) + external + view + override + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) + { + ChainConfig memory cfg = laneConfig[msg.sender]; + bytes memory envelope = _wrap(msg.sender, amount, payload); + return _quoteFee(envelope, cfg, token, amount); + } + + // --- Outbound hooks (concrete adapters implement) ---------------------- + + /// @dev Send a message-only envelope through the bridge transport. `fee` is the native + /// value to attach to the underlying bridge call; 0 when the protocol auto-deducts. + function _sendMessage( + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal virtual; + + /// @dev Send a message + tokens through the bridge transport. Same `fee` semantics as + /// `_sendMessage`. + function _sendMessageAndTokens( + address token, + uint256 amount, + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal virtual; + + /// @dev Compute the fee details for the outbound op. See `IBridgeAdapter.quoteFee` for + /// the meaning of each return value. The three-value form lets the strategy + /// separate "is action required?" from "what token / how much?" — important for + /// bridges like CCTP V2 where the fee is real but auto-deducted (caller takes no + /// action) vs CCIP where the caller must supply native. + function _quoteFee( + bytes memory envelope, + ChainConfig memory cfg, + address token, + uint256 amount + ) + internal + view + virtual + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ); + + // --- Inbound helpers (concrete adapter calls from its transport entry) -- + + /** + * @dev Validate an inbound envelope against the configured lane. Concrete adapters + * pass: + * - `srcChain` — source chain ID extracted from the bridge transport. + * - `transportSender` — source-chain caller that originated the bridge tx. Under + * CREATE3 parity, this must equal `address(this)` (the peer + * adapter has the same address). + * - `envelope` — full wrapped bytes received from the transport. + * Returns the decoded `envelopeSender` (also the destination strategy address on + * this chain), `intendedAmount` (sender's intent for the token leg; 0 for + * message-only), and the strategy-owned `payload`. + */ + function _validateInbound( + uint64 srcChain, + address transportSender, + bytes memory envelope + ) + internal + view + returns ( + address envelopeSender, + uint256 intendedAmount, + bytes memory payload + ) + { + (envelopeSender, intendedAmount, payload) = _unwrap(envelope); + require(authorised[envelopeSender], "Adapter: not authorised"); + ChainConfig memory cfg = laneConfig[envelopeSender]; + require(!cfg.paused, "Adapter: lane paused"); + require(srcChain == cfg.chainSelector, "Adapter: wrong source chain"); + require( + transportSender == address(this), + "Adapter: not from peer adapter" + ); + } + + /** + * @dev Atomically transfer `amountReceived` of `token` to the target strategy and call + * `receiveMessage`. The target strategy address equals `envelopeSender` under + * CREATE3 parity. + */ + function _deliver( + address envelopeSender, + address token, + uint256 amountReceived, + uint256 feePaid, + bytes memory payload + ) internal { + if (amountReceived > 0 && token != address(0)) { + IERC20(token).safeTransfer(envelopeSender, amountReceived); + } + // feePaid is NOT forwarded to the strategy (no strategy reads it); off-chain + // consumers read it from the MessageDelivered event below. + // The call target and the `sender` argument are the same `envelopeSender`: the + // target == sender under CREATE3 parity (see @dev), and the strategy expects its + // own peer address as `sender`. + IBridgeReceiver(envelopeSender).receiveMessage( + envelopeSender, + token, + amountReceived, + payload + ); + emit MessageDelivered(envelopeSender, token, amountReceived, feePaid); + } + + // --- Envelope wrap / unwrap -------------------------------------------- + + /// @dev Header byte length: 20 (sender) + 32 (intendedAmount). + uint256 internal constant HEADER_LENGTH = 52; + + /// @dev Wire envelope: 20-byte `sender` + 32-byte `intendedAmount` + opaque `payload`. + /// `intendedAmount` is the token leg the sender intends to land on the destination + /// (0 for message-only). The receiving adapter compares against the actual landed + /// amount to surface any transport-side fee delta to the strategy. + function _wrap( + address sender, + uint256 intendedAmount, + bytes memory payload + ) internal pure returns (bytes memory) { + return abi.encodePacked(sender, intendedAmount, payload); + } + + /// @dev Inverse of `_wrap`. Reverts when the envelope is shorter than the header. + function _unwrap(bytes memory envelope) + internal + pure + returns ( + address sender, + uint256 intendedAmount, + bytes memory payload + ) + { + require(envelope.length >= HEADER_LENGTH, "Adapter: bad envelope"); + // Load first 20 bytes as address. + // solhint-disable-next-line no-inline-assembly + assembly { + sender := shr(96, mload(add(envelope, 32))) + // intendedAmount lives at offset 20; mload reads 32 bytes from there. + intendedAmount := mload(add(envelope, 52)) + } + // Copy the remainder into a new bytes buffer. + uint256 payloadLength = envelope.length - HEADER_LENGTH; + payload = new bytes(payloadLength); + // solhint-disable-next-line no-inline-assembly + assembly { + let src := add(envelope, 84) // 32-byte length + 20 sender + 32 amount + let dst := add(payload, 32) + for { + let i := 0 + } lt(i, payloadLength) { + i := add(i, 32) + } { + mstore(add(dst, i), mload(add(src, i))) + } + } + } + + // --- Native receive ---------------------------------------------------- + + /// @dev Accepts native ETH (e.g., refunds from underlying transports). Concrete adapters + /// may override to add behaviour (e.g., SuperbridgeAdapter wrapping incoming bridge + /// ETH to WETH on the L2 side). + receive() external payable virtual {} +} diff --git a/contracts/contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol b/contracts/contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol new file mode 100644 index 0000000000..926b6fef5b --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { IRouterClient } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; +// solhint-disable-next-line max-line-length +import { IAny2EVMMessageReceiver } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol"; + +import { AbstractAdapter } from "./AbstractAdapter.sol"; +import { CCIPMessageBuilder } from "../libraries/CCIPMessageBuilder.sol"; + +/** + * @title CCIPAdapter + * @author Origin Protocol Inc + * + * @notice Atomic bidirectional adapter over Chainlink CCIP. Carries token + message + * (`sendMessageAndTokens`) or message-only (`sendMessage`) to the configured peer. + * Receives inbound via `ccipReceive`, validates against the lane config (source + * chain, peer adapter identity), and forwards to the destination strategy + * (CREATE3 parity: envelope sender == destination strategy on this chain). + * + * The CCIP fee is paid in native and sourced from `msg.value`; excess is NOT + * refunded — it stays on the adapter (recover via `transferToken`). + */ +contract CCIPAdapter is AbstractAdapter, IAny2EVMMessageReceiver, IERC165 { + using SafeERC20 for IERC20; + + /// @notice CCIP Router on this chain. + IRouterClient public immutable ccipRouter; + + constructor(IRouterClient _ccipRouter) { + require(address(_ccipRouter) != address(0), "CCIP: zero router"); + ccipRouter = _ccipRouter; + } + + modifier onlyRouter() { + require(msg.sender == address(ccipRouter), "CCIP: not router"); + _; + } + + // --- Outbound hooks ---------------------------------------------------- + + /// @dev CCIP charges a native fee per message; LINK-mode is not supported here. + /// `requiresExternalPayment = true` forces the strategy to supply msg.value or + /// cover from its pool. + function _quoteFee( + bytes memory envelope, + ChainConfig memory cfg, + address token, + uint256 amount + ) + internal + view + override + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) + { + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + token, + amount, + envelope, + address(this), // peer adapter address (CREATE3 parity) + cfg.destGasLimit + ); + fee = ccipRouter.getFee(cfg.chainSelector, ccipMessage); + feeToken = address(0); // native + requiresExternalPayment = true; + } + + function _sendMessage( + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal override { + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + address(0), + 0, + envelope, + address(this), + cfg.destGasLimit + ); + ccipRouter.ccipSend{ value: fee }(cfg.chainSelector, ccipMessage); + } + + function _sendMessageAndTokens( + address token, + uint256 amount, + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal override { + IERC20(token).safeApprove(address(ccipRouter), amount); + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + token, + amount, + envelope, + address(this), + cfg.destGasLimit + ); + ccipRouter.ccipSend{ value: fee }(cfg.chainSelector, ccipMessage); + } + + // --- Inbound (IAny2EVMMessageReceiver) --------------------------------- + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + external + pure + override + returns (bool) + { + return + interfaceId == type(IAny2EVMMessageReceiver).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + + /// @inheritdoc IAny2EVMMessageReceiver + function ccipReceive(Client.Any2EVMMessage calldata message) + external + override + onlyRouter + { + // Decode the transport-level sender (the source-chain caller of router.ccipSend). + address transportSender = abi.decode(message.sender, (address)); + + ( + address envelopeSender, + uint256 intendedAmount, + bytes memory payload + ) = _validateInbound( + message.sourceChainSelector, + transportSender, + message.data + ); + + // Single token amount expected at most; V3 doesn't multi-bundle. + // NOTE: the delivered token is forwarded as-is on CCIP-router trust; it is NOT pinned + // to an expected bridge asset here (unlike CCTPAdapter's `usdcToken` and + // SuperbridgeAdapter's `weth`). The destination strategy ignores the token argument + // and accounts against its own configured `bridgeAsset` balance, so a correctly + // configured lane is the load-bearing assumption for token identity. + address token = address(0); + uint256 amount = 0; + if (message.destTokenAmounts.length > 0) { + token = message.destTokenAmounts[0].token; + amount = message.destTokenAmounts[0].amount; + } + + // CCIP delivers exactly the burned amount on the destination — no transport-side + // token fee, so `feePaid` is 0. Sanity-check the envelope intent matches. + require( + intendedAmount == amount, + "CCIP: amount mismatch with envelope" + ); + _deliver(envelopeSender, token, amount, 0, payload); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol b/contracts/contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol new file mode 100644 index 0000000000..c7e17af026 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { ICCTPTokenMessenger, ICCTPMessageTransmitter, IMessageHandlerV2 } from "../../../interfaces/cctp/ICCTP.sol"; +import { AbstractAdapter } from "./AbstractAdapter.sol"; +import { CCTPMessageHelper } from "../libraries/CCTPMessageHelper.sol"; + +/** + * @title CCTPAdapter + * @author Origin Protocol Inc + * + * @notice Atomic bidirectional adapter over Circle CCTP V2. + * - Outbound (`sendMessageAndTokens`): burn USDC via `depositForBurnWithHook` with + * the wrapped envelope as the hook data. The recipient mint amount equals the + * burn amount minus CCTP's protocol fee (0 for finalised threshold, > 0 for + * fast finality). The fee is absorbed by the protocol; the receiving strategy + * accounts on the actual landed amount. + * - Outbound (`sendMessage`): post a hook-only message via the MessageTransmitter. + * - Inbound (`handleReceiveFinalizedMessage`): CCTP MessageTransmitter has minted + * USDC to this adapter. Validate source domain + sender against the lane config, + * then forward the actual minted amount to the destination strategy. + * + * CCTP has no native bridge fee — `_quoteFee` returns 0 so no `msg.value` is + * required. Anything a caller forwards anyway is NOT refunded; it stays on the + * adapter (recover via `transferToken`). + */ +contract CCTPAdapter is AbstractAdapter, IMessageHandlerV2 { + using SafeERC20 for IERC20; + + /// @notice CCTP V2 protocol cap per burn. Hard-coded as a `constant` so it can't be + /// raised by governance — Circle decides this number, not us. Higher values + /// would revert at the TokenMessenger anyway; we reject early for a cleaner + /// error message. If Circle ever raises the cap, this constant gets bumped via + /// contract upgrade, not via governance setter. + uint256 public constant MAX_TRANSFER_AMOUNT = 10_000_000 * 10**6; // 10M USDC + + /// @notice USDC on this chain. + address public immutable usdcToken; + /// @notice Circle CCTP V2 Token Messenger. + ICCTPTokenMessenger public immutable tokenMessenger; + /// @notice Circle CCTP V2 Message Transmitter (message-only sends + inbound delivery). + ICCTPMessageTransmitter public immutable messageTransmitter; + + /// @notice Minimum finality threshold sent on every transfer. Range: 1000–2000. + /// 2000 = finalised (zero protocol fee, ~13 minute delay on Ethereum). + /// 1000–1999 = fast finality (non-zero token-side fee, sub-minute delivery). + /// + /// NOT initialised at declaration — that would only set the storage slot on + /// the implementation, not on the proxy. Governor must call + /// `setMinFinalityThreshold` post-deploy. Send-side guard catches the + /// pre-init mistake with a clear revert message. + uint32 public minFinalityThreshold; + + /// @notice Lower bound on USDC transfers; governor-settable. Avoids dust burns that + /// waste gas + CCTP attestation latency on negligible amounts. Exposed via the + /// `minTransferAmount()` getter (overrides AbstractAdapter's default 0). + uint256 internal _minTransferAmount; + + /// @notice Account allowed to invoke `relay(message, attestation)` — the off-chain + /// attestation poller / relayer. Single address; governor-settable. CCTP is + /// pull-driven so this role is required at the adapter level; CCIP and + /// Superbridge don't need it. + address public operator; + + event MinFinalityThresholdUpdated(uint32 oldThreshold, uint32 newThreshold); + event MinTransferAmountUpdated(uint256 oldAmount, uint256 newAmount); + event OperatorUpdated(address oldOperator, address newOperator); + event MessageRelayed(address indexed by, uint32 sourceDomain); + + constructor( + address _usdcToken, + ICCTPTokenMessenger _tokenMessenger, + ICCTPMessageTransmitter _messageTransmitter + ) { + require(_usdcToken != address(0), "CCTP: zero usdc"); + require(address(_tokenMessenger) != address(0), "CCTP: zero messenger"); + require( + address(_messageTransmitter) != address(0), + "CCTP: zero transmitter" + ); + usdcToken = _usdcToken; + tokenMessenger = _tokenMessenger; + messageTransmitter = _messageTransmitter; + } + + modifier onlyCCTP() { + require( + msg.sender == address(messageTransmitter), + "CCTP: not message transmitter" + ); + _; + } + + modifier onlyOperator() { + require(msg.sender == operator, "CCTP: not operator"); + _; + } + + function setMinFinalityThreshold(uint32 _t) external onlyGovernor { + require(_t >= 1000 && _t <= 2000, "CCTP: bad threshold"); + emit MinFinalityThresholdUpdated(minFinalityThreshold, _t); + minFinalityThreshold = _t; + } + + function setMinTransferAmount(uint256 _amount) external onlyGovernor { + emit MinTransferAmountUpdated(_minTransferAmount, _amount); + _minTransferAmount = _amount; + } + + /// @notice Dust floor for USDC transfers (overrides AbstractAdapter's default 0). + function minTransferAmount() public view override returns (uint256) { + return _minTransferAmount; + } + + /// @notice Effective per-tx cap: the governance value clamped to the hard CCTP protocol + /// cap, and the hard cap itself when unset. Strategies sizing against this never + /// exceed what `_sendMessageAndTokens` will accept (avoids a leg-2 hard revert). + function maxTransferAmount() public view override returns (uint256) { + uint256 configured = _maxTransferAmount; + if (configured == 0 || configured > MAX_TRANSFER_AMOUNT) { + return MAX_TRANSFER_AMOUNT; + } + return configured; + } + + function setOperator(address _operator) external onlyGovernor { + emit OperatorUpdated(operator, _operator); + operator = _operator; + } + + // --- Relay (operator-driven inbound finalisation) --------------------- + + /** + * @notice Operator entry point: hand a Circle-signed CCTP message + attestation pair + * to the local MessageTransmitter, then dispatch the payload to the destination + * strategy. + * + * CCTP V2 has two on-wire message shapes that both arrive here: + * + * 1. **Burn-message + hook** (sourced from `TokenMessenger.depositForBurnWithHook`). + * The transport `sender` is the source-side `TokenMessenger`. The transport + * `recipient` is the destination `TokenMessenger`, NOT this adapter. The + * body is a CCTP burn body containing burnToken / mintRecipient / amount / + * msgSender / feeExecuted / hookData. Auto-dispatch via + * `handleReceiveMessage` on the mintRecipient is V2.1-only and not + * universally available across Circle's chain deployments, so we DON'T rely + * on it. Instead: we call `messageTransmitter.receiveMessage` (which credits + * USDC to this adapter as the configured mintRecipient), then parse the burn + * body ourselves and call `_deliver` with the authoritative `amount - + * feeExecuted` and the hookData (our application envelope). This mirrors + * the older `AbstractCCTPIntegrator.relay()` pattern, which has been + * exercised in production. + * + * 2. **Pure message** (sourced from `MessageTransmitter.sendMessage`). Transport + * `sender` and `recipient` are both this adapter (CREATE3 parity). The body + * is our application envelope directly. `messageTransmitter.receiveMessage` + * triggers our own `handleReceiveFinalizedMessage` hook, which calls + * `_handleInbound` and dispatches. + */ + function relay(bytes calldata message, bytes calldata attestation) + external + onlyOperator + { + ( + uint32 version, + uint32 sourceDomain, + address transportSender, + address transportRecipient, + bytes memory body + ) = CCTPMessageHelper.decodeMessageHeader(message); + require( + version == CCTPMessageHelper.CCTP_V2_VERSION, + "CCTP: bad msg version" + ); + + // Burn messages have the source TokenMessenger as their transport sender. Pure + // messages have this adapter as both transport sender and recipient (CREATE3 + // parity). + // INVARIANT: this branch compares the SOURCE-chain TokenMessenger (transportSender) + // against THIS chain's `tokenMessenger` immutable. It is only correct because CCTP V2 + // deploys TokenMessenger at the same address on every chain. If a future supported + // chain breaks that parity, a legitimate burn would mis-route into the pure-message + // branch and revert at the `transportRecipient == address(this)` check — re-validate + // before integrating such a chain. + if (transportSender == address(tokenMessenger)) { + _relayBurn(sourceDomain, body, message, attestation); + } else { + require(transportRecipient == address(this), "CCTP: not for us"); + require( + messageTransmitter.receiveMessage(message, attestation), + "CCTP: relay failed" + ); + // MessageTransmitter has now invoked our `handleReceiveFinalizedMessage` (or + // unfinalized variant). Nothing more to do here. + } + emit MessageRelayed(msg.sender, sourceDomain); + } + + /// @dev Burn-message path. Parse the burn body for authoritative amount/fee/hookData, + /// then `receiveMessage` to credit USDC, then validate + dispatch. + function _relayBurn( + uint32 sourceDomain, + bytes memory body, + bytes calldata message, + bytes calldata attestation + ) internal { + ( + address burnToken, + address mintRecipient, + uint256 amount, + address msgSender, + uint256 feeExecuted, + bytes memory hookData + ) = CCTPMessageHelper.decodeBurnBody(body); + // `burnToken` is the SOURCE-chain USDC address, which differs from this chain's + // `usdcToken` for cross-chain transfers. CCTP's MessageTransmitter validates the + // burn record cryptographically via the attestation; what gets minted here is + // always the local USDC by protocol design. So no local burnToken equality check. + burnToken; // silence unused-var + // The burn branch skips the pure-message branch's `transportRecipient` parity check, + // so enforce mint-recipient parity here: a forged burn that mints elsewhere reverts + // cleanly instead of silently delivering 0. + require(mintRecipient == address(this), "CCTP: bad mint recipient"); + + uint256 balanceBefore = IERC20(usdcToken).balanceOf(address(this)); + require( + messageTransmitter.receiveMessage(message, attestation), + "CCTP: relay failed" + ); + uint256 landed = _landedAmount( + IERC20(usdcToken).balanceOf(address(this)) - balanceBefore, + amount - feeExecuted + ); + _dispatchBurn( + sourceDomain, + msgSender, + hookData, + landed, + feeExecuted, + amount + ); + } + + /// @dev Choose the authoritative landed amount: prefer the smaller of the actual + /// mint delta and the expected `amount - feeExecuted`. This isolates donations + /// (extra balance arriving between the snapshot and the mint stays on the + /// adapter) and also defends against short mints (deliver what we actually got). + function _landedAmount(uint256 minted, uint256 expected) + internal + pure + returns (uint256) + { + return minted < expected ? minted : expected; + } + + function _dispatchBurn( + uint32 sourceDomain, + address msgSender, + bytes memory hookData, + uint256 landed, + uint256 feeExecuted, + uint256 burnAmount + ) internal { + ( + address envelopeSender, + uint256 intendedAmount, + bytes memory payload + ) = _validateInbound(uint64(sourceDomain), msgSender, hookData); + // Sanity: source-side intent equals the full burn `amount` (the application's + // pre-fee intent). `intendedAmount == 0` is permitted for envelopes that don't + // pre-set the amount. + require( + intendedAmount == 0 || intendedAmount == burnAmount, + "CCTP: intent mismatch" + ); + _deliver(envelopeSender, usdcToken, landed, feeExecuted, payload); + } + + // --- Outbound hooks ---------------------------------------------------- + + /// @dev CCTP V2 has NO native fee. The protocol fee (when fast finality is used) is + /// deducted by CCTP itself from the burned token amount — the caller doesn't need + /// to supply anything separately. We report this as `requiresExternalPayment = + /// false` so the strategy skips the msg.value check entirely. + /// + /// For token-carrying sends we still report `fee = getMinFeeAmount(amount)` and + /// `feeToken = usdcToken` for telemetry/observability; this is the upper bound + /// the protocol could take. For message-only sends, no token, no fee. + function _quoteFee( + bytes memory, // envelope + ChainConfig memory, // cfg + address token, + uint256 amount + ) + internal + view + override + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) + { + if (token == address(0) || amount == 0) { + return (0, address(0), false); + } + fee = _minFeeOrZero(amount); + feeToken = usdcToken; + requiresExternalPayment = false; + } + + function _sendMessage( + bytes memory envelope, + ChainConfig memory cfg, + uint256 /* fee */ + ) internal override { + // Hook-only message via the transmitter (no token leg). destinationCaller is the + // peer adapter address (CREATE3 parity) so only it can finalise on the destination. + require(minFinalityThreshold > 0, "CCTP: threshold not set"); + messageTransmitter.sendMessage( + uint32(cfg.chainSelector), + _addressToBytes32(address(this)), + _addressToBytes32(address(this)), + minFinalityThreshold, + envelope + ); + } + + function _sendMessageAndTokens( + address token, + uint256 amount, + bytes memory envelope, + ChainConfig memory cfg, + uint256 /* fee */ + ) internal override { + require(token == usdcToken, "CCTP: token must be usdc"); + require(minFinalityThreshold > 0, "CCTP: threshold not set"); + // Bounds: dust floor (governor-set) + Circle's hard per-burn cap. The base + // `sendMessageAndTokens` now enforces the cap via `maxTransferAmount()`, whose CCTP + // override already surfaces MAX_TRANSFER_AMOUNT — so an over-cap burn is rejected at + // the base layer first and the cap require below is belt-and-suspenders. The min + // floor is NOT checked at the base layer, so it stays load-bearing here. + require(amount >= _minTransferAmount, "CCTP: amount below min"); + require(amount <= MAX_TRANSFER_AMOUNT, "CCTP: amount above CCTP cap"); + + // CCTP V2 deducts an actual fee (<= maxFee) from the burn; recipient mints the + // remainder. maxFee is the upper bound the protocol authorises (0 at the finalised + // threshold 2000). See `_minFeeOrZero` for the testnet fallback. + uint256 maxFee = _minFeeOrZero(amount); + IERC20(token).safeApprove(address(tokenMessenger), amount); + tokenMessenger.depositForBurnWithHook( + amount, + uint32(cfg.chainSelector), + _addressToBytes32(address(this)), // mintRecipient = peer adapter + token, + _addressToBytes32(address(this)), // destinationCaller = peer adapter + maxFee, + minFinalityThreshold, + envelope + ); + } + + /// @dev `getMinFeeAmount` isn't exposed by every CCTP V2 deployment (notably some + /// testnets ship `depositForBurnWithHook` without it). Treat its absence as fee=0 — + /// correct for the finalised threshold (2000, zero protocol fee); fast finality on + /// such a chain would revert deeper in CCTP if a real fee were required. + function _minFeeOrZero(uint256 amount) private view returns (uint256) { + try tokenMessenger.getMinFeeAmount(amount) returns (uint256 fee) { + return fee; + } catch { + return 0; + } + } + + function _addressToBytes32(address _addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(_addr))); + } + + function _bytes32ToAddress(bytes32 _b) internal pure returns (address) { + return address(uint160(uint256(_b))); + } + + // --- Inbound (IMessageHandlerV2) --------------------------------------- + + /// @inheritdoc IMessageHandlerV2 + /// @dev Finalised inbound is accepted unconditionally (no `minFinalityThreshold` guard, + /// unlike `handleReceiveUnfinalizedMessage`): a fully-finalised message is the + /// strongest case, and `minFinalityThreshold` is a SEND-side parameter that has no + /// bearing on an already-finalised inbound. Auth is via `onlyCCTP` + `_validateInbound`. + function handleReceiveFinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32, // finalityThresholdExecuted + bytes calldata messageBody + ) external override onlyCCTP returns (bool) { + _handleInbound(sourceDomain, sender, messageBody); + return true; + } + + /// @inheritdoc IMessageHandlerV2 + /// @dev Accepts pre-finalised inbound when CCTP has executed at least the configured + /// `minFinalityThreshold`. This is how fast-finality (1000 <= threshold < 2000) + /// actually delivers — MessageTransmitter routes via this handler when + /// `finalityThresholdExecuted < 2000`, and we accept if it's >= our floor. + /// + /// If `minFinalityThreshold == 2000` (default for finalised-only deployments), + /// this rejects every unfinalised callback — the strict-finalised mode. + /// + /// If `minFinalityThreshold == 0` (governor hasn't called the setter yet), we + /// reject everything, defensive against pre-init relays. + function handleReceiveUnfinalizedMessage( + uint32 sourceDomain, + bytes32 sender, + uint32 finalityThresholdExecuted, + bytes calldata messageBody + ) external override onlyCCTP returns (bool) { + require(minFinalityThreshold > 0, "CCTP: threshold not set"); + require( + finalityThresholdExecuted >= minFinalityThreshold, + "CCTP: insufficient finality" + ); + _handleInbound(sourceDomain, sender, messageBody); + return true; + } + + /// @dev Pure-message-only inbound hook. The MessageTransmitter calls this on us + /// directly only for message-only sends (no token leg). Burn messages flow + /// through `relay()`'s manual parsing path instead — we don't take the chance + /// that CCTP's auto-callback fires only on V2.1 chains. + /// + /// `messageBody` here IS our application envelope (because `sendMessage` + /// forwards it verbatim to the recipient hook). `intendedAmount` should be 0 + /// since this is the no-token path; reject otherwise to surface design drift + /// early. + function _handleInbound( + uint32 sourceDomain, + bytes32 sender, + bytes calldata messageBody + ) internal { + ( + address envelopeSender, + uint256 intendedAmount, + bytes memory payload + ) = _validateInbound( + uint64(sourceDomain), + _bytes32ToAddress(sender), + messageBody + ); + require(intendedAmount == 0, "CCTP: token leg via pure-message path"); + _deliver(envelopeSender, address(0), 0, 0, payload); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/adapters/SuperbridgeAdapter.sol b/contracts/contracts/strategies/crosschainV3/adapters/SuperbridgeAdapter.sol new file mode 100644 index 0000000000..baf90901cc --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/SuperbridgeAdapter.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { IRouterClient } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol"; +import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; +// solhint-disable-next-line max-line-length +import { IAny2EVMMessageReceiver } from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol"; + +import { IWETH9 } from "../../../interfaces/IWETH9.sol"; +import { ISplitInboundAdapter } from "../../../interfaces/crosschainV3/ISplitInboundAdapter.sol"; +import { AbstractAdapter } from "./AbstractAdapter.sol"; +import { CCIPMessageBuilder } from "../libraries/CCIPMessageBuilder.sol"; + +interface IL1StandardBridge { + /// @notice OP Stack canonical bridge ETH deposit. Native ETH arrives at `_to` on L2. + function bridgeETHTo( + address _to, + uint32 _minGasLimit, + bytes calldata _extraData + ) external payable; +} + +/** + * @title SuperbridgeAdapter + * @author Origin Protocol Inc + * + * @notice Split-delivery bidirectional adapter for Ethereum ↔ OP-Stack-L2, ETH-only. + * - Outbound (Ethereum → L2): take WETH from the calling strategy, unwrap to native + * ETH, send via `L1StandardBridge.bridgeETHTo{value: amount}(...)`. A separate + * CCIP message-only send carries the V3 envelope (sender + intendedAmount + + * payload). + * - Inbound (L2 receives from Ethereum): the canonical bridge credits native ETH + * to this adapter's address. `receive()` wraps it back to WETH so the destination + * strategy (which uses `bridgeAsset = WETH`) gets the asset shape it expects. + * The CCIP message lands via `ccipReceive`; if WETH balance < intendedAmount, the + * message is held in a pending slot until `processStoredMessage(target)`. + * + * Same contract code on both chains; deployment role is set by `_l1`: + * - `_l1 != address(0)` (Ethereum, outbound-only): `receive()` keeps incoming ETH + * raw — used as a CCIP-fee top-up reserve only when needed. Inbound entry points + * aren't expected to be exercised. + * - `_l1 == address(0)` (L2, inbound-only): `receive()` wraps incoming ETH to WETH. + * Outbound entry points revert at call time. + */ +contract SuperbridgeAdapter is + AbstractAdapter, + IAny2EVMMessageReceiver, + IERC165, + ISplitInboundAdapter +{ + using SafeERC20 for IERC20; + + IL1StandardBridge public immutable l1StandardBridge; + IRouterClient public immutable ccipRouter; + + /// @notice Local WETH on this chain. Required on both deployment roles: L1 side unwraps + /// before calling `bridgeETHTo`; L2 side wraps incoming bridge ETH. + address public immutable weth; + + /// @notice Per-sender canonical bridge minimum gas hint (typically 200k for OP Stack). + mapping(address => uint32) public canonicalMinGasFor; + + struct PendingMessage { + bool exists; + uint256 intendedAmount; + bytes payload; + address target; + } + + /// @notice Per-target pending split-delivery slot. + /// @dev SINGLE-TENANT ASSUMPTION: delivery gates on this adapter's GLOBAL WETH balance, + /// not a per-slot reservation. That is sound only with ONE authorised WETH strategy + /// per Superbridge adapter (the live OETHb config) — with one strategy the only + /// token-carrying inbound (WITHDRAW_CLAIM_ACK) is one-op-in-flight, so balances never + /// contend. Do NOT authorise a second WETH-bridging strategy on the same adapter + /// without adding per-target WETH reservation accounting (see DESIGN.md), or one + /// target's landed WETH could be delivered against another's pending message. The + /// generic "one adapter serves all authorised strategies" note on `IBridgeAdapter` + /// does NOT extend to Superbridge's shared WETH pool. + mapping(address => PendingMessage) internal pendingFor; + + event CanonicalMinGasConfigured(address sender, uint32 canonicalMinGas); + event MessageStored(address indexed target, uint256 intendedAmount); + event AdaptedPendingMessageFromOldAdapter( + address indexed oldAdapter, + address indexed target + ); + + constructor( + IL1StandardBridge _l1, + IRouterClient _ccip, + address _weth + ) { + require(address(_ccip) != address(0), "Super: zero CCIP"); + require(_weth != address(0), "Super: zero WETH"); + l1StandardBridge = _l1; + ccipRouter = _ccip; + weth = _weth; + } + + modifier onlyRouter() { + require(msg.sender == address(ccipRouter), "Super: not router"); + _; + } + + function setCanonicalMinGas(address _sender, uint32 _g) + external + onlyGovernor + { + canonicalMinGasFor[_sender] = _g; + emit CanonicalMinGasConfigured(_sender, _g); + } + + /** + * @notice Auto-wrap incoming ETH on the L2-side deployment so bridge ETH becomes WETH + * immediately. L1-side deployment keeps ETH raw (used as fee top-up reserve). + */ + receive() external payable override { + if (msg.value > 0 && address(l1StandardBridge) == address(0)) { + IWETH9(weth).deposit{ value: msg.value }(); + } + } + + // --- Outbound hooks ---------------------------------------------------- + + /// @dev Outbound (L1-side): CCIP charges native for the message leg. Canonical bridge + /// itself takes no fee. Token-carrying sends use the same CCIP message leg, so the + /// fee is the same regardless of whether tokens accompany. + /// + /// Inbound-only deployment (`_l1 == 0`) never has this called for an actual send + /// (outbound reverts in `_sendMessageAndTokens`), but we still return a sensible + /// value for off-chain quoting. + function _quoteFee( + bytes memory envelope, + ChainConfig memory cfg, + address, // token + uint256 // amount + ) + internal + view + override + returns ( + uint256 fee, + address feeToken, + bool requiresExternalPayment + ) + { + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + address(0), + 0, + envelope, + address(this), + cfg.destGasLimit + ); + fee = ccipRouter.getFee(cfg.chainSelector, ccipMessage); + feeToken = address(0); // native + requiresExternalPayment = true; + } + + function _sendMessage( + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal override { + // CCIP-only path. `l1StandardBridge` is only relevant in `_sendMessageAndTokens` + // when a canonical ETH leg is required; pure messages don't need it. + _sendCCIPMessage(envelope, cfg, fee); + } + + function _sendMessageAndTokens( + address token, + uint256 amount, + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal override { + require( + address(l1StandardBridge) != address(0), + "Super: outbound unsupported" + ); + require(token == weth, "Super: token must be WETH"); + + // WETH already pulled by AbstractAdapter.sendMessageAndTokens — unwrap to ETH. + IWETH9(weth).withdraw(amount); + + // Leg 1: canonical bridge — carry native ETH to the peer adapter on L2. + l1StandardBridge.bridgeETHTo{ value: amount }( + address(this), + canonicalMinGasFor[msg.sender], + "" + ); + + // Leg 2: CCIP message-only carrying the envelope. + _sendCCIPMessage(envelope, cfg, fee); + } + + function _sendCCIPMessage( + bytes memory envelope, + ChainConfig memory cfg, + uint256 fee + ) internal { + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + address(0), + 0, + envelope, + address(this), + cfg.destGasLimit + ); + ccipRouter.ccipSend{ value: fee }(cfg.chainSelector, ccipMessage); + } + + // --- Inbound (IAny2EVMMessageReceiver + split delivery) ---------------- + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + external + pure + override + returns (bool) + { + return + interfaceId == type(IAny2EVMMessageReceiver).interfaceId || + interfaceId == type(IERC165).interfaceId; + } + + /// @inheritdoc IAny2EVMMessageReceiver + function ccipReceive(Client.Any2EVMMessage calldata message) + external + override + onlyRouter + { + address transportSender = abi.decode(message.sender, (address)); + + ( + address envelopeSender, + uint256 intendedAmount, + bytes memory payload + ) = _validateInbound( + message.sourceChainSelector, + transportSender, + message.data + ); + + // Message-only or tokens already landed — atomic delivery. + if ( + intendedAmount == 0 || + IERC20(weth).balanceOf(address(this)) >= intendedAmount + ) { + _deliver( + envelopeSender, + intendedAmount > 0 ? weth : address(0), + intendedAmount, + 0, + payload + ); + return; + } + + // Token leg not landed yet — store the message for later finalisation. + _storePending(envelopeSender, intendedAmount, payload); + } + + /// @inheritdoc ISplitInboundAdapter + function hasPendingMessage(address _target) + external + view + override + returns (bool) + { + return pendingFor[_target].exists; + } + + /// @inheritdoc ISplitInboundAdapter + function processStoredMessage(address _target) external override { + PendingMessage memory p = pendingFor[_target]; + require(p.exists, "Super: nothing pending"); + require( + IERC20(weth).balanceOf(address(this)) >= p.intendedAmount, + "Super: tokens not yet landed" + ); + delete pendingFor[_target]; + _deliver(p.target, weth, p.intendedAmount, 0, p.payload); + } + + /** + * @notice Adopt a pending message from a previous adapter during a governance-driven + * adapter swap. The old adapter must `approve` this contract for the WETH it + * holds; we pull the WETH and copy the pending slot under the right target. + */ + function adoptPendingMessage( + address _oldAdapter, + PendingMessage calldata _pending + ) external onlyGovernor { + require(_pending.target != address(0), "Super: zero target"); + require(!pendingFor[_pending.target].exists, "Super: already pending"); + if (_pending.intendedAmount > 0) { + // `_oldAdapter` is governor-supplied (this function is onlyGovernor); the + // arbitrary-from disclaimer doesn't apply. + // slither-disable-next-line arbitrary-send-erc20 + IERC20(weth).safeTransferFrom( + _oldAdapter, + address(this), + _pending.intendedAmount + ); + } + // slither-disable-next-line reentrancy-no-eth + pendingFor[_pending.target] = _pending; + pendingFor[_pending.target].exists = true; + emit MessageStored(_pending.target, _pending.intendedAmount); + emit AdaptedPendingMessageFromOldAdapter(_oldAdapter, _pending.target); + } + + function _storePending( + address target, + uint256 intendedAmount, + bytes memory payload + ) internal { + require(!pendingFor[target].exists, "Super: slot busy"); + pendingFor[target] = PendingMessage({ + exists: true, + intendedAmount: intendedAmount, + payload: payload, + target: target + }); + emit MessageStored(target, intendedAmount); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/libraries/CCIPMessageBuilder.sol b/contracts/contracts/strategies/crosschainV3/libraries/CCIPMessageBuilder.sol new file mode 100644 index 0000000000..75f1497188 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/libraries/CCIPMessageBuilder.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Client } from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol"; + +/** + * @title CCIPMessageBuilder + * @author Origin Protocol Inc + * + * @notice Shared builder for CCIP `Client.EVM2AnyMessage` payloads used by V3 adapters. + * Centralises the construction so the same shape (single token amount or zero, + * native fee, V1 extraArgs with a destination gas limit) lives in one place. + * + * All V3 CCIP sends: + * - pay the bridge fee in native (`feeToken = address(0)`) + * - carry at most one token amount alongside the message + * - use `EVMExtraArgsV1` with the caller-supplied `destGasLimit` + */ +library CCIPMessageBuilder { + /** + * @dev Build the CCIP `Client.EVM2AnyMessage`. + * @param token Token to bridge alongside the message; `address(0)` for message-only. + * @param amount Token amount; ignored when `token == address(0)`. + * @param message Envelope-wrapped V3 message bytes (may be empty). + * @param peerReceiver Destination-chain receiver address (the peer adapter). + * @param destGasLimit Gas to make available on the destination for the receiver callback. + */ + function build( + address token, + uint256 amount, + bytes memory message, + address peerReceiver, + uint256 destGasLimit + ) internal pure returns (Client.EVM2AnyMessage memory) { + Client.EVMTokenAmount[] memory tokenAmounts; + if (token != address(0) && amount > 0) { + tokenAmounts = new Client.EVMTokenAmount[](1); + tokenAmounts[0] = Client.EVMTokenAmount({ + token: token, + amount: amount + }); + } else { + tokenAmounts = new Client.EVMTokenAmount[](0); + } + return + Client.EVM2AnyMessage({ + receiver: abi.encode(peerReceiver), + data: message, + tokenAmounts: tokenAmounts, + feeToken: address(0), // pay in native + extraArgs: Client._argsToBytes( + Client.EVMExtraArgsV1({ gasLimit: destGasLimit }) + ) + }); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/libraries/CCTPMessageHelper.sol b/contracts/contracts/strategies/crosschainV3/libraries/CCTPMessageHelper.sol new file mode 100644 index 0000000000..650806d919 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/libraries/CCTPMessageHelper.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { BytesHelper } from "../../../utils/BytesHelper.sol"; + +/** + * @title CCTPMessageHelper + * @author Origin Protocol Inc + * + * @notice Minimal decoder for the CCTP V2 transport-level message header. Used by + * `CCTPAdapter.relay` to do cheap pre-validation (correct CCTP message version, + * correct on-chain recipient) before paying the gas for attestation verification + * and the downstream `handleReceiveFinalizedMessage` callback. + * + * The CCTP V2 wire format is owned by Circle and looks like: + * [0..4) uint32 version + * [4..8) uint32 sourceDomain + * [8..12) uint32 destinationDomain + * [12..44) bytes32 nonce + * [44..76) bytes32 sender (right-aligned address) + * [76..108) bytes32 recipient (right-aligned address) + * [108..140) bytes32 destinationCaller (right-aligned address) + * [140..144) uint32 minFinalityThreshold + * [144..148) uint32 finalityThresholdExecuted + * [148..] bytes messageBody (our application envelope) + * + * See https://developers.circle.com/cctp/technical-guide#message-header for the + * authoritative spec. + */ +library CCTPMessageHelper { + using BytesHelper for bytes; + + /// @notice Wire-format version of CCTP V2 messages. + uint32 internal constant CCTP_V2_VERSION = 1; + + uint256 private constant VERSION_INDEX = 0; + uint256 private constant SOURCE_DOMAIN_INDEX = 4; + uint256 private constant SENDER_INDEX = 44; + uint256 private constant RECIPIENT_INDEX = 76; + uint256 private constant MESSAGE_BODY_INDEX = 148; + + /// @notice Inner burn-message body offsets for CCTP V2 burn messages. The burn body is + /// what TokenMessenger constructs and ships inside the transport `messageBody` + /// field when `depositForBurnWithHook` is called. We parse it manually in + /// `CCTPAdapter.relay()` so the adapter has the authoritative `amount`, + /// `feeExecuted`, and `hookData` rather than relying on + /// `IERC20.balanceOf(adapter)` (susceptible to donations) or on the + /// `IMessageHandlerV2` callback (which behaves differently across CCTP V2.0 + /// and V2.1 deployments). + /// + /// Ref: https://github.com/circlefin/evm-cctp-contracts/blob/master/src/messages/v2/BurnMessageV2.sol + uint256 private constant BURN_BODY_VERSION_INDEX = 0; + uint256 private constant BURN_BODY_BURN_TOKEN_INDEX = 4; + uint256 private constant BURN_BODY_MINT_RECIPIENT_INDEX = 36; + uint256 private constant BURN_BODY_AMOUNT_INDEX = 68; + uint256 private constant BURN_BODY_MESSAGE_SENDER_INDEX = 100; + uint256 private constant BURN_BODY_FEE_EXECUTED_INDEX = 164; + uint256 private constant BURN_BODY_HOOK_DATA_INDEX = 228; + + /** + * @notice Split a CCTP V2 wire message into its transport header fields plus the inner + * `messageBody`. The body is either: + * - a burn-message body (for `depositForBurnWithHook`-sourced messages), or + * - the raw application envelope (for `MessageTransmitter.sendMessage`). + * @param message The CCTP V2 wire message bytes as received from Circle's attestation API. + */ + function decodeMessageHeader(bytes memory message) + internal + pure + returns ( + uint32 version, + uint32 sourceDomain, + address sender, + address recipient, + bytes memory messageBody + ) + { + version = message.extractUint32(VERSION_INDEX); + sourceDomain = message.extractUint32(SOURCE_DOMAIN_INDEX); + sender = message.extractAddress(SENDER_INDEX); + recipient = message.extractAddress(RECIPIENT_INDEX); + messageBody = message.extractSlice(MESSAGE_BODY_INDEX, message.length); + } + + /** + * @notice Decode a CCTP V2 burn-message body into its authoritative fields. Use this + * when the transport header's `sender` indicates the message originated from + * the source-side TokenMessenger (i.e., a `depositForBurnWithHook` rather than + * a plain `sendMessage`). + * @param body The inner CCTP V2 burn message body. + * @return burnToken The token burned on source (the SOURCE-chain USDC; informational — + * the local mint is always `usdcToken`). + * @return mintRecipient Destination mint recipient from the burn body; under CREATE3 + * parity this must be the relaying adapter (`relay` enforces it). + * @return amount Source-side burn amount. + * @return msgSender The source-side caller of `depositForBurnWithHook` (peer adapter + * under CREATE3 parity). + * @return feeExecuted Protocol fee deducted from `amount` on destination. `amount - + * feeExecuted` USDC arrives at the mintRecipient. + * @return hookData Opaque payload set by the source side via the `hookData` arg of + * `depositForBurnWithHook`. This is our application envelope. + */ + function decodeBurnBody(bytes memory body) + internal + pure + returns ( + address burnToken, + address mintRecipient, + uint256 amount, + address msgSender, + uint256 feeExecuted, + bytes memory hookData + ) + { + require( + body.length >= BURN_BODY_HOOK_DATA_INDEX, + "CCTP: burn body too short" + ); + burnToken = body.extractAddress(BURN_BODY_BURN_TOKEN_INDEX); + mintRecipient = body.extractAddress(BURN_BODY_MINT_RECIPIENT_INDEX); + amount = body.extractUint256(BURN_BODY_AMOUNT_INDEX); + msgSender = body.extractAddress(BURN_BODY_MESSAGE_SENDER_INDEX); + feeExecuted = body.extractUint256(BURN_BODY_FEE_EXECUTED_INDEX); + hookData = body.extractSlice(BURN_BODY_HOOK_DATA_INDEX, body.length); + } +} diff --git a/contracts/contracts/strategies/crosschainV3/libraries/NativeFeeHelper.sol b/contracts/contracts/strategies/crosschainV3/libraries/NativeFeeHelper.sol new file mode 100644 index 0000000000..5d9b708771 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/libraries/NativeFeeHelper.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title NativeFeeHelper + * @author Origin Protocol Inc + * + * @notice Legacy native-fee consumption helper used by `BridgedWOETHMigrationStrategy`. + * New crosschainV3 adapters source fees from `msg.value` / the pool and do NOT + * refund excess (it stays on the adapter); they do not use this library. + * + * Two source paths: + * - `msg.value == 0` → pre-funded: the caller's `address(this).balance` covers + * the fee. Used by protocol-driven operations where the entry function is + * non-payable. + * - `msg.value > 0` → user-paid: caller supplied the fee; excess refunds to + * `msg.sender`. + * + * Reverts when the chosen source doesn't cover `fee`. + */ +library NativeFeeHelper { + function consume(uint256 fee) internal { + if (msg.value == 0) { + require(address(this).balance >= fee, "Fee: unfunded"); + return; + } + require(msg.value >= fee, "Fee: insufficient"); + if (msg.value > fee) { + // slither-disable-next-line low-level-calls + (bool ok, ) = msg.sender.call{ value: msg.value - fee }(""); + require(ok, "Fee: refund failed"); + } + } +} diff --git a/contracts/contracts/utils/BytesHelper.sol b/contracts/contracts/utils/BytesHelper.sol index 75a0fa1875..878cc06f0c 100644 --- a/contracts/contracts/utils/BytesHelper.sol +++ b/contracts/contracts/utils/BytesHelper.sol @@ -6,6 +6,8 @@ uint256 constant UINT64_LENGTH = 8; uint256 constant UINT256_LENGTH = 32; // Address is 20 bytes, but we expect the data to be padded with 0s to 32 bytes uint256 constant ADDRESS_LENGTH = 32; +// Raw 20-byte address (no padding), used for abi.encodePacked envelopes. +uint256 constant ADDRESS_PACKED_LENGTH = 20; library BytesHelper { /** @@ -58,6 +60,30 @@ library BytesHelper { return decodeUint32(extractSlice(data, start, start + UINT32_LENGTH)); } + /** + * @dev Decode a uint64 from a bytes memory + * @param data The bytes memory to decode + * @return uint64 The decoded uint64 + */ + function decodeUint64(bytes memory data) internal pure returns (uint64) { + require(data.length == 8, "Invalid data length"); + return uint64(uint256(bytes32(data)) >> 192); + } + + /** + * @dev Extract a uint64 from a bytes memory + * @param data The bytes memory to extract from + * @param start The start index (inclusive) + * @return uint64 The extracted uint64 + */ + function extractUint64(bytes memory data, uint256 start) + internal + pure + returns (uint64) + { + return decodeUint64(extractSlice(data, start, start + UINT64_LENGTH)); + } + /** * @dev Decode an address from a bytes memory. * Expects the data to be padded with 0s to 32 bytes. @@ -84,6 +110,31 @@ library BytesHelper { return decodeAddress(extractSlice(data, start, start + ADDRESS_LENGTH)); } + /** + * @dev Extract a 20-byte (unpadded) address from a bytes memory. Use this when the + * source is `abi.encodePacked(..., address, ...)` rather than `abi.encode(...)`, + * where addresses occupy 20 bytes instead of 32. + * @param data The bytes memory to extract from + * @param start The start index (inclusive) + * @return result The extracted address + */ + function extractAddressPacked(bytes memory data, uint256 start) + internal + pure + returns (address result) + { + require( + data.length >= start + ADDRESS_PACKED_LENGTH, + "Invalid data length" + ); + // solhint-disable-next-line no-inline-assembly + assembly { + // Load 32 bytes starting at the address offset; the address occupies the high + // 20 bytes when read from packed encoding, so right-shift by 96 (= 12 * 8) bits. + result := shr(96, mload(add(add(data, 0x20), start))) + } + } + /** * @dev Decode a uint256 from a bytes memory * @param data The bytes memory to decode diff --git a/contracts/deploy/base/100_oethb_v3_master_proxy.js b/contracts/deploy/base/100_oethb_v3_master_proxy.js new file mode 100644 index 0000000000..525cc43a3d --- /dev/null +++ b/contracts/deploy/base/100_oethb_v3_master_proxy.js @@ -0,0 +1,37 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const { deployProxyWithCreateX } = require("../deployActions"); + +// Salt for the OETHb wOETH V3 strategy pair. Must match the salt used on the +// Ethereum side so Master (Base) and Remote (Ethereum) deploy to matching +// addresses via CreateX. +// +// Salt-naming convention for V3 cross-chain deployments: +// * Same salt on PAIRED chains (peer parity is required for the adapter +// `transportSender == address(this)` check and the strategy `envelopeSender` +// dispatch). +// * Different salt between testnet (prefixed with "Testnet" — see +// `deploy/baseSepolia/002_master_strategy.js`) and production to keep +// CreateX deployments isolated even when the deployer EOA is identical. +// * Version suffix (`1`, `2`, …) increments only when deploying a fresh pair +// while keeping a previous version live. +const SALT = "OETHb wOETH V3 Strategy 1"; + +module.exports = deployOnBase( + { + deployName: "100_oethb_v3_master_proxy", + }, + async () => { + const proxyAddress = await deployProxyWithCreateX( + SALT, + "CrossChainStrategyProxy", + false, + null, + "OETHbV3MasterProxy" + ); + console.log(`OETHbV3MasterProxy address: ${proxyAddress}`); + + return { + actions: [], + }; + } +); diff --git a/contracts/deploy/base/101_oethb_v3_master_impl.js b/contracts/deploy/base/101_oethb_v3_master_impl.js new file mode 100644 index 0000000000..3f70aeb57c --- /dev/null +++ b/contracts/deploy/base/101_oethb_v3_master_impl.js @@ -0,0 +1,218 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const addresses = require("../../utils/addresses"); +const { + getCreate2ProxyAddress, + deployProxyWithCreateX, +} = require("../deployActions"); + +// Default per-receive destination gas limit for cross-chain message handling. +const DEFAULT_DEST_GAS_LIMIT = 500000; + +// CREATE3 salts for the adapter proxies. MUST match the Ethereum-side salts used +// in `deploy/mainnet/211_oethb_v3_remote_impl.js` so the proxy addresses are +// identical across chains (peer-parity requirement on the +// `transportSender == address(this)` check). +const CCIP_ADAPTER_PROXY_SALT = "OETHb V3 CCIPAdapter Proxy 1"; +const SUPERBRIDGE_ADAPTER_PROXY_SALT = "OETHb V3 SuperbridgeAdapter Proxy 1"; + +module.exports = deployOnBase( + { + deployName: "101_oethb_v3_master_impl", + dependencies: ["100_oethb_v3_master_proxy"], + }, + async ({ deployWithConfirmation, withConfirmation, ethers }) => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + // --- Resolve dependencies on chain --- + const masterProxyAddress = await getCreate2ProxyAddress( + "OETHbV3MasterProxy" + ); + console.log(`OETHbV3MasterProxy resolved at: ${masterProxyAddress}`); + + const cOETHBaseVaultProxy = await ethers.getContract("OETHBaseVaultProxy"); + const cOETHb = await ethers.getContract("OETHBaseProxy"); + + // --- 1. Deploy Master impl --- + await deployWithConfirmation("MasterWOTokenStrategy", [ + { + platformAddress: addresses.zero, + vaultAddress: cOETHBaseVaultProxy.address, + }, + addresses.base.WETH, + cOETHb.address, + ]); + const dMasterImpl = await ethers.getContract("MasterWOTokenStrategy"); + console.log(`MasterWOTokenStrategy impl: ${dMasterImpl.address}`); + + // --- 2. Initialise the strategy proxy: set impl, governor=timelock, call initialize(operator) --- + const cMasterProxy = await ethers.getContractAt( + "CrossChainStrategyProxy", + masterProxyAddress + ); + const initData = dMasterImpl.interface.encodeFunctionData( + "initialize(address)", + [addresses.talosRelayer] + ); + const proxyInitCalldata = cMasterProxy.interface.encodeFunctionData( + "initialize(address,address,bytes)", + [dMasterImpl.address, addresses.base.timelock, initData] + ); + await withConfirmation( + sDeployer.sendTransaction({ + to: cMasterProxy.address, + data: proxyInitCalldata, + }) + ); + + // --- 3. Deploy adapter impls (plain; chain-specific args baked into bytecode) --- + // + // Adapters live behind `BridgeAdapterProxy` (CREATE3 → identical address on both + // chains, mandatory for the `transportSender == address(this)` peer-parity check). + // The impls are deployed plain — their addresses differ across chains but only the + // proxy is part of the parity check. + // + // Outbound (B→E): CCIPAdapter + await deployWithConfirmation("CCIPAdapter", [addresses.base.CCIPRouter]); + const dCCIPImpl = await ethers.getContract("CCIPAdapter"); + console.log(`CCIPAdapter impl: ${dCCIPImpl.address}`); + + // Inbound (E→B): SuperbridgeAdapter — split delivery, ETH-only. Tokens arrive as + // native ETH via the canonical bridge; `receive()` auto-wraps to WETH so Master sees + // its `bridgeAsset = WETH`. Base side never sends outbound via this adapter, so the + // L1StandardBridge constructor slot is passed as address(0); outbound entry points + // revert if invoked. + await deployWithConfirmation("SuperbridgeAdapter", [ + addresses.zero, + addresses.base.CCIPRouter, + addresses.base.WETH, // local WETH (wraps incoming bridge ETH) + ]); + const dSuperImpl = await ethers.getContract("SuperbridgeAdapter"); + console.log(`SuperbridgeAdapter impl: ${dSuperImpl.address}`); + + // --- 4. Deploy adapter proxies via CREATE3 --- + const ccipProxyAddr = await deployProxyWithCreateX( + CCIP_ADAPTER_PROXY_SALT, + "BridgeAdapterProxy", + false, + null, + "OETHbV3CCIPAdapterProxy" + ); + console.log(`CCIPAdapter proxy: ${ccipProxyAddr}`); + const superProxyAddr = await deployProxyWithCreateX( + SUPERBRIDGE_ADAPTER_PROXY_SALT, + "BridgeAdapterProxy", + false, + null, + "OETHbV3SuperbridgeAdapterProxy" + ); + console.log(`SuperbridgeAdapter proxy: ${superProxyAddr}`); + + // --- 5. Initialise adapter proxies to point at impls. Proxy constructor set + // governor = deployer; `initialize` is onlyGovernor and re-asserts governor. + const cCCIPProxyRaw = await ethers.getContractAt( + "InitializeGovernedUpgradeabilityProxy", + ccipProxyAddr, + sDeployer + ); + await withConfirmation( + cCCIPProxyRaw["initialize(address,address,bytes)"]( + dCCIPImpl.address, + deployerAddr, + "0x" + ) + ); + const cSuperProxyRaw = await ethers.getContractAt( + "InitializeGovernedUpgradeabilityProxy", + superProxyAddr, + sDeployer + ); + await withConfirmation( + cSuperProxyRaw["initialize(address,address,bytes)"]( + dSuperImpl.address, + deployerAddr, + "0x" + ) + ); + + // After this, the proxy address is the "real" adapter — configure it as such. + const dCCIPOutbound = await ethers.getContractAt( + "CCIPAdapter", + ccipProxyAddr + ); + const dSuperRx = await ethers.getContractAt( + "SuperbridgeAdapter", + superProxyAddr + ); + + // --- 6. Adapter configuration (deployer is governor here, so do it now) --- + // + // ChainConfig fields: { paused, chainSelector, destGasLimit } + const masterChainCfg = { + paused: false, + chainSelector: addresses.mainnet.CCIPChainSelector, + destGasLimit: DEFAULT_DEST_GAS_LIMIT, + }; + await withConfirmation( + dCCIPOutbound + .connect(sDeployer) + .authorise(masterProxyAddress, masterChainCfg) + ); + await withConfirmation( + dSuperRx.connect(sDeployer).authorise(masterProxyAddress, masterChainCfg) + ); + // Strategist (multichain strategist) can pause/unpause lanes for fast incident response. + await withConfirmation( + dCCIPOutbound + .connect(sDeployer) + .addStrategist(addresses.multichainStrategist) + ); + await withConfirmation( + dSuperRx.connect(sDeployer).addStrategist(addresses.multichainStrategist) + ); + + // --- 7. Transfer adapter proxy governance to Base timelock --- + await withConfirmation( + dCCIPOutbound + .connect(sDeployer) + .transferGovernance(addresses.base.timelock) + ); + await withConfirmation( + dSuperRx.connect(sDeployer).transferGovernance(addresses.base.timelock) + ); + + // --- 8. Resolve Master as IStrategy / IGovernable for the governance actions --- + const cMaster = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxyAddress + ); + + return { + name: "Deploy OETHb V3 Master strategy + adapters on Base", + actions: [ + // Timelock claims governance on the two adapter proxies. + { + contract: dCCIPOutbound, + signature: "claimGovernance()", + args: [], + }, + { + contract: dSuperRx, + signature: "claimGovernance()", + args: [], + }, + // Wire the adapter PROXY addresses into Master (governor-gated on Master). + { + contract: cMaster, + signature: "setOutboundAdapter(address)", + args: [ccipProxyAddr], + }, + { + contract: cMaster, + signature: "setInboundAdapter(address)", + args: [superProxyAddr], + }, + ], + }; + } +); diff --git a/contracts/deploy/base/102_oethb_v3_woeth_v2_upgrade.js b/contracts/deploy/base/102_oethb_v3_woeth_v2_upgrade.js new file mode 100644 index 0000000000..8cd3f2dd99 --- /dev/null +++ b/contracts/deploy/base/102_oethb_v3_woeth_v2_upgrade.js @@ -0,0 +1,84 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const addresses = require("../../utils/addresses"); +const { getCreate2ProxyAddress } = require("../deployActions"); + +// Per-call wOETH bridge cap as a decimal string. Mirrors the CCIP rate-limit +// budget. Parsed to a BigNumber inside the deploy function — defining it at +// module scope would require `ethers` to be globally available at module +// load, which is not guaranteed by hardhat-deploy. +const MAX_PER_BRIDGE_ETH = "1000"; + +module.exports = deployOnBase( + { + deployName: "102_oethb_v3_woeth_v2_upgrade", + dependencies: ["101_oethb_v3_master_impl"], + }, + async ({ deployWithConfirmation, ethers }) => { + const MAX_PER_BRIDGE = ethers.utils.parseEther(MAX_PER_BRIDGE_ETH); + const cOETHBaseVaultProxy = await ethers.getContract("OETHBaseVaultProxy"); + const cOETHb = await ethers.getContract("OETHBaseProxy"); + const cOracleRouter = await ethers.getContract("OETHBaseOracleRouter"); + + const cBridgedWOETHStrategyProxy = await ethers.getContract( + "BridgedWOETHStrategyProxy" + ); + + // Master on Base and Remote on Ethereum live at the same address by CreateX parity. + // The migration impl stores that single address as `master` and uses it both as the + // local Master read target (`checkBalance` in-flight reconciliation) and the + // cross-chain CCIP recipient. + const masterProxyAddress = await getCreate2ProxyAddress( + "OETHbV3MasterProxy" + ); + console.log( + `Master/Remote (CreateX parity) resolved at: ${masterProxyAddress}` + ); + + // --- Deploy migration impl (V1 constructor + master/ccipRouter/chainSelector) --- + await deployWithConfirmation("BridgedWOETHMigrationStrategy", [ + [addresses.zero, cOETHBaseVaultProxy.address], + addresses.base.WETH, + addresses.base.BridgedWOETH, + cOETHb.address, + cOracleRouter.address, + masterProxyAddress, + addresses.base.CCIPRouter, + addresses.mainnet.CCIPChainSelector, + ]); + const dMigrationImpl = await ethers.getContract( + "BridgedWOETHMigrationStrategy" + ); + console.log( + `BridgedWOETHMigrationStrategy impl: ${dMigrationImpl.address}` + ); + + const cMigration = await ethers.getContractAt( + "BridgedWOETHMigrationStrategy", + cBridgedWOETHStrategyProxy.address + ); + + return { + name: "Upgrade BridgedWOETHStrategy → BridgedWOETHMigrationStrategy + wire CCIP", + actions: [ + // 1. Upgrade the existing proxy. + { + contract: cBridgedWOETHStrategyProxy, + signature: "upgradeTo(address)", + args: [dMigrationImpl.address], + }, + // 2. Set the per-call cap. (Governor-or-strategist gate; runs as governance here.) + { + contract: cMigration, + signature: "setMaxPerBridge(uint256)", + args: [MAX_PER_BRIDGE], + }, + // 3. Authorise the multichain strategist as the operator for `bridgeToRemote`. + { + contract: cMigration, + signature: "setOperator(address)", + args: [addresses.multichainStrategist], + }, + ], + }; + } +); diff --git a/contracts/deploy/base/103_oethb_v3_vault_wiring.js b/contracts/deploy/base/103_oethb_v3_vault_wiring.js new file mode 100644 index 0000000000..44bd243f7d --- /dev/null +++ b/contracts/deploy/base/103_oethb_v3_vault_wiring.js @@ -0,0 +1,36 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); +const { getCreate2ProxyAddress } = require("../deployActions"); + +module.exports = deployOnBase( + { + deployName: "103_oethb_v3_vault_wiring", + dependencies: ["102_oethb_v3_woeth_v2_upgrade"], + }, + async ({ ethers }) => { + const cOETHBaseVaultProxy = await ethers.getContract("OETHBaseVaultProxy"); + const cVault = await ethers.getContractAt( + "IVault", + cOETHBaseVaultProxy.address + ); + + const masterProxyAddress = await getCreate2ProxyAddress( + "OETHbV3MasterProxy" + ); + + return { + name: "Approve OETHb V3 Master strategy + add to mint whitelist", + actions: [ + { + contract: cVault, + signature: "approveStrategy(address)", + args: [masterProxyAddress], + }, + { + contract: cVault, + signature: "addStrategyToMintWhitelist(address)", + args: [masterProxyAddress], + }, + ], + }; + } +); diff --git a/contracts/deploy/base/104_oethb_v3_remove_old_strategy.js b/contracts/deploy/base/104_oethb_v3_remove_old_strategy.js new file mode 100644 index 0000000000..d009da1293 --- /dev/null +++ b/contracts/deploy/base/104_oethb_v3_remove_old_strategy.js @@ -0,0 +1,40 @@ +const { deployOnBase } = require("../../utils/deploy-l2"); + +/** + * Post-migration cleanup proposal. + * + * forceSkip: true — sits in the repo but does not auto-fire when + * `pnpm run node:base` runs through deploys (the migration would not yet be + * complete on the fork). Flip `forceSkip` to `false` after all 9x + * `bridgeToRemote(1000e18)` calls have settled on mainnet — at which point + * the existing BridgedWOETHStrategy's `checkBalance` will be at or below + * dust and the vault will accept `removeStrategy`. + */ +module.exports = deployOnBase( + { + deployName: "104_oethb_v3_remove_old_strategy", + forceSkip: true, + }, + async ({ ethers }) => { + const cOETHBaseVaultProxy = await ethers.getContract("OETHBaseVaultProxy"); + const cVault = await ethers.getContractAt( + "IVault", + cOETHBaseVaultProxy.address + ); + + const cBridgedWOETHStrategyProxy = await ethers.getContract( + "BridgedWOETHStrategyProxy" + ); + + return { + name: "Remove old BridgedWOETHStrategy from the OETHb vault post-migration", + actions: [ + { + contract: cVault, + signature: "removeStrategy(address)", + args: [cBridgedWOETHStrategyProxy.address], + }, + ], + }; + } +); diff --git a/contracts/deploy/mainnet/000_mock.js b/contracts/deploy/mainnet/000_mock.js index 924c0a9cf6..83c737b7f6 100644 --- a/contracts/deploy/mainnet/000_mock.js +++ b/contracts/deploy/mainnet/000_mock.js @@ -135,6 +135,6 @@ const deployMocks = async ({ getNamedAccounts, deployments }) => { deployMocks.id = "000_mock"; deployMocks.tags = ["mocks", "unit_tests"]; -deployMocks.skip = () => isMainnetOrFork; +deployMocks.skip = () => isMainnetOrFork || hre.network.live === true; module.exports = deployMocks; diff --git a/contracts/deploy/mainnet/001_core.js b/contracts/deploy/mainnet/001_core.js index 40ea071dd6..bd42b83970 100644 --- a/contracts/deploy/mainnet/001_core.js +++ b/contracts/deploy/mainnet/001_core.js @@ -40,6 +40,6 @@ const main = async () => { main.id = "001_core"; main.dependencies = ["mocks"]; main.tags = ["unit_tests", "arb_unit_tests"]; -main.skip = () => isFork; +main.skip = () => isFork || hre.network.live === true; module.exports = main; diff --git a/contracts/deploy/mainnet/210_oethb_v3_remote_proxy.js b/contracts/deploy/mainnet/210_oethb_v3_remote_proxy.js new file mode 100644 index 0000000000..e41d4b5b61 --- /dev/null +++ b/contracts/deploy/mainnet/210_oethb_v3_remote_proxy.js @@ -0,0 +1,30 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); +const { deployProxyWithCreateX } = require("../deployActions"); + +// Salt MUST match the Base side (deploy/base/100_oethb_v3_master_proxy.js) +// so Master and Remote land at the same address via CreateX on both chains. +const SALT = "OETHb wOETH V3 Strategy 1"; + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "210_oethb_v3_remote_proxy", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async () => { + const proxyAddress = await deployProxyWithCreateX( + SALT, + "CrossChainStrategyProxy", + false, + null, + "OETHbV3RemoteProxy" + ); + console.log(`OETHbV3RemoteProxy address: ${proxyAddress}`); + + return { + actions: [], + }; + } +); diff --git a/contracts/deploy/mainnet/211_oethb_v3_remote_impl.js b/contracts/deploy/mainnet/211_oethb_v3_remote_impl.js new file mode 100644 index 0000000000..b03a65221f --- /dev/null +++ b/contracts/deploy/mainnet/211_oethb_v3_remote_impl.js @@ -0,0 +1,228 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); +const addresses = require("../../utils/addresses"); +const { + getCreate2ProxyAddress, + deployProxyWithCreateX, +} = require("../deployActions"); + +// Per-receive destination gas limit for cross-chain message handling. +const DEFAULT_DEST_GAS_LIMIT = 500000; + +// Canonical bridge minGasLimit hint for the ETH deposit (OP Stack default). +const CANONICAL_MIN_GAS = 200000; + +// CREATE3 salts for the adapter proxies. MUST match the Base-side salts used +// in `deploy/base/101_oethb_v3_master_impl.js` so the proxy addresses are +// identical across chains (peer-parity requirement on the +// `transportSender == address(this)` check). +const CCIP_ADAPTER_PROXY_SALT = "OETHb V3 CCIPAdapter Proxy 1"; +const SUPERBRIDGE_ADAPTER_PROXY_SALT = "OETHb V3 SuperbridgeAdapter Proxy 1"; + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "211_oethb_v3_remote_impl", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + dependencies: ["210_oethb_v3_remote_proxy"], + }, + async ({ deployWithConfirmation, withConfirmation, ethers }) => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + // --- Resolve dependencies --- + const remoteProxyAddress = await getCreate2ProxyAddress( + "OETHbV3RemoteProxy" + ); + console.log(`OETHbV3RemoteProxy resolved at: ${remoteProxyAddress}`); + + // --- 1. Deploy Remote impl --- + await deployWithConfirmation("RemoteWOTokenStrategy", [ + { + platformAddress: addresses.mainnet.WOETHProxy, + vaultAddress: addresses.zero, + }, + addresses.mainnet.WETH, + addresses.mainnet.OETHProxy, + addresses.mainnet.WOETHProxy, + addresses.mainnet.OETHVaultProxy, + ]); + const dRemoteImpl = await ethers.getContract("RemoteWOTokenStrategy"); + console.log(`RemoteWOTokenStrategy impl: ${dRemoteImpl.address}`); + + // --- 2. Initialise the strategy proxy: impl + governor=Timelock + initialize(operator) --- + const cRemoteProxy = await ethers.getContractAt( + "CrossChainStrategyProxy", + remoteProxyAddress + ); + const initData = dRemoteImpl.interface.encodeFunctionData( + "initialize(address)", + [addresses.talosRelayer] + ); + const proxyInitCalldata = cRemoteProxy.interface.encodeFunctionData( + "initialize(address,address,bytes)", + [dRemoteImpl.address, addresses.mainnet.Timelock, initData] + ); + await withConfirmation( + sDeployer.sendTransaction({ + to: cRemoteProxy.address, + data: proxyInitCalldata, + }) + ); + + // --- 3. Deploy adapter impls (plain; chain-specific args baked into bytecode) --- + // + // Adapters live behind `BridgeAdapterProxy` (CREATE3 → identical address on both + // chains, mandatory for the `transportSender == address(this)` peer-parity check). + // The impls are deployed plain — their addresses differ across chains but only the + // proxy is part of the parity check. + // + // Outbound (E→B, split delivery): SuperbridgeAdapter — ETH-only. Takes WETH from + // Remote, unwraps to native ETH, sends via the canonical bridge. + await deployWithConfirmation("SuperbridgeAdapter", [ + addresses.mainnet.BaseL1StandardBridge, + addresses.mainnet.ccipRouterMainnet, + addresses.mainnet.WETH, + ]); + const dSuperImpl = await ethers.getContract("SuperbridgeAdapter"); + console.log(`SuperbridgeAdapter impl: ${dSuperImpl.address}`); + + // Inbound (B→E, atomic): CCIPAdapter + await deployWithConfirmation("CCIPAdapter", [ + addresses.mainnet.ccipRouterMainnet, + ]); + const dCCIPImpl = await ethers.getContract("CCIPAdapter"); + console.log(`CCIPAdapter impl: ${dCCIPImpl.address}`); + + // --- 4. Deploy adapter proxies via CREATE3 (deterministic, peer-parity addresses) --- + const superProxyAddr = await deployProxyWithCreateX( + SUPERBRIDGE_ADAPTER_PROXY_SALT, + "BridgeAdapterProxy", + false, + null, + "OETHbV3SuperbridgeAdapterProxy" + ); + console.log(`SuperbridgeAdapter proxy: ${superProxyAddr}`); + const ccipProxyAddr = await deployProxyWithCreateX( + CCIP_ADAPTER_PROXY_SALT, + "BridgeAdapterProxy", + false, + null, + "OETHbV3CCIPAdapterProxy" + ); + console.log(`CCIPAdapter proxy: ${ccipProxyAddr}`); + + // --- 5. Initialise adapter proxies to point at impls. Proxy constructor set + // governor = deployer; `initialize` is onlyGovernor and re-asserts governor. + const cSuperProxyRaw = await ethers.getContractAt( + "InitializeGovernedUpgradeabilityProxy", + superProxyAddr, + sDeployer + ); + await withConfirmation( + cSuperProxyRaw["initialize(address,address,bytes)"]( + dSuperImpl.address, + deployerAddr, + "0x" + ) + ); + const cCCIPProxyRaw = await ethers.getContractAt( + "InitializeGovernedUpgradeabilityProxy", + ccipProxyAddr, + sDeployer + ); + await withConfirmation( + cCCIPProxyRaw["initialize(address,address,bytes)"]( + dCCIPImpl.address, + deployerAddr, + "0x" + ) + ); + + // After this, the proxy address is the "real" adapter — configure it as such. + const dSuperOut = await ethers.getContractAt( + "SuperbridgeAdapter", + superProxyAddr + ); + const dCCIPRx = await ethers.getContractAt("CCIPAdapter", ccipProxyAddr); + + // --- 6. Adapter configuration --- + // ChainConfig fields: { paused, chainSelector, destGasLimit } + const remoteChainCfg = { + paused: false, + chainSelector: addresses.base.CCIPChainSelector, + destGasLimit: DEFAULT_DEST_GAS_LIMIT, + }; + await withConfirmation( + dSuperOut.connect(sDeployer).authorise(remoteProxyAddress, remoteChainCfg) + ); + await withConfirmation( + dCCIPRx.connect(sDeployer).authorise(remoteProxyAddress, remoteChainCfg) + ); + // Superbridge needs the OP Stack canonical bridge min-gas hint per sender. + await withConfirmation( + dSuperOut + .connect(sDeployer) + .setCanonicalMinGas(remoteProxyAddress, CANONICAL_MIN_GAS) + ); + // Strategist (multichain strategist) can pause/unpause lanes for fast incident response. + await withConfirmation( + dSuperOut.connect(sDeployer).addStrategist(addresses.multichainStrategist) + ); + await withConfirmation( + dCCIPRx.connect(sDeployer).addStrategist(addresses.multichainStrategist) + ); + + // --- 7. Transfer adapter proxy governance to mainnet Timelock --- + await withConfirmation( + dSuperOut + .connect(sDeployer) + .transferGovernance(addresses.mainnet.Timelock) + ); + await withConfirmation( + dCCIPRx.connect(sDeployer).transferGovernance(addresses.mainnet.Timelock) + ); + + const cRemote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxyAddress + ); + + return { + name: "Deploy OETHb V3 Remote strategy + adapters on Ethereum", + actions: [ + // Timelock claims governance on the two adapter proxies. + { + contract: dSuperOut, + signature: "claimGovernance()", + args: [], + }, + { + contract: dCCIPRx, + signature: "claimGovernance()", + args: [], + }, + // Wire the adapter PROXY addresses into Remote. + { + contract: cRemote, + signature: "setOutboundAdapter(address)", + args: [superProxyAddr], + }, + { + contract: cRemote, + signature: "setInboundAdapter(address)", + args: [ccipProxyAddr], + }, + // safeApproveAllTokens primes the static (token, spender) pairs Remote uses: + // bridgeAsset→oTokenVault, oToken→oTokenVault, oToken→woToken. + // The dynamic bridgeAsset→outboundAdapter approval is set by setOutboundAdapter above. + { + contract: cRemote, + signature: "safeApproveAllTokens()", + args: [], + }, + ], + }; + } +); diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index dd6dbcaacf..717049b140 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -39,13 +39,13 @@ importers: version: 1.41.0 '@talos/client': specifier: github:oplabs/talos#e0c53b2f06b8aa627640a7cb3631140dcae14f41&path:packages/client - version: git+https://git@github.com:oplabs/talos.git#e0c53b2f06b8aa627640a7cb3631140dcae14f41&path:packages/client(@types/pg@8.20.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: git+https://git@github.com:oplabs/talos.git#e0c53b2f06b8aa627640a7cb3631140dcae14f41&path:packages/client(@types/pg@8.20.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@types/pg': specifier: ^8.20.0 version: 8.20.0 origin-morpho-utils: specifier: ^0.1.1 - version: 0.1.1(viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) + version: 0.1.1(viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) pg: specifier: ^8.20.0 version: 8.20.0 @@ -148,7 +148,7 @@ importers: version: 0.3.0-beta.13(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10)) hardhat-gas-reporter: specifier: 2.3.0 - version: 2.3.0(bufferutil@4.1.0)(debug@4.3.4)(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.3.0(bufferutil@4.1.0)(debug@4.3.4)(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10) hardhat-tracer: specifier: 3.2.1 version: 3.2.1(bufferutil@4.1.0)(chai@4.3.7)(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) @@ -211,7 +211,7 @@ importers: version: 9.0.1 viem: specifier: ^2.27.0 - version: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) packages: @@ -6256,10 +6256,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -6669,9 +6671,6 @@ packages: peerDependencies: ethers: ^5.7.0 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - snapshots: '@adraffy/ens-normalize@1.10.1': {} @@ -10476,7 +10475,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@talos/client@git+https://git@github.com:oplabs/talos.git#e0c53b2f06b8aa627640a7cb3631140dcae14f41&path:packages/client(@types/pg@8.20.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@talos/client@git+https://git@github.com:oplabs/talos.git#e0c53b2f06b8aa627640a7cb3631140dcae14f41&path:packages/client(@types/pg@8.20.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) croner: 10.0.1 @@ -10485,7 +10484,7 @@ snapshots: ethers-v6: ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) hono: 4.12.14 pg: 8.20.0 - viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -10778,10 +10777,9 @@ snapshots: abbrev@1.0.9: {} - abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + abitype@1.2.3(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 - zod: 3.25.76 abortcontroller-polyfill@1.7.8: {} @@ -12702,7 +12700,7 @@ snapshots: - supports-color - utf-8-validate - hardhat-gas-reporter@2.3.0(bufferutil@4.1.0)(debug@4.3.4)(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + hardhat-gas-reporter@2.3.0(bufferutil@4.1.0)(debug@4.3.4)(hardhat@2.26.2(bufferutil@4.1.0)(ts-node@10.9.2(@types/node@25.6.1)(typescript@5.9.3))(typescript@5.9.3)(utf-8-validate@5.0.10))(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: '@ethersproject/abi': 5.8.0 '@ethersproject/bytes': 5.8.0 @@ -12719,7 +12717,7 @@ snapshots: lodash: 4.17.21 markdown-table: 2.0.0 sha1: 1.1.1 - viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - debug @@ -13649,9 +13647,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - origin-morpho-utils@0.1.1(viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)): + origin-morpho-utils@0.1.1(viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)): optionalDependencies: - viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) os-locale@1.4.0: dependencies: @@ -13659,7 +13657,7 @@ snapshots: os-tmpdir@1.0.2: {} - ox@0.11.1(typescript@5.9.3)(zod@3.25.76): + ox@0.11.1(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -13667,7 +13665,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -14995,15 +14993,15 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.43.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3) isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.11.1(typescript@5.9.3)(zod@3.25.76) + ox: 0.11.1(typescript@5.9.3) ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 @@ -15619,6 +15617,3 @@ snapshots: zksync-web3@0.14.4(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)): dependencies: ethers: 5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) - - zod@3.25.76: - optional: true diff --git a/contracts/pnpm-workspace.yaml b/contracts/pnpm-workspace.yaml index 40595e8400..22c61b6746 100644 --- a/contracts/pnpm-workspace.yaml +++ b/contracts/pnpm-workspace.yaml @@ -1,6 +1,6 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: -- "origin-morpho-utils" + - "origin-morpho-utils" ignoredBuiltDependencies: - "@arbitrum/nitro-contracts" @@ -26,4 +26,16 @@ packageExtensions: origin-morpho-utils: peerDependenciesMeta: vitest: - optional: true \ No newline at end of file + optional: true + +allowBuilds: + '@trufflesuite/bigint-buffer': true + bufferutil: true + core-js-pure: true + es5-ext: true + keccak: true + secp256k1: true + utf-8-validate: true + web3: true + web3-bzz: true + web3-shh: true diff --git a/contracts/test/strategies/crosschainV3/_helpers.js b/contracts/test/strategies/crosschainV3/_helpers.js new file mode 100644 index 0000000000..6dfb065746 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/_helpers.js @@ -0,0 +1,81 @@ +const { ethers } = require("hardhat"); + +/** + * Strategy-level message-type enum (mirror of `CrossChainV3Helper.sol`'s + * `uint32` constants). Strategies wrap each cross-chain operation's body in + * `abi.encode(msgType, nonce, body)` before handing it to the adapter as the + * `payload` argument. + */ +const MSG = { + DEPOSIT: 1, + DEPOSIT_ACK: 2, + WITHDRAW_REQUEST: 3, + WITHDRAW_REQUEST_ACK: 4, + WITHDRAW_CLAIM: 5, + WITHDRAW_CLAIM_ACK: 6, + BALANCE_CHECK_REQUEST: 7, + BALANCE_CHECK_RESPONSE: 8, + SETTLE_BRIDGE_ACCOUNTING: 9, + SETTLE_BRIDGE_ACCOUNTING_ACK: 10, + BRIDGE_IN: 11, + BRIDGE_OUT: 12, +}; + +/** + * Strategy-level envelope: `abi.encode(uint32 msgType, uint64 nonce, bytes body)`. + * This is what `MockBridgeAdapter` and `_validateInbound` consume as the + * application payload. The adapter wraps an outer 52-byte (sender + + * intendedAmount) header around it before sending across the wire. + */ +const encodePackedEnvelope = (msgType, nonce, payloadHex) => { + return ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [msgType, nonce, payloadHex] + ); +}; + +/** + * Bridge-channel user payload: `(bridgeId, amount, recipient, callData, callGasLimit)`. + * Used as the `body` argument inside the strategy envelope for BRIDGE_IN / + * BRIDGE_OUT messages. + */ +const encodeBridgeUserPayload = ({ + bridgeId, + amount, + recipient, + callData = "0x", + callGasLimit = 0, +}) => { + return ethers.utils.defaultAbiCoder.encode( + ["bytes32", "uint256", "address", "bytes", "uint32"], + [bridgeId, amount, recipient, callData, callGasLimit] + ); +}; + +/** + * Single-`uint256` body. Used by DEPOSIT_ACK, WITHDRAW_REQUEST_ACK, and + * SETTLE_BRIDGE_ACCOUNTING_ACK whose body is just `newBalance`. + */ +const encodeNewBalancePayload = (newBalance) => + ethers.utils.defaultAbiCoder.encode(["uint256"], [newBalance]); + +/** + * Adapter-level envelope (the OUTER 52-byte header + opaque payload). The + * MockBridgeAdapter / real adapters build this in Solidity; tests that + * synthesize raw CCTP wire messages (e.g., cctp-burn-relay.js, cctp-relay.js) + * build it manually with `solidityPack`. + */ +const wrapAppEnvelope = (envelopeSender, intendedAmount, payload) => { + return ethers.utils.solidityPack( + ["address", "uint256", "bytes"], + [envelopeSender, intendedAmount, payload] + ); +}; + +module.exports = { + MSG, + encodePackedEnvelope, + encodeBridgeUserPayload, + encodeNewBalancePayload, + wrapAppEnvelope, +}; diff --git a/contracts/test/strategies/crosschainV3/bridge-fee.js b/contracts/test/strategies/crosschainV3/bridge-fee.js new file mode 100644 index 0000000000..b2975526e6 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/bridge-fee.js @@ -0,0 +1,164 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const { MSG } = require("./_helpers"); + +/** + * Covers bridgeFeeBps on the bridge channel: + * - default 0 + * - capped at MAX_BRIDGE_FEE_BPS (10%) + * - governor-only setter + * - burn-full / deliver-net semantics: source burns _amount, the envelope payload carries + * `net = _amount - fee`, bridgeAdjustment captures `net`. + */ +describe("Unit: AbstractWOTokenStrategy bridge fee (burn-full / deliver-net)", function () { + let deployer, governor, alice; + let bridgeAsset, oToken, mockVault, master; + let outboundAdapter, inboundAdapter; + + const ONE_K = ethers.utils.parseUnits("1000", 6); + + beforeEach(async () => { + [deployer, governor, , alice] = await ethers.getSigners(); + + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockVault = await VaultFactory.deploy(); + + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oToken = await OTokenFactory.deploy( + "Mock OToken", + "mOT", + mockVault.address + ); + await mockVault.setOToken(oToken.address); + + const ImplFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const impl = await ImplFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockVault.address, + }, + bridgeAsset.address, + oToken.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const proxy = await ProxyFactory.connect(deployer).deploy(); + const initData = impl.interface.encodeFunctionData("initialize", [ + governor.address, + ]); + await proxy + .connect(deployer) + .initialize(impl.address, governor.address, initData); + + master = await ethers.getContractAt("MasterWOTokenStrategy", proxy.address); + await mockVault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + outboundAdapter = await AdapterFactory.deploy(); + inboundAdapter = await AdapterFactory.deploy(); + await outboundAdapter.setSender(master.address); + await inboundAdapter.setPeer(master.address); + await master.connect(governor).setOutboundAdapter(outboundAdapter.address); + await master.connect(governor).setInboundAdapter(inboundAdapter.address); + + // Seed Remote balance so bridge-out has liquidity. + await bridgeAsset.mintTo(master.address, ONE_K); + await mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K); + const ackBody = ethers.utils.defaultAbiCoder.encode(["uint256"], [ONE_K]); + const ackEnvelope = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [2, 1, ackBody] // DEPOSIT_ACK, nonce 1 + ); + await inboundAdapter.sendMessage(ackEnvelope); + }); + + const mintAndApprove = async (signer, amount) => { + const bridgeId = ethers.utils.id("seed-" + Math.random()); + const body = ethers.utils.defaultAbiCoder.encode( + ["bytes32", "uint256", "address", "bytes", "uint32"], + [bridgeId, amount, signer.address, "0x", 0] + ); + const envelope = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [11, 0, body] // BRIDGE_IN, nonceless + ); + await inboundAdapter.sendMessage(envelope); + await oToken.connect(signer).approve(master.address, amount); + }; + + it("defaults to 0 (no fee)", async () => { + expect(await master.bridgeFeeBps()).to.equal(0); + }); + + it("caps fee at MAX_BRIDGE_FEE_BPS (10%)", async () => { + expect(await master.MAX_BRIDGE_FEE_BPS()).to.equal(1000); + await expect( + master.connect(governor).setBridgeFeeBps(1001) + ).to.be.revertedWith("WOT: fee too high"); + }); + + it("only governor can set bridgeFeeBps", async () => { + await expect(master.connect(alice).setBridgeFeeBps(100)).to.be.revertedWith( + "Caller is not the Governor" + ); + }); + + it("burn-full / deliver-net: bridges burn the full amount but the peer envelope carries `net`", async () => { + await master.connect(governor).setBridgeFeeBps(100); // 1% + + const amount = ethers.utils.parseUnits("100", 6); + const fee = amount.div(100); + const net = amount.sub(fee); + + await mintAndApprove(alice, amount); + + const adjBefore = await master.bridgeAdjustment(); + const totalSupplyBefore = await oToken.totalSupply(); + + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(amount, ethers.constants.AddressZero, "0x", 0) + ).to.emit(master, "BridgeRequested"); + + // Master burned the FULL amount. + expect(await oToken.totalSupply()).to.equal(totalSupplyBefore.sub(amount)); + + // bridgeAdjustment captured only `net` (peer's obligation). + expect(await master.bridgeAdjustment()).to.equal(adjBefore.sub(net)); + + // Envelope payload carries `net`, not `amount`. + const stored = await outboundAdapter.lastMessageSent(); + const [msgType, , body] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + stored + ); + expect(msgType).to.equal(MSG.BRIDGE_OUT); + const [, decodedAmount] = ethers.utils.defaultAbiCoder.decode( + ["bytes32", "uint256", "address", "bytes", "uint32"], + body + ); + expect(decodedAmount).to.equal(net); + }); + + it("net=0 after fee is rejected", async () => { + // With max 10% fee, net only goes to zero if amount is 0 (already caught by zero-bridge guard) + // — so test the boundary: max-fee 1000bps and amount of 1 produces net=1, never zero. + // Direct test: try amount=0 (caught earlier). + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(0, ethers.constants.AddressZero, "0x", 0) + ).to.be.revertedWith("WOT: zero bridge"); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/cctp-burn-relay.js b/contracts/test/strategies/crosschainV3/cctp-burn-relay.js new file mode 100644 index 0000000000..2f98f21f20 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/cctp-burn-relay.js @@ -0,0 +1,331 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const { wrapAppEnvelope } = require("./_helpers"); + +/** + * Coverage for `CCTPAdapter.relay()`'s burn-message path: the operator passes a CCTP V2 + * wire message whose transport `sender` is the source-side `TokenMessenger`. The adapter + * must: + * - parse the inner burn body for `burnToken / amount / msgSender / feeExecuted / hookData` + * - call `messageTransmitter.receiveMessage` (which credits USDC to the adapter) + * - validate the hook data envelope via `_validateInbound` + * - dispatch `_deliver` with `amount - feeExecuted` (authoritative; not balanceOf-derived) + * - leave pre-existing residue/donation on the adapter (isolation) + * + * This path replaces the older "rely on CCTP V2.1 auto-callback" assumption that was + * untested and unsafe on V2.0 deployments. + */ +describe("Unit: CCTPAdapter burn relay", function () { + let governor, operator; + let usdc, tokenMessenger, transmitter, adapter, strategy; + + const SOURCE_DOMAIN = 6; + const DEST_GAS_LIMIT = 500000; + + // Address acting as the source-side TokenMessenger. The mock transmitter routes burn + // messages based on transport sender == this value. Doesn't have to be a real contract. + const SRC_TOKEN_MESSENGER = "0x000000000000000000000000000000000000C0DE"; + + // CCTP V2 transport message builder (mirrors CCTPMessageHelper layout). + function buildTransportMessage({ sourceDomain, sender, recipient, body }) { + return ethers.utils.solidityPack( + [ + "uint32", + "uint32", + "uint32", + "bytes32", + "bytes32", + "bytes32", + "bytes32", + "uint32", + "uint32", + "bytes", + ], + [ + 1, // version + sourceDomain, + 0, // destDomain + ethers.constants.HashZero, // nonce + ethers.utils.hexZeroPad(sender, 32), + ethers.utils.hexZeroPad(recipient, 32), + ethers.constants.HashZero, // destinationCaller + 2000, // minFinalityThreshold + 2000, // finalityThresholdExecuted + body, + ] + ); + } + + // CCTP V2 burn body builder (mirrors CCTPMessageHelper burn-body offsets): + // [0..4) uint32 version + // [4..36) bytes32 burnToken (right-aligned address) + // [36..68) bytes32 mintRecipient + // [68..100) uint256 amount + // [100..132) bytes32 msgSender + // [132..164) uint256 maxFee + // [164..196) uint256 feeExecuted + // [196..228) uint256 expirationBlock + // [228..] bytes hookData + function buildBurnBody({ + burnToken, + mintRecipient, + amount, + msgSender, + feeExecuted, + hookData, + }) { + return ethers.utils.solidityPack( + [ + "uint32", + "bytes32", + "bytes32", + "uint256", + "bytes32", + "uint256", + "uint256", + "uint256", + "bytes", + ], + [ + 1, + ethers.utils.hexZeroPad(burnToken, 32), + ethers.utils.hexZeroPad(mintRecipient, 32), + amount, + ethers.utils.hexZeroPad(msgSender, 32), + 0, // maxFee — informational only + feeExecuted, + 0, // expirationBlock + hookData, + ] + ); + } + + beforeEach(async () => { + [governor, operator] = await ethers.getSigners(); + + const USDCFactory = await ethers.getContractFactory("MockUSDC"); + usdc = await USDCFactory.deploy(); + + const TransmitterFactory = await ethers.getContractFactory( + "MockCCTPRelayTransmitter" + ); + transmitter = await TransmitterFactory.deploy(); + // Configure the mock to recognize burn messages from SRC_TOKEN_MESSENGER and mint + // USDC accordingly. + await transmitter.setBurnConfig(SRC_TOKEN_MESSENGER, usdc.address); + + const TokenMessengerFactory = await ethers.getContractFactory( + "CCTPTokenMessengerMock" + ); + tokenMessenger = await TokenMessengerFactory.deploy( + usdc.address, + transmitter.address + ); + + const AdapterFactory = await ethers.getContractFactory("CCTPAdapter"); + adapter = await AdapterFactory.connect(governor).deploy( + usdc.address, + tokenMessenger.address, + transmitter.address + ); + // tokenMessenger address used by the adapter as "is this a burn message" check on the + // transport sender — but our mock transmitter routes by SRC_TOKEN_MESSENGER instead + // (real CCTP V2 has the source-side and dest-side TokenMessengers at the same address + // under CREATE3 parity; the mock just lets us pick). + await transmitter.setBurnConfig(tokenMessenger.address, usdc.address); + + await adapter.connect(governor).setOperator(operator.address); + await adapter.connect(governor).setMinFinalityThreshold(2000); + + const StrategyFactory = await ethers.getContractFactory( + "MockBridgeReceiver" + ); + strategy = await StrategyFactory.connect(governor).deploy(); + await adapter.connect(governor).authorise(strategy.address, { + paused: false, + chainSelector: SOURCE_DOMAIN, + destGasLimit: DEST_GAS_LIMIT, + }); + }); + + it("dispatches authoritative amount - feeExecuted from the burn body", async () => { + const amount = ethers.utils.parseUnits("100", 6); + const feeExecuted = ethers.utils.parseUnits("0.5", 6); // 0.5 USDC fast-finality fee + const payload = ethers.utils.defaultAbiCoder.encode(["string"], ["claim"]); + const hookData = wrapAppEnvelope(strategy.address, amount, payload); + + const burnBody = buildBurnBody({ + burnToken: usdc.address, + mintRecipient: adapter.address, + amount, + msgSender: adapter.address, // peer adapter under CREATE3 parity + feeExecuted, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, // burn message — transport sender is TokenMessenger + recipient: tokenMessenger.address, // (destination TokenMessenger; not enforced for burns) + body: burnBody, + }); + + const landed = amount.sub(feeExecuted); + // feePaid is no longer forwarded to the strategy; the adapter emits it on + // MessageDelivered for off-chain consumers. Assert the event carries feeExecuted. + await expect(adapter.connect(operator).relay(message, "0x")) + .to.emit(adapter, "MessageDelivered") + .withArgs(strategy.address, usdc.address, landed, feeExecuted); + + // Strategy received exactly `amount - feeExecuted` USDC. + expect(await strategy.callCount()).to.equal(1); + expect(await strategy.lastSender()).to.equal(strategy.address); + expect(await strategy.lastToken()).to.equal(usdc.address); + expect(await strategy.lastAmount()).to.equal(landed); + expect(await strategy.lastPayload()).to.equal(payload); + expect(await usdc.balanceOf(strategy.address)).to.equal(landed); + expect(await usdc.balanceOf(adapter.address)).to.equal(0); + }); + + it("isolates pre-existing residue/donation on the adapter from this op's accounting", async () => { + // Donate 13 USDC to the adapter before the relay fires. + const donation = ethers.utils.parseUnits("13", 6); + await usdc.mintTo(adapter.address, donation); + + const amount = ethers.utils.parseUnits("100", 6); + const feeExecuted = 0; // finalized, no fee + const hookData = wrapAppEnvelope(strategy.address, amount, "0x"); + + const burnBody = buildBurnBody({ + burnToken: usdc.address, + mintRecipient: adapter.address, + amount, + msgSender: adapter.address, // peer adapter under CREATE3 parity + feeExecuted, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + }); + + await adapter.connect(operator).relay(message, "0x"); + + // Strategy receives exactly the operation amount — not amount + donation. + expect(await strategy.lastAmount()).to.equal(amount); + expect(await usdc.balanceOf(strategy.address)).to.equal(amount); + // Donation stays on the adapter, isolated from this delivery. + expect(await usdc.balanceOf(adapter.address)).to.equal(donation); + }); + + it("ignores the burn body's `burnToken` — always credits local USDC", async () => { + const amount = ethers.utils.parseUnits("100", 6); + // `burnToken` is the SOURCE-chain USDC address, which differs from this chain's + // local USDC for a real cross-chain transfer. The adapter no longer checks it: + // the credited token is bound to local `usdcToken` by the balanceOf-delta + the + // hard-coded `_deliver(..., usdcToken, ...)`, so a forged source burnToken can't + // mint anything but local USDC. The relay therefore succeeds. + const fakeToken = "0x000000000000000000000000000000000000BAD0"; + const hookData = wrapAppEnvelope(strategy.address, amount, "0x"); + const burnBody = buildBurnBody({ + burnToken: fakeToken, + mintRecipient: adapter.address, + amount, + msgSender: adapter.address, // peer adapter under CREATE3 parity + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + }); + + await adapter.connect(operator).relay(message, "0x"); + + // Local USDC was minted and delivered to the strategy regardless of burnToken. + expect(await strategy.lastToken()).to.equal(usdc.address); + expect(await strategy.lastAmount()).to.equal(amount); + expect(await usdc.balanceOf(strategy.address)).to.equal(amount); + }); + + it("rejects when the burn body's mintRecipient is not this adapter", async () => { + const amount = ethers.utils.parseUnits("100", 6); + const wrongRecipient = "0x000000000000000000000000000000000000c0DE"; + const hookData = wrapAppEnvelope(strategy.address, amount, "0x"); + const burnBody = buildBurnBody({ + burnToken: usdc.address, + mintRecipient: wrongRecipient, // not this adapter (CREATE3 parity broken) + amount, + msgSender: adapter.address, // peer adapter under CREATE3 parity + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + }); + + // The burn branch enforces mint-recipient parity (the pure-message branch checks + // transportRecipient; the burn branch checks the burn body's mintRecipient). + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: bad mint recipient"); + }); + + it("rejects when envelope intendedAmount disagrees with the burn `amount`", async () => { + const amount = ethers.utils.parseUnits("100", 6); + const wrongIntended = ethers.utils.parseUnits("999", 6); + const hookData = wrapAppEnvelope(strategy.address, wrongIntended, "0x"); + const burnBody = buildBurnBody({ + burnToken: usdc.address, + mintRecipient: adapter.address, + amount, + msgSender: adapter.address, // peer adapter under CREATE3 parity + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + }); + + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: intent mismatch"); + }); + + it("rejects when msgSender (peer adapter under CREATE3 parity) is not authorised", async () => { + const stranger = "0x000000000000000000000000000000000000BEEF"; + const amount = ethers.utils.parseUnits("100", 6); + const hookData = wrapAppEnvelope(strategy.address, amount, "0x"); + const burnBody = buildBurnBody({ + burnToken: usdc.address, + mintRecipient: adapter.address, + amount, + msgSender: stranger, // unauthorised peer adapter + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + }); + + // _validateInbound checks `transportSender == address(this)` (peer parity). The + // burn-path passes msgSender from the burn body as the transport identity, so this + // surfaces the parity check failure. + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("Adapter: not from peer adapter"); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/cctp-relay.js b/contracts/test/strategies/crosschainV3/cctp-relay.js new file mode 100644 index 0000000000..fed1b75036 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/cctp-relay.js @@ -0,0 +1,288 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const { wrapAppEnvelope } = require("./_helpers"); + +/** + * Covers CCTPAdapter.relay — the operator-driven entry point that finalises an inbound + * CCTP message by handing it (with attestation) to the local MessageTransmitter, which then + * calls back into `handleReceiveFinalizedMessage`. Because we set + * `destinationCaller = address(this)` on the source burn, only this adapter can drive the + * finalisation. + */ +describe("Unit: CCTPAdapter relay", function () { + let governor, operator, stranger; + let usdc, tokenMessenger, messageTransmitter, adapter, strategy; + + // Source-chain CCTP V2 domain; arbitrary non-zero for tests (AbstractAdapter rejects + // chainSelector=0 at authorise time). + const SOURCE_DOMAIN = 6; + const DEST_GAS_LIMIT = 500000; + + // CCTP V2 wire-format encoder. Field offsets per Circle's spec: + // [0..4) version (uint32) + // [4..8) sourceDomain (uint32) + // [8..12) destinationDomain (uint32) + // [12..44) nonce (bytes32) + // [44..76) sender (bytes32, right-aligned address) + // [76..108) recipient (bytes32, right-aligned address) + // [108..140) destinationCaller (bytes32) + // [140..144) minFinalityThreshold (uint32) + // [144..148) finalityThresholdExecuted (uint32) + // [148..] messageBody + function buildCCTPMessage({ + version = 1, + sourceDomain = SOURCE_DOMAIN, + sender, + recipient, + body, + }) { + return ethers.utils.solidityPack( + [ + "uint32", + "uint32", + "uint32", + "bytes32", + "bytes32", + "bytes32", + "bytes32", + "uint32", + "uint32", + "bytes", + ], + [ + version, + sourceDomain, + 0, + ethers.constants.HashZero, // 32-byte nonce — zero is fine for these unit tests + ethers.utils.hexZeroPad(sender, 32), + ethers.utils.hexZeroPad(recipient, 32), + ethers.constants.HashZero, // destinationCaller — zero means "any caller can finalise" in CCTP V2; production sets this to the peer adapter under CREATE3 parity, but tests use the unrestricted form for simplicity + 0, + 0, + body, + ] + ); + } + + beforeEach(async () => { + [governor, operator, stranger] = await ethers.getSigners(); + + const USDCFactory = await ethers.getContractFactory("MockUSDC"); + usdc = await USDCFactory.deploy(); + + const TransmitterFactory = await ethers.getContractFactory( + "MockCCTPRelayTransmitter" + ); + messageTransmitter = await TransmitterFactory.deploy(); + + // CCTP TokenMessenger mock; constructor takes (usdc, transmitter). Outbound burn + // isn't exercised in these tests but the adapter constructor wants a non-zero address. + const TokenMessengerFactory = await ethers.getContractFactory( + "CCTPTokenMessengerMock" + ); + tokenMessenger = await TokenMessengerFactory.deploy( + usdc.address, + messageTransmitter.address + ); + + const AdapterFactory = await ethers.getContractFactory("CCTPAdapter"); + adapter = await AdapterFactory.connect(governor).deploy( + usdc.address, + tokenMessenger.address, + messageTransmitter.address + ); + + // Operator gets the relay role; strangers don't. + await adapter.connect(governor).setOperator(operator.address); + + // Strategy is just a recorder — MockBridgeReceiver — authorised on the adapter as the + // peer strategy. Under CREATE3 parity its address would equal the source strategy's + // address; we use the same address for both sides in unit tests. + const StrategyFactory = await ethers.getContractFactory( + "MockBridgeReceiver" + ); + strategy = await StrategyFactory.connect(governor).deploy(); + + await adapter.connect(governor).authorise(strategy.address, { + paused: false, + chainSelector: SOURCE_DOMAIN, + destGasLimit: DEST_GAS_LIMIT, + }); + }); + + describe("access control", () => { + it("rejects non-operator callers", async () => { + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect( + adapter.connect(stranger).relay(message, "0x") + ).to.be.revertedWith("CCTP: not operator"); + }); + + it("governor can rotate the operator", async () => { + const newOperator = stranger; + await expect(adapter.connect(governor).setOperator(newOperator.address)) + .to.emit(adapter, "OperatorUpdated") + .withArgs(operator.address, newOperator.address); + + // Old operator no longer authorised. + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: not operator"); + + // New operator works. + await expect(adapter.connect(newOperator).relay(message, "0x")).to.not.be + .reverted; + }); + + it("non-governor cannot set the operator", async () => { + await expect( + adapter.connect(stranger).setOperator(stranger.address) + ).to.be.revertedWith("Caller is not the Governor"); + }); + }); + + describe("pre-validation", () => { + it("rejects a message with an unexpected CCTP version", async () => { + const message = buildCCTPMessage({ + version: 2, // not CCTP V2 + sender: adapter.address, + recipient: adapter.address, + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: bad msg version"); + }); + + it("rejects a message whose recipient field is a different address", async () => { + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: stranger.address, // not us + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: not for us"); + }); + }); + + describe("MessageTransmitter integration", () => { + it("propagates MessageTransmitter.receiveMessage failure", async () => { + await messageTransmitter.setShouldSucceed(false); + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: relay failed"); + }); + + it("emits MessageRelayed and forwards via the transmitter on success", async () => { + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body: wrapAppEnvelope(strategy.address, 0, "0x"), + }); + await expect(adapter.connect(operator).relay(message, "0x")) + .to.emit(adapter, "MessageRelayed") + .withArgs(operator.address, SOURCE_DOMAIN); + }); + }); + + describe("end-to-end through _validateInbound + _deliver", () => { + it("message-only delivery reaches the destination strategy with no token leg", async () => { + const payload = ethers.utils.defaultAbiCoder.encode( + ["string"], + ["hello"] + ); + const body = wrapAppEnvelope(strategy.address, 0, payload); + const message = buildCCTPMessage({ + sender: adapter.address, // CREATE3 parity: source adapter == this adapter + recipient: adapter.address, + body, + }); + + // Pure-message path delivers with token = address(0) and feePaid = 0 (feePaid lives + // on the event, not forwarded to the strategy). + await expect(adapter.connect(operator).relay(message, "0x")) + .to.emit(adapter, "MessageDelivered") + .withArgs(strategy.address, ethers.constants.AddressZero, 0, 0); + + // The mock recorder captured the receiveMessage callback. Pure-message path + // delivers with token = address(0) (no token leg), regardless of the configured USDC. + expect(await strategy.callCount()).to.equal(1); + expect(await strategy.lastSender()).to.equal(strategy.address); + expect(await strategy.lastToken()).to.equal(ethers.constants.AddressZero); + expect(await strategy.lastAmount()).to.equal(0); + expect(await strategy.lastPayload()).to.equal(payload); + }); + + it("rejects a pure-message envelope that smuggles a non-zero intendedAmount", async () => { + // Token-bearing messages MUST go through `relay()`'s burn-message path (with a + // real CCTP burn body). Forcing intendedAmount > 0 down the pure-message hook is + // a design violation and must revert. + const body = wrapAppEnvelope( + strategy.address, + ethers.utils.parseUnits("100", 6), + "0x" + ); + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body, + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: token leg via pure-message path"); + }); + + it("rejects when the envelope sender isn't authorised", async () => { + const body = wrapAppEnvelope(stranger.address, 0, "0x"); + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body, + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("Adapter: not authorised"); + }); + + it("rejects when the source chain doesn't match the lane config", async () => { + const body = wrapAppEnvelope(strategy.address, 0, "0x"); + const message = buildCCTPMessage({ + sourceDomain: 99, // unrelated domain + sender: adapter.address, + recipient: adapter.address, + body, + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("Adapter: wrong source chain"); + }); + + it("rejects when the peer adapter parity check fails", async () => { + const body = wrapAppEnvelope(strategy.address, 0, "0x"); + const message = buildCCTPMessage({ + sender: stranger.address, // not the peer adapter address + recipient: adapter.address, + body, + }); + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("Adapter: not from peer adapter"); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/crosschain-v3-helper.js b/contracts/test/strategies/crosschainV3/crosschain-v3-helper.js new file mode 100644 index 0000000000..944a1724cb --- /dev/null +++ b/contracts/test/strategies/crosschainV3/crosschain-v3-helper.js @@ -0,0 +1,195 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const MSG = { + DEPOSIT: 1, + DEPOSIT_ACK: 2, + WITHDRAW_REQUEST: 3, + WITHDRAW_REQUEST_ACK: 4, + WITHDRAW_CLAIM: 5, + WITHDRAW_CLAIM_ACK: 6, + BALANCE_CHECK_REQUEST: 7, + BALANCE_CHECK_RESPONSE: 8, + SETTLE_BRIDGE_ACCOUNTING: 9, + SETTLE_BRIDGE_ACCOUNTING_ACK: 10, + BRIDGE_IN: 11, + BRIDGE_OUT: 12, +}; + +describe("Unit: CrossChainV3Helper", function () { + let harness; + + before(async () => { + const Harness = await ethers.getContractFactory( + "MockCrossChainV3HelperHarness" + ); + harness = await Harness.deploy(); + await harness.deployed(); + }); + + describe("packPayload / unpackPayload (strategy envelope)", () => { + it("round-trips every yield-channel message type with a nonzero nonce", async () => { + const cases = [ + { type: MSG.DEPOSIT, body: "0x" }, + { + type: MSG.DEPOSIT_ACK, + body: ethers.utils.defaultAbiCoder.encode(["uint256"], [12345]), + }, + { + type: MSG.WITHDRAW_REQUEST, + body: ethers.utils.defaultAbiCoder.encode(["uint256"], [777]), + }, + { + type: MSG.WITHDRAW_REQUEST_ACK, + body: ethers.utils.defaultAbiCoder.encode(["uint256"], [9000]), + }, + { type: MSG.WITHDRAW_CLAIM, body: "0x" }, + { + type: MSG.WITHDRAW_CLAIM_ACK, + body: ethers.utils.defaultAbiCoder.encode( + ["uint256", "bool", "uint256"], + [42, true, 7] + ), + }, + { + type: MSG.BALANCE_CHECK_REQUEST, + body: ethers.utils.defaultAbiCoder.encode(["uint256"], [1700000000]), + }, + { + type: MSG.BALANCE_CHECK_RESPONSE, + body: ethers.utils.defaultAbiCoder.encode( + ["uint256", "uint256"], + [99, 1700000001] + ), + }, + { type: MSG.SETTLE_BRIDGE_ACCOUNTING, body: "0x" }, + { + type: MSG.SETTLE_BRIDGE_ACCOUNTING_ACK, + body: ethers.utils.defaultAbiCoder.encode(["uint256"], [555]), + }, + ]; + + const nonce = ethers.BigNumber.from("123456789012345678"); + for (const c of cases) { + const packed = await harness.packPayload(c.type, nonce, c.body); + const [msgType, gotNonce, gotBody] = await harness.unpackPayload( + packed + ); + expect(msgType).to.equal(c.type); + expect(gotNonce).to.equal(nonce); + expect(gotBody).to.equal(c.body === "0x" ? "0x" : c.body); + } + }); + + it("round-trips bridge-channel messages with nonce 0", async () => { + const bridgeId = ethers.utils.id("bridge-1"); + const body = await harness.encodeBridgeUserPayload( + bridgeId, + ethers.utils.parseEther("100"), + "0x000000000000000000000000000000000000beef", + "0xdeadbeef", + 300000 + ); + + const packed = await harness.packPayload(MSG.BRIDGE_IN, 0, body); + const [msgType, gotNonce, gotBody] = await harness.unpackPayload(packed); + expect(msgType).to.equal(MSG.BRIDGE_IN); + expect(gotNonce).to.equal(0); + expect(gotBody).to.equal(body); + }); + }); + + describe("payload encoders / decoders", () => { + it("encodeNewBalancePayload round-trips", async () => { + const v = ethers.utils.parseEther("123.456"); + const encoded = await harness.encodeNewBalancePayload(v); + expect(await harness.decodeNewBalancePayload(encoded)).to.equal(v); + }); + + it("encodeAmountPayload round-trips", async () => { + const v = ethers.utils.parseUnits("999.99", 6); + const encoded = await harness.encodeAmountPayload(v); + expect(await harness.decodeAmountPayload(encoded)).to.equal(v); + }); + + it("encodeWithdrawClaimAckPayload round-trips all branches", async () => { + for (const [bal, ok, amt] of [ + [ethers.utils.parseEther("10"), true, ethers.utils.parseEther("3")], + [ethers.utils.parseEther("0"), false, ethers.BigNumber.from(0)], + [ethers.constants.MaxUint256, true, ethers.constants.MaxUint256], + ]) { + const encoded = await harness.encodeWithdrawClaimAckPayload( + bal, + ok, + amt + ); + const [gotBal, gotOk, gotAmt] = + await harness.decodeWithdrawClaimAckPayload(encoded); + expect(gotBal).to.equal(bal); + expect(gotOk).to.equal(ok); + expect(gotAmt).to.equal(amt); + } + }); + + it("encodeBalanceCheckRequestPayload round-trips", async () => { + const ts = 1718000000; + const encoded = await harness.encodeBalanceCheckRequestPayload(ts); + expect(await harness.decodeBalanceCheckRequestPayload(encoded)).to.equal( + ts + ); + }); + + it("encodeBalanceCheckResponsePayload round-trips", async () => { + const bal = ethers.utils.parseEther("42.42"); + const ts = 1718000001; + const encoded = await harness.encodeBalanceCheckResponsePayload(bal, ts); + const [gotBal, gotTs] = await harness.decodeBalanceCheckResponsePayload( + encoded + ); + expect(gotBal).to.equal(bal); + expect(gotTs).to.equal(ts); + }); + + it("encodeBridgeUserPayload preserves empty callData", async () => { + const bridgeId = ethers.utils.id("empty-call"); + const amount = ethers.utils.parseEther("1.5"); + const recipient = "0x000000000000000000000000000000000000abcd"; + const encoded = await harness.encodeBridgeUserPayload( + bridgeId, + amount, + recipient, + "0x", + 0 + ); + const [gotBridgeId, gotAmount, gotRecipient, gotCallData, gotGasLimit] = + await harness.decodeBridgeUserPayload(encoded); + expect(gotBridgeId).to.equal(bridgeId); + expect(gotAmount).to.equal(amount); + expect(gotRecipient).to.equal(ethers.utils.getAddress(recipient)); + expect(gotCallData).to.equal("0x"); + expect(gotGasLimit).to.equal(0); + }); + + it("encodeBridgeUserPayload preserves non-trivial callData", async () => { + const bridgeId = ethers.utils.id("with-call"); + const amount = ethers.utils.parseEther("7"); + const recipient = "0x000000000000000000000000000000000000f00d"; + const callData = "0x" + "ab".repeat(200); + const callGasLimit = 250000; + const encoded = await harness.encodeBridgeUserPayload( + bridgeId, + amount, + recipient, + callData, + callGasLimit + ); + const [gotBridgeId, gotAmount, gotRecipient, gotCallData, gotGasLimit] = + await harness.decodeBridgeUserPayload(encoded); + expect(gotBridgeId).to.equal(bridgeId); + expect(gotAmount).to.equal(amount); + expect(gotRecipient).to.equal(ethers.utils.getAddress(recipient)); + expect(gotCallData).to.equal(callData); + expect(gotGasLimit).to.equal(callGasLimit); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/decimal-identity.js b/contracts/test/strategies/crosschainV3/decimal-identity.js new file mode 100644 index 0000000000..490e7a2f9d --- /dev/null +++ b/contracts/test/strategies/crosschainV3/decimal-identity.js @@ -0,0 +1,172 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +/** + * 18/18 identity sanity check for the OETHb deployment config (bridgeAsset and OToken share + * 18 decimals, like WETH/OETH). With matched decimals every scaleBy in the strategy is the + * identity, so deposit / checkBalance / bridge magnitudes must show NO scale factor — i.e. + * the deployed OETHb behaviour is unchanged by the decimal-scaling work added for OUSD V3. + * + * Uses MockDAI (18dp) as the bridgeAsset. The 6/18 (USDC/OUSD) scaling is covered by the + * rest of the crosschainV3 suite, which now runs against the scaling MockEthOTokenVault. + */ +describe("Unit: V3 decimal identity (18/18, OETHb config)", function () { + let deployer, governor, alice; + let bridgeAsset, oTokenL2, mockL2Vault; + let oTokenEth, woTokenEth, ethVault; + let master, remote; + + const AMOUNT = ethers.utils.parseUnits("1000", 18); + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + // bridgeAsset is 18dp (DAI-like, standing in for WETH on the OETHb lane). + const DAIFactory = await ethers.getContractFactory("MockDAI"); + bridgeAsset = await DAIFactory.deploy(); + + const L2VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await L2VaultFactory.deploy(); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken L2", + "mOTL2", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const EthVaultFactory = await ethers.getContractFactory( + "MockEthOTokenVault" + ); + const ethNonce = await ethers.provider.getTransactionCount( + deployer.address + ); + const futureEthVault = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: ethNonce + 1, + }); + oTokenEth = await OTokenFactory.deploy( + "Mock OToken Eth", + "mOTEth", + futureEthVault + ); + ethVault = await EthVaultFactory.deploy( + bridgeAsset.address, + oTokenEth.address + ); + + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woTokenEth = await WoFactory.deploy(oTokenEth.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const masterImpl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + + const RemoteFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const remoteImpl = await RemoteFactory.connect(deployer).deploy( + { + platformAddress: woTokenEth.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oTokenEth.address, + woTokenEth.address, + ethVault.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const masterProxy = await ProxyFactory.connect(deployer).deploy(); + await masterProxy + .connect(deployer) + .initialize( + masterImpl.address, + governor.address, + masterImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxy.address + ); + + const remoteProxy = await ProxyFactory.connect(deployer).deploy(); + await remoteProxy + .connect(deployer) + .initialize( + remoteImpl.address, + governor.address, + remoteImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + remote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxy.address + ); + + await mockL2Vault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + const adapterME = await AdapterFactory.deploy(); + const adapterRM = await AdapterFactory.deploy(); + await adapterME.setSender(master.address); + await adapterME.setPeer(remote.address); + await adapterRM.setSender(remote.address); + await adapterRM.setPeer(master.address); + + await master.connect(governor).setOutboundAdapter(adapterME.address); + await master.connect(governor).setInboundAdapter(adapterRM.address); + await remote.connect(governor).setOutboundAdapter(adapterRM.address); + await remote.connect(governor).setInboundAdapter(adapterME.address); + await remote.connect(governor).safeApproveAllTokens(); + }); + + it("stores matched 18/18 decimals on both legs", async () => { + expect(await master.bridgeAssetDecimals()).to.equal(18); + expect(await master.oTokenDecimals()).to.equal(18); + expect(await remote.bridgeAssetDecimals()).to.equal(18); + expect(await remote.oTokenDecimals()).to.equal(18); + }); + + it("deposit round-trip has no scale factor (rsb / checkBalance / shares all == AMOUNT)", async () => { + await bridgeAsset.mintTo(master.address, AMOUNT); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, AMOUNT); + + // No 1e12 factor anywhere: every value equals AMOUNT. + expect(await master.remoteStrategyBalance()).to.equal(AMOUNT); + expect(await master.checkBalance(bridgeAsset.address)).to.equal(AMOUNT); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(AMOUNT); + expect(await woTokenEth.balanceOf(remote.address)).to.equal(AMOUNT); + expect(await master.availableBridgeLiquidity()).to.equal(AMOUNT); + }); + + it("bridge-in adds to checkBalance 1:1 at matched decimals", async () => { + const BRIDGE = ethers.utils.parseUnits("250", 18); + await bridgeAsset.mintTo(alice.address, BRIDGE); + await bridgeAsset.connect(alice).approve(ethVault.address, BRIDGE); + await ethVault.connect(alice).mint(BRIDGE); + await oTokenEth.connect(alice).approve(remote.address, BRIDGE); + await remote + .connect(alice) + .bridgeOTokenToPeer(BRIDGE, alice.address, "0x", 0); + + // bridgeAdjustment and the resulting checkBalance contribution are 1:1 (no scaling). + expect(await master.bridgeAdjustment()).to.equal(BRIDGE); + expect(await master.checkBalance(bridgeAsset.address)).to.equal(BRIDGE); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/failure-recovery.js b/contracts/test/strategies/crosschainV3/failure-recovery.js new file mode 100644 index 0000000000..871f1bf539 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/failure-recovery.js @@ -0,0 +1,290 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +// bridgeAsset (MockUSDC) is 6dp; oToken / wOToken are 18dp. SCALE is the 6→18 factor. +const SCALE = ethers.BigNumber.from(10).pow(12); +const usdc = (n) => ethers.utils.parseUnits(n, 6); +const oToken18 = (n) => ethers.utils.parseUnits(n, 18); + +/** + * Failure-recovery tests for the V3 Master+Remote pair (PR #2909 review): + * - Remote inbound yield handlers are revert-free: a failed mint/wrap (deposit) or + * unwrap/queue (withdraw-request) no longer bricks the serialized channel. + * - 291: Master's withdraw paths fold a negative `bridgeAdjustment` into the draw bound so a + * net BRIDGE_OUT can't make Master over-request shares Remote no longer holds. + * + * Same in-process loopback harness as `master-remote-pair.js`. + */ +describe("Unit: V3 failure recovery + drawable-balance gate", function () { + let deployer, governor, alice; + let bridgeAsset, oTokenL2, mockL2Vault; + let oTokenEth, woTokenEth, ethVault; + let master, remote; + let adapterME, adapterRM; + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const L2VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await L2VaultFactory.deploy(); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken L2", + "mOTL2", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const masterImpl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + + const EthVaultFactory = await ethers.getContractFactory( + "MockEthOTokenVault" + ); + const ethNonce = await ethers.provider.getTransactionCount( + deployer.address + ); + const futureEthVault = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: ethNonce + 1, + }); + oTokenEth = await OTokenFactory.deploy( + "Mock OToken Eth", + "mOTEth", + futureEthVault + ); + ethVault = await EthVaultFactory.deploy( + bridgeAsset.address, + oTokenEth.address + ); + + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woTokenEth = await WoFactory.deploy(oTokenEth.address); + + const RemoteFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const remoteImpl = await RemoteFactory.connect(deployer).deploy( + { + platformAddress: woTokenEth.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oTokenEth.address, + woTokenEth.address, + ethVault.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const masterProxy = await ProxyFactory.connect(deployer).deploy(); + const masterInitData = masterImpl.interface.encodeFunctionData( + "initialize", + [governor.address] + ); + await masterProxy + .connect(deployer) + .initialize(masterImpl.address, governor.address, masterInitData); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxy.address + ); + + const remoteProxy = await ProxyFactory.connect(deployer).deploy(); + const remoteInitData = remoteImpl.interface.encodeFunctionData( + "initialize", + [governor.address] + ); + await remoteProxy + .connect(deployer) + .initialize(remoteImpl.address, governor.address, remoteInitData); + remote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxy.address + ); + + await mockL2Vault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + adapterME = await AdapterFactory.deploy(); + adapterRM = await AdapterFactory.deploy(); + await adapterME.setSender(master.address); + await adapterME.setPeer(remote.address); + await adapterRM.setSender(remote.address); + await adapterRM.setPeer(master.address); + + await master.connect(governor).setOutboundAdapter(adapterME.address); + await master.connect(governor).setInboundAdapter(adapterRM.address); + await remote.connect(governor).setOutboundAdapter(adapterRM.address); + await remote.connect(governor).setInboundAdapter(adapterME.address); + await remote.connect(governor).safeApproveAllTokens(); + }); + + it("deposit mint failure is revert-free; value idle; retryDeposit recovers; channel lives", async () => { + const AMOUNT = usdc("1000"); + + // Remote's vault mint fails (e.g. paused vault). The deposit must NOT revert. + await ethVault.setRevertOnMint(true); + await bridgeAsset.mintTo(master.address, AMOUNT); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, AMOUNT); + + // Master accounting resolved via DEPOSIT_ACK; nonce advanced on both sides. + expect(await master.pendingDepositAmount()).to.equal(0); + expect(await master.isYieldOpInFlight()).to.equal(false); + expect(await master.lastYieldNonce()).to.equal(1); + expect(await remote.lastYieldNonce()).to.equal(1); + + // The bridgeAsset sits idle on Remote (mint failed) — still counted by the baseline, so + // Master's value is unchanged. No wOToken shares yet. + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(AMOUNT); + expect(await woTokenEth.balanceOf(remote.address)).to.equal(0); + expect(await master.remoteStrategyBalance()).to.equal(AMOUNT.mul(SCALE)); + expect(await master.checkBalance(bridgeAsset.address)).to.equal(AMOUNT); + + // Recover: re-enable mint and retry — idle value becomes productive wOToken. + await ethVault.setRevertOnMint(false); + await remote.connect(governor).retryDeposit(); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(0); + expect(await woTokenEth.balanceOf(remote.address)).to.equal( + AMOUNT.mul(SCALE) + ); + + // Channel is not bricked: a second deposit completes normally. + await bridgeAsset.mintTo(master.address, AMOUNT); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, AMOUNT); + expect(await master.lastYieldNonce()).to.equal(2); + expect(await master.pendingDepositAmount()).to.equal(0); + }); + + it("retryDeposit reverts when there is nothing idle to recover", async () => { + await expect(remote.connect(governor).retryDeposit()).to.be.revertedWith( + "Remote: nothing to retry" + ); + }); + + it("withdraw-request queue failure: success=false, idle oToken recoverable, channel lives", async () => { + const SEED = usdc("1000"); + const WITHDRAW = usdc("400"); + const SEED18 = SEED.mul(SCALE); + const WITHDRAW18 = WITHDRAW.mul(SCALE); + + // Seed Remote shares with a successful deposit. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + expect(await woTokenEth.balanceOf(remote.address)).to.equal(SEED18); + + // The queue fails AFTER a successful unwrap. The request handler must not revert. + await ethVault.setRevertOnRequestWithdrawal(true); + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + // success=false: nothing queued, Master cleared its pending withdrawal, channel free. + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await master.isYieldOpInFlight()).to.equal(false); + + // Non-atomic: the unwrapped OToken is left idle (shares dropped, idle oToken up). Value is + // preserved — the idle oToken is counted, so Master's balance is unchanged. + expect(await woTokenEth.balanceOf(remote.address)).to.equal( + SEED18.sub(WITHDRAW18) + ); + expect(await oTokenEth.balanceOf(remote.address)).to.equal(WITHDRAW18); + expect(await master.checkBalance(bridgeAsset.address)).to.equal(SEED); + + // Recover the idle oToken via retryDeposit (re-wrap to wOToken). + await remote.connect(governor).retryDeposit(); + expect(await oTokenEth.balanceOf(remote.address)).to.equal(0); + expect(await woTokenEth.balanceOf(remote.address)).to.equal(SEED18); + + // Channel lives: re-enable and a fresh withdraw request succeeds. + await ethVault.setRevertOnRequestWithdrawal(false); + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + expect(await master.pendingWithdrawalAmount()).to.equal(WITHDRAW); + expect(await remote.outstandingRequestId()).to.not.equal(0); + }); + + it("balance check does not freeze when bridge rounding drives B-A a hair negative (clamp to 0)", async () => { + const BIN = oToken18("100"); // 18dp bridge-in amount + const BIN_USDC = usdc("100"); // USDC alice spends to acquire the OToken + + // alice acquires OToken on the Eth side and BRIDGE_INs it: Remote wraps it, so + // bridgeAdjustment (A) = wOToken value (B) = BIN, i.e. the yield baseline B - A = 0. + await bridgeAsset.mintTo(alice.address, BIN_USDC); + await bridgeAsset.connect(alice).approve(ethVault.address, BIN_USDC); + await ethVault.connect(alice).mint(BIN_USDC); + await oTokenEth.connect(alice).approve(remote.address, BIN); + await remote.connect(alice).bridgeOTokenToPeer(BIN, alice.address, "0x", 0); + expect(await master.bridgeAdjustment()).to.equal(BIN); + + // The wOToken 4626 rounding (here a 1-wei loss) tips B below A → B - A = -1. + await woTokenEth.simulateLoss(1); + + // _yieldOnlyBaseline (via the balance-check round-trip) must NOT revert — it clamps to 0 + // instead of freezing the serialized yield channel on dust. + await expect(master.connect(governor).requestBalanceCheck()).to.not.be + .reverted; + expect(await master.remoteStrategyBalance()).to.equal(0); + + // Channel is alive: a fresh deposit still completes (its ack also routes through + // _yieldOnlyBaseline, which would have frozen pre-fix). + const DEP = usdc("500"); + await bridgeAsset.mintTo(master.address, DEP); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, DEP); + expect(await master.pendingDepositAmount()).to.equal(0); + }); + + it("291: withdrawAll is bounded by drawable balance after a net BRIDGE_OUT", async () => { + const SEED = usdc("1000"); + const BRIDGE_OUT = oToken18("300"); + + // 1. Deposit → rsb = 1000 (18dp), Remote holds 1000 shares, bridgeAdjustment = 0. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + expect(await master.remoteStrategyBalance()).to.equal(SEED.mul(SCALE)); + + // 2. Give alice L2 OToken via a (simulated) real deposit — NOT a bridge-in — then BRIDGE_OUT. + // This drives master.bridgeAdjustment negative and drops Remote's shares to 700, while + // Master's rsb stays a stale 1000 until the next balance check. + await mockL2Vault.mintOTokenTo(alice.address, BRIDGE_OUT); + await oTokenL2.connect(alice).approve(master.address, BRIDGE_OUT); + await master + .connect(alice) + .bridgeOTokenToPeer(BRIDGE_OUT, alice.address, "0x", 0); + + expect(await master.bridgeAdjustment()).to.equal(BRIDGE_OUT.mul(-1)); + expect(await woTokenEth.balanceOf(remote.address)).to.equal( + SEED.mul(SCALE).sub(BRIDGE_OUT) + ); + + // 3. withdrawAll must request only the drawable 700 (rsb + min(adj,0)), not the stale 1000. + // Pre-291 it requested 1000, which Remote couldn't unwrap. Now it requests 700 and Remote + // queues it successfully (success=true → pendingWithdrawalAmount stays set). + await master.connect(governor).withdrawAll(); + expect(await master.pendingWithdrawalAmount()).to.equal(usdc("700")); + expect(await remote.outstandingRequestId()).to.not.equal(0); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/fee-path.js b/contracts/test/strategies/crosschainV3/fee-path.js new file mode 100644 index 0000000000..b87f6fb55b --- /dev/null +++ b/contracts/test/strategies/crosschainV3/fee-path.js @@ -0,0 +1,87 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +/** + * Adapter fee-path coverage for the new uniform model: `msg.value` covers the bridge fee + * and any excess is refunded back to the caller. There is no pre-funded native pool — + * operators fund their own yield-channel msg.value, users fund their own user-initiated + * msg.value. + */ +describe("Unit: CCIPAdapter fee path", function () { + let governor, sender; + let adapter, router; + const CCIP_DEST = ethers.BigNumber.from("5009297550715157269"); + const GAS_LIMIT = 200000; + + beforeEach(async () => { + [governor, sender] = await ethers.getSigners(); + + const RouterFactory = await ethers.getContractFactory("MockCCIPRouter"); + router = await RouterFactory.connect(governor).deploy(); + + const AdapterFactory = await ethers.getContractFactory("CCIPAdapter"); + adapter = await AdapterFactory.connect(governor).deploy(router.address); + + // Authorise sender with the lane config. + await adapter.connect(governor).authorise(sender.address, { + paused: false, + chainSelector: CCIP_DEST, + destGasLimit: GAS_LIMIT, + }); + }); + + it("msg.value exactly covers fee — no refund needed", async () => { + const fee = ethers.utils.parseEther("0.03"); + await router.setFee(fee); + + await expect(adapter.connect(sender).sendMessage("0xabcd", { value: fee })) + .to.not.be.reverted; + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + }); + + it("msg.value above fee retains the excess on the adapter (no refund)", async () => { + // Locked design: no refunds. Overpayment stays on the adapter for governor sweep + // via `transferToken(address(0), amount)`. Rationale: refunds add code surface; the + // caller can quote exact fee via `quoteFee` to avoid donations. + const fee = ethers.utils.parseEther("0.03"); + const overpay = ethers.utils.parseEther("0.1"); + await router.setFee(fee); + + const tx = await adapter + .connect(sender) + .sendMessage("0xabcd", { value: overpay }); + await tx.wait(); + + // Adapter balance increased by the FULL overpay (not just fee — the router consumed + // `fee`, the rest stayed put). + expect(await ethers.provider.getBalance(adapter.address)).to.equal( + overpay.sub(fee) + ); + expect(await router.sentMessagesLength()).to.equal(1); + }); + + it("reverts when msg.value < fee", async () => { + const fee = ethers.utils.parseEther("0.05"); + await router.setFee(fee); + + await expect( + adapter.connect(sender).sendMessage("0xabcd", { value: fee.sub(1) }) + ).to.be.revertedWith("Adapter: insufficient fee"); + }); + + it("reverts when called by a non-authorised sender", async () => { + const [, , stranger] = await ethers.getSigners(); + await expect( + adapter.connect(stranger).sendMessage("0xabcd", { value: 1 }) + ).to.be.revertedWith("Adapter: not authorised"); + }); + + it("respects per-lane pause", async () => { + const fee = ethers.utils.parseEther("0.01"); + await router.setFee(fee); + await adapter.connect(governor).pauseLane(sender.address); + await expect( + adapter.connect(sender).sendMessage("0xabcd", { value: fee }) + ).to.be.revertedWith("Adapter: lane paused"); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/master-remote-pair.js b/contracts/test/strategies/crosschainV3/master-remote-pair.js new file mode 100644 index 0000000000..a8205ed54a --- /dev/null +++ b/contracts/test/strategies/crosschainV3/master-remote-pair.js @@ -0,0 +1,288 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +// bridgeAsset (MockUSDC) is 6dp; oToken / wOToken are 18dp. The strategy keeps the OToken +// domain (remoteStrategyBalance, wOToken shares, bridgeAdjustment) in 18dp and scales to +// bridgeAsset units only at the vault edge (checkBalance). SCALE is the 6→18 factor. +const SCALE = ethers.BigNumber.from(10).pow(12); + +/** + * Paired Master+Remote loopback integration test. + * + * Two MockBridgeAdapters wire the strategies in-process: + * - adapterME ("Master → Remote") : sender = master, peer = remote + * - adapterRM ("Remote → Master") : sender = remote, peer = master + * + * remote.outboundAdapter = adapterRM ; remote.inboundAdapter = adapterME + * master.outboundAdapter = adapterME ; master.inboundAdapter = adapterRM + * + * That way, when Master sends, adapterME forwards to Remote, and Remote's onlyInboundAdapter + * gate accepts the call. When Remote replies, adapterRM forwards to Master, and Master's gate + * accepts. + */ + +describe("Unit: V3 Master+Remote loopback", function () { + let deployer, governor, alice; + let bridgeAsset, oTokenL2, mockL2Vault; + let oTokenEth, woTokenEth, ethVault; + let master, remote; + let adapterME, adapterRM; + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + // --- bridgeAsset (shared, both sides) --- + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + // --- L2 side: Master + L2 vault + L2 OToken --- + const L2VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await L2VaultFactory.deploy(); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken L2", + "mOTL2", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const masterImpl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + + // --- Ethereum side: Remote + ETH vault + ETH OToken + wOToken --- + const EthVaultFactory = await ethers.getContractFactory( + "MockEthOTokenVault" + ); + const ethNonce = await ethers.provider.getTransactionCount( + deployer.address + ); + const futureEthVault = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: ethNonce + 1, + }); + oTokenEth = await OTokenFactory.deploy( + "Mock OToken Eth", + "mOTEth", + futureEthVault + ); + ethVault = await EthVaultFactory.deploy( + bridgeAsset.address, + oTokenEth.address + ); + + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woTokenEth = await WoFactory.deploy(oTokenEth.address); + + const RemoteFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const remoteImpl = await RemoteFactory.connect(deployer).deploy( + { + platformAddress: woTokenEth.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oTokenEth.address, + woTokenEth.address, + ethVault.address + ); + + // --- Proxies --- + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const masterProxy = await ProxyFactory.connect(deployer).deploy(); + const masterInitData = masterImpl.interface.encodeFunctionData( + "initialize", + [governor.address] + ); + await masterProxy + .connect(deployer) + .initialize(masterImpl.address, governor.address, masterInitData); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxy.address + ); + + const remoteProxy = await ProxyFactory.connect(deployer).deploy(); + const remoteInitData = remoteImpl.interface.encodeFunctionData( + "initialize", + [governor.address] + ); + await remoteProxy + .connect(deployer) + .initialize(remoteImpl.address, governor.address, remoteInitData); + remote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxy.address + ); + + await mockL2Vault.whitelistStrategy(master.address); + + // --- Adapters wiring --- + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + adapterME = await AdapterFactory.deploy(); + adapterRM = await AdapterFactory.deploy(); + + await adapterME.setSender(master.address); + await adapterME.setPeer(remote.address); + await adapterRM.setSender(remote.address); + await adapterRM.setPeer(master.address); + + await master.connect(governor).setOutboundAdapter(adapterME.address); + await master.connect(governor).setInboundAdapter(adapterRM.address); + await remote.connect(governor).setOutboundAdapter(adapterRM.address); + await remote.connect(governor).setInboundAdapter(adapterME.address); + await remote.connect(governor).safeApproveAllTokens(); + }); + + it("deposit flows Master → Remote and the ack updates Master in one round-trip", async () => { + const AMOUNT = ethers.utils.parseUnits("1000", 6); + + // Vault funds Master with bridgeAsset and calls deposit. + await bridgeAsset.mintTo(master.address, AMOUNT); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, AMOUNT); + + // After the deposit: + // - Master's tokens flowed: master → adapterME → remote + // - Remote minted OToken via ethVault, wrapped to wOToken + // - Remote sent DEPOSIT_ACK back via adapterRM + // - adapterRM called master.receiveMessage with the ack + // - Master cleared pendingDepositAmount and set remoteStrategyBalance = newBalance + + expect(await master.pendingDepositAmount()).to.equal(0); + // remoteStrategyBalance is the OToken-denominated (18dp) yield baseline. + expect(await master.remoteStrategyBalance()).to.equal(AMOUNT.mul(SCALE)); + expect(await master.isYieldOpInFlight()).to.equal(false); + + // checkBalance is bridgeAsset-denominated (6dp) — scaled back down at the vault edge. + expect(await master.checkBalance(bridgeAsset.address)).to.equal(AMOUNT); + + // Remote holds the wOToken shares for the minted OToken (18dp, 1:1 with OToken here). + expect(await woTokenEth.balanceOf(remote.address)).to.equal( + AMOUNT.mul(SCALE) + ); + + // Nonces synced on both sides. + expect(await master.lastYieldNonce()).to.equal(1); + expect(await remote.lastYieldNonce()).to.equal(1); + expect(await master.nonceProcessed(1)).to.equal(true); + expect(await remote.nonceProcessed(1)).to.equal(true); + }); + + it("Remote-initiated BRIDGE_IN mints OToken on Master to the configured recipient", async () => { + const AMOUNT = ethers.utils.parseUnits("250", 6); + + // Alice on Ethereum buys OToken first via the eth vault and approves Remote. + await bridgeAsset.mintTo(alice.address, AMOUNT); + await bridgeAsset.connect(alice).approve(ethVault.address, AMOUNT); + await ethVault.connect(alice).mint(AMOUNT); + await oTokenEth.connect(alice).approve(remote.address, AMOUNT); + + // Alice bridges from Ethereum to L2 with a custom recipient (governor). + await remote + .connect(alice) + .bridgeOTokenToPeer(AMOUNT, governor.address, "0x", 0); + + // Master should have minted AMOUNT of OTokenL2 to governor. + expect(await oTokenL2.balanceOf(governor.address)).to.equal(AMOUNT); + + // Both sides recorded the bridge adjustment. + expect(await remote.bridgeAdjustment()).to.equal(AMOUNT); + expect(await master.bridgeAdjustment()).to.equal(AMOUNT); + }); + + it("Master-initiated BRIDGE_OUT releases OToken on Remote to the configured recipient", async () => { + const SEED = ethers.utils.parseUnits("1000", 6); + const AMOUNT = ethers.utils.parseUnits("200", 6); + + // Seed Remote with shares via a deposit round-trip. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + + // Give Alice OTokenL2 (via a synthetic BRIDGE_IN initiated by some other user + // would normally do it; here we just fund her directly through the same path). + await bridgeAsset.mintTo(deployer.address, AMOUNT); + await bridgeAsset.approve(ethVault.address, AMOUNT); + await ethVault.mint(AMOUNT); + await oTokenEth.approve(remote.address, AMOUNT); + await remote.bridgeOTokenToPeer(AMOUNT, alice.address, "0x", 0); + expect(await oTokenL2.balanceOf(alice.address)).to.equal(AMOUNT); + + // Now Alice bridges those L2 OTokens back to Ethereum to a chosen recipient. + await oTokenL2.connect(alice).approve(master.address, AMOUNT); + await master + .connect(alice) + .bridgeOTokenToPeer(AMOUNT, governor.address, "0x", 0); + + // The Ethereum side delivered AMOUNT OTokenEth to governor. + expect(await oTokenEth.balanceOf(governor.address)).to.equal(AMOUNT); + + // Net bridge adjustment is zero on each side (one bridge-in then one bridge-out). + expect(await master.bridgeAdjustment()).to.equal(0); + expect(await remote.bridgeAdjustment()).to.equal(0); + }); + + it("yield ack reports the yield-only baseline — no double-count with bridge activity (P0)", async () => { + // Deposits are bridgeAsset (USDC 6dp); the bridge channel moves OToken (18dp). The + // bridged amount is 200 OToken (= 200 USDC of value); BRIDGE_IN_USDC is what Alice + // spends to obtain it. + const DEPOSIT1 = ethers.utils.parseUnits("1000", 6); + const DEPOSIT2 = ethers.utils.parseUnits("500", 6); + const BRIDGE_IN_USDC = ethers.utils.parseUnits("200", 6); + const BRIDGE_IN = ethers.utils.parseUnits("200", 18); + + // 1. Deposit 1000 USDC → rsb = 1000 (18dp), bridgeAdjustment = 0. + await bridgeAsset.mintTo(master.address, DEPOSIT1); + await mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + DEPOSIT1 + ); + expect(await master.remoteStrategyBalance()).to.equal(DEPOSIT1.mul(SCALE)); + expect(await master.bridgeAdjustment()).to.equal(0); + + // 2. A user BRIDGE_INs 200 OToken from Remote → Master. bridgeAdjustment = 200 OToken + // (18dp) on both sides; Remote wraps the user's OToken on top of its yield shares. + await bridgeAsset.mintTo(alice.address, BRIDGE_IN_USDC); + await bridgeAsset.connect(alice).approve(ethVault.address, BRIDGE_IN_USDC); + await ethVault.connect(alice).mint(BRIDGE_IN_USDC); + await oTokenEth.connect(alice).approve(remote.address, BRIDGE_IN); + await remote + .connect(alice) + .bridgeOTokenToPeer(BRIDGE_IN, alice.address, "0x", 0); + expect(await master.bridgeAdjustment()).to.equal(BRIDGE_IN); + + // 3. Second deposit of 500 USDC. Its DEPOSIT_ACK must report the YIELD-ONLY baseline + // (_viewCheckBalance - bridgeAdjustment), NOT the full balance — Master re-adds its + // own bridgeAdjustment in checkBalance, so a full-balance ack would double-count the + // 200 OToken bridge. + await bridgeAsset.mintTo(master.address, DEPOSIT2); + await mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + DEPOSIT2 + ); + + // rsb = yield-only baseline = just the two deposits (18dp), bridge excluded. + expect(await master.remoteStrategyBalance()).to.equal( + DEPOSIT1.add(DEPOSIT2).mul(SCALE) + ); + // checkBalance (6dp) = deposits(1500) + bridge(200) = 1700 — the bridge counted ONCE. + expect(await master.checkBalance(bridgeAsset.address)).to.equal( + DEPOSIT1.add(DEPOSIT2).add(BRIDGE_IN_USDC) + ); + expect(await master.pendingDepositAmount()).to.equal(0); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/master-v3.base.fork-test.js b/contracts/test/strategies/crosschainV3/master-v3.base.fork-test.js new file mode 100644 index 0000000000..02d085fb8a --- /dev/null +++ b/contracts/test/strategies/crosschainV3/master-v3.base.fork-test.js @@ -0,0 +1,177 @@ +const { createFixtureLoader } = require("../../_fixture"); +const { defaultBaseFixture } = require("../../_fixture-base"); +const { expect } = require("chai"); +const { isCI } = require("../../helpers"); +const { impersonateAndFund } = require("../../../utils/signers"); +const addresses = require("../../../utils/addresses"); + +const { + MSG, + encodeBridgeUserPayload, + encodePackedEnvelope, +} = require("./_helpers"); + +const baseFixture = createFixtureLoader(defaultBaseFixture); + +/** + * Master fork test: drives MasterWOTokenStrategy against the real Base OETHb vault. + * + * Master is already deployed and wired by deploy/base/101 (master proxy + adapters). + * We impersonate the configured receiver adapter to push synthetic BRIDGE_IN messages + * into Master, exercising the real `mintForStrategy` / `burnForStrategy` plumbing on + * the OETHb vault. + */ +describe("ForkTest: MasterWOTokenStrategy on Base (real OETHb vault wiring)", function () { + this.timeout(0); + this.retries(isCI ? 3 : 0); + + let fixture; + let master; + let oethb; + let inboundAdapter; + let woethMigration; + + beforeEach(async () => { + fixture = await baseFixture(); + + woethMigration = await ethers.getContractAt( + "BridgedWOETHMigrationStrategy", + fixture.woethStrategy.address + ); + const masterAddr = await woethMigration.master(); + master = await ethers.getContractAt("MasterWOTokenStrategy", masterAddr); + oethb = fixture.oethb; + + inboundAdapter = await ethers.getContractAt( + "SuperbridgeAdapter", + await master.inboundAdapter() + ); + }); + + it("is wired to the deployed adapters and vault", async () => { + expect(await master.vaultAddress()).to.equal(fixture.oethbVault.address); + expect(await master.bridgeAsset()).to.equal(addresses.base.WETH); + expect(await master.oToken()).to.equal(oethb.address); + expect(await master.operator()).to.equal(addresses.talosRelayer); + expect((await master.outboundAdapter()).toLowerCase()).to.match( + /^0x[0-9a-f]+$/ + ); + expect((await master.inboundAdapter()).toLowerCase()).to.equal( + inboundAdapter.address.toLowerCase() + ); + }); + + it("receiving BRIDGE_IN mints OETHb via the real vault and credits the recipient", async () => { + const recipient = fixture.governor.address; + const amount = ethers.utils.parseEther("100"); + + const balanceBefore = await oethb.balanceOf(recipient); + const totalSupplyBefore = await oethb.totalSupply(); + + // Impersonate the receiver adapter (only address allowed to call receiveMessage). + const sAdapter = await impersonateAndFund(inboundAdapter.address); + + const bridgeId = ethers.utils.id("master-fork-1"); + const body = encodeBridgeUserPayload({ + bridgeId, + amount, + recipient, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, body); + + await master + .connect(sAdapter) + .receiveMessage( + master.address, + ethers.constants.AddressZero, + 0, + envelope + ); + + expect(await oethb.balanceOf(recipient)).to.equal( + balanceBefore.add(amount) + ); + expect(await oethb.totalSupply()).to.equal(totalSupplyBefore.add(amount)); + expect(await master.consumedBridgeIds(bridgeId)).to.equal(true); + expect(await master.bridgeAdjustment()).to.equal(amount); + }); + + it("user bridgeOTokenToPeer burns OETHb via the real vault and emits BridgeRequested", async () => { + // Swap the production CCIP outbound for a mock so the test doesn't hit the real CCIP router + // (the peer adapter on Ethereum hasn't been wired in this single-chain fork). + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(master.address); + + const sTimelock = await impersonateAndFund(addresses.base.timelock); + await master.connect(sTimelock).setOutboundAdapter(mockOut.address); + + // First seed Master's remoteStrategyBalance + alice's OETHb via a BRIDGE_IN. + const sAdapter = await impersonateAndFund(inboundAdapter.address); + const seedAmount = ethers.utils.parseEther("500"); + const aliceAddr = fixture.governor.address; + + const seedBody = encodeBridgeUserPayload({ + bridgeId: ethers.utils.id("master-fork-seed"), + amount: seedAmount, + recipient: aliceAddr, + }); + const seedEnvelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, seedBody); + await master + .connect(sAdapter) + .receiveMessage( + master.address, + ethers.constants.AddressZero, + 0, + seedEnvelope + ); + + // Now alice bridges 100 back to Ethereum. Liquidity check: bridgeAdjustment alone covers it. + const bridgeAmount = ethers.utils.parseEther("100"); + await oethb.connect(fixture.governor).approve(master.address, bridgeAmount); + + const supplyBefore = await oethb.totalSupply(); + const adjBefore = await master.bridgeAdjustment(); + + await expect( + master + .connect(fixture.governor) + .bridgeOTokenToPeer(bridgeAmount, aliceAddr, "0x", 0) + ).to.emit(master, "BridgeRequested"); + + expect(await oethb.totalSupply()).to.equal(supplyBefore.sub(bridgeAmount)); + expect(await master.bridgeAdjustment()).to.equal( + adjBefore.sub(bridgeAmount) + ); + }); + + it("rejects BRIDGE_IN replay using the same bridgeId", async () => { + const sAdapter = await impersonateAndFund(inboundAdapter.address); + const bridgeId = ethers.utils.id("master-fork-replay"); + const body = encodeBridgeUserPayload({ + bridgeId, + amount: ethers.utils.parseEther("1"), + recipient: fixture.governor.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, body); + await master + .connect(sAdapter) + .receiveMessage( + master.address, + ethers.constants.AddressZero, + 0, + envelope + ); + await expect( + master + .connect(sAdapter) + .receiveMessage( + master.address, + ethers.constants.AddressZero, + 0, + envelope + ) + ).to.be.revertedWith("WOT: bridgeId replayed"); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/master-v3.js b/contracts/test/strategies/crosschainV3/master-v3.js new file mode 100644 index 0000000000..09d3fe8b35 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/master-v3.js @@ -0,0 +1,513 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { impersonateAndFund } = require("../../../utils/signers"); + +const { + MSG, + encodePackedEnvelope, + encodeBridgeUserPayload, + encodeNewBalancePayload, +} = require("./_helpers"); + +// bridgeAsset (MockUSDC) is 6dp; remoteStrategyBalance is OToken-denominated (18dp). SCALE +// is the 6→18 factor used where a balance reported from Remote (18dp) meets a bridgeAsset +// (6dp) amount. +const SCALE = ethers.BigNumber.from(10).pow(12); + +describe("Unit: MasterWOTokenStrategy", function () { + let deployer, governor, alice, bob; + let bridgeAsset, oToken, mockVault, master; + let outboundAdapter, inboundAdapter; + + beforeEach(async () => { + [deployer, governor, , alice, bob] = await ethers.getSigners(); + + // --- Tokens & mock vault --- + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockVault = await VaultFactory.deploy(); + + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oToken = await OTokenFactory.deploy( + "Mock OToken", + "mOT", + mockVault.address + ); + + await mockVault.setOToken(oToken.address); + + // --- Master strategy: deploy impl behind the standard proxy --- + const ImplFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const impl = await ImplFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockVault.address, + }, + bridgeAsset.address, + oToken.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const proxy = await ProxyFactory.connect(deployer).deploy(); + + const initData = impl.interface.encodeFunctionData("initialize", [ + governor.address, + ]); + await proxy + .connect(deployer) + .initialize(impl.address, governor.address, initData); + + master = await ethers.getContractAt("MasterWOTokenStrategy", proxy.address); + + await mockVault.whitelistStrategy(master.address); + + // --- Adapters --- + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + outboundAdapter = await AdapterFactory.deploy(); + inboundAdapter = await AdapterFactory.deploy(); + + // Master is the sole authorised sender on its outbound adapter. + await outboundAdapter.setSender(master.address); + // Outbound has no peer in PR 2 tests — Master sends, we inspect lastMessageSent. + + // Receiver adapter forwards inbound messages to Master. + await inboundAdapter.setPeer(master.address); + // sender == 0 means anyone can drive the receiver in tests. + + await master.connect(governor).setOutboundAdapter(outboundAdapter.address); + await master.connect(governor).setInboundAdapter(inboundAdapter.address); + }); + + describe("initialisation & roles", () => { + it("stores constructor immutables", async () => { + expect(await master.bridgeAsset()).to.equal(bridgeAsset.address); + expect(await master.oToken()).to.equal(oToken.address); + expect(await master.vaultAddress()).to.equal(mockVault.address); + }); + + it("supportsAsset returns true only for bridgeAsset", async () => { + expect(await master.supportsAsset(bridgeAsset.address)).to.equal(true); + expect(await master.supportsAsset(oToken.address)).to.equal(false); + }); + + it("only governor can set adapters / operator", async () => { + await expect( + master.connect(alice).setOutboundAdapter(alice.address) + ).to.be.revertedWith("Caller is not the Governor"); + await expect( + master.connect(alice).setInboundAdapter(alice.address) + ).to.be.revertedWith("Caller is not the Governor"); + await expect( + master.connect(alice).setOperator(alice.address) + ).to.be.revertedWith("Caller is not the Governor"); + }); + + it("only inboundAdapter can call receiveMessage", async () => { + await expect( + master + .connect(alice) + .receiveMessage(master.address, ethers.constants.AddressZero, 0, "0x") + ).to.be.revertedWith("V3: only inbound adapter"); + }); + }); + + describe("deposit flow (DEPOSIT)", () => { + const ONE_K = ethers.utils.parseUnits("1000", 6); + + it("vault.deposit assigns a yield nonce, sets pendingDepositAmount, sends DEPOSIT", async () => { + await bridgeAsset.mintTo(master.address, ONE_K); + + await mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K); + + expect(await master.pendingDepositAmount()).to.equal(ONE_K); + expect(await master.lastYieldNonce()).to.equal(1); + expect(await master.isYieldOpInFlight()).to.equal(true); + + // Adapter received the tokens. + expect(await bridgeAsset.balanceOf(outboundAdapter.address)).to.equal( + ONE_K + ); + expect(await outboundAdapter.lastAmountSent()).to.equal(ONE_K); + expect(await outboundAdapter.lastTokenSent()).to.equal( + bridgeAsset.address + ); + + // Stored message decodes as DEPOSIT with nonce 1 and empty payload. + // Master tags the envelope with its own address as the source strategy. + const stored = await outboundAdapter.lastMessageSent(); + const expected = encodePackedEnvelope( + MSG.DEPOSIT, + 1, + "0x", + master.address + ); + expect(stored.toLowerCase()).to.equal(expected.toLowerCase()); + + // checkBalance counts the in-flight amount. + expect(await master.checkBalance(bridgeAsset.address)).to.equal(ONE_K); + }); + + it("rejects a second deposit while a yield op is in flight", async () => { + await bridgeAsset.mintTo(master.address, ONE_K.mul(2)); + await mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K); + + await expect( + mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K) + ).to.be.revertedWith("Master: deposit or withdrawal pending"); + }); + + it("non-vault callers cannot deposit", async () => { + await bridgeAsset.mintTo(master.address, ONE_K); + await expect( + master.connect(alice).deposit(bridgeAsset.address, ONE_K) + ).to.be.revertedWith("Caller is not the Vault"); + }); + + it("DEPOSIT_ACK clears pendingDepositAmount and updates remoteStrategyBalance", async () => { + await bridgeAsset.mintTo(master.address, ONE_K); + await mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K); + + // Simulate the ack arriving from Remote: encode envelope and have the receiver + // adapter forward it to Master. + const newBalance = ONE_K.mul(1).add(ethers.BigNumber.from("12345")); // arbitrary + const ackEnvelope = encodePackedEnvelope( + MSG.DEPOSIT_ACK, + 1, + encodeNewBalancePayload(newBalance) + ); + await inboundAdapter.sendMessage(ackEnvelope); + + expect(await master.pendingDepositAmount()).to.equal(0); + expect(await master.remoteStrategyBalance()).to.equal(newBalance); + expect(await master.isYieldOpInFlight()).to.equal(false); + + // Replaying the same ack must fail (nonce already processed). + await expect(inboundAdapter.sendMessage(ackEnvelope)).to.be.revertedWith( + "V3: nonce already processed" + ); + }); + + it("rejects a DEPOSIT_ACK with a stale nonce", async () => { + await bridgeAsset.mintTo(master.address, ONE_K); + await mockVault.callDeposit(master.address, bridgeAsset.address, ONE_K); + + const bogus = encodePackedEnvelope( + MSG.DEPOSIT_ACK, + 99, + encodeNewBalancePayload(0) + ); + await expect(inboundAdapter.sendMessage(bogus)).to.be.revertedWith( + "V3: stale or unknown nonce" + ); + }); + }); + + describe("bridge-out (user-facing)", () => { + beforeEach(async () => { + // Seed Remote balance so the liquidity check passes via a synthetic deposit round-trip. + const seed = ethers.utils.parseUnits("10000", 6); + await bridgeAsset.mintTo(master.address, seed); + await mockVault.callDeposit(master.address, bridgeAsset.address, seed); + + // Remote reports its yield baseline in OToken (18dp), so seed the ack in 18dp. + const ack = encodePackedEnvelope( + MSG.DEPOSIT_ACK, + 1, + encodeNewBalancePayload(seed.mul(SCALE)) + ); + await inboundAdapter.sendMessage(ack); + }); + + const mintAndApprove = async (signer, amount) => { + // Mint OToken to the user by simulating a BRIDGE_IN delivery first. + const bridgeId = ethers.utils.id("seed-" + Math.random()); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount, + recipient: signer.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + await inboundAdapter.sendMessage(envelope); + await oToken.connect(signer).approve(master.address, amount); + }; + + it("burns OToken, decreases bridgeAdjustment, emits BridgeRequested", async () => { + const amount = ethers.utils.parseUnits("100", 6); + await mintAndApprove(alice, amount); + + const totalSupplyBefore = await oToken.totalSupply(); + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(amount, ethers.constants.AddressZero, "0x", 0) + ).to.emit(master, "BridgeRequested"); + + // OToken was burned. + expect(await oToken.totalSupply()).to.equal( + totalSupplyBefore.sub(amount) + ); + + // bridgeAdjustment net zero: +amount from BRIDGE_IN, -amount from BRIDGE_OUT. + expect(await master.bridgeAdjustment()).to.equal(0); + + // Outbound adapter captured a BRIDGE_OUT message — decode the strategy envelope + // (msgType, nonce, body) which the strategy packed via CrossChainV3Helper.packPayload. + const stored = await outboundAdapter.lastMessageSent(); + const [msgType, nonce] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + stored + ); + expect(msgType).to.equal(MSG.BRIDGE_OUT); + expect(nonce).to.equal(0); + }); + + it("reverts when bridge-out exceeds available liquidity", async () => { + // Mint Alice OToken DIRECTLY via the mock vault (skipping BRIDGE_IN, which would + // inflate bridgeAdjustment by the minted amount and defeat the test). The mock + // vault is permissionless on `oToken.mint(addr, amount)` once it's set as the + // OToken's `vaultAddress`. + // tooBig is an OToken (18dp) amount that exceeds the seeded liquidity (10000 OToken). + const tooBig = ethers.utils.parseUnits("999999999", 18); + const sVault = await impersonateAndFund(mockVault.address); + await oToken.connect(sVault).mint(alice.address, tooBig); + await oToken.connect(alice).approve(master.address, tooBig); + + // Available = remoteStrategyBalance + bridgeAdjustment (OToken, 18dp). The seed flow + // above left remoteStrategyBalance = 10000 OToken and bridgeAdjustment = 0. + // Bridging `tooBig` exceeds available → preflight reverts. + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(tooBig, ethers.constants.AddressZero, "0x", 0) + ).to.be.revertedWith("WOT: insufficient bridge liquidity"); + }); + + it("availableBridgeLiquidity subtracts an in-flight withdrawal (P1-b)", async () => { + const seed = ethers.utils.parseUnits("10000", 6); + // availableBridgeLiquidity is OToken-denominated (18dp) — it gates an OToken bridge. + expect(await master.availableBridgeLiquidity()).to.equal(seed.mul(SCALE)); + + // Initiate a withdrawal → pendingWithdrawalAmount = W (bridgeAsset, 6dp). Those shares + // are committed to the queue on Remote and are NOT deliverable for a bridge-out, so the + // preflight must exclude them (scaled up to OToken units) — otherwise a bridge could + // burn locally what Remote can't deliver. + const W = ethers.utils.parseUnits("3000", 6); + await mockVault.callWithdraw( + master.address, + mockVault.address, + bridgeAsset.address, + W + ); + expect(await master.pendingWithdrawalAmount()).to.equal(W); + expect(await master.availableBridgeLiquidity()).to.equal( + seed.sub(W).mul(SCALE) + ); + }); + + it("rejects bridge-out when caller has no OToken", async () => { + // bob never received any OToken, so `bridgeOTokenToPeer` reverts on the + // burn (transferFrom-style) regardless of liquidity. Separate concern from + // the liquidity check above. + const amount = ethers.utils.parseUnits("100", 6); + await expect( + master + .connect(bob) + .bridgeOTokenToPeer(amount, ethers.constants.AddressZero, "0x", 0) + ).to.be.reverted; + }); + + it("rejects callGasLimit above MAX_BRIDGE_CALL_GAS", async () => { + const amount = ethers.utils.parseUnits("1", 6); + await mintAndApprove(alice, amount); + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(amount, alice.address, "0xdeadbeef", 600000) + ).to.be.revertedWith("WOT: callGasLimit too high"); + }); + + it("rejects non-empty callData with zero gas", async () => { + const amount = ethers.utils.parseUnits("1", 6); + await mintAndApprove(alice, amount); + await expect( + master + .connect(alice) + .bridgeOTokenToPeer(amount, alice.address, "0xdeadbeef", 0) + ).to.be.revertedWith("WOT: callData needs gas"); + }); + }); + + describe("bridge-in (received from Remote)", () => { + const AMT = ethers.utils.parseUnits("250", 6); + + it("mints OToken to recipient, increases bridgeAdjustment, marks bridgeId consumed", async () => { + const bridgeId = ethers.utils.id("bridge-in-1"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)) + .to.emit(master, "BridgeDelivered") + .withArgs(bridgeId, alice.address, AMT); + + expect(await oToken.balanceOf(alice.address)).to.equal(AMT); + expect(await master.bridgeAdjustment()).to.equal(AMT); + expect(await master.consumedBridgeIds(bridgeId)).to.equal(true); + }); + + it("rejects a replayed bridgeId", async () => { + const bridgeId = ethers.utils.id("bridge-in-replay"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + await inboundAdapter.sendMessage(envelope); + await expect(inboundAdapter.sendMessage(envelope)).to.be.revertedWith( + "WOT: bridgeId replayed" + ); + }); + + it("invokes optional callData on success", async () => { + const TargetFactory = await ethers.getContractFactory( + "MockBridgeCallTarget" + ); + const target = await TargetFactory.deploy(); + + const iface = new ethers.utils.Interface([ + "function onBridgeDelivered(bytes32 bridgeId, uint256 tokenAmount)", + ]); + const bridgeId = ethers.utils.id("bridge-in-call-ok"); + const callData = iface.encodeFunctionData("onBridgeDelivered", [ + bridgeId, + AMT, + ]); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: target.address, + callData, + callGasLimit: 200000, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)).to.emit( + master, + "BridgeCallSucceeded" + ); + + expect(await target.callCount()).to.equal(1); + expect(await target.lastBridgeId()).to.equal(bridgeId); + expect(await oToken.balanceOf(target.address)).to.equal(AMT); + }); + + it("still delivers tokens when the callData reverts", async () => { + const TargetFactory = await ethers.getContractFactory( + "MockBridgeCallTarget" + ); + const target = await TargetFactory.deploy(); + await target.setAlwaysRevert(true); + + const iface = new ethers.utils.Interface([ + "function onBridgeDelivered(bytes32 bridgeId, uint256 tokenAmount)", + ]); + const bridgeId = ethers.utils.id("bridge-in-call-revert"); + const callData = iface.encodeFunctionData("onBridgeDelivered", [ + bridgeId, + AMT, + ]); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: target.address, + callData, + callGasLimit: 200000, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)).to.emit( + master, + "BridgeCallFailed" + ); + + // Tokens were still delivered. + expect(await oToken.balanceOf(target.address)).to.equal(AMT); + expect(await master.consumedBridgeIds(bridgeId)).to.equal(true); + }); + + it("rejects callGasLimit above MAX_BRIDGE_CALL_GAS in the payload", async () => { + const bridgeId = ethers.utils.id("bridge-in-gas-too-high"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + callData: "0xdeadbeef", + callGasLimit: 600000, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)).to.be.revertedWith( + "WOT: callGasLimit too high" + ); + }); + + it("emits BridgeCallFailed when callback runs out of gas, still delivers tokens", async () => { + // Exercises the real-world failure mode the bounded-gas guard exists for: + // callee with an infinite-loop function runs out of gas inside the cap, + // callback fails, but tokens were delivered first (CEI). + const TargetFactory = await ethers.getContractFactory( + "MockBridgeCallTarget" + ); + const target = await TargetFactory.deploy(); + + const iface = new ethers.utils.Interface(["function burnGas()"]); + const bridgeId = ethers.utils.id("bridge-in-call-oom"); + const callData = iface.encodeFunctionData("burnGas"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: target.address, + callData, + callGasLimit: 50000, // low enough that burnGas can't possibly complete + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_IN, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)).to.emit( + master, + "BridgeCallFailed" + ); + + // Tokens still delivered (CEI verified): target holds AMT OToken. + expect(await oToken.balanceOf(target.address)).to.equal(AMT); + expect(await master.consumedBridgeIds(bridgeId)).to.equal(true); + // Callback never ran to completion (no Pinged event, callCount stays 0). + expect(await target.callCount()).to.equal(0); + }); + }); + + describe("balance-check + settlement (operator-driven)", () => { + it("rejects requestBalanceCheck from non-operator non-governor", async () => { + await expect( + master.connect(alice).requestBalanceCheck() + ).to.be.revertedWith("WOT: not authorised"); + }); + + it("rejects requestSettlement from non-operator non-governor", async () => { + await expect( + master.connect(alice).requestSettlement() + ).to.be.revertedWith("WOT: not authorised"); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/oethb-phase1-migration.base.fork-test.js b/contracts/test/strategies/crosschainV3/oethb-phase1-migration.base.fork-test.js new file mode 100644 index 0000000000..4f32c907d6 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/oethb-phase1-migration.base.fork-test.js @@ -0,0 +1,189 @@ +const { createFixtureLoader } = require("../../_fixture"); +const { defaultBaseFixture } = require("../../_fixture-base"); +const { expect } = require("chai"); +const { oethUnits, isCI } = require("../../helpers"); +const { impersonateAndFund } = require("../../../utils/signers"); +const addresses = require("../../../utils/addresses"); + +const baseFixture = createFixtureLoader(defaultBaseFixture); + +/** + * OETHb Phase 1 migration fork test. + * + * Validates: + * 1. The V1→Migration upgrade on BridgedWOETHStrategyProxy preserves V1 state. + * 2. `bridgeToRemote(amount)` enforces the per-call cap and increments `totalBridged`. + * 3. The migration invariant: `oldStrategy.checkBalance + master.checkBalance` is + * conserved across the state table. + * + * The CCIP router on the deployed impl is an immutable, so we replicate the same + * impl with `MockCCIPRouter` and upgrade the proxy to the replica for the test — + * `bridgeToRemote` then doesn't attempt real CCIP delivery (we only care about + * strategy-side accounting on this fork). + */ +describe("ForkTest: OETHb Phase 1 wOETH migration", function () { + this.timeout(0); + this.retries(isCI ? 3 : 0); + + let fixture; + let woethMigration; + let masterStrategy; + let mockRouter; + let woeth; + let weth; + let baseTimelock; + + beforeEach(async () => { + fixture = await baseFixture(); + + // Rebind the V1 strategy address as the migration impl now that the upgrade ran. + woethMigration = await ethers.getContractAt( + "BridgedWOETHMigrationStrategy", + fixture.woethStrategy.address + ); + woeth = fixture.woeth; + weth = fixture.weth; + + // Resolve the new V3 Master deployed by the PR 12 Base scripts. + const masterProxyAddr = await woethMigration.master(); + expect(masterProxyAddr).to.not.equal(addresses.zero); + masterStrategy = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxyAddr + ); + + // CCIP router is immutable on the migration impl. To avoid hitting real CCIP in + // this fork, redeploy the impl with a mock router and upgrade the proxy to it. + const MockRouterF = await ethers.getContractFactory("MockCCIPRouter"); + mockRouter = await MockRouterF.deploy(); + await mockRouter.deployed(); + + const MigrationF = await ethers.getContractFactory( + "BridgedWOETHMigrationStrategy" + ); + const vaultAddr = await woethMigration.vaultAddress(); + const oethbAddr = await woethMigration.oethb(); + const oracleAddr = await woethMigration.oracle(); + const chainSelector = await woethMigration.ccipChainSelectorMainnet(); + const replicaImpl = await MigrationF.deploy( + [addresses.zero, vaultAddr], + addresses.base.WETH, + addresses.base.BridgedWOETH, + oethbAddr, + oracleAddr, + masterProxyAddr, + mockRouter.address, + chainSelector + ); + await replicaImpl.deployed(); + + baseTimelock = await impersonateAndFund(addresses.base.timelock); + const proxy = await ethers.getContractAt( + "InitializeGovernedUpgradeabilityProxy", + woethMigration.address + ); + await proxy.connect(baseTimelock).upgradeTo(replicaImpl.address); + + // Make sure the strategy has native to pay the (zero) fee in the mock. + await fixture.governor.sendTransaction({ + to: woethMigration.address, + value: ethers.utils.parseEther("1"), + }); + }); + + it("preserves V1 state across the V1→Migration upgrade", async () => { + // V1 storage variables must remain readable through the migration impl at the same slots. + const lastOraclePrice = await woethMigration.lastOraclePrice(); + const maxPriceDiffBps = await woethMigration.maxPriceDiffBps(); + expect(lastOraclePrice).to.be.gt(0); + expect(maxPriceDiffBps).to.be.gt(0); + + // Inherited immutables resolve to the same Base-side token addresses. + expect(await woethMigration.weth()).to.equal(addresses.base.WETH); + expect(await woethMigration.bridgedWOETH()).to.equal(woeth.address); + + // Migration-impl immutables: master + ccipChainSelectorMainnet. + expect(await woethMigration.master()).to.equal(masterStrategy.address); + expect(await woethMigration.maxPerBridge()).to.equal(oethUnits("1000")); + expect(await woethMigration.totalBridged()).to.equal(0); + }); + + it("rejects bridgeToRemote above MAX_PER_BRIDGE", async () => { + const sStrategist = await impersonateAndFund( + addresses.multichainStrategist + ); + await expect( + woethMigration.connect(sStrategist).bridgeToRemote(oethUnits("1001")) + ).to.be.revertedWith("BWM: bad amount"); + }); + + it("walks the migration state-table invariant across multiple batches", async () => { + const sStrategist = await impersonateAndFund( + addresses.multichainStrategist + ); + + // Total expected to bridge (in wOETH units). + const startingLocal = await woeth.balanceOf(woethMigration.address); + expect(startingLocal).to.be.gt(0); + const oraclePrice = await woethMigration.lastOraclePrice(); + + // Initial state: Row 1 — local = X, totalBridged = 0, master.checkBalance = 0. + const totalBefore = await woethMigration.checkBalance(weth.address); + const masterBefore = await masterStrategy.checkBalance(weth.address); + expect(masterBefore).to.equal(0); + + // Drive 3 batches of bridgeToRemote (less than the migration's 9 to keep test fast). + const batchSize = oethUnits("1000"); + const batchCount = startingLocal.gte(batchSize.mul(3)) ? 3 : 1; + let bridgedSoFar = ethers.BigNumber.from(0); + for (let i = 0; i < batchCount; i++) { + await woethMigration.connect(sStrategist).bridgeToRemote(batchSize); + bridgedSoFar = bridgedSoFar.add(batchSize); + + // After each batch the wOETH leaves the strategy but `totalBridged` rises. + // Master hasn't received any balance updates yet (CCIP delivery is mocked), + // so it still reports zero. The in-transit slot covers the bridged value. + const local = await woeth.balanceOf(woethMigration.address); + const totalBridged = await woethMigration.totalBridged(); + const checkBal = await woethMigration.checkBalance(weth.address); + const masterBal = await masterStrategy.checkBalance(weth.address); + + expect(totalBridged).to.equal(bridgedSoFar); + expect(local).to.equal(startingLocal.sub(bridgedSoFar)); + expect(masterBal).to.equal(0); + + // checkBalance = (local + inTransit) * oraclePrice / 1e18 + const expected = local + .add(bridgedSoFar) // inTransit = totalBridged - master(=0) = totalBridged + .mul(oraclePrice) + .div(ethers.utils.parseEther("1")); + expect(checkBal).to.equal(expected); + + // Invariant: thisStrategy.checkBalance + master.checkBalance is non-decreasing + // and stays at the original total (within rounding). + const sum = checkBal.add(masterBal); + expect(sum).to.equal(totalBefore); + } + + // Confirm a CCIP send actually happened per batch. + expect(await mockRouter.sentMessagesLength()).to.equal( + ethers.BigNumber.from(batchCount) + ); + }); + + it("after a batch, the mock router holds the wOETH (proxying real CCIP custody)", async () => { + const sStrategist = await impersonateAndFund( + addresses.multichainStrategist + ); + const batchSize = oethUnits("1000"); + const stratBefore = await woeth.balanceOf(woethMigration.address); + expect(stratBefore).to.be.gte(batchSize); + + await woethMigration.connect(sStrategist).bridgeToRemote(batchSize); + + expect(await woeth.balanceOf(woethMigration.address)).to.equal( + stratBefore.sub(batchSize) + ); + expect(await woeth.balanceOf(mockRouter.address)).to.equal(batchSize); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/remote-v3.js b/contracts/test/strategies/crosschainV3/remote-v3.js new file mode 100644 index 0000000000..95e2be6b9d --- /dev/null +++ b/contracts/test/strategies/crosschainV3/remote-v3.js @@ -0,0 +1,441 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +const { + MSG, + encodePackedEnvelope, + encodeBridgeUserPayload, +} = require("./_helpers"); + +// bridgeAsset (MockUSDC) is 6dp; oToken / wOToken are 18dp. The strategy holds value in the +// OToken (18dp) domain and reports checkBalance in bridgeAsset (6dp) units. SCALE is 6→18. +const SCALE = ethers.BigNumber.from(10).pow(12); + +describe("Unit: RemoteWOTokenStrategy", function () { + let deployer, governor, alice; + let bridgeAsset, oToken, woToken, ethVault, remote; + let outboundAdapter, inboundAdapter; + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + // bridgeAsset (USDC stand-in) + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + // OToken + Ethereum vault + const VaultFactory = await ethers.getContractFactory("MockEthOTokenVault"); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + + // Two-step bootstrap: vault refers to oToken, oToken refers to vault. + // We deploy oToken with a placeholder vault, then redeploy vault with the real + // oToken, but since oToken's vault is immutable we must compute the vault + // address first. Easier: deploy oToken with deployer as a temporary vault. + // Simplest fix: deploy vault first with a placeholder oToken, then patch oToken + // separately. But oToken vault is immutable too. Use CREATE2-style two-pass: + // 1) compute vault address; 2) deploy oToken bound to that address; 3) deploy vault. + // For mocks we cheat with a self-deployment helper: deploy vault first with a known + // oToken slot we'll write to, but that's hacky. + // Cleanest workable approach: rebuild the mock pair so that oToken's vault is also a + // constructor arg passed at deploy time, and the vault stores oToken via setter. + // (Our MockEthOTokenVault has oToken as immutable via constructor arg — see below.) + // Two-pass deployment with pre-computed address: + const nonce = await ethers.provider.getTransactionCount(deployer.address); + const futureVaultAddress = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: nonce + 1, // oToken is deployed first (nonce), vault next (nonce+1) + }); + oToken = await OTokenFactory.deploy( + "Mock OToken", + "mOT", + futureVaultAddress + ); + ethVault = await VaultFactory.deploy(bridgeAsset.address, oToken.address); + expect(ethVault.address).to.equal(futureVaultAddress); + + // wOToken (ERC-4626 over OToken) + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woToken = await WoFactory.deploy(oToken.address); + + // RemoteWOTokenStrategy behind proxy + const ImplFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const impl = await ImplFactory.connect(deployer).deploy( + { + platformAddress: woToken.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oToken.address, + woToken.address, + ethVault.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const proxy = await ProxyFactory.connect(deployer).deploy(); + const initData = impl.interface.encodeFunctionData("initialize", [ + governor.address, + ]); + await proxy + .connect(deployer) + .initialize(impl.address, governor.address, initData); + + remote = await ethers.getContractAt("RemoteWOTokenStrategy", proxy.address); + + // Adapters + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + outboundAdapter = await AdapterFactory.deploy(); + inboundAdapter = await AdapterFactory.deploy(); + await outboundAdapter.setSender(remote.address); + await inboundAdapter.setPeer(remote.address); + + await remote.connect(governor).setOutboundAdapter(outboundAdapter.address); + await remote.connect(governor).setInboundAdapter(inboundAdapter.address); + // safeApproveAllTokens primes the static (token, spender) pairs Remote transfers + // through (replaces the per-call _ensureApproval). + await remote.connect(governor).safeApproveAllTokens(); + }); + + describe("initialisation", () => { + it("stores immutables and rejects unsupported assets", async () => { + expect(await remote.bridgeAsset()).to.equal(bridgeAsset.address); + expect(await remote.oToken()).to.equal(oToken.address); + expect(await remote.woToken()).to.equal(woToken.address); + expect(await remote.oTokenVault()).to.equal(ethVault.address); + expect(await remote.supportsAsset(bridgeAsset.address)).to.equal(true); + expect(await remote.supportsAsset(oToken.address)).to.equal(false); + }); + + it("vault-driven entry points revert (Remote is bridge-driven only)", async () => { + await expect( + remote.connect(governor).deposit(bridgeAsset.address, 1) + ).to.be.revertedWith("Remote: use bridge"); + await expect(remote.connect(governor).depositAll()).to.be.revertedWith( + "Remote: use bridge" + ); + await expect( + remote + .connect(governor) + .withdraw(governor.address, bridgeAsset.address, 1) + ).to.be.revertedWith("Remote: use bridge"); + await expect(remote.connect(governor).withdrawAll()).to.be.revertedWith( + "Remote: use bridge" + ); + }); + }); + + describe("checkBalance sums all state-table slots", () => { + const FIVE = ethers.utils.parseUnits("5", 6); // 5 bridgeAsset (USDC, 6dp) + const FIVE_OT = ethers.utils.parseUnits("5", 18); // 5 OToken (18dp) == 5 USDC of value + + it("returns 0 when idle", async () => { + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(0); + }); + + it("includes wOToken shares (via previewRedeem)", async () => { + // mint(FIVE) USDC produces FIVE_OT OToken (scaled); wrap all of it. + await bridgeAsset.mintTo(deployer.address, FIVE); + await bridgeAsset.approve(ethVault.address, FIVE); + await ethVault.mint(FIVE); + await oToken.approve(woToken.address, FIVE_OT); + await woToken.deposit(FIVE_OT, remote.address); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(FIVE); + }); + + it("includes loose OToken balance", async () => { + await bridgeAsset.mintTo(deployer.address, FIVE); + await bridgeAsset.approve(ethVault.address, FIVE); + await ethVault.mint(FIVE); + await oToken.transfer(remote.address, FIVE_OT); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(FIVE); + }); + + it("includes loose bridgeAsset balance", async () => { + await bridgeAsset.mintTo(remote.address, FIVE); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(FIVE); + }); + }); + + describe("DEPOSIT inbound handling", () => { + const ONE_K = ethers.utils.parseUnits("1000", 6); + + it("mints OToken, wraps to wOToken, sends DEPOSIT_ACK with new balance", async () => { + // Drive an atomic tokens-with-message delivery through the receiver adapter. + // The test EOA plays the role of the bridge transport: pre-funded with + // bridgeAsset and approves the adapter to pull it as if it had arrived from + // the source chain. + await bridgeAsset.mintTo(deployer.address, ONE_K); + await bridgeAsset.approve(inboundAdapter.address, ONE_K); + + const envelope = encodePackedEnvelope(MSG.DEPOSIT, 7, "0x"); + await inboundAdapter.sendMessageAndTokens( + bridgeAsset.address, + ONE_K, + envelope + ); + + // ONE_K USDC mints ONE_K*SCALE OToken (18dp), all wrapped to wOToken (1:1 in mock). + expect(await woToken.balanceOf(remote.address)).to.equal( + ONE_K.mul(SCALE) + ); + expect(await oToken.balanceOf(remote.address)).to.equal(0); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(0); + + // Master would have received the ack with the new balance. + const sent = await outboundAdapter.lastMessageSent(); + const [msgType, ackNonce] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sent + ); + expect(msgType).to.equal(MSG.DEPOSIT_ACK); + expect(ackNonce).to.equal(7); + + expect(await remote.nonceProcessed(7)).to.equal(true); + expect(await remote.lastYieldNonce()).to.equal(7); + }); + + it("rejects a non-monotonic yield nonce on a second inbound deposit", async () => { + await bridgeAsset.mintTo(deployer.address, ONE_K.mul(2)); + await bridgeAsset.approve(inboundAdapter.address, ONE_K.mul(2)); + + await inboundAdapter.sendMessageAndTokens( + bridgeAsset.address, + ONE_K, + encodePackedEnvelope(MSG.DEPOSIT, 5, "0x") + ); + + // Reusing nonce 5 or going backward must be rejected. + await expect( + inboundAdapter.sendMessageAndTokens( + bridgeAsset.address, + ONE_K, + encodePackedEnvelope(MSG.DEPOSIT, 5, "0x") + ) + ).to.be.revertedWith("V3: nonce not monotonic"); + + await expect( + inboundAdapter.sendMessageAndTokens( + bridgeAsset.address, + ONE_K, + encodePackedEnvelope(MSG.DEPOSIT, 4, "0x") + ) + ).to.be.revertedWith("V3: nonce not monotonic"); + }); + }); + + describe("bridge channel: bridge-in (user-facing, R→M)", () => { + const AMT = ethers.utils.parseUnits("250", 6); + + const mintOTokenToAlice = async (amount) => { + await bridgeAsset.mintTo(alice.address, amount); + await bridgeAsset.connect(alice).approve(ethVault.address, amount); + await ethVault.connect(alice).mint(amount); + }; + + it("wraps OToken, emits BridgeRequested, sends BRIDGE_IN message", async () => { + await mintOTokenToAlice(AMT); + await oToken.connect(alice).approve(remote.address, AMT); + + await expect( + remote + .connect(alice) + .bridgeOTokenToPeer(AMT, ethers.constants.AddressZero, "0x", 0) + ).to.emit(remote, "BridgeRequested"); + + expect(await woToken.balanceOf(remote.address)).to.equal(AMT); + expect(await remote.bridgeAdjustment()).to.equal(AMT); + + const sent = await outboundAdapter.lastMessageSent(); + const [msgType, nonce] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sent + ); + expect(msgType).to.equal(MSG.BRIDGE_IN); + expect(nonce).to.equal(0); + }); + + it("rejects callGasLimit above MAX_BRIDGE_CALL_GAS", async () => { + await mintOTokenToAlice(AMT); + await oToken.connect(alice).approve(remote.address, AMT); + await expect( + remote + .connect(alice) + .bridgeOTokenToPeer(AMT, alice.address, "0xdeadbeef", 600000) + ).to.be.revertedWith("WOT: callGasLimit too high"); + }); + }); + + describe("bridge channel: BRIDGE_OUT inbound (M→R)", () => { + const AMT = ethers.utils.parseUnits("100", 6); + + const seedRemoteShares = async (amount) => { + await bridgeAsset.mintTo(deployer.address, amount); + await bridgeAsset.approve(ethVault.address, amount); + await ethVault.mint(amount); + await oToken.approve(woToken.address, amount); + await woToken.deposit(amount, remote.address); + }; + + it("unwraps wOToken, transfers OToken to recipient, decrements bridgeAdjustment", async () => { + await seedRemoteShares(AMT.mul(2)); + + const bridgeId = ethers.utils.id("bridge-out-1"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)) + .to.emit(remote, "BridgeDelivered") + .withArgs(bridgeId, alice.address, AMT); + + expect(await oToken.balanceOf(alice.address)).to.equal(AMT); + expect(await remote.bridgeAdjustment()).to.equal( + ethers.BigNumber.from(0).sub(AMT) + ); + expect(await woToken.balanceOf(remote.address)).to.equal(AMT); + expect(await remote.consumedBridgeIds(bridgeId)).to.equal(true); + }); + + it("rejects a replayed bridgeId", async () => { + await seedRemoteShares(AMT.mul(2)); + const bridgeId = ethers.utils.id("bridge-out-replay"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + await inboundAdapter.sendMessage(envelope); + await expect(inboundAdapter.sendMessage(envelope)).to.be.revertedWith( + "WOT: bridgeId replayed" + ); + }); + + it("reverts with insufficient remote wOToken", async () => { + // No shares. + const bridgeId = ethers.utils.id("bridge-out-low"); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: alice.address, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + await expect(inboundAdapter.sendMessage(envelope)).to.be.revertedWith( + "Remote: insufficient remote wOToken" + ); + }); + + it("invokes optional callData on the recipient", async () => { + await seedRemoteShares(AMT.mul(2)); + const TargetFactory = await ethers.getContractFactory( + "MockBridgeCallTarget" + ); + const target = await TargetFactory.deploy(); + + const bridgeId = ethers.utils.id("bridge-out-call"); + const iface = new ethers.utils.Interface([ + "function onBridgeDelivered(bytes32,uint256)", + ]); + const callData = iface.encodeFunctionData("onBridgeDelivered", [ + bridgeId, + AMT, + ]); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: target.address, + callData, + callGasLimit: 200000, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + + await expect(inboundAdapter.sendMessage(envelope)).to.emit( + remote, + "BridgeCallSucceeded" + ); + expect(await target.callCount()).to.equal(1); + }); + + it("still delivers tokens when callData reverts", async () => { + await seedRemoteShares(AMT.mul(2)); + const TargetFactory = await ethers.getContractFactory( + "MockBridgeCallTarget" + ); + const target = await TargetFactory.deploy(); + await target.setAlwaysRevert(true); + + const bridgeId = ethers.utils.id("bridge-out-revert"); + const iface = new ethers.utils.Interface([ + "function onBridgeDelivered(bytes32,uint256)", + ]); + const callData = iface.encodeFunctionData("onBridgeDelivered", [ + bridgeId, + AMT, + ]); + const payload = encodeBridgeUserPayload({ + bridgeId, + amount: AMT, + recipient: target.address, + callData, + callGasLimit: 200000, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + await expect(inboundAdapter.sendMessage(envelope)).to.emit( + remote, + "BridgeCallFailed" + ); + expect(await oToken.balanceOf(target.address)).to.equal(AMT); + }); + }); + + describe("transferToken custody protection (round-4 #17)", () => { + it("rejects sweeping woToken / oToken / bridgeAsset", async () => { + await expect( + remote.connect(governor).transferToken(woToken.address, 1) + ).to.be.revertedWith("Cannot transfer custody asset"); + await expect( + remote.connect(governor).transferToken(oToken.address, 1) + ).to.be.revertedWith("Cannot transfer custody asset"); + await expect( + remote.connect(governor).transferToken(bridgeAsset.address, 1) + ).to.be.revertedWith("Cannot transfer custody asset"); + }); + + it("still rescues an unrelated token to governor, governor-only", async () => { + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + const stray = await ERC20Factory.deploy(); + const amt = ethers.utils.parseUnits("10", 6); + await stray.mintTo(remote.address, amt); + + await expect( + remote.connect(alice).transferToken(stray.address, amt) + ).to.be.revertedWith("Caller is not the Governor"); + + await remote.connect(governor).transferToken(stray.address, amt); + expect(await stray.balanceOf(governor.address)).to.equal(amt); + expect(await stray.balanceOf(remote.address)).to.equal(0); + }); + }); + + describe("inbound bridge zero-recipient guard (round-4 #2)", () => { + it("reverts a BRIDGE_OUT whose payload recipient is address(0)", async () => { + const payload = encodeBridgeUserPayload({ + bridgeId: ethers.utils.id("zero-recipient"), + amount: ethers.utils.parseEther("1"), + recipient: ethers.constants.AddressZero, + }); + const envelope = encodePackedEnvelope(MSG.BRIDGE_OUT, 0, payload); + await expect(inboundAdapter.sendMessage(envelope)).to.be.revertedWith( + "WOT: zero recipient" + ); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/remote-v3.mainnet.fork-test.js b/contracts/test/strategies/crosschainV3/remote-v3.mainnet.fork-test.js new file mode 100644 index 0000000000..b00c5f83c6 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/remote-v3.mainnet.fork-test.js @@ -0,0 +1,246 @@ +const { createFixtureLoader, defaultFixture } = require("../../_fixture"); +const { expect } = require("chai"); +const { isCI } = require("../../helpers"); +const { impersonateAndFund } = require("../../../utils/signers"); +const addresses = require("../../../utils/addresses"); +const { getCreate2ProxyAddress } = require("../../../deploy/deployActions"); + +const { MSG, encodeBridgeUserPayload } = require("./_helpers"); + +const mainnetFixture = createFixtureLoader(defaultFixture); + +/** + * Mainnet fork test covering RemoteWOTokenStrategy against the real wOETH (ERC-4626) and + * the real OETH vault. + * + * The withdrawal flow (leg 1 + queue + leg 2) is covered by + * `withdrawal.mainnet.fork-test.js`. This file focuses on: + * - Wiring sanity against the typed contract refs. + * - The YIELD_DEPOSIT pipeline (WETH → OETH via vault → wOETH via 4626). + * - The user-initiated BRIDGE_IN outbound path (OETH → wOETH wrap; envelope round-trip). + * + * Both functional tests swap Remote's adapters to a fresh impersonated inbound signer + + * MockBridgeAdapter outbound so we don't need to drive the real CCIP router on a fork. + */ +describe("ForkTest: RemoteWOTokenStrategy on mainnet (real wOETH + OETH vault)", function () { + this.timeout(0); + this.retries(isCI ? 3 : 0); + + let remote; + let woeth; + let oeth; + let weth; + let oethVault; + let outboundAdapter; + let inboundAdapter; + + beforeEach(async () => { + await mainnetFixture(); + + const proxyAddr = await getCreate2ProxyAddress("OETHbV3RemoteProxy"); + remote = await ethers.getContractAt("RemoteWOTokenStrategy", proxyAddr); + + woeth = await ethers.getContractAt( + "IERC4626", + addresses.mainnet.WOETHProxy + ); + oeth = await ethers.getContractAt( + "@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20", + addresses.mainnet.OETHProxy + ); + weth = await ethers.getContractAt("IWETH9", addresses.mainnet.WETH); + oethVault = await ethers.getContractAt( + "IVault", + addresses.mainnet.OETHVaultProxy + ); + + outboundAdapter = await ethers.getContractAt( + "SuperbridgeAdapter", + await remote.outboundAdapter() + ); + inboundAdapter = await ethers.getContractAt( + "CCIPAdapter", + await remote.inboundAdapter() + ); + }); + + it("is wired to the real mainnet wOETH / OETH / OETH vault", async () => { + expect(await remote.bridgeAsset()).to.equal(weth.address); + expect(await remote.oToken()).to.equal(oeth.address); + expect(await remote.woToken()).to.equal(woeth.address); + expect(await remote.oTokenVault()).to.equal(oethVault.address); + expect(await remote.operator()).to.equal(addresses.talosRelayer); + + // The 4626 wraps the same OETH that the strategy holds. + expect(await woeth.asset()).to.equal(oeth.address); + }); + + it("claimRemoteWithdrawal is idempotent when nothing is outstanding", async () => { + await expect(remote.claimRemoteWithdrawal()).to.not.be.reverted; + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await remote.outstandingRequestAmount()).to.equal(0); + }); + + it("checkBalance is zero on a freshly deployed Remote", async () => { + expect(await remote.checkBalance(weth.address)).to.equal(0); + }); + + describe("YIELD_DEPOSIT pipeline (WETH → OETH → wOETH)", () => { + const DEPOSIT_AMOUNT = ethers.utils.parseEther("1"); + + it("mints OETH via the vault, wraps to wOETH, emits DEPOSIT_ACK", async () => { + // Swap adapters: fresh impersonated inbound signer + MockBridgeAdapter outbound. + const sTimelock = await impersonateAndFund(addresses.mainnet.Timelock); + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(remote.address); + await remote.connect(sTimelock).setOutboundAdapter(mockOut.address); + + const [deployer] = await ethers.getSigners(); + const inboundSigner = await impersonateAndFund(deployer.address); + await remote.connect(sTimelock).setInboundAdapter(deployer.address); + + // Fund Remote with WETH (wrap native via WETH9). + await deployer.sendTransaction({ + to: weth.address, + value: DEPOSIT_AMOUNT, + }); + await weth.connect(deployer).transfer(remote.address, DEPOSIT_AMOUNT); + expect(await weth.balanceOf(remote.address)).to.equal(DEPOSIT_AMOUNT); + + const sharesBefore = await woeth.balanceOf(remote.address); + + // Drive the inbound DEPOSIT — pack strategy envelope (msgType, nonce, body) and + // pass it via receiveMessage. + const depositPayload = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [MSG.DEPOSIT, 1, "0x"] + ); + await remote + .connect(inboundSigner) + .receiveMessage( + remote.address, + weth.address, + DEPOSIT_AMOUNT, + depositPayload + ); + + // WETH was consumed by the vault mint. + expect(await weth.balanceOf(remote.address)).to.equal(0); + // OETH was wrapped into wOETH — share count grew. + expect(await woeth.balanceOf(remote.address)).to.be.gt(sharesBefore); + // No bare OETH left on Remote. + expect(await oeth.balanceOf(remote.address)).to.equal(0); + + // checkBalance reflects the wrapped value. Allow a few wei for the OETH-vault mint + + // ERC4626 deposit/previewRedeem double-rounding, which shifts slightly with the live + // wOETH share price on a fork (so the margin can't be a fixed 1 wei). + const total = await remote.checkBalance(weth.address); + expect(total).to.be.closeTo(DEPOSIT_AMOUNT, 10); + + // The outbound MockBridgeAdapter recorded the DEPOSIT_ACK envelope. + const sent = await mockOut.lastMessageSent(); + const [msgType] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sent + ); + expect(msgType).to.equal(MSG.DEPOSIT_ACK); + }); + }); + + describe("BRIDGE_IN outbound (user wraps OETH on Ethereum)", () => { + const BRIDGE_AMOUNT = ethers.utils.parseEther("0.5"); + + it("wraps user OETH to wOETH and emits a BRIDGE_IN envelope to the outbound adapter", async () => { + // Swap the outbound adapter to MockBridgeAdapter so the test doesn't drive the real CCIP. + const sTimelock = await impersonateAndFund(addresses.mainnet.Timelock); + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(remote.address); + await remote.connect(sTimelock).setOutboundAdapter(mockOut.address); + + // A user signer mints OETH via the real vault, then bridges it. + const [, user] = await ethers.getSigners(); + await user.sendTransaction({ + to: weth.address, + value: BRIDGE_AMOUNT, + }); + await weth.connect(user).approve(oethVault.address, BRIDGE_AMOUNT); + await oethVault.connect(user)["mint(uint256)"](BRIDGE_AMOUNT); + const userOETH = await oeth.balanceOf(user.address); + expect(userOETH).to.be.gte(BRIDGE_AMOUNT); + + const sharesBefore = await woeth.balanceOf(remote.address); + + // User approves Remote and bridges. + await oeth.connect(user).approve(remote.address, BRIDGE_AMOUNT); + await expect( + remote + .connect(user) + .bridgeOTokenToPeer(BRIDGE_AMOUNT, user.address, "0x", 0) + ).to.emit(remote, "BridgeRequested"); + + // wOETH share count on Remote grew (4626 deposit landed). + expect(await woeth.balanceOf(remote.address)).to.be.gt(sharesBefore); + + // The outbound adapter recorded a BRIDGE_IN envelope — unpack (msgType, nonce, body). + const sent = await mockOut.lastMessageSent(); + const [msgType, , body] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sent + ); + expect(msgType).to.equal(MSG.BRIDGE_IN); + + // body is the BridgeUserPayload. + const decoded = ethers.utils.defaultAbiCoder.decode( + ["bytes32", "uint256", "address", "bytes", "uint32"], + body + ); + expect(decoded[1]).to.equal(BRIDGE_AMOUNT); // amount + expect(decoded[2].toLowerCase()).to.equal(user.address.toLowerCase()); + + // Sanity: encodeBridgeUserPayload helper produces matching bytes for the same fields. + const roundTrip = encodeBridgeUserPayload({ + bridgeId: decoded[0], + amount: decoded[1], + recipient: decoded[2], + callData: decoded[3], + callGasLimit: decoded[4], + }); + expect(roundTrip).to.equal(body); + }); + }); + + describe("SuperbridgeAdapter (outbound, real deployment)", () => { + it("knows the local WETH (ETH-only adapter)", async () => { + expect(await outboundAdapter.weth()).to.equal(addresses.mainnet.WETH); + }); + + it("is governed by the mainnet Timelock", async () => { + expect(await outboundAdapter.governor()).to.equal( + addresses.mainnet.Timelock + ); + }); + + it("has Remote authorised as a sender", async () => { + expect(await outboundAdapter.authorised(remote.address)).to.equal(true); + }); + }); + + describe("CCIPAdapter (inbound, real deployment)", () => { + it("only the CCIP router can drive ccipReceive", async () => { + const [a] = await ethers.getSigners(); + await expect( + inboundAdapter.connect(a).ccipReceive({ + messageId: ethers.utils.hexZeroPad("0x0", 32), + sourceChainSelector: 0, + sender: "0x", + data: "0x", + destTokenAmounts: [], + }) + ).to.be.revertedWith("CCIP: not router"); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/settlement-balance-check.js b/contracts/test/strategies/crosschainV3/settlement-balance-check.js new file mode 100644 index 0000000000..277bc0bb4f --- /dev/null +++ b/contracts/test/strategies/crosschainV3/settlement-balance-check.js @@ -0,0 +1,325 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +/** + * End-to-end exercise of the operator-driven yield-channel round-trips: + * - requestBalanceCheck → BALANCE_CHECK_RESPONSE (updates remoteStrategyBalance from + * Remote's previewRedeem) + * - requestSettlement → SETTLE_BRIDGE_ACCOUNTING_ACK (zeros both sides' bridgeAdjustment and updates + * remoteStrategyBalance to the post-settlement view) + * + * Verifies the checkBalance invariant across yield accrual (mocked by sending OToken to + * the 4626 vault to inflate previewRedeem) and across bridge-channel net inflows. + */ + +describe("Unit: V3 settlement + balance check", function () { + let deployer, governor, alice; + let bridgeAsset, oTokenL2, mockL2Vault; + let oTokenEth, woTokenEth, ethVault; + let master, remote; + + const SEED = ethers.utils.parseUnits("5000", 6); + // bridgeAsset (USDC) is 6dp; remoteStrategyBalance / bridgeAdjustment are OToken (18dp). + // SCALE is the 6→18 factor: a SEED-USDC deposit shows up as SEED.mul(SCALE) on Remote. + const SCALE = ethers.BigNumber.from(10).pow(12); + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const L2VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await L2VaultFactory.deploy(); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken L2", + "mOTL2", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const EthVaultFactory = await ethers.getContractFactory( + "MockEthOTokenVault" + ); + const ethNonce = await ethers.provider.getTransactionCount( + deployer.address + ); + const futureEthVault = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: ethNonce + 1, + }); + oTokenEth = await OTokenFactory.deploy( + "Mock OToken Eth", + "mOTEth", + futureEthVault + ); + ethVault = await EthVaultFactory.deploy( + bridgeAsset.address, + oTokenEth.address + ); + + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woTokenEth = await WoFactory.deploy(oTokenEth.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const masterImpl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + + const RemoteFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const remoteImpl = await RemoteFactory.connect(deployer).deploy( + { + platformAddress: woTokenEth.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oTokenEth.address, + woTokenEth.address, + ethVault.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const masterProxy = await ProxyFactory.connect(deployer).deploy(); + await masterProxy + .connect(deployer) + .initialize( + masterImpl.address, + governor.address, + masterImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxy.address + ); + + const remoteProxy = await ProxyFactory.connect(deployer).deploy(); + await remoteProxy + .connect(deployer) + .initialize( + remoteImpl.address, + governor.address, + remoteImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + remote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxy.address + ); + + await mockL2Vault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + const adapterME = await AdapterFactory.deploy(); + const adapterRM = await AdapterFactory.deploy(); + await adapterME.setSender(master.address); + await adapterME.setPeer(remote.address); + await adapterRM.setSender(remote.address); + await adapterRM.setPeer(master.address); + + await master.connect(governor).setOutboundAdapter(adapterME.address); + await master.connect(governor).setInboundAdapter(adapterRM.address); + await remote.connect(governor).setOutboundAdapter(adapterRM.address); + await remote.connect(governor).setInboundAdapter(adapterME.address); + await remote.connect(governor).safeApproveAllTokens(); + + // Seed Remote with SEED via a deposit round-trip. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + }); + + it("requestBalanceCheck picks up yield accrued on the wOToken", async () => { + // Simulate yield: airdrop OToken to the wOToken vault to inflate previewRedeem. Mint + // YIELD USDC → YIELD*SCALE OToken (18dp), then donate all of it to the vault so the + // increase is meaningful at the bridgeAsset (6dp) scale. + const YIELD = ethers.utils.parseUnits("100", 6); + await bridgeAsset.mintTo(deployer.address, YIELD); + await bridgeAsset.approve(ethVault.address, YIELD); + await ethVault.mint(YIELD); + await oTokenEth.transfer(woTokenEth.address, YIELD.mul(SCALE)); + // Now previewRedeem(SEED shares) > SEED. + + // Before: Master's cached balance still equals the seeded baseline (18dp). + expect(await master.remoteStrategyBalance()).to.equal(SEED.mul(SCALE)); + + await master.connect(governor).requestBalanceCheck(); + + // After: balance reflects the yield (18dp on Remote, 6dp on checkBalance). + expect(await master.remoteStrategyBalance()).to.be.gt(SEED.mul(SCALE)); + expect(await master.checkBalance(bridgeAsset.address)).to.be.gt(SEED); + }); + + it("requestSettlement zeros both sides' bridgeAdjustment and refreshes balance", async () => { + // Drive a bridge-in round trip to create unsettled deltas on both sides. AMT is the + // bridged OToken amount (18dp); alice mints the bridgeAsset (6dp) needed to obtain it. + const AMT = ethers.utils.parseUnits("250", 18); + await bridgeAsset.mintTo(alice.address, AMT.div(SCALE)); + await bridgeAsset.connect(alice).approve(ethVault.address, AMT.div(SCALE)); + await ethVault.connect(alice).mint(AMT.div(SCALE)); + await oTokenEth.connect(alice).approve(remote.address, AMT); + await remote.connect(alice).bridgeOTokenToPeer(AMT, alice.address, "0x", 0); + + expect(await master.bridgeAdjustment()).to.equal(AMT); + expect(await remote.bridgeAdjustment()).to.equal(AMT); + + // Settlement + await master.connect(governor).requestSettlement(); + + expect(await master.bridgeAdjustment()).to.equal(0); + expect(await remote.bridgeAdjustment()).to.equal(0); + // remoteStrategyBalance now reflects the seeded deposit (18dp) plus the bridged-in shares. + expect(await master.remoteStrategyBalance()).to.equal( + SEED.mul(SCALE).add(AMT) + ); + }); + + it("balance check does NOT advance the yield nonce", async () => { + // Locked design: balance check is non-blocking and nonce-echo. It uses + // `lastYieldNonce` as an epoch marker without incrementing it. + const nonceBefore = await master.lastYieldNonce(); + await master.connect(governor).requestBalanceCheck(); + await master.connect(governor).requestBalanceCheck(); + expect(await master.lastYieldNonce()).to.equal(nonceBefore); + }); + + it("requestBalanceCheck is non-blocking even when a withdrawal is pending", async () => { + // Old design rejected with "Master: withdrawal pending"; new design is non-blocking. + // The response is filtered at acceptance time (three guards in + // _processBalanceCheckResponse) — pending op skips, nonce mismatch skips, + // stale timestamp skips. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + ethers.utils.parseUnits("100", 6) + ); + + await expect(master.connect(governor).requestBalanceCheck()).to.not.be + .reverted; + }); + + it("yield-only baseline: balance check reports correctly with bridgeAdjustment != 0", async () => { + // Bridge-in 250 OToken (18dp) to create non-zero bridgeAdjustment on both sides — a + // meaningful amount (not sub-bridgeAsset dust) so a double-count would actually move + // checkBalance and the equality assertion is discriminating. + const AMT = ethers.utils.parseUnits("250", 18); + await bridgeAsset.mintTo(alice.address, AMT.div(SCALE)); + await bridgeAsset.connect(alice).approve(ethVault.address, AMT.div(SCALE)); + await ethVault.connect(alice).mint(AMT.div(SCALE)); + await oTokenEth.connect(alice).approve(remote.address, AMT); + await remote.connect(alice).bridgeOTokenToPeer(AMT, alice.address, "0x", 0); + + expect(await master.bridgeAdjustment()).to.equal(AMT); + expect(await remote.bridgeAdjustment()).to.equal(AMT); + + const checkBalBefore = await master.checkBalance(bridgeAsset.address); + + // Run balance check. Old design would double-count (remoteStrategyBalance updated to + // reflect bridge effect, bridgeAdjustment still set). New design: Remote reports + // yield-only baseline (_viewCheckBalance - bridgeAdjustment), Master combines with + // its own bridgeAdjustment to reconstruct the correct total. + await master.connect(governor).requestBalanceCheck(); + + const checkBalAfter = await master.checkBalance(bridgeAsset.address); + + // Without yield-only baseline, checkBalAfter would be (SEED + AMT + AMT) = SEED + 2*AMT. + // With yield-only baseline, checkBalAfter == checkBalBefore == SEED + AMT. + expect(checkBalAfter).to.equal(checkBalBefore); + }); + + it("settlement snapshot preserves in-flight bridge ops", async () => { + // Drive an initial bridge-in to set non-zero bridgeAdjustment. + const FIRST = ethers.utils.parseUnits("100", 6); + await bridgeAsset.mintTo(alice.address, FIRST); + await bridgeAsset.connect(alice).approve(ethVault.address, FIRST); + await ethVault.connect(alice).mint(FIRST); + await oTokenEth.connect(alice).approve(remote.address, FIRST); + await remote + .connect(alice) + .bridgeOTokenToPeer(FIRST, alice.address, "0x", 0); + + expect(await master.bridgeAdjustment()).to.equal(FIRST); + expect(await remote.bridgeAdjustment()).to.equal(FIRST); + + // Pause the adapter that takes Master's settle message to Remote, so Master fires + // settle but Remote doesn't process it yet. Meanwhile, a second bridge-in lands. + const inboundOnRemote = await ethers.getContractAt( + "MockBridgeAdapter", + await remote.inboundAdapter() + ); + await inboundOnRemote.setDeliveryEnabled(false); + + // Master fires settle. Snapshot captured = FIRST (current bridgeAdjustment). + await master.connect(governor).requestSettlement(); + expect(await master.settlementSnapshot()).to.equal(FIRST); + + // Master.bridgeAdjustment unchanged until ack lands; still = FIRST. + expect(await master.bridgeAdjustment()).to.equal(FIRST); + + // While settle is in flight, another bridge-in for SECOND. + const SECOND = ethers.utils.parseUnits("75", 6); + await bridgeAsset.mintTo(alice.address, SECOND); + await bridgeAsset.connect(alice).approve(ethVault.address, SECOND); + await ethVault.connect(alice).mint(SECOND); + await oTokenEth.connect(alice).approve(remote.address, SECOND); + await remote + .connect(alice) + .bridgeOTokenToPeer(SECOND, alice.address, "0x", 0); + + // Master's bridgeAdjustment is now FIRST + SECOND (second bridge_in applied locally). + expect(await master.bridgeAdjustment()).to.equal(FIRST.add(SECOND)); + // Remote hasn't processed settle OR new bridge_in yet (delivery disabled). + + // Re-enable delivery and flush pending; both messages reach Remote and settle ack + // reaches Master. + await inboundOnRemote.setDeliveryEnabled(true); + await inboundOnRemote.flushPendingDelivery(); + + // Both sides should converge: bridgeAdjustment -= snapshot (FIRST), leaving SECOND. + expect(await master.bridgeAdjustment()).to.equal(SECOND); + expect(await master.settlementSnapshot()).to.equal(0); // cleared + }); + + it("governor can sweep native ETH from the strategy via transferNative", async () => { + // Send some ETH to Master (simulating operator top-up of the fee pool). + const POOL = ethers.utils.parseEther("0.5"); + await deployer.sendTransaction({ to: master.address, value: POOL }); + expect(await ethers.provider.getBalance(master.address)).to.equal(POOL); + + const govBefore = await ethers.provider.getBalance(governor.address); + const tx = await master.connect(governor).transferNative(POOL); + const receipt = await tx.wait(); + const gasCost = receipt.gasUsed.mul(receipt.effectiveGasPrice); + const govAfter = await ethers.provider.getBalance(governor.address); + + // Governor received POOL - gas spent on the call. + expect(govAfter.sub(govBefore)).to.equal(POOL.sub(gasCost)); + expect(await ethers.provider.getBalance(master.address)).to.equal(0); + }); + + it("non-governor cannot call transferNative", async () => { + await deployer.sendTransaction({ + to: master.address, + value: ethers.utils.parseEther("0.1"), + }); + await expect(master.connect(alice).transferNative(1)).to.be.revertedWith( + "Caller is not the Governor" + ); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/split-inbound-adapter.js b/contracts/test/strategies/crosschainV3/split-inbound-adapter.js new file mode 100644 index 0000000000..d86971fa42 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/split-inbound-adapter.js @@ -0,0 +1,287 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { impersonateAndFund } = require("../../../utils/signers"); + +/** + * Unit coverage for SuperbridgeAdapter exact-amount delivery semantics and + * multi-tenant routing via the envelope-sender whitelist. + * + * Under the new envelope shape `(sender, intendedAmount, payload)`: + * - Split delivery is driven by `intendedAmount`: > 0 means tokens accompany the + * message via the canonical bridge; 0 means message-only. + * - The adapter waits in a per-target pending slot until WETH balance covers + * `intendedAmount`, then forwards via `receiveMessage`. + * - Inbound trust: transport sender must equal `address(this)` (CREATE3 parity), + * envelope sender must be authorised, source chain must match the lane config. + */ +describe("Unit: SuperbridgeAdapter split delivery", function () { + let governor, routerSigner, otherSigner; + let receiver, strategy, strategy2, wethMock; + + // Ethereum CCIP selector (mirrors `addresses.mainnet.CCIPChainSelector`). + // Inlined as a literal because this test only needs the value, not the + // address resolution; `BigNumber.from(string)` avoids the BigInt literal + // syntax (`n` suffix) that eslint refuses to parse in this repo. + const PEER_CHAIN = ethers.BigNumber.from("5009297550715157269"); + const DEST_GAS_LIMIT = 500000; + + // Build the CCIP message struct (Client.Any2EVMMessage). The transport `sender` + // field must equal the receiving adapter's own address — CREATE3 parity binds the + // peer adapter to the same address. Tests default to that. + function buildAny2EvmMessage({ + messageId = ethers.utils.hexZeroPad("0x1", 32), + transportSender, + data, + destTokenAmounts = [], + }) { + return { + messageId, + sourceChainSelector: PEER_CHAIN, + sender: ethers.utils.defaultAbiCoder.encode( + ["address"], + [transportSender] + ), + data, + destTokenAmounts, + }; + } + + // Wire envelope: 20-byte sender + 32-byte intendedAmount + payload. + function wrapEnvelope(sender, intendedAmount, payload) { + return ethers.utils.solidityPack( + ["address", "uint256", "bytes"], + [sender, intendedAmount, payload] + ); + } + + // Strategy-level payload — opaque to the adapter; we pass arbitrary bytes here. + function packPayload(label) { + return ethers.utils.defaultAbiCoder.encode(["string"], [label]); + } + + beforeEach(async () => { + [governor, routerSigner, otherSigner] = await ethers.getSigners(); + + // Mock CCIP router (we'll impersonate it to call ccipReceive directly). + const RouterFactory = await ethers.getContractFactory("MockCCIPRouter"); + const router = await RouterFactory.connect(governor).deploy(); + + const WETHFactory = await ethers.getContractFactory("MockWETH"); + wethMock = await WETHFactory.connect(governor).deploy(); + + const ReceiverFactory = await ethers.getContractFactory( + "SuperbridgeAdapter" + ); + // Inbound-only deployment: pass address(0) for the L1StandardBridge (unused on + // the L2 side; outbound entrypoints revert when invoked). The L2-side `receive()` + // wraps incoming native ETH to WETH (the adapter's `weth` immutable). + receiver = await ReceiverFactory.connect(governor).deploy( + ethers.constants.AddressZero, + router.address, + wethMock.address + ); + + const StrategyFactory = await ethers.getContractFactory( + "MockBridgeReceiver" + ); + strategy = await StrategyFactory.connect(governor).deploy(); + strategy2 = await StrategyFactory.connect(governor).deploy(); + + // Lane config for each authorised sender: paused=false, chain=mainnet, gas=500k. + const cfg = { + paused: false, + chainSelector: PEER_CHAIN, + destGasLimit: DEST_GAS_LIMIT, + }; + await receiver.connect(governor).authorise(strategy.address, cfg); + }); + + // Simulate the OP Stack canonical bridge delivering native ETH to the adapter. + // The adapter's `receive()` wraps the ETH into the local WETH automatically. + const deliverBridgeEth = async (amount) => { + await governor.sendTransaction({ to: receiver.address, value: amount }); + }; + + it("token-carrying message with tokens already on adapter delivers atomically", async () => { + const amount = ethers.utils.parseUnits("100", 6); + await deliverBridgeEth(amount); + + const data = wrapEnvelope( + strategy.address, + amount, + packPayload("claim-ack") + ); + + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ); + + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(false); + expect(await strategy.callCount()).to.equal(1); + expect(await strategy.lastAmount()).to.equal(amount); + expect(await strategy.lastToken()).to.equal(wethMock.address); + expect(await wethMock.balanceOf(strategy.address)).to.equal(amount); + expect(await wethMock.balanceOf(receiver.address)).to.equal(0); + }); + + it("message-first: stores until tokens land, then exact delivery", async () => { + const amount = ethers.utils.parseUnits("250", 6); + const data = wrapEnvelope(strategy.address, amount, packPayload("pending")); + + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ); + + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(true); + expect(await strategy.callCount()).to.equal(0); + + await expect( + receiver.processStoredMessage(strategy.address) + ).to.be.revertedWith("Super: tokens not yet landed"); + + // Tokens arrive (canonical bridge credits native ETH to the adapter; `receive()` + // wraps to WETH). Donate one extra wei to confirm the receiver delivers exactly + // `intendedAmount` rather than the full balance. + await deliverBridgeEth(amount.add(1)); + + await receiver.processStoredMessage(strategy.address); + + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(false); + expect(await strategy.callCount()).to.equal(1); + expect(await strategy.lastAmount()).to.equal(amount); + expect(await wethMock.balanceOf(strategy.address)).to.equal(amount); + expect(await wethMock.balanceOf(receiver.address)).to.equal(1); + }); + + it("intendedAmount=0 is message-only — delivers immediately, no token leg", async () => { + const data = wrapEnvelope(strategy.address, 0, packPayload("message-only")); + + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ); + + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(false); + expect(await strategy.callCount()).to.equal(1); + expect(await strategy.lastAmount()).to.equal(0); + expect(await strategy.lastToken()).to.equal(ethers.constants.AddressZero); + }); + + it("rejects an envelope whose sender is not whitelisted", async () => { + const data = wrapEnvelope(otherSigner.address, 0, packPayload("evil")); + + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await expect( + receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ) + ).to.be.revertedWith("Adapter: not authorised"); + + // Direct call from a non-router caller is rejected by the modifier. + const authData = wrapEnvelope(strategy.address, 0, packPayload("noop")); + await expect( + receiver.connect(routerSigner).ccipReceive( + buildAny2EvmMessage({ + data: authData, + transportSender: receiver.address, + }) + ) + ).to.be.revertedWith("Super: not router"); + }); + + it("rejects a message whose transport sender is not the peer adapter", async () => { + // CREATE3 parity: transport sender must equal address(this). A spoofed source-chain + // contract that managed to craft a CCIP message with a forged envelope sender still + // fails this check. + const data = wrapEnvelope(strategy.address, 0, packPayload("spoof")); + + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await expect( + receiver.connect(sRouter).ccipReceive( + buildAny2EvmMessage({ + data, + transportSender: otherSigner.address, + }) + ) + ).to.be.revertedWith("Adapter: not from peer adapter"); + }); + + it("respects per-lane pause for inbound delivery", async () => { + await receiver.connect(governor).pauseLane(strategy.address); + + const data = wrapEnvelope(strategy.address, 0, packPayload("paused")); + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + await expect( + receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ) + ).to.be.revertedWith("Adapter: lane paused"); + + // Unpause restores delivery. + await receiver.connect(governor).unpauseLane(strategy.address); + await receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ); + expect(await strategy.callCount()).to.equal(1); + }); + + it("multi-tenant: one adapter routes messages to distinct targets by envelope sender", async () => { + const cfg = { + paused: false, + chainSelector: PEER_CHAIN, + destGasLimit: DEST_GAS_LIMIT, + }; + await receiver.connect(governor).authorise(strategy2.address, cfg); + + const amount1 = ethers.utils.parseUnits("100", 6); + const amount2 = ethers.utils.parseUnits("250", 6); + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + + await receiver.connect(sRouter).ccipReceive( + buildAny2EvmMessage({ + data: wrapEnvelope(strategy.address, amount1, packPayload("a")), + transportSender: receiver.address, + }) + ); + await receiver.connect(sRouter).ccipReceive( + buildAny2EvmMessage({ + data: wrapEnvelope(strategy2.address, amount2, packPayload("b")), + transportSender: receiver.address, + }) + ); + + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(true); + expect(await receiver.hasPendingMessage(strategy2.address)).to.equal(true); + + // Fund the bridge-ETH leg for the second tenant first and process — confirms slots + // don't collide and tokens credit the right target. + await deliverBridgeEth(amount2); + await receiver.processStoredMessage(strategy2.address); + expect(await receiver.hasPendingMessage(strategy2.address)).to.equal(false); + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(true); + expect(await strategy2.lastAmount()).to.equal(amount2); + expect(await wethMock.balanceOf(strategy2.address)).to.equal(amount2); + expect(await strategy.callCount()).to.equal(0); + + // Now fund the bridge-ETH leg for the first tenant and process. + await deliverBridgeEth(amount1); + await receiver.processStoredMessage(strategy.address); + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(false); + expect(await strategy.lastAmount()).to.equal(amount1); + expect(await wethMock.balanceOf(strategy.address)).to.equal(amount1); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/transfer-caps.js b/contracts/test/strategies/crosschainV3/transfer-caps.js new file mode 100644 index 0000000000..a4bac39b2f --- /dev/null +++ b/contracts/test/strategies/crosschainV3/transfer-caps.js @@ -0,0 +1,567 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { impersonateAndFund } = require("../../../utils/signers"); + +// remoteStrategyBalance is OToken-denominated (18dp); withdraw amounts and adapter transfer +// caps are bridgeAsset units (6dp for USDC). SCALE is the 6→18 factor used when seeding rsb. +const SCALE = ethers.BigNumber.from(10).pow(12); + +/** + * Coverage for the adapter-level transfer caps + CCTPAdapter-specific behaviour + * (MAX_TRANSFER_AMOUNT constant, minTransferAmount setter, minFinalityThreshold + * pre-init guard, fast-finality unfinalised handler). + * + * Separated from `fee-path.js` because the caps mechanism is orthogonal to fee + * plumbing and warrants standalone coverage. + */ +describe("Unit: Adapter transfer caps", function () { + describe("AbstractAdapter (via CCIPAdapter)", function () { + let governor, sender, alice; + let router, weth, adapter; + const CCIP_DEST = ethers.BigNumber.from("5009297550715157269"); + + beforeEach(async () => { + [governor, sender, , alice] = await ethers.getSigners(); + + const RouterFactory = await ethers.getContractFactory("MockCCIPRouter"); + router = await RouterFactory.connect(governor).deploy(); + + const WETHFactory = await ethers.getContractFactory("MockWETH"); + weth = await WETHFactory.connect(governor).deploy(); + + const AdapterFactory = await ethers.getContractFactory("CCIPAdapter"); + adapter = await AdapterFactory.connect(governor).deploy(router.address); + await adapter.connect(governor).authorise(sender.address, { + paused: false, + chainSelector: CCIP_DEST, + destGasLimit: 200000, + }); + }); + + it("default maxTransferAmount = 0 disables the cap", async () => { + expect(await adapter.maxTransferAmount()).to.equal(0); + + // Mint a large amount and approve; the router will accept any size at fee=0. + await router.setFee(0); + const big = ethers.utils.parseEther("999999"); + await weth.connect(sender).deposit({ value: 0 }); + // MockWETH supports mintTo; use it for convenience. + await weth.mintTo(sender.address, big); + await weth.connect(sender).approve(adapter.address, big); + + await expect( + adapter + .connect(sender) + .sendMessageAndTokens(weth.address, big, "0xdead") + ).to.not.be.reverted; + }); + + it("enforces maxTransferAmount when set", async () => { + await router.setFee(0); + const cap = ethers.utils.parseEther("1000"); + await adapter.connect(governor).setMaxTransferAmount(cap); + + const tooBig = cap.add(1); + await weth.mintTo(sender.address, tooBig); + await weth.connect(sender).approve(adapter.address, tooBig); + + await expect( + adapter.connect(sender).sendMessageAndTokens(weth.address, tooBig, "0x") + ).to.be.revertedWith("Adapter: amount above max"); + + // Exactly at the cap succeeds. + await weth.connect(sender).approve(adapter.address, cap); + await expect( + adapter.connect(sender).sendMessageAndTokens(weth.address, cap, "0x") + ).to.not.be.reverted; + }); + + it("setMaxTransferAmount is governor-only and emits", async () => { + await expect( + adapter.connect(alice).setMaxTransferAmount(1) + ).to.be.revertedWith("Caller is not the Governor"); + + await expect(adapter.connect(governor).setMaxTransferAmount(123)) + .to.emit(adapter, "MaxTransferAmountUpdated") + .withArgs(0, 123); + expect(await adapter.maxTransferAmount()).to.equal(123); + }); + + it("transferToken sweep — native ETH path, governor-only", async () => { + // Donate 1 ETH directly to the adapter. + const donation = ethers.utils.parseEther("1"); + await governor.sendTransaction({ to: adapter.address, value: donation }); + expect(await ethers.provider.getBalance(adapter.address)).to.equal( + donation + ); + + // Non-governor cannot sweep. + await expect( + adapter + .connect(alice) + .transferToken(ethers.constants.AddressZero, donation) + ).to.be.revertedWith("Caller is not the Governor"); + + // Governor sweep — ETH lands at governor's address. + const balanceBefore = await ethers.provider.getBalance(governor.address); + const tx = await adapter + .connect(governor) + .transferToken(ethers.constants.AddressZero, donation); + const receipt = await tx.wait(); + const gas = receipt.gasUsed.mul(receipt.effectiveGasPrice); + const balanceAfter = await ethers.provider.getBalance(governor.address); + // Net change = +donation - gas. + expect(balanceAfter.sub(balanceBefore).add(gas)).to.equal(donation); + expect(await ethers.provider.getBalance(adapter.address)).to.equal(0); + }); + + it("transferToken sweep — ERC20 path", async () => { + const donation = ethers.utils.parseEther("5"); + await weth.mintTo(adapter.address, donation); + expect(await weth.balanceOf(adapter.address)).to.equal(donation); + + await expect( + adapter.connect(alice).transferToken(weth.address, donation) + ).to.be.revertedWith("Caller is not the Governor"); + + await adapter.connect(governor).transferToken(weth.address, donation); + expect(await weth.balanceOf(adapter.address)).to.equal(0); + expect(await weth.balanceOf(governor.address)).to.equal(donation); + }); + }); + + describe("CCTPAdapter — constant cap + min + threshold + fast finality", function () { + let governor, operator, alice; + let usdc, transmitter, tokenMessenger, adapter, strategy; + const SOURCE_DOMAIN = 6; + const TEN_MILLION = ethers.utils.parseUnits("10000000", 6); + + function addrToBytes32(addr) { + return ethers.utils.hexZeroPad(addr, 32); + } + + function buildCCTPMessage({ + version = 1, + sourceDomain = SOURCE_DOMAIN, + sender, + recipient, + body, + }) { + return ethers.utils.solidityPack( + [ + "uint32", + "uint32", + "uint32", + "bytes32", + "bytes32", + "bytes32", + "bytes32", + "uint32", + "uint32", + "bytes", + ], + [ + version, + sourceDomain, + 0, + ethers.constants.HashZero, + addrToBytes32(sender), + addrToBytes32(recipient), + ethers.constants.HashZero, + 0, + 0, + body, + ] + ); + } + + function appEnvelope(envSender, intendedAmount, payload) { + return ethers.utils.solidityPack( + ["address", "uint256", "bytes"], + [envSender, intendedAmount, payload] + ); + } + + beforeEach(async () => { + [governor, operator, alice] = await ethers.getSigners(); + + const USDCFactory = await ethers.getContractFactory("MockUSDC"); + usdc = await USDCFactory.deploy(); + + const TransmitterFactory = await ethers.getContractFactory( + "MockCCTPRelayTransmitter" + ); + transmitter = await TransmitterFactory.deploy(); + + const TokenMessengerFactory = await ethers.getContractFactory( + "CCTPTokenMessengerMock" + ); + tokenMessenger = await TokenMessengerFactory.deploy( + usdc.address, + transmitter.address + ); + + const AdapterFactory = await ethers.getContractFactory("CCTPAdapter"); + adapter = await AdapterFactory.connect(governor).deploy( + usdc.address, + tokenMessenger.address, + transmitter.address + ); + + await adapter.connect(governor).setOperator(operator.address); + + const StrategyFactory = await ethers.getContractFactory( + "MockBridgeReceiver" + ); + strategy = await StrategyFactory.connect(governor).deploy(); + await adapter.connect(governor).authorise(strategy.address, { + paused: false, + chainSelector: SOURCE_DOMAIN, + destGasLimit: 500000, + }); + }); + + it("exposes MAX_TRANSFER_AMOUNT = 10M USDC as a constant", async () => { + expect(await adapter.MAX_TRANSFER_AMOUNT()).to.equal(TEN_MILLION); + }); + + it("_sendMessage reverts when minFinalityThreshold is not set", async () => { + await adapter.connect(governor).authorise(alice.address, { + paused: false, + chainSelector: SOURCE_DOMAIN, + destGasLimit: 500000, + }); + await expect( + adapter.connect(alice).sendMessage("0xdeadbeef") + ).to.be.revertedWith("CCTP: threshold not set"); + }); + + it("_sendMessageAndTokens reverts when below min, above CCTP cap, and when threshold unset", async () => { + const sender = await impersonateAndFund(strategy.address); + + // Threshold unset → revert + await usdc.mintTo(strategy.address, TEN_MILLION); + await usdc.connect(sender).approve(adapter.address, TEN_MILLION); + await expect( + adapter.connect(sender).sendMessageAndTokens(usdc.address, 1000, "0x") + ).to.be.revertedWith("CCTP: threshold not set"); + + // Set threshold + min, now bounds apply + await adapter.connect(governor).setMinFinalityThreshold(2000); + await adapter.connect(governor).setMinTransferAmount(1000); + + // Below min + await expect( + adapter.connect(sender).sendMessageAndTokens(usdc.address, 999, "0x") + ).to.be.revertedWith("CCTP: amount below min"); + + // Above the effective cap (10M + 1 wei). After round-4 #18 the base-layer check + // (via maxTransferAmount(), whose CCTP override surfaces the 10M constant) catches + // this first, so the revert now comes from AbstractAdapter, not CCTP's own require. + const tooBig = TEN_MILLION.add(1); + await usdc.mintTo(strategy.address, tooBig); + await usdc.connect(sender).approve(adapter.address, tooBig); + await expect( + adapter.connect(sender).sendMessageAndTokens(usdc.address, tooBig, "0x") + ).to.be.revertedWith("Adapter: amount above max"); + + // We don't assert the in-bounds happy path here — the TokenMessenger mock used by + // these tests (MockCCTPRelayTransmitter) is wired for inbound-relay testing and + // doesn't accept the outbound burn callback. Coverage for successful burns lives + // in the broader cctp-relay test using the v2 mock transmitter family. + }); + + it("setMinFinalityThreshold rejects out-of-range values + governor-only", async () => { + await expect( + adapter.connect(governor).setMinFinalityThreshold(999) + ).to.be.revertedWith("CCTP: bad threshold"); + await expect( + adapter.connect(governor).setMinFinalityThreshold(2001) + ).to.be.revertedWith("CCTP: bad threshold"); + await expect( + adapter.connect(alice).setMinFinalityThreshold(2000) + ).to.be.revertedWith("Caller is not the Governor"); + }); + + it("handleReceiveUnfinalizedMessage requires finalityThresholdExecuted >= minFinalityThreshold", async () => { + // Set fast finality at 1500. + await adapter.connect(governor).setMinFinalityThreshold(1500); + + // We can't easily drive handleReceiveUnfinalizedMessage from MockCCTPRelayTransmitter + // because the mock always calls handleReceiveFinalizedMessage. Call it directly + // by impersonating the transmitter. + const sTransmitter = await impersonateAndFund(transmitter.address); + + const body = appEnvelope(strategy.address, 0, "0x"); + // Build only the message body (not the full CCTP wire frame) — the handler takes + // it as the `messageBody` parameter. + + // Below threshold → revert + await expect( + adapter + .connect(sTransmitter) + .handleReceiveUnfinalizedMessage( + SOURCE_DOMAIN, + addrToBytes32(adapter.address), + 1499, + body + ) + ).to.be.revertedWith("CCTP: insufficient finality"); + + // At threshold → accepted + await adapter + .connect(sTransmitter) + .handleReceiveUnfinalizedMessage( + SOURCE_DOMAIN, + addrToBytes32(adapter.address), + 1500, + body + ); + expect(await strategy.callCount()).to.equal(1); + + // Above threshold but below 2000 (still unfinalised path) → accepted + await adapter + .connect(sTransmitter) + .handleReceiveUnfinalizedMessage( + SOURCE_DOMAIN, + addrToBytes32(adapter.address), + 1999, + body + ); + expect(await strategy.callCount()).to.equal(2); + }); + + it("handleReceiveUnfinalizedMessage reverts when threshold not set", async () => { + const sTransmitter = await impersonateAndFund(transmitter.address); + const body = appEnvelope(strategy.address, 0, "0x"); + await expect( + adapter + .connect(sTransmitter) + .handleReceiveUnfinalizedMessage( + SOURCE_DOMAIN, + addrToBytes32(adapter.address), + 1500, + body + ) + ).to.be.revertedWith("CCTP: threshold not set"); + }); + + it("handleReceiveFinalizedMessage still works at finalityThresholdExecuted=2000", async () => { + // Even without setMinFinalityThreshold being called, finalized handler accepts + // (it doesn't check minFinalityThreshold). + const message = buildCCTPMessage({ + sender: adapter.address, + recipient: adapter.address, + body: appEnvelope(strategy.address, 0, "0x"), + }); + await adapter.connect(governor).setMinFinalityThreshold(2000); // for relay path + await adapter.connect(operator).relay(message, "0x"); + expect(await strategy.callCount()).to.equal(1); + }); + }); + + describe("Master.depositAll / withdrawAll clamping by adapter caps", function () { + let deployer, governor; + let bridgeAsset, oTokenL2, mockL2Vault, master; + let outbound, inbound; + + const ONE_K = ethers.utils.parseUnits("1000", 6); + + beforeEach(async () => { + [deployer, governor] = await ethers.getSigners(); + + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await VaultFactory.deploy(); + + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken", + "mOT", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const impl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const proxy = await ProxyFactory.connect(deployer).deploy(); + await proxy + .connect(deployer) + .initialize( + impl.address, + governor.address, + impl.interface.encodeFunctionData("initialize", [governor.address]) + ); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + proxy.address + ); + await mockL2Vault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory( + "MockBridgeAdapter" + ); + outbound = await AdapterFactory.deploy(); + inbound = await AdapterFactory.deploy(); + await outbound.setSender(master.address); + await inbound.setPeer(master.address); + await master.connect(governor).setOutboundAdapter(outbound.address); + await master.connect(governor).setInboundAdapter(inbound.address); + }); + + it("depositAll clamps localBalance by outboundAdapter.maxTransferAmount", async () => { + // Fund Master with 3000 USDC (vault-style). + await bridgeAsset.mintTo(master.address, ONE_K.mul(3)); + // Cap the outbound at 1000. + await outbound.setMaxTransferAmountOverride(ONE_K); + + await mockL2Vault.callDepositAll(master.address); + + // Adapter saw exactly 1000. + expect(await outbound.lastAmountSent()).to.equal(ONE_K); + // Remainder still on Master for the next depositAll cycle. + expect(await bridgeAsset.balanceOf(master.address)).to.equal( + ONE_K.mul(2) + ); + }); + + it("depositAll sends the full balance when cap is 0 (unlimited)", async () => { + await bridgeAsset.mintTo(master.address, ONE_K.mul(3)); + await outbound.setMaxTransferAmountOverride(0); + await mockL2Vault.callDepositAll(master.address); + expect(await outbound.lastAmountSent()).to.equal(ONE_K.mul(3)); + }); + + it("withdrawAll clamps remoteStrategyBalance by inboundAdapter.maxTransferAmount", async () => { + // Seed Master with a remoteStrategyBalance of 5000 via a fake deposit+ack cycle. + // Simplest: directly call deposit + flush the ack envelope via the mock adapter. + await bridgeAsset.mintTo(master.address, ONE_K.mul(5)); + await mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + ONE_K.mul(5) + ); + // Send DEPOSIT_ACK back so pendingDepositAmount clears and remoteStrategyBalance = 5000 + // OToken (18dp — Remote reports its baseline in OToken units). + const ackBody = ethers.utils.defaultAbiCoder.encode( + ["uint256"], + [ONE_K.mul(5).mul(SCALE)] + ); + const ackEnvelope = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [2, 1, ackBody] // DEPOSIT_ACK msgType=2, nonce=1 + ); + await inbound.sendMessage(ackEnvelope); + expect(await master.remoteStrategyBalance()).to.equal( + ONE_K.mul(5).mul(SCALE) + ); + + // Cap the inbound at 2000 (bridgeAsset units). withdrawAll clamps the scaled-down amount. + await inbound.setMaxTransferAmountOverride(ONE_K.mul(2)); + await mockL2Vault.callWithdrawAll(master.address); + + // Master sent WITHDRAW_REQUEST with amount = 2000 via outbound. + const sentEnvelope = await outbound.lastMessageSent(); + const [msgType, , body] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sentEnvelope + ); + expect(msgType).to.equal(3); // WITHDRAW_REQUEST + const [amount] = ethers.utils.defaultAbiCoder.decode(["uint256"], body); + expect(amount).to.equal(ONE_K.mul(2)); + expect(await master.pendingWithdrawalAmount()).to.equal(ONE_K.mul(2)); + }); + + it("withdrawAll sends the full remoteStrategyBalance when inbound cap is 0", async () => { + // Same setup as the clamp test, but with the inbound cap left at 0 (unlimited). + await bridgeAsset.mintTo(master.address, ONE_K.mul(5)); + await mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + ONE_K.mul(5) + ); + const ackBody = ethers.utils.defaultAbiCoder.encode( + ["uint256"], + [ONE_K.mul(5).mul(SCALE)] + ); + const ackEnvelope = ethers.utils.defaultAbiCoder.encode( + ["uint32", "uint64", "bytes"], + [2, 1, ackBody] + ); + await inbound.sendMessage(ackEnvelope); + + // Inbound cap = 0 (default override). withdrawAll ships the full 5000 (bridgeAsset units). + await inbound.setMaxTransferAmountOverride(0); + await mockL2Vault.callWithdrawAll(master.address); + + const sentEnvelope = await outbound.lastMessageSent(); + const [, , body] = ethers.utils.defaultAbiCoder.decode( + ["uint32", "uint64", "bytes"], + sentEnvelope + ); + const [amount] = ethers.utils.defaultAbiCoder.decode(["uint256"], body); + expect(amount).to.equal(ONE_K.mul(5)); + expect(await master.pendingWithdrawalAmount()).to.equal(ONE_K.mul(5)); + }); + + // round-4 #1: deposit side mirrors the withdraw-side min floor as a best-effort no-op. + it("depositAll no-ops when the swept balance is below the outbound min floor (#1)", async () => { + await bridgeAsset.mintTo(master.address, ONE_K.div(2)); // 500 + await outbound.setMinTransferAmountOverride(ONE_K); // floor 1000 + + await mockL2Vault.callDepositAll(master.address); + + // Nothing bridged; funds stay local and are still counted in checkBalance. + expect(await outbound.lastAmountSent()).to.equal(0); + expect(await master.pendingDepositAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(master.address)).to.equal( + ONE_K.div(2) + ); + expect(await master.checkBalance(bridgeAsset.address)).to.equal( + ONE_K.div(2) + ); + }); + + it("deposit no-ops on a sub-min amount, leaving funds local (#1)", async () => { + await bridgeAsset.mintTo(master.address, ONE_K.div(2)); // 500 + await outbound.setMinTransferAmountOverride(ONE_K); // floor 1000 + + await mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + ONE_K.div(2) + ); + + expect(await master.pendingDepositAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(master.address)).to.equal( + ONE_K.div(2) + ); + }); + + it("deposit at the min floor still bridges (#1)", async () => { + await bridgeAsset.mintTo(master.address, ONE_K); // 1000 == floor + await outbound.setMinTransferAmountOverride(ONE_K); + + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, ONE_K); + + expect(await outbound.lastAmountSent()).to.equal(ONE_K); + expect(await master.pendingDepositAmount()).to.equal(ONE_K); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/withdrawal.js b/contracts/test/strategies/crosschainV3/withdrawal.js new file mode 100644 index 0000000000..b1b5b7a497 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/withdrawal.js @@ -0,0 +1,530 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); + +/** + * End-to-end exercise of the cross-chain withdrawal flow with idempotent claim, run on the + * paired Master+Remote loopback. + * + * Flow under test: + * 1. Master.deposit (SEED) → Remote wraps to wOToken (post-ack, remoteBalance == SEED) + * 2. Vault calls master.withdraw(SEED, …) → leg 1 WITHDRAW_REQUEST + * - Remote unwraps shares, calls oTokenVault.requestWithdrawal, stores requestId+amount + * - Remote replies WITHDRAW_REQUEST_ACK with newBalance + * 3. Time advances past the OToken-vault claim delay + * 4. Permissionless `claimRemoteWithdrawal` from any caller pulls bridgeAsset onto Remote + * (idempotent: safe to call twice) + * 5. Operator calls master.triggerClaim() → leg 2 WITHDRAW_CLAIM + * - Remote bridges bridgeAsset back, replies WITHDRAW_CLAIM_ACK (success=true) + * - Master clears pendingWithdrawalAmount, forwards bridgeAsset to vault + * + * Also covers: NACK path (claim attempted before vault delay elapsed), + * opportunistic claim within leg 2 (no automation involved), + * double-claim idempotency. + */ + +describe("Unit: V3 Withdrawal", function () { + let deployer, governor, alice; + let bridgeAsset, oTokenL2, mockL2Vault; + let oTokenEth, woTokenEth, ethVault; + let master, remote; + let adapterME, adapterRM; + + const SEED = ethers.utils.parseUnits("10000", 6); + const WITHDRAW = ethers.utils.parseUnits("4000", 6); + const DELAY = 86400; // 1 day queue delay + // bridgeAsset (USDC) is 6dp; remoteStrategyBalance / wOToken shares are OToken (18dp). + // Withdraw amounts, outstandingRequestAmount, and checkBalance are bridgeAsset units. + const SCALE = ethers.BigNumber.from(10).pow(12); + + beforeEach(async () => { + [deployer, governor, alice] = await ethers.getSigners(); + + const ERC20Factory = await ethers.getContractFactory("MockUSDC"); + bridgeAsset = await ERC20Factory.deploy(); + + const L2VaultFactory = await ethers.getContractFactory("MockOTokenVault"); + mockL2Vault = await L2VaultFactory.deploy(); + const OTokenFactory = await ethers.getContractFactory( + "MockMintableBurnableOToken" + ); + oTokenL2 = await OTokenFactory.deploy( + "Mock OToken L2", + "mOTL2", + mockL2Vault.address + ); + await mockL2Vault.setOToken(oTokenL2.address); + + const EthVaultFactory = await ethers.getContractFactory( + "MockEthOTokenVault" + ); + const ethNonce = await ethers.provider.getTransactionCount( + deployer.address + ); + const futureEthVault = ethers.utils.getContractAddress({ + from: deployer.address, + nonce: ethNonce + 1, + }); + oTokenEth = await OTokenFactory.deploy( + "Mock OToken Eth", + "mOTEth", + futureEthVault + ); + ethVault = await EthVaultFactory.deploy( + bridgeAsset.address, + oTokenEth.address + ); + await ethVault.setWithdrawalClaimDelay(DELAY); + + const WoFactory = await ethers.getContractFactory("MockERC4626Vault"); + woTokenEth = await WoFactory.deploy(oTokenEth.address); + + const MasterFactory = await ethers.getContractFactory( + "MasterWOTokenStrategy" + ); + const masterImpl = await MasterFactory.connect(deployer).deploy( + { + platformAddress: ethers.constants.AddressZero, + vaultAddress: mockL2Vault.address, + }, + bridgeAsset.address, + oTokenL2.address + ); + + const RemoteFactory = await ethers.getContractFactory( + "RemoteWOTokenStrategy" + ); + const remoteImpl = await RemoteFactory.connect(deployer).deploy( + { + platformAddress: woTokenEth.address, + vaultAddress: ethers.constants.AddressZero, + }, + bridgeAsset.address, + oTokenEth.address, + woTokenEth.address, + ethVault.address + ); + + const ProxyFactory = await ethers.getContractFactory( + "InitializeGovernedUpgradeabilityProxy" + ); + const masterProxy = await ProxyFactory.connect(deployer).deploy(); + await masterProxy + .connect(deployer) + .initialize( + masterImpl.address, + governor.address, + masterImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + master = await ethers.getContractAt( + "MasterWOTokenStrategy", + masterProxy.address + ); + + const remoteProxy = await ProxyFactory.connect(deployer).deploy(); + await remoteProxy + .connect(deployer) + .initialize( + remoteImpl.address, + governor.address, + remoteImpl.interface.encodeFunctionData("initialize", [ + governor.address, + ]) + ); + remote = await ethers.getContractAt( + "RemoteWOTokenStrategy", + remoteProxy.address + ); + + await mockL2Vault.whitelistStrategy(master.address); + + const AdapterFactory = await ethers.getContractFactory("MockBridgeAdapter"); + adapterME = await AdapterFactory.deploy(); + adapterRM = await AdapterFactory.deploy(); + await adapterME.setSender(master.address); + await adapterME.setPeer(remote.address); + await adapterRM.setSender(remote.address); + await adapterRM.setPeer(master.address); + + await master.connect(governor).setOutboundAdapter(adapterME.address); + await master.connect(governor).setInboundAdapter(adapterRM.address); + await remote.connect(governor).setOutboundAdapter(adapterRM.address); + await remote.connect(governor).setInboundAdapter(adapterME.address); + await remote.connect(governor).safeApproveAllTokens(); + + // Seed Remote with SEED via a deposit round-trip so withdrawals have something to draw on. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + + expect(await master.remoteStrategyBalance()).to.equal(SEED.mul(SCALE)); + expect(await woTokenEth.balanceOf(remote.address)).to.equal( + SEED.mul(SCALE) + ); + }); + + it("happy path: leg1 → automation claim → leg2 returns tokens to vault", async () => { + // Leg 1: vault triggers a withdrawal request. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + expect(await master.pendingWithdrawalAmount()).to.equal(WITHDRAW); + // Remote's checkBalance stays at SEED — queue + remaining shares. outstandingRequestAmount + // tracks the bridgeAsset value (6dp) committed to the queue. + expect(await remote.outstandingRequestAmount()).to.equal(WITHDRAW); + expect(await remote.outstandingRequestId()).to.be.gt(0); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(SEED); + expect(await master.remoteStrategyBalance()).to.equal(SEED.mul(SCALE)); + + // Advance past the queue delay and claim from Ethereum (permissionless). + await time.increase(DELAY + 1); + await remote.connect(alice).claimRemoteWithdrawal(); + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(SEED); + + // Leg 2: operator triggers the bridge-back. + await master.connect(governor).triggerClaim(); + + // Master forwarded WITHDRAW tokens to the vault. + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + // Remote's balance dropped by WITHDRAW (18dp on Remote, 6dp on checkBalance). + expect(await master.remoteStrategyBalance()).to.equal( + SEED.sub(WITHDRAW).mul(SCALE) + ); + expect(await remote.checkBalance(bridgeAsset.address)).to.equal( + SEED.sub(WITHDRAW) + ); + }); + + it("handles a fresh-vault requestId of 0 without bricking (P1 offset-by-one)", async () => { + // Fresh OToken vault: the first-ever withdrawal returns requestId 0. Pre-fix, Remote + // stored 0 verbatim — indistinguishable from "no request" — which dropped the queued + // value from checkBalance and NACK-looped leg-2 forever. With the offset, id 0 is stored + // as 1, so the full lifecycle completes. + await ethVault.setNextRequestId(0); + + // Leg 1. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + // Real vault requestId 0 → stored offset-by-one as 1 (recognised as a live queue request). + expect(await remote.outstandingRequestId()).to.equal(1); + // Queued value still counted (not lost) — checkBalance preserved. + expect(await remote.checkBalance(bridgeAsset.address)).to.equal(SEED); + expect(await master.remoteStrategyBalance()).to.equal(SEED.mul(SCALE)); + + // Leg 2 completes — would NACK-loop / brick the channel without the offset fix. + await time.increase(DELAY + 1); + await master.connect(governor).triggerClaim(); + + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + }); + + it("opportunistic claim path: leg 2 claims and ships without prior automation", async () => { + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + // Skip the automation call; just advance past the delay. + await time.increase(DELAY + 1); + + // Leg 2 triggers the opportunistic claim inside Remote._processWithdrawClaim. + await master.connect(governor).triggerClaim(); + + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + }); + + it("NACK path: leg 2 before delay elapses returns no tokens, retains pending state", async () => { + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + // No advance — queue delay not yet met. + // Permissionless claim attempt is a no-op. + await remote.claimRemoteWithdrawal(); + expect(await remote.outstandingRequestId()).to.be.gt(0); + + // Leg 2 attempts and gets a NACK. + await master.connect(governor).triggerClaim(); + + // Pending state must still be set so retry is possible. + expect(await master.pendingWithdrawalAmount()).to.equal(WITHDRAW); + expect(await remote.outstandingRequestId()).to.be.gt(0); + // No tokens reached the vault. + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(0); + + // Now elapse the delay and re-trigger leg 2 — it should succeed. + await time.increase(DELAY + 1); + await master.connect(governor).triggerClaim(); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + }); + + it("donation during the queue window is NOT shipped and does NOT orphan the request (P0-B)", async () => { + // Leg 1: queue a withdrawal. The OToken-vault claim delay (DELAY) has NOT elapsed. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + expect(await remote.outstandingRequestId()).to.be.gt(0); + + // An attacker donates >= the target bridgeAsset to Remote DURING the delay window. + await bridgeAsset.mintTo(remote.address, WITHDRAW); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); + + // Leg 2 fires before the queue is claimable. The opportunistic claim reverts (delay), so + // outstandingRequestId stays set. Even though held >= target (the donation), the id-gate + // forces a NACK: the donation must NOT ship and the real queue must NOT be orphaned. + await master.connect(governor).triggerClaim(); + + expect(await master.pendingWithdrawalAmount()).to.equal(WITHDRAW); // still pending + expect(await remote.outstandingRequestId()).to.be.gt(0); // queue intact, not orphaned + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(0); // nothing shipped + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); // donation stays + + // After the delay, the real claim lands. Leg 2 ships EXACTLY the claimed amount and the + // donation is left behind on Remote (realised as yield on the next balance report). + await time.increase(DELAY + 1); + await master.connect(governor).triggerClaim(); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + // The donation (WITHDRAW) remains on Remote — never attributed to the withdrawal. + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); + }); + + it("claimRemoteWithdrawal is idempotent (safe to call twice)", async () => { + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + await time.increase(DELAY + 1); + await remote.claimRemoteWithdrawal(); + expect(await remote.outstandingRequestId()).to.equal(0); + + // Second call is a no-op — does not revert. + await remote.claimRemoteWithdrawal(); + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); + }); + + it("rejects a concurrent withdrawal while one is already pending", async () => { + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + await expect( + mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ) + ).to.be.revertedWith("Master: deposit or withdrawal pending"); + }); + + it("rejects triggerClaim when no withdrawal is pending", async () => { + await expect(master.connect(governor).triggerClaim()).to.be.revertedWith( + "Master: no pending withdrawal" + ); + }); + + it("leg 2 ships only the requested amount, leaving donated residual on Remote", async () => { + // Leg 1. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + + // Permissionless claim materialises bridgeAsset on Remote. + await time.increase(DELAY + 1); + await remote.claimRemoteWithdrawal(); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(WITHDRAW); + expect(await remote.outstandingRequestAmount()).to.equal(WITHDRAW); + + // Donate residual bridgeAsset to Remote (donation, leftover, rounding gain). + const DONATION = ethers.utils.parseUnits("777", 6); + await bridgeAsset.mintTo(remote.address, DONATION); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal( + WITHDRAW.add(DONATION) + ); + + // Leg 2 must only ship WITHDRAW, leaving DONATION behind. + await master.connect(governor).triggerClaim(); + + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + expect(await bridgeAsset.balanceOf(remote.address)).to.equal(DONATION); + // Master's view of Remote reflects shares-remaining + donation that stayed on Remote. + // The donation is real value the strategy now holds — it should appear in Master's view. + expect(await master.remoteStrategyBalance()).to.equal( + SEED.sub(WITHDRAW).add(DONATION).mul(SCALE) + ); + // outstandingRequestAmount cleared after leg-2 success. + expect(await remote.outstandingRequestAmount()).to.equal(0); + }); + + it("WITHDRAW_CLAIM_ACK payload carries the exact shipped amount", async () => { + // Drive a full happy-path withdrawal and capture the most recent message in adapterRM. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + await time.increase(DELAY + 1); + await remote.claimRemoteWithdrawal(); + await master.connect(governor).triggerClaim(); + + // The Master view confirms the ack amount matched the payload (else it would have + // reverted with "Master: claim above ack" under the relaxed equality form). + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(WITHDRAW); + }); + + it("claim ack tolerates `amount < ackAmount` (CCTP fast-finality fee scenario)", async () => { + // Drive leg 1 then claim on Remote. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + await time.increase(DELAY + 1); + await remote.claimRemoteWithdrawal(); + + // Inspect the adapter that ships WITHDRAW_CLAIM_ACK from Remote to Master. We swap + // the delivered amount to be SHORT of `ackAmount` by 1 unit, simulating a fast- + // finality fee deduction during cross-chain transit. + // + // Use the mock-adapter override: when leg 2 fires, instead of delivering the exact + // ackAmount, we intercept and deliver amount-1. The relaxed `amount <= ackAmount` + // check must accept it. + const FEE = 1; + await adapterRM.setUnderdeliveryForNextMessage(FEE); + + // The relaxed `amount <= ackAmount` check accepts the shortfall; Master emits + // WithdrawClaimAcked with `success = true` even though delivered < ackAmount. + // (The shortfall is yield drag, refreshed on the next BALANCE_CHECK.) + await expect(master.connect(governor).triggerClaim()).to.emit( + master, + "WithdrawClaimAcked" + ); + + expect(await master.pendingWithdrawalAmount()).to.equal(0); + // Vault received `WITHDRAW - FEE` because that's what landed on Master. + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal( + WITHDRAW.sub(FEE) + ); + }); + + describe("bridge bounds (leg-1 pre-check + leg-2 NACK)", () => { + // Master's inbound adapter (adapterRM) mirrors Remote's outbound — the bounds source for + // both the Master leg-1 pre-check and the Remote leg-2 NACK. + it("leg-1 rejects a sub-min withdrawal", async () => { + await adapterRM.setMinTransferAmountOverride( + ethers.utils.parseUnits("5000", 6) + ); + await expect( + mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW // 4000 < 5000 floor + ) + ).to.be.revertedWith("Master: amount below bridge min"); + }); + + it("leg-1 rejects an above-cap withdrawal", async () => { + await adapterRM.setMaxTransferAmountOverride( + ethers.utils.parseUnits("1000", 6) + ); + await expect( + mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW // 4000 > 1000 cap + ) + ).to.be.revertedWith("Master: amount above bridge max"); + }); + + it("withdrawAll no-ops below the bridge floor", async () => { + // Floor above the whole seeded balance → nothing is sweepable; best-effort no-op. + await adapterRM.setMinTransferAmountOverride( + ethers.utils.parseUnits("20000", 6) + ); + await mockL2Vault.callWithdrawAll(master.address); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + }); + + it("withdrawAll no-ops when the inbound adapter is unset", async () => { + await master + .connect(governor) + .setInboundAdapter(ethers.constants.AddressZero); + await mockL2Vault.callWithdrawAll(master.address); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + }); + + it("leg-2 NACKs (no revert/brick) on a bounds desync, then completes once resolved", async () => { + // Leg 1 passes the pre-check (default bounds); a desync then shrinks the outbound floor + // above the claimed amount before leg 2. + await mockL2Vault.callWithdraw( + master.address, + mockL2Vault.address, + bridgeAsset.address, + WITHDRAW + ); + await time.increase(DELAY + 1); + await adapterRM.setMinTransferAmountOverride( + ethers.utils.parseUnits("5000", 6) // > WITHDRAW (4000) + ); + + // Leg 2 NACKs instead of reverting: pending stays set, nothing shipped, channel free. + await master.connect(governor).triggerClaim(); + expect(await master.pendingWithdrawalAmount()).to.equal(WITHDRAW); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal(0); + expect(await remote.outstandingRequestId()).to.equal(0); // claimed, held on Remote + expect(await master.isYieldOpInFlight()).to.equal(false); + + // Resolve the desync and retry — the withdrawal completes. + await adapterRM.setMinTransferAmountOverride(0); + await master.connect(governor).triggerClaim(); + expect(await master.pendingWithdrawalAmount()).to.equal(0); + expect(await bridgeAsset.balanceOf(mockL2Vault.address)).to.equal( + WITHDRAW + ); + }); + }); +}); diff --git a/contracts/test/strategies/crosschainV3/withdrawal.mainnet.fork-test.js b/contracts/test/strategies/crosschainV3/withdrawal.mainnet.fork-test.js new file mode 100644 index 0000000000..bdc23a4954 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/withdrawal.mainnet.fork-test.js @@ -0,0 +1,217 @@ +const { createFixtureLoader, defaultFixture } = require("../../_fixture"); +const { expect } = require("chai"); +const { isCI } = require("../../helpers"); +const { impersonateAndFund } = require("../../../utils/signers"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); +const addresses = require("../../../utils/addresses"); +const { getCreate2ProxyAddress } = require("../../../deploy/deployActions"); + +const { MSG, encodePackedEnvelope } = require("./_helpers"); + +const mainnetFixture = createFixtureLoader(defaultFixture); + +const encodeAmountPayload = (amount) => + ethers.utils.defaultAbiCoder.encode(["uint256"], [amount]); + +/** + * Mainnet fork test for the cross-chain withdrawal flow. + * + * Seeds Remote with wOETH shares by routing WETH → OETH (via the OETH vault `mint`) → wOETH + * (via the 4626 deposit). Then drives leg 1 (WITHDRAW_REQUEST), advances past the OETH + * vault's `withdrawalClaimDelay`, calls the permissionless `claimRemoteWithdrawal`, and + * verifies state cleanup. + * + * Leg 2 (`triggerClaim` → outbound CCIP) is exercised against a mock outbound adapter so + * the test doesn't try to bridge to Base. + */ +describe("ForkTest: Withdrawal against mainnet OETH vault queue", function () { + this.timeout(0); + this.retries(isCI ? 3 : 0); + + let remote; + let woeth; + let oeth; + let weth; + let oethVault; + + const SEED_AMOUNT = ethers.utils.parseEther("2"); + const WITHDRAW_AMOUNT = ethers.utils.parseEther("1"); + + beforeEach(async () => { + await mainnetFixture(); + + const proxyAddr = await getCreate2ProxyAddress("OETHbV3RemoteProxy"); + remote = await ethers.getContractAt("RemoteWOTokenStrategy", proxyAddr); + + woeth = await ethers.getContractAt( + "IERC4626", + addresses.mainnet.WOETHProxy + ); + oeth = await ethers.getContractAt( + "@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20", + addresses.mainnet.OETHProxy + ); + weth = await ethers.getContractAt("IWETH9", addresses.mainnet.WETH); + oethVault = await ethers.getContractAt( + "IVault", + addresses.mainnet.OETHVaultProxy + ); + + // Seed Remote with wOETH: deposit WETH→OETH→wOETH for `SEED_AMOUNT`. + // 1. Have the deployer wrap ETH into WETH. + const [deployer] = await ethers.getSigners(); + await deployer.sendTransaction({ + to: weth.address, + value: SEED_AMOUNT, + }); + expect(await weth.balanceOf(deployer.address)).to.be.gte(SEED_AMOUNT); + // 2. Approve WETH to the OETH vault and mint OETH. + await weth.connect(deployer).approve(oethVault.address, SEED_AMOUNT); + await oethVault.connect(deployer)["mint(uint256)"](SEED_AMOUNT); + expect(await oeth.balanceOf(deployer.address)).to.be.gt(0); + // 3. Deposit OETH into wOETH, receive shares to Remote. + await oeth.connect(deployer).approve(woeth.address, SEED_AMOUNT); + await woeth.connect(deployer).deposit(SEED_AMOUNT, remote.address); + expect(await woeth.balanceOf(remote.address)).to.be.gt(0); + }); + + it("leg 1 unwraps shares, queues a withdrawal, and acks with new balance", async () => { + const receiverAddr = await remote.inboundAdapter(); + const sAdapter = await impersonateAndFund(receiverAddr); + + // Master-side mock outbound: install a MockBridgeAdapter so Remote's reply to leg 1 lands + // somewhere recordable (the real outbound is the canonical bridge, which needs the L1 + // L1StandardBridge state). + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(remote.address); + + const sTimelock = await impersonateAndFund(addresses.mainnet.Timelock); + await remote.connect(sTimelock).setOutboundAdapter(mockOut.address); + + // The fork test bypasses the inbound adapter — it calls receiveMessage + // directly via the impersonated adapter signer below, wrapping the + // single-uint256 body in the strategy envelope. + + const totalBefore = await remote.checkBalance(addresses.mainnet.WETH); + const sharesBefore = await woeth.balanceOf(remote.address); + expect(sharesBefore).to.be.gt(0); + + // The receiver adapter delivers it. + const envelope = encodePackedEnvelope( + MSG.WITHDRAW_REQUEST, + 1, + encodeAmountPayload(WITHDRAW_AMOUNT) + ); + await remote + .connect(sAdapter) + .receiveMessage( + remote.address, + ethers.constants.AddressZero, + 0, + envelope + ); + + // wOETH shares should have been unwrapped. + expect(await woeth.balanceOf(remote.address)).to.be.lt(sharesBefore); + expect(await remote.outstandingRequestAmount()).to.equal(WITHDRAW_AMOUNT); + expect(await remote.outstandingRequestId()).to.be.gt(0); + + // Invariant: checkBalance is preserved (within rounding) — value shifted from shares → queue. + const totalAfter = await remote.checkBalance(addresses.mainnet.WETH); + // Allow 1 wei rounding from wOETH↔OETH conversion. + expect(totalAfter).to.be.closeTo(totalBefore, 1); + }); + + // The real OETH vault queue requires both (a) the claim delay to elapse AND (b) enough + // claimable liquidity in the queue. Time-warp alone doesn't grow claimable liquidity — that + // requires `addWithdrawalQueueLiquidity` or background activity from other holders. The + // unit-test loopback fully exercises the claim path; this fork test focuses on leg 1. + it.skip("claimRemoteWithdrawal succeeds after the OETH vault delay elapses", async () => { + const receiverAddr = await remote.inboundAdapter(); + const sAdapter = await impersonateAndFund(receiverAddr); + + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(remote.address); + + const sTimelock = await impersonateAndFund(addresses.mainnet.Timelock); + await remote.connect(sTimelock).setOutboundAdapter(mockOut.address); + + // Leg 1. + const envelope = encodePackedEnvelope( + MSG.WITHDRAW_REQUEST, + 1, + encodeAmountPayload(WITHDRAW_AMOUNT) + ); + await remote + .connect(sAdapter) + .receiveMessage( + remote.address, + ethers.constants.AddressZero, + 0, + envelope + ); + const requestId = await remote.outstandingRequestId(); + expect(requestId).to.be.gt(0); + + // Read the OETH vault's claim delay from its metadata. Use try-catch in case the layout + // changes; fall back to 600s. + let claimDelay = 600; + try { + const md = await oethVault.withdrawalQueueMetadata(); + if (md && md.length >= 4) { + // Field ordering: queued, claimable, claimed, nextWithdrawalIndex... + // We don't strictly need the exact value; just advance by 1 day to be safe. + } + } catch (e) { + // ignore + } + void claimDelay; + + // Advance past any reasonable claim delay. + await time.increase(86400); + + // Anyone can claim. + const wethBefore = await weth.balanceOf(remote.address); + await remote.claimRemoteWithdrawal(); + + // After claim: outstandingRequestId cleared, WETH on Remote increased. + expect(await remote.outstandingRequestId()).to.equal(0); + expect(await weth.balanceOf(remote.address)).to.be.gt(wethBefore); + }); + + it("claimRemoteWithdrawal is idempotent — calling twice doesn't revert", async () => { + const receiverAddr = await remote.inboundAdapter(); + const sAdapter = await impersonateAndFund(receiverAddr); + + const MockAdapterF = await ethers.getContractFactory("MockBridgeAdapter"); + const mockOut = await MockAdapterF.deploy(); + await mockOut.deployed(); + await mockOut.setSender(remote.address); + + const sTimelock = await impersonateAndFund(addresses.mainnet.Timelock); + await remote.connect(sTimelock).setOutboundAdapter(mockOut.address); + + const envelope = encodePackedEnvelope( + MSG.WITHDRAW_REQUEST, + 1, + encodeAmountPayload(WITHDRAW_AMOUNT) + ); + await remote + .connect(sAdapter) + .receiveMessage( + remote.address, + ethers.constants.AddressZero, + 0, + envelope + ); + await time.increase(86400); + + await remote.claimRemoteWithdrawal(); + // Second call: outstandingRequestId is 0, so early-return. + await expect(remote.claimRemoteWithdrawal()).to.not.be.reverted; + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index e71805ad57..f1dda29e5f 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -146,6 +146,11 @@ addresses.mainnet.chainlinkBAL_ETH = "0xC1438AA3823A6Ba0C159CfA8D98dF5A994bA120b"; addresses.mainnet.ccipRouterMainnet = "0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D"; +// CCIP chain selector for Ethereum mainnet (Chainlink CCIP directory). +addresses.mainnet.CCIPChainSelector = "5009297550715157269"; +// OP Stack L1StandardBridge for the Base rollup, deployed on Ethereum. +addresses.mainnet.BaseL1StandardBridge = + "0x3154Cf16ccdb4C6d922629664174b904d80F2C35"; addresses.mainnet.ccipWoethTokenPool = "0xdCa0A2341ed5438E06B9982243808A76B9ADD6d0"; @@ -478,6 +483,8 @@ addresses.base.HydrexOETHb_WETH.gauge = "0x762aEFD13Ec33eb916f124E26336a148177eB093"; addresses.base.CCIPRouter = "0x881e3A65B4d4a04dD529061dd0071cf975F58bCD"; +// CCIP chain selector for Base (Chainlink CCIP directory). +addresses.base.CCIPChainSelector = "15971525489660198786"; addresses.base.MerklDistributor = "0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd";