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..17a7feca8c --- /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 CreateX/CREATE2 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..23b6aa819c --- /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 CreateX/CREATE2 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/mocks/crosschainV3/MockReentrantReceiver.sol b/contracts/contracts/mocks/crosschainV3/MockReentrantReceiver.sol new file mode 100644 index 0000000000..cd89774d87 --- /dev/null +++ b/contracts/contracts/mocks/crosschainV3/MockReentrantReceiver.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title MockReentrantReceiver + * @notice TEST-ONLY bridge-delivery recipient that attempts to re-enter the strategy from the + * post-delivery callback (`_postDeliveryCall`). Used to prove the `nonReentrant` guard on + * `_handleInboundBridgeMessage` blocks re-entry while delivery still completes. + * @dev Uses a low-level call for the re-entry so it records the outcome instead of bubbling the + * revert (a bubbled revert would roll back `reentered`, making it unobservable). + */ +contract MockReentrantReceiver { + address public target; + bytes public reentryCallData; + bool public reentered; + bool public reentrySucceeded; + + /// @notice Configure which contract to re-enter and with what calldata. + function arm(address _target, bytes calldata _reentryCallData) external { + target = _target; + reentryCallData = _reentryCallData; + } + + /// @notice Invoked by the strategy's post-delivery callback; attempts the re-entry. + function attack() external { + reentered = true; + // slither-disable-next-line low-level-calls + (bool ok, ) = target.call(reentryCallData); + reentrySucceeded = ok; + } +} diff --git a/contracts/contracts/proxies/create2/BridgeAdapterProxy.sol b/contracts/contracts/proxies/create2/BridgeAdapterProxy.sol new file mode 100644 index 0000000000..ffb3153b8c --- /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 CreateX/CREATE2 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..ed943b1861 --- /dev/null +++ b/contracts/contracts/strategies/BridgedWOETHMigrationStrategy.sol @@ -0,0 +1,217 @@ +// 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 { 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 two new ones (totalBridged, maxPerBridge) 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 governor or strategist. + * - `setMaxPerBridge` callable by governor or strategist. + * - 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; + + uint256[48] private __gap; + + // --- Events ----------------------------------------------------------- + + event MaxPerBridgeSet(uint256 maxPerBridge); + 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; + } + + // --- Cap configuration ------------------------------------------------ + + 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 + onlyGovernorOrStrategist + 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 CreateX/CREATE2 deployment. + Client.EVM2AnyMessage memory ccipMessage = CCIPMessageBuilder.build( + address(bridgedWOETH), + _amount, + "", + master, + 0 + ); + + uint256 fee = ccipRouter.getFee(ccipChainSelectorMainnet, ccipMessage); + _consumeNativeFee(fee); + + IERC20(address(bridgedWOETH)).safeApprove(address(ccipRouter), _amount); + ccipRouter.ccipSend{ value: fee }( + ccipChainSelectorMainnet, + ccipMessage + ); + + totalBridged += _amount; + emit WOETHBridgedToRemote(_amount, totalBridged); + } + + receive() external payable {} + + /// @dev Consume `fee` in native for the CCIP send. Two sources: + /// - `msg.value == 0` → pre-funded: `address(this).balance` covers the fee. + /// - `msg.value > 0` → user-paid: excess refunds to `msg.sender`. + /// Reverts when the chosen source doesn't cover `fee`. + function _consumeNativeFee(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"); + } + } + + // --- 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..9cc218a30e --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol @@ -0,0 +1,273 @@ +// 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 + { + // Reject zero: outbound sends (deposits / withdraw requests / acks) all route through it; + // a zero outbound would silently break the channel. Symmetric with setInboundAdapter. + require(_outboundAdapter != address(0), "V3: zero outbound adapter"); + _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 { + // Reject zero: a withdrawal's leg-2 ack is delivered through the inbound adapter + // (`onlyInboundAdapter`), so zeroing it — even mid-flight — would stall an in-flight + // withdrawal. Keeping it non-zero also makes the bounds checks in `_withdrawRequest` + // unconditional. + require(_inboundAdapter != address(0), "V3: zero inbound adapter"); + 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 + CreateX/CREATE2 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..d014d60902 --- /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 CreateX/CREATE2 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 CreateX/CREATE2 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..674a259345 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/DESIGN.md @@ -0,0 +1,626 @@ +# 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. +- **CreateX/CREATE2 proxies** (`BridgeAdapterProxy`, `CrossChainStrategyProxy`), + deployed via `deployProxyWithCreateX` 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 CreateX/CREATE2; adapters + behind `BridgeAdapterProxy` (also CreateX/CREATE2) 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 CreateX/CREATE2 + (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 CreateX / CREATE2 peer parity for both proxies and adapter proxies + +**Decision.** Master, Remote, and every adapter live behind a proxy deployed +deterministically through the **CreateX factory** (same factory address on every +chain) using **CREATE2** — `deployProxyWithCreateX` → `factory.deployCreate2`, +addresses resolved via `getCreate2ProxyAddress` (the CREATE3 path in the helper is +unused). 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. CREATE2 from the same CreateX factory with a fixed +per-proxy salt and identical proxy initcode gives that. + +**Why proxy + plain impl, not deploy the impl directly.** Impls have +chain-specific constructor args (CCIPRouter, L1StandardBridge, USDC, +WETH, etc.) — different initcode → different CREATE2 address. The proxy has +uniform initcode across chains, so its CREATE2 address is identical. The proxy +delegates to the chain-specific impl. + +**See:** `BridgeAdapterProxy.sol`, `CrossChainStrategyProxy.sol`, +`deployProxyWithCreateX` / `getCreate2ProxyAddress` in `contracts/deploy/deployActions.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. + +**Draw bound.** A withdrawal is gated by `_drawableRemoteBalance()` — +`remoteStrategyBalance + min(bridgeAdjustment, 0)`, scaled via `_toAsset` — not +by `remoteStrategyBalance` alone. After a net BRIDGE_OUT (`bridgeAdjustment < 0`), +Remote holds fewer shares than `remoteStrategyBalance` implies, so folding in the +negative part stops Master over-requesting more than Remote can actually unwrap. +Positive `bridgeAdjustment` stays excluded (realise it via `requestSettlement` +first), preserving the conservative draw. + +**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`) likewise **clamps to 0** rather than reverting. It can't go +_significantly_ negative because (w)OTokens never negative-rebase, so the value +is principal + yield + retained fees; the only thing that drives it slightly +negative is the wOToken 4626's ~1-wei-per-bridge-op rounding, and reverting on +that dust would freeze the whole serialized yield channel. Clamping reports a +dust-accurate 0 instead (consistent with the checkBalance-never-reverts rule +above). The one scenario the clamp masks is a real **negative rebase**: it would +make Master read 0 instead of the true (negative) value — out of scope here, +recoverable by governor via an implementation upgrade. + +--- + +### 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 +against Circle's per-chain contract list: +https://developers.circle.com/stablecoins/evm-smart-contracts + +### 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. + +**On-chain handler-failure recovery (not just lost transport).** Remote's +inbound yield handlers are revert-free so a failed underlying call can't brick +the serialized channel: + +- **Leg-1 NACK.** If the unwrap/queue in `_processWithdrawRequest` reverts, + Remote queues nothing and the `WITHDRAW_REQUEST_ACK` carries `success = false`; + Master clears `pendingWithdrawalAmount` so the channel frees up (the withdrawal + simply didn't happen and can be re-requested). The unwrap is non-atomic — any + OToken it unwrapped before the queue failed is left idle, counted by + `_viewCheckBalance` and re-wrappable via `retryDeposit`. +- **Failed deposit.** If `_processDeposit`'s mint/wrap reverts, the bridgeAsset + (or OToken) is left idle on Remote — still counted, value preserved — and the + operator-only **`retryDeposit()`** re-runs the mint/wrap pipeline to put it back + into productive wOToken. The DEPOSIT_ACK is still sent with the true baseline. + +### 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/deploy/deployActions.js`** — shared `deployProxyWithCreateX` / + `getCreate2ProxyAddress` helpers (CreateX/CREATE2) used by the strategy and + adapter proxy deploy scripts. + +--- + +## 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. CreateX/CREATE2 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 uses a `REQUEST_ID_EMPTY` sentinel.** `outstandingRequestId` + stores the vault's `requestId` verbatim; `REQUEST_ID_EMPTY` (`type(uint256).max`, + set in `initialize`) means "no request", so a real requestId of 0 (first + withdrawal on a fresh vault) is unambiguous. `outstandingRequestId != +REQUEST_ID_EMPTY` 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..f958350259 --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/FLOWS.md @@ -0,0 +1,1332 @@ +# 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 + +Entry points that move Vault funds into the Master Strategy: + +- `Vault.allocate()` is a permissionless vault operation. It calls + `Master.deposit(asset, amount)` after transferring the allocatable bridge asset + to the Master Strategy. +- `Vault.mint(amount)` is the LP deposit path. It can auto-allocate when + `amount >= autoAllocateThreshold`, which follows the same internal + `_allocate()` path and calls `Master.deposit(asset, amount)`. +- `Vault.depositToStrategy(Master Strategy, [asset], [amount])` is the + governor/strategist direct path. It transfers funds to the Master Strategy, + then calls `Master.depositAll()`, which enters the same cross-chain deposit + machinery. + +The sequence below shows the `Vault.allocate()` / auto-allocation shape where +the Vault calls `Master.deposit()`. In that same transaction, the Master +Strategy receives the bridge asset and asks its outbound adapter to send the +cross-chain deposit message. Delivery to the Remote Strategy, and the later ACK +back to the Master Strategy, happen asynchronously via the bridge. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + box Base + participant Vault as L2 Vault + participant Master as Master Strategy + participant Adapter as CCIPAdapter <> + participant SuperBase as SuperbridgeAdapter <> + end + + participant Bridge as CCIP DON + + box Ethereum + participant AdapterEth as CCIPAdapter <> + participant SuperEth as SuperbridgeAdapter <> + participant Remote as Remote Strategy + participant OEV as OETH Vault + participant wOETH as wOETH <<4626>> + end + + Note over Master: lastYieldNonce = N + Note over Vault: transfer X WETH from Vault to Master Strategy + Vault->>Master: deposit(bridgeAsset, X) + Note over Master,Vault: deposit is non-payable
calls with msg.value > 0 revert + Master->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = N+1
returns N+1 + Note over Master: store pendingDepositAmount = X + Master->>Master: _send(WETH, X, DEPOSIT, N+1, "", false) + Note over Master: payload = packPayload(DEPOSIT, N+1, "") + Master->>Adapter: quoteFee(WETH, X, payload) + Adapter-->>Master: fee, feeToken = native, requiresExternalPayment = true + Note over Master: Master strategy pays the CCIP fee from its own ETH balance + Master->>Adapter: sendMessageAndTokens{value:fee}
(WETH, X, payload) + Note over Adapter: transfers X WETH from Master Strategy to CCIPAdapter via standing max allowance + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = WETH X, feeToken = native
envelope = (envelopeSender, intendedAmount = X, payload) + Bridge->>AdapterEth: ccipReceive(message) + Note over Bridge: delivers the message via the destination router + AdapterEth->>AdapterEth: _validateInbound
(BASE_SELECTOR, transportSender, message.data) + Note over AdapterEth: checks source chain, peer adapter, authorised recipient, and pause status + Note over AdapterEth: transfers X WETH from CCIPAdapter to Remote Strategy + AdapterEth->>Remote: receiveMessage
(Remote, WETH, X, payload) + Remote->>Remote: unpackPayload(payload) + Note over Remote: decoded payload = (DEPOSIT, N+1, "") + Note over Remote: mint + wrap are each try/catch-guarded (revert-free). On failure the
bridgeAsset/OToken is left idle (still counted by _viewCheckBalance, recoverable
via retryDeposit) and the DEPOSIT_ACK is still sent below. + Remote->>OEV: try mint(X) + Note over OEV: transfers X WETH from Remote Strategy to OETH Vault + OEV-->>Remote: «OETH X» minted + Remote->>wOETH: try deposit(OETH balance, Remote) + Note over wOETH: transfers X OETH from Remote Strategy to wOETH + wOETH-->>Remote: «wOETH shares» minted + Note over Remote: minted wOETH shares are held by the Remote Strategy + Remote->>Remote: _viewCheckBalance() + Note over Remote: viewCheckBalance = value of held wOETH shares + idle OETH + idle WETH scaled to OETH + queued withdrawal value + Note over Remote: yieldBaseline = _viewCheckBalance() - bridgeAdjustment + Note over Remote: Remote sends ACKs through its outbound adapter: SuperbridgeAdapter.
Because this ACK carries no assets, Superbridge uses only its CCIP message leg.
No canonical ETH bridge transfer happens on this path.
The outbound adapter could be changed to plain CCIPAdapter.
The Remote strategy pays the message fee. + Remote->>Remote: _send(address(0), 0, DEPOSIT_ACK, N+1, body, false) + Note over Remote: body = abi.encode(yieldBaseline)
payload = packPayload(DEPOSIT_ACK, N+1, body) + Remote->>SuperEth: sendMessage(payload) + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Remote->>Remote: _acceptYieldNonce(N+1) + Note over Remote: store lastYieldNonce = N+1
store nonceProcessed[N+1] = true + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(DEPOSIT_ACK, N+1, body) + Master->>Master: _processDepositAck(body) + Note over Master: body = abi.encode(yieldBaseline) + Master->>Master: _markYieldNonceProcessed(N+1) + Note over Master: lastYieldNonce stays N+1
store nonceProcessed[N+1] = true + Note over Master: store remoteStrategyBalance = yieldBaseline
store pendingDepositAmount = 0 +``` + +### State changes + +**Phase 1 — `Master.deposit(WETH, X)` (Base):** + +Assumes `X >= outboundAdapter.minTransferAmount()`. If `X` is below the +adapter minimum, Master leaves the WETH on the Master Strategy and returns +without advancing the nonce. + +- `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` (transferred 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):** + +- Happy path: WETH is consumed by the OETH Vault mint, then the minted OETH is + wrapped into wOETH. +- `Remote.wOETH balance: increased by ≈X-worth of shares` on the happy path. +- If mint or wrap fails, the WETH or OETH stays idle on the Remote Strategy and + is still counted by `_viewCheckBalance()`. +- `Remote.lastYieldNonce: → N+1`; `nonceProcessed[N+1] = true` + +**Phase 3 — `Master._processDepositAck(N+1, yieldBaseline)` (Base):** + +- `remoteStrategyBalance: B → yieldBaseline` (the Remote Strategy's reported + yield-only baseline) +- `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 on the happy path. + +### OUSD V3 Deposit Differences + +The strategy-side accounting is the same as the WETH deposit flow: Master sends +a deposit message, Remote mints/wraps, then Remote ACKs the new baseline. The +transport is different: CCTP burns USDC on the source chain, mints USDC on the +destination chain, and each inbound CCTP message is delivered by an operator +calling `relay(message, attestation)` after Circle attests it. + +```mermaid +sequenceDiagram + autonumber + box Spoke + participant Vault as Spoke sub-OUSD Vault + participant Master as Master Strategy + participant Adapter as CCTPAdapter <> + end + + actor Op as Operator + participant CCTP as Circle CCTP + + box Ethereum + participant AdapterEth as CCTPAdapter <> + participant Remote as Remote Strategy + participant OUV as OUSD Vault + participant wOUSD as wOUSD <<4626>> + end + + Note over Master: lastYieldNonce = N + Note over Vault: transfer X USDC from Spoke sub-OUSD Vault to Master Strategy + Vault->>Master: deposit(USDC, X) + Note over Master,Vault: deposit is non-payable
calls with msg.value > 0 revert + Master->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = N+1
returns N+1 + Note over Master: store pendingDepositAmount = X + Master->>Master: _send(USDC, X, DEPOSIT, N+1, "", false) + Note over Master: payload = packPayload(DEPOSIT, N+1, "") + Master->>Adapter: quoteFee(USDC, X, payload) + Adapter-->>Master: fee = getMinFeeAmount(X)
feeToken = USDC
requiresExternalPayment = false + Note over Master: no native fee
msg.value = 0
no ETH needed in the Master Strategy + Master->>Adapter: sendMessageAndTokens(USDC, X, payload) + Note over Adapter: transfers X USDC from Master Strategy to CCTPAdapter + Adapter->>CCTP: depositForBurnWithHook(X) + Note over Adapter,CCTP: burns USDC
hook carries envelope = (envelopeSender, intendedAmount = X, payload) + Note over Op: polls for Circle's attestation + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>AdapterEth: decodeBurnBody → amount, feeExecuted, envelope
messageTransmitter.receiveMessage → mints USDC to CCTPAdapter + Note over AdapterEth: landed = min(mint, amount - feeExecuted) + Note over AdapterEth: transfers landed USDC from CCTPAdapter to Remote Strategy + AdapterEth->>Remote: receiveMessage(Remote, USDC, landed, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = USDC, amountReceived = landed
payload = packPayload(DEPOSIT, N+1, "") + Remote->>Remote: unpackPayload(payload) + Note over Remote: decoded payload = (DEPOSIT, N+1, "") + Note over Remote: mint + wrap are each try/catch-guarded (revert-free). On failure the
bridgeAsset/OToken is left idle (still counted by _viewCheckBalance, recoverable
via retryDeposit) and the DEPOSIT_ACK is still sent below. + Remote->>OUV: mint(landed) + Note over OUV: transfers landed USDC from Remote Strategy to OUSD Vault on mint + OUV-->>Remote: «OUSD» minted + Remote->>wOUSD: deposit(OUSD balance, Remote) + Note over wOUSD: transfers OUSD from Remote Strategy to wOUSD on deposit + wOUSD-->>Remote: «wOUSD shares» minted + Note over Remote: minted wOUSD shares are held by the Remote Strategy + Remote->>Remote: _viewCheckBalance() + Note over Remote: viewCheckBalance = value of held wOUSD shares + idle OUSD + idle USDC scaled to OUSD + queued withdrawal value + Note over Remote: yieldBaseline = _viewCheckBalance() - bridgeAdjustment + Note over Remote: Remote sends ACKs through its outbound adapter: CCTPAdapter.
Because this ACK carries no assets, CCTP sends a message-only envelope.
No token burn happens on this path. + Remote->>Remote: _send(address(0), 0, DEPOSIT_ACK, N+1, body, false) + Note over Remote: body = abi.encode(yieldBaseline)
payload = packPayload(DEPOSIT_ACK, N+1, body) + Remote->>AdapterEth: sendMessage(payload) + AdapterEth->>CCTP: sendMessage(message-only) + Note over AdapterEth,CCTP: CCTP message body carries envelope = (envelopeSender, intendedAmount = 0, payload)
no token burn + Remote->>Remote: _acceptYieldNonce(N+1) + Note over Remote: store lastYieldNonce = N+1
store nonceProcessed[N+1] = true + Op->>Adapter: relay(message, attestation) + Note over Op,Adapter: spoke side relay after Circle attestation + Adapter->>Adapter: messageTransmitter.receiveMessage(message, attestation) + Note over Adapter: message body decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + Adapter->>Master: receiveMessage(Master, 0, 0, payload) + Note over Adapter,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(DEPOSIT_ACK, N+1, body) + Master->>Master: _processDepositAck(body) + Note over Master: body = abi.encode(yieldBaseline) + Master->>Master: _markYieldNonceProcessed(N+1) + Note over Master: lastYieldNonce stays N+1
store nonceProcessed[N+1] = true + Note over Master: store remoteStrategyBalance = yieldBaseline
store pendingDepositAmount = 0 +``` + +Key differences: + +- Outbound adapter: `CCTPAdapter`. For the token-bearing deposit, + `quoteFee(USDC, X, payload)` returns `(getMinFeeAmount(X), USDC, false)`. + There is no native fee, so `msg.value = 0` works without ETH in the Master + Strategy. CCTP handles any token-side fee by deducting it from the burned + USDC. +- Token-bearing inbound uses the CCTP burn-message path. The source-side + `depositForBurnWithHook` burns USDC and puts the adapter envelope in the + hook. The destination operator later calls + `CCTPAdapter.relay(message, attestation)` after Circle attests the message. +- `relay()` manually decodes the CCTP burn body to read `amount`, + `feeExecuted`, `msgSender` (the peer adapter), and `hookData` (the adapter + envelope). It then calls `messageTransmitter.receiveMessage`, computes + `landed = min(actualMint, amount - feeExecuted)`, validates the envelope, and + calls `_deliver(envelopeSender, USDC, landed, feeExecuted, payload)`. +- `intendedAmount` in the adapter envelope is the full burn amount `X`. + `amountReceived` passed to the Remote Strategy is `landed`, which may be + lower if CCTP took a token-side fee. The Deposit ACK's `yieldBaseline` + reflects the Remote Strategy's actual post-mint/wrap accounting. +- `DEPOSIT_ACK` is message-only. The ACK has no token leg, so CCTP sends a pure + message whose body is the adapter envelope. On the Master side, + `relay(message, attestation)` triggers the message hook path, validates + `intendedAmount == 0`, and dispatches + `_deliver(envelopeSender, address(0), 0, 0, payload)`. + +--- + +## 4. Withdraw + +Withdraw is split into two cross-chain legs. The Vault starts leg 1 by asking +the Master Strategy to request liquidity from the Remote Strategy, which unwraps +wOToken and queues a withdrawal from the Ethereum OToken vault. After that queue +has matured, an operator starts leg 2: the Remote Strategy claims the withdrawn +bridge asset and sends it back to the Master Strategy. + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + box Base + participant Vault as L2 Vault + participant Master as Master Strategy + participant Adapter as CCIPAdapter <> + participant SuperBase as SuperbridgeAdapter <> + end + + actor Op as Operator + participant Bridge as CCIP DON + + box Ethereum + participant AdapterEth as CCIPAdapter <> + participant SuperEth as SuperbridgeAdapter <> + participant Remote as Remote Strategy + participant OEV as OETH Vault + participant wOETH as wOETH <<4626>> + end + + Note over Master,Remote: ─── Phase A: vault.withdraw triggers leg 1 synchronously ─── + Vault->>Master: withdraw(vault, WETH, amount) + Note over Master,Vault: withdraw is non-payable
calls with msg.value > 0 revert + Note over Vault: recipient = Vault + Master->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = N+1
returns N+1 + Note over Master: store pendingWithdrawalAmount = amount + Note over Master: requested amount must fit within the drawable Remote Strategy balance
drawable = remoteStrategyBalance plus any negative bridgeAdjustment, scaled to WETH + Note over Master: body = abi.encode(amount)
payload = packPayload(WITHDRAW_REQUEST, N+1, body) + Note over Master: message-only send. No token, no amount + Master->>Adapter: quoteFee(address(0), 0, payload) + Adapter-->>Master: fee, feeToken = native, requiresExternalPayment = true + Note over Master: Master Strategy pays the CCIP fee from its own ETH balance + Master->>Adapter: sendMessage{value:fee}(payload) + Note over Master,Adapter: adapter call is payable
fee is forwarded as msg.value + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>AdapterEth: ccipReceive(message) + Note over Bridge,AdapterEth: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
payload = packPayload(WITHDRAW_REQUEST, N+1, body) + Note over Remote: unwrap + queue are try/catch-guarded (revert-free). On failure: success=false,
nothing queued (any unwrapped OToken left idle, recoverable via retryDeposit). + Remote->>wOETH: try withdraw(amount, Remote, Remote) + Note over Remote,wOETH: unwrap shares to OETH + wOETH-->>Remote: «OETH A» unwrapped + Remote->>OEV: try requestWithdrawal(amount) + Note over Remote,OEV: OETH A queued for withdrawal + OEV-->>Remote: requestId + Note over Remote: success=true
store outstandingRequestId = requestId (verbatim)
store outstandingRequestAmount = amount + + Note over Master,Remote: ─── Phase B: Remote sends WITHDRAW_REQUEST_ACK ─── + Remote->>Remote: _viewCheckBalance() + Note over Remote: viewCheckBalance = value of held wOETH shares + idle OETH + idle WETH scaled to OETH + queued withdrawal value + Note over Remote: yieldBaseline = _viewCheckBalance() - bridgeAdjustment + Note over Remote: body = abi.encode(yieldBaseline, success)
payload = packPayload(WITHDRAW_REQUEST_ACK, N+1, body) + Remote->>SuperEth: sendMessage(payload) + Note over SuperEth: Remote's outbound = SuperbridgeAdapter (Eth).
Message-only rides its CCIP leg (no canonical bridge). + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(WITHDRAW_REQUEST_ACK, N+1, body) + alt success == true (queued) + Master->>Master: _processWithdrawRequestAck(N+1, body) + Note over Master: store nonceProcessed[N+1] = true
store remoteStrategyBalance = yieldBaseline + Note over Master: pendingWithdrawalAmount stays set — gates leg-2 + else success == false (leg-1 NACK, nothing queued) + Master->>Master: _processWithdrawRequestAck(N+1, body) + Note over Master: store nonceProcessed[N+1] = true
store remoteStrategyBalance = yieldBaseline
store pendingWithdrawalAmount = 0 + Note over Master: channel freed — the withdrawal can be re-requested + end + + 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() + Note over Master: store lastYieldNonce = N+2
returns N+2 + Note over Master: body = ""
payload = packPayload(WITHDRAW_CLAIM, N+2, body) + Master->>Adapter: sendMessage(payload) + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>AdapterEth: ccipReceive(message) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
body = ""
payload = packPayload(WITHDRAW_CLAIM, N+2, body) + Remote->>Remote: _opportunisticClaim() + Remote->>OEV: claimWithdrawal(requestId) + OEV-->>Remote: «WETH claimed» paid out + Note over Remote: claimed = the WETH the vault actually paid out
store outstandingRequestId = REQUEST_ID_EMPTY
store outstandingRequestAmount = claimed (refined to the payout) + alt claim succeeded and tokens are in hand + Note over Remote: yieldBaseline = _yieldOnlyBaselineAfter(_toOToken(claimed))
body = encode(yieldBaseline, true, claimed)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Remote->>SuperEth: sendMessageAndTokens(WETH, claimed, payload) + Note over SuperEth: transfers claimed WETH from Remote Strategy to SuperbridgeAdapter + Note over SuperEth: split delivery Ethereum→Base:
WETH unwrapped to ETH → L1StandardBridge
CCIP message in parallel + Note over SuperEth,SuperBase: canonical bridge delivers ETH
receive() wraps it to WETH on Base + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = claimed, payload) + Remote->>Remote: _acceptYieldNonce(N+2) + Note over Remote: store lastYieldNonce = N+2
store nonceProcessed[N+2] = true + Bridge->>SuperBase: ccipReceive(message) + SuperBase->>SuperBase: processStoredMessage if needed (split fin.) + Note over SuperBase: transfers claimed WETH from SuperbridgeAdapter to Master Strategy + SuperBase->>Master: receiveMessage(Master, WETH, claimed, payload) + Master->>Master: _processWithdrawClaimAck(N+2, claimed, body) + Note over Master: store nonceProcessed[N+2] = true
store pendingWithdrawalAmount = 0
store remoteStrategyBalance = yieldBaseline + Note over Master: transfers full WETH balance from Master Strategy to L2 Vault + else NACK (outstandingRequestId != EMPTY / amount == 0 / bridgeAssetHeld < amount / ship out of [min,max]) + Note over Remote: currentBalance = _yieldOnlyBaseline()
body = encode(currentBalance, false, 0)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Remote->>SuperEth: sendMessage(payload) + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Remote->>Remote: _acceptYieldNonce(N+2) + Note over Remote: store lastYieldNonce = N+2
store nonceProcessed[N+2] = true + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
body = encode(currentBalance, false, 0)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Master->>Master: _processWithdrawClaimAck(N+2, 0, body) + Note over Master: store nonceProcessed[N+2] = true
store remoteStrategyBalance = yieldBaseline
pendingWithdrawalAmount stays set + Note over Master: operator retries triggerClaim later
retry uses a fresh nonce, N+3 + 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(_drawableRemoteBalance(), 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 | EMPTY | X | +| Requested (post-leg-1) | X − A | 0 | 0 | A | id (verbatim) | X | +| Claimed (post-`claimRemoteWithdrawal`) | X − A | 0 | A | 0 | EMPTY | X | +| Bridging-out (post-leg-2 send) | X − A | 0 | 0 | 0 | EMPTY | X − A | +| Completed | X − A | 0 | 0 | 0 | EMPTY | X − A | + +Failure branches (revert-free handlers; value preserved, recoverable): + +| State | wOETH share value | OToken bal | bridgeAsset bal | queued | outstandingRequestId | checkBalance | +| ----------------------------------- | ----------------- | ---------- | --------------- | ------ | -------------------- | ------------ | +| Deposit mint-failed | X | 0 | D (idle) | 0 | EMPTY | X + D | +| Unwrap-ok / queue-fail (leg-1 NACK) | X − A | A (idle) | 0 | 0 | EMPTY | X | + +The idle `D` / `A` are re-wrapped into wOETH by the operator `retryDeposit()`; the leg-1 NACK also +clears Master's `pendingWithdrawalAmount`. `EMPTY` = `REQUEST_ID_EMPTY` (`type(uint256).max`). + +\* `queued` is no longer a stored slot — it's derived as +`outstandingRequestId != REQUEST_ID_EMPTY ? 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 Withdraw Differences + +OUSD uses the same two-leg withdraw cycle, but the transport is CCTP instead +of CCIP/Superbridge. Request, request ACK, and claim trigger are message-only +relays; the successful claim ACK burns USDC on Ethereum and mints it on the +spoke with the ACK payload attached. Every inbound delivery is operator-relayed: + +```mermaid +sequenceDiagram + autonumber + box Spoke + participant Vault as Spoke sub-OUSD Vault + participant Master as Master Strategy + participant Adapter as CCTPAdapter <> + end + + actor Op as Operator + participant CCTP as Circle CCTP + + box Ethereum + participant AdapterEth as CCTPAdapter <> + participant Remote as Remote Strategy + participant OUV as OUSD Vault + participant wOUSD as wOUSD <<4626>> + end + + Note over Master,Remote: ─── Leg 1: request (message-only) ─── + Vault->>Master: withdraw(vault, USDC, amount) + Note over Master,Vault: withdraw is non-payable
calls with msg.value > 0 revert + Note over Vault: recipient = Vault + Master->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = N+1
returns N+1 + Note over Master: store pendingWithdrawalAmount = amount + Note over Master: requested amount must fit within the drawable Remote Strategy balance
drawable = remoteStrategyBalance plus any negative bridgeAdjustment, scaled to USDC + Note over Master: body = abi.encode(amount)
payload = packPayload(WITHDRAW_REQUEST, N+1, body) + Master->>Adapter: sendMessage(payload) + Adapter->>CCTP: sendMessage(message) + Note over Adapter,CCTP: message-only leg
native fee = 0 + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
payload = packPayload(WITHDRAW_REQUEST, N+1, body) + Note over Remote: unwrap + queue are try/catch-guarded (revert-free). On failure: success=false,
nothing queued (any unwrapped OToken left idle, recoverable via retryDeposit). + Remote->>wOUSD: withdraw(amount) + Note over Remote,wOUSD: unwrap wOUSD to OUSD + Remote->>OUV: requestWithdrawal(amount) + OUV-->>Remote: requestId + Note over Remote: success=true
store outstandingRequestId = requestId (verbatim)
store outstandingRequestAmount = amount + Remote->>Remote: _viewCheckBalance() + Note over Remote: viewCheckBalance = value of held wOUSD shares + idle OUSD + idle USDC scaled to OUSD + queued withdrawal value + Note over Remote: yieldBaseline = _viewCheckBalance() - bridgeAdjustment + Note over Remote: body = abi.encode(yieldBaseline, success)
payload = packPayload(WITHDRAW_REQUEST_ACK, N+1, body) + Remote->>AdapterEth: sendMessage(payload) + AdapterEth->>CCTP: sendMessage(message) + Note over AdapterEth,CCTP: message-only ACK
native fee = 0 + Op->>Adapter: relay(message, attestation) + Note over Op,Adapter: spoke side relay + Adapter->>Master: receiveMessage(Master, 0, 0, payload) + Note over Adapter,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(WITHDRAW_REQUEST_ACK, N+1, body) + alt success == true (queued) + Master->>Master: _processWithdrawRequestAck(N+1, body) + Note over Master: store nonceProcessed[N+1] = true
store remoteStrategyBalance = yieldBaseline + Note over Master: pendingWithdrawalAmount stays set — gates leg-2 + else success == false (leg-1 NACK, nothing queued) + Master->>Master: _processWithdrawRequestAck(N+1, body) + Note over Master: store nonceProcessed[N+1] = true
store remoteStrategyBalance = yieldBaseline
store pendingWithdrawalAmount = 0 + Note over Master: channel freed — the withdrawal can be re-requested + end + + 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->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = N+2
returns N+2 + Note over Master: body = ""
payload = packPayload(WITHDRAW_CLAIM, N+2, body) + Master->>Adapter: sendMessage(payload) + Adapter->>CCTP: sendMessage(message) + Note over Adapter,CCTP: message-only leg
native fee = 0 + Op->>AdapterEth: relay(message, attestation) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
body = ""
payload = packPayload(WITHDRAW_CLAIM, N+2, body) + Remote->>Remote: _opportunisticClaim() + Remote->>OUV: claimWithdrawal(requestId) + OUV-->>Remote: «USDC claimed» paid out + Note over Remote: claimed = the USDC the vault actually paid out
store outstandingRequestId = REQUEST_ID_EMPTY
store outstandingRequestAmount = claimed (refined to the payout) + alt claim succeeded and tokens are in hand + Note over Remote: yieldBaseline = _yieldOnlyBaselineAfter(_toOToken(claimed))
body = encode(yieldBaseline, true, claimed)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Remote->>AdapterEth: sendMessageAndTokens(USDC, claimed, payload) + Note over AdapterEth: transfers claimed USDC from Remote Strategy to CCTPAdapter + AdapterEth->>CCTP: depositForBurnWithHook(claimed) + Note over AdapterEth,CCTP: burns USDC and carries the ACK hook atomically + Remote->>Remote: _acceptYieldNonce(N+2) + Note over Remote: store lastYieldNonce = N+2
store nonceProcessed[N+2] = true + Op->>Adapter: relay(message, attestation) + Note over Op,Adapter: spoke side relay + Adapter->>Adapter: messageTransmitter.receiveMessage(message, attestation) + Note over Adapter: CCTP mints USDC to CCTPAdapter + Note over Adapter: transfers landed USDC from CCTPAdapter to Master Strategy
landed = min(mint, claimed - feeExecuted) + Adapter->>Master: receiveMessage(Master, USDC, landed, payload) + Master->>Master: _processWithdrawClaimAck(N+2, landed, body) + Note over Master: store nonceProcessed[N+2] = true
store pendingWithdrawalAmount = 0
store remoteStrategyBalance = yieldBaseline + Note over Master: transfers full USDC balance from Master Strategy to Spoke sub-OUSD Vault + else NACK (outstandingRequestId != EMPTY / amount == 0 / bridgeAssetHeld < amount / ship out of [min,max]) + Note over Remote: currentBalance = _yieldOnlyBaseline()
body = encode(currentBalance, false, 0)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Remote->>AdapterEth: sendMessage(payload) + AdapterEth->>CCTP: sendMessage(message) + Note over AdapterEth,CCTP: message-only NACK
native fee = 0 + Remote->>Remote: _acceptYieldNonce(N+2) + Note over Remote: store lastYieldNonce = N+2
store nonceProcessed[N+2] = true + Op->>Adapter: relay(message, attestation) + Note over Op,Adapter: spoke side relay + Adapter->>Master: receiveMessage(Master, 0, 0, payload) + Note over Adapter,Master: params: sender = Master, token = address(0), amountReceived = 0
body = encode(currentBalance, false, 0)
payload = packPayload(WITHDRAW_CLAIM_ACK, N+2, body) + Master->>Master: _processWithdrawClaimAck(N+2, 0, body) + Note over Master: store nonceProcessed[N+2] = true
store remoteStrategyBalance = yieldBaseline
pendingWithdrawalAmount stays set + Note over Master: operator retries triggerClaim later
retry uses a fresh nonce, N+3 + end +``` + +Key differences: + +- Transport is CCTP for every hop. The request, request ACK, and claim trigger + are message-only CCTP relays. The successful claim ACK is token-bearing: CCTP + burns USDC on Ethereum, carries the ACK payload as hook data, and mints USDC + on the spoke when relayed. +- Each inbound delivery requires an operator `relay(message, attestation)`. + A full successful cycle has four relays: request to Remote Strategy, request + ACK to Master Strategy, claim trigger to Remote Strategy, and claim ACK to + Master Strategy. +- On the token-bearing claim ACK, CCTP fast-finality can deduct a token-side fee. + Remote encodes the claimed amount in the ACK body, while Master receives the + landed amount. Master accepts `landed <= claimed`, forwards the landed USDC to + the vault, and the shortfall is absorbed as yield drag until the next balance + refresh. With finalised delivery and no token-side fee, `landed == claimed`. + +--- + +## 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 + box Base + participant Master as Master Strategy + participant Adapter as CCIPAdapter <> + participant SuperBase as SuperbridgeAdapter <> + end + + actor Op as Operator + participant Bridge as CCIP DON + + box Ethereum + participant AdapterEth as CCIPAdapter <> + participant Remote as Remote Strategy + participant SuperEth as SuperbridgeAdapter <> + end + + Note over Master: lastYieldNonce = N (any value)
bridgeAdjustment = B (any value) + Op->>Master: requestBalanceCheck{value: optionalTopUp}() + Note over Master: timestamp = block.timestamp
body = abi.encode(timestamp)
payload = packPayload(BALANCE_CHECK_REQUEST, N, body) + Master->>Adapter: quoteFee(address(0), 0, payload) + Adapter-->>Master: fee, feeToken = native, requiresExternalPayment = true + Note over Master: Master Strategy pays the CCIP fee from its own ETH balance + Master->>Adapter: sendMessage{value:fee}(payload) + Note over Master,Adapter: adapter call is payable
fee is forwarded as msg.value + Note over Master: NONCE ECHOED, NOT ADVANCED.
lastYieldNonce stays N. + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>AdapterEth: ccipReceive(message) + Note over Bridge,AdapterEth: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
payload = packPayload(BALANCE_CHECK_REQUEST, N, body) + Remote->>Remote: _yieldOnlyBaseline() + Note over Remote: viewCheckBalance = value of held wOETH shares + idle OETH + idle WETH scaled to OETH + queued withdrawal value + Note over Remote: yieldBaseline = viewCheckBalance - bridgeAdjustment, clamped to 0
clamp only covers tiny 4626 rounding dust + Note over Remote: srcTimestamp = timestamp from request body
body = encode(yieldBaseline, srcTimestamp)
payload = packPayload(BALANCE_CHECK_RESPONSE, N, body) + Remote->>SuperEth: quoteFee(address(0), 0, payload) + SuperEth-->>Remote: fee, feeToken = native, requiresExternalPayment = true + Note over Remote: Remote Strategy pays the CCIP fee from its own ETH balance + Remote->>SuperEth: sendMessage{value:fee}(payload) + Note over Remote,SuperEth: DOES NOT call _acceptYieldNonce
read-only on Remote Strategy
adapter call is payable and forwards fee as msg.value + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(BALANCE_CHECK_RESPONSE, N, body) + Master->>Master: _processBalanceCheckResponse(N, body) + Note over Master: guard 1: if isYieldOpInFlight() then return
guard 2: if response nonce != lastYieldNonce then return
guard 3: if response timestamp <= lastBalanceCheckTimestamp then return + alt all guards pass + Note over Master: store lastBalanceCheckTimestamp = respTimestamp
store 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 + box Base + actor Alice as User (Alice) + participant Master as Master Strategy + participant L2V as L2 OETHb Vault + participant Adapter as CCIPAdapter <> + end + + participant Bridge as CCIP DON + + box Ethereum + participant AdapterEth as CCIPAdapter <> + participant Remote as Remote Strategy + participant wOETH as wOETH <<4626>> + actor AliceEth as Alice + end + + Alice->>Master: approve(Master, X) [OETHb] + Alice->>Master: bridgeOTokenToPeer{value: fee}(X, alice_eth, "0x", 0) + Note over Master: bridgeFee = X * bridgeFeeBps / 10_000
net = X - bridgeFee
net must be greater than 0 + Note over Master: net must fit within available bridge liquidity
available = remoteStrategyBalance + bridgeAdjustment - pendingWithdrawalAmount + Note over Master: transfers X OETHb from Alice to Master Strategy + Master->>L2V: burnForStrategy(X) + Note over Master: store bridgeAdjustment -= net (NOT -= X)
store bridgeIdCounter += 1
store bridgeId = keccak256(strategy, counter) + Note over Master: body = encode(bridgeId, net, alice_eth, callData, callGasLimit)
payload = packPayload(BRIDGE_OUT, 0, body) + Master->>Adapter: quoteFee(address(0), 0, payload) + Adapter-->>Master: ccipFee, feeToken = native, requiresExternalPayment = true + Note over Master: user pays CCIP fee with msg.value
strategy ETH pool is not used + Master->>Adapter: sendMessage{value:ccipFee}(payload) + Note over Master,Adapter: adapter call is payable
ccipFee is forwarded as msg.value
excess user msg.value remains in Master Strategy + Adapter->>Bridge: ccipSend{value:ccipFee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Note over Master: emit BridgeRequested(bridgeId, alice, alice_eth, net, bridgeFee, ...) + Bridge->>AdapterEth: ccipReceive(message) + Note over Bridge,AdapterEth: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
payload = packPayload(BRIDGE_OUT, 0, body) + Remote->>Remote: unpackPayload(payload) + Note over Remote: body decodes to BridgeUserPayload
bridgeId, amount = net, recipient = alice_eth, callData, callGasLimit + Note over Remote: require(!consumedBridgeIds[bridgeId])
store consumedBridgeIds[bridgeId] = true
store bridgeAdjustment -= net + Remote->>wOETH: withdraw(net, Remote, Remote) + Note over Remote,wOETH: unwrap wOETH shares to OETH + wOETH-->>Remote: «OETH net» unwrapped + Note over Remote: transfers net OETH from Remote Strategy to Alice + Note over Remote: emit BridgeDelivered(bridgeId, alice_eth, net) + opt callData provided + Remote->>Remote: _postDeliveryCall(p) + Note over Remote: recipient.call{value:0, gas:callGasLimit}(callData) + Note over Remote: emit BridgeCallSucceeded / BridgeCallFailed + end +``` + +### BRIDGE_IN (Remote wraps, Master mints) — mirror image + +```mermaid +sequenceDiagram + autonumber + box Ethereum + actor Bob as User (Bob) + participant Remote as Remote Strategy + participant wOETH as wOETH <<4626>> + participant SuperEth as SuperbridgeAdapter <> + end + + participant Bridge as CCIP DON + + box Base + participant SuperBase as SuperbridgeAdapter <> + participant Master as Master Strategy + participant L2V as L2 OETHb Vault + actor BobBase as Bob + end + + Bob->>Remote: approve(Remote, Y) [OETH] + Bob->>Remote: bridgeOTokenToPeer{value: fee}(Y, bob_base, callData, callGasLimit) + Note over Remote: bridgeFee = Y * bridgeFeeBps / 10_000
net = Y - bridgeFee
net must be greater than 0 + Note over Remote: callGasLimit must be within MAX_BRIDGE_CALL_GAS
callData requires nonzero callGasLimit + Note over Remote: transfers Y OETH from Bob to Remote Strategy + Remote->>wOETH: deposit(Y, Remote) + Note over Remote,wOETH: wrap full Y OETH into wOETH shares held by Remote Strategy + Note over Remote: store bridgeAdjustment += net
store bridgeIdCounter += 1
store bridgeId = keccak256(strategy, counter) + Note over Remote: body = encode(bridgeId, net, bob_base, callData, callGasLimit)
payload = packPayload(BRIDGE_IN, 0, body) + Remote->>SuperEth: quoteFee(address(0), 0, payload) + SuperEth-->>Remote: ccipFee, feeToken = native, requiresExternalPayment = true + Note over Remote: user pays CCIP fee with msg.value
strategy ETH pool is not used + Remote->>SuperEth: sendMessage{value:ccipFee}(payload) + Note over Remote,SuperEth: adapter call is payable
ccipFee is forwarded as msg.value
excess user msg.value remains in Remote Strategy + Note over SuperEth: message-only send through CCIP leg
no canonical bridge transfer + SuperEth->>Bridge: ccipSend{value:ccipFee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Note over Remote: emit BridgeRequested(bridgeId, bob, bob_base, net, bridgeFee, ...) + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(BRIDGE_IN, 0, body) + Master->>Master: unpackPayload(payload) + Note over Master: body decodes to BridgeUserPayload
bridgeId, amount = net, recipient = bob_base, callData, callGasLimit + Note over Master: require(!consumedBridgeIds[bridgeId])
store consumedBridgeIds[bridgeId] = true
store bridgeAdjustment += net + Master->>L2V: mintForStrategy(net) + Note over Master: transfers net OETHb from Master Strategy to Bob + Note over Master: emit BridgeDelivered(bridgeId, bob_base, net) + opt callData provided + Master->>Master: _postDeliveryCall(p) + Note over Master: recipient.call{value:0, gas:callGasLimit}(callData) + Note over Master: emit BridgeCallSucceeded / BridgeCallFailed + end +``` + +### 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 + box Base + participant Master as Master Strategy + participant Adapter as CCIPAdapter <> + participant SuperBase as SuperbridgeAdapter <> + end + + actor Op as Operator + participant Bridge as CCIP DON + + box Ethereum + participant AdapterEth as CCIPAdapter <> + participant Remote as Remote Strategy + participant SuperEth as SuperbridgeAdapter <> + end + + Note over Master: bridgeAdjustment = -10 (one BRIDGE_OUT for net=10 happened)
Remote.bridgeAdjustment = -10 also + Op->>Master: requestSettlement{value: fee}() + Master->>Master: _getNextYieldNonce() + Note over Master: store lastYieldNonce = nonce
returns nonce + Note over Master: store settlementSnapshot = -10
persisted for ack handler + Note over Master: body = abi.encode(int256(-10))
payload = packPayload(SETTLE_BRIDGE_ACCOUNTING, nonce, body) + Master->>Adapter: quoteFee(address(0), 0, payload) + Adapter-->>Master: fee, feeToken = native, requiresExternalPayment = true + Note over Master: Master Strategy pays the CCIP fee from its own ETH balance
operator msg.value can top up that balance + Master->>Adapter: sendMessage{value:fee}(payload) + Note over Master,Adapter: adapter call is payable
fee is forwarded as msg.value + Note over Master: emit SettlementRequested(nonce, -10)
Master.bridgeAdjustment STILL -10 (NOT zeroed yet) + Adapter->>Bridge: ccipSend{value:fee}(ETH_SELECTOR, ccipMessage) + Note over Adapter,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + + 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(message) + Note over Bridge,AdapterEth: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + AdapterEth->>Remote: receiveMessage(Remote, 0, 0, payload) + Note over AdapterEth,Remote: params: sender = Remote, token = address(0), amountReceived = 0
payload = packPayload(SETTLE_BRIDGE_ACCOUNTING, nonce, body) + Remote->>Remote: _processSettlement(nonce, body) + Note over Remote: subtract only the snapshot amount, do not reset to zero
snapshot = -10, so store bridgeAdjustment -= -10
no in-flight bridge: -10 - (-10) = 0
new BRIDGE_OUT already applied: -15 - (-10) = -5
new BRIDGE_OUT not applied yet: -10 - (-10) = 0 + Remote->>Remote: _yieldOnlyBaseline() + Note over Remote: yieldBaseline = _viewCheckBalance() - bridgeAdjustment, clamped to 0
yield-only baseline preserves consistency across orderings + Note over Remote: body = abi.encode(yieldBaseline)
payload = packPayload(SETTLE_BRIDGE_ACCOUNTING_ACK, nonce, body) + Remote->>SuperEth: quoteFee(address(0), 0, payload) + SuperEth-->>Remote: fee, feeToken = native, requiresExternalPayment = true + Note over Remote: Remote Strategy pays the CCIP fee from its own ETH balance + Remote->>SuperEth: sendMessage{value:fee}(payload) + Note over Remote,SuperEth: adapter call is payable
fee is forwarded as msg.value + Remote->>Remote: _acceptYieldNonce(nonce) + Note over Remote: store lastYieldNonce = nonce
store nonceProcessed[nonce] = true + SuperEth->>Bridge: ccipSend{value:fee}(BASE_SELECTOR, ccipMessage) + Note over SuperEth,Bridge: ccipMessage fields: receiver = peer adapter, data = envelope, tokenAmounts = empty, feeToken = native
envelope = (envelopeSender, intendedAmount = 0, payload) + Bridge->>SuperBase: ccipReceive(message) + Note over Bridge,SuperBase: ccipReceive gets the CCIP message
message.data decodes to envelope = (envelopeSender, intendedAmount = 0, payload) + SuperBase->>Master: receiveMessage(Master, 0, 0, payload) + Note over SuperBase,Master: params: sender = Master, token = address(0), amountReceived = 0
payload = packPayload(SETTLE_BRIDGE_ACCOUNTING_ACK, nonce, body) + Master->>Master: _processSettlementAck(nonce, body) + Master->>Master: _markYieldNonceProcessed(nonce) + Note over Master: lastYieldNonce stays nonce
store nonceProcessed[nonce] = true + Note over Master: subtract only settlementSnapshot, do not reset to zero
settlementSnapshot = -10, so store bridgeAdjustment -= -10
no in-flight bridge: -10 - (-10) = 0
new BRIDGE_OUT happened: -15 - (-10) = -5
then store settlementSnapshot = 0 and store remoteStrategyBalance = yieldBaseline + Note over Master: emit SettlementAcked(nonce, yieldBaseline) +``` + +### 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` draws `_drawableRemoteBalance()` (`remoteStrategyBalance + +min(bridgeAdjustment, 0)` — folds in a negative bridge adjustment so a net + BRIDGE_OUT can't over-request), clamped 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..f16851e6de --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol @@ -0,0 +1,612 @@ +// 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 `FLOWS.md` (Remote state-transition table) and `DESIGN.md`. + */ +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 DepositSkipped(uint256 amount, uint256 minTransfer); + 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. + /// + /// Drawable bound is `_drawableRemoteBalance()` = `remoteStrategyBalance + + /// min(0, bridgeAdjustment)`, NOT `remoteStrategyBalance` alone: + /// - a NEGATIVE `bridgeAdjustment` (net BRIDGE_OUT) is folded in, lowering the cap so we + /// never request more shares than Remote can actually unwrap (else it NACKs on Remote); + /// - a POSITIVE `bridgeAdjustment` is excluded here even though `checkBalance` counts it + /// (that + local bridgeAsset can report more). To realise it, `requestSettlement()` + /// first (folds bridgeAdjustment into remoteStrategyBalance) and/or use the local + /// bridgeAsset, then withdraw. + 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 no-op if the inbound adapter isn't wired yet (pre-configuration only — + // setInboundAdapter now rejects zero, so it can't be cleared mid-migration). + 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`. + uint256 minTransfer = IBridgeAdapter(outboundAdapter) + .minTransferAmount(); + if (_amount < minTransfer) { + // Signal the skipped deposit so off-chain monitoring can see the amount stayed + // local (still counted in `checkBalance`) instead of being silently dropped. + emit DepositSkipped(_amount, minTransfer); + return; + } + + uint64 nonce = _getNextYieldNonce(); + // Store the pending deposit amount until the remote deposit is acknowledged. + 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). A withdrawal can't complete without an inbound adapter + // (leg-2's ack is delivered through it), so require it rather than skipping the checks — + // `setInboundAdapter` rejects zero, so this only guards the pre-configuration window. + address inbound = inboundAdapter; + require(inbound != address(0), "Master: inbound not set"); + 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(); + // Store the pending withdrawal amount until the remote withdrawal is claimed. + 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); + // Store the acknowledged deposit in the remote balance and clear the pending amount. + 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..52d971cf5e --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/README.md @@ -0,0 +1,197 @@ +# 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 + +contracts/proxies/create2/ + CrossChainStrategyProxy.sol — Master/Remote strategy proxy (CreateX/CREATE2-deployable for peer parity) + BridgeAdapterProxy.sol — Adapter proxy (CreateX/CREATE2-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, bool success)` | success=false ⇒ leg-1 NACK (nothing queued) | +| 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 CreateX/CREATE2 (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 CreateX/CREATE2 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..ab79ff6b4f --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol @@ -0,0 +1,659 @@ +// 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 `FLOWS.md`. + */ +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; + + /// @notice Sentinel value of `outstandingRequestId` meaning "no outstanding queue request". + /// Using `type(uint256).max` (not 0) lets the vault's real `requestId` — which starts + /// at 0 for the first-ever withdrawal on a fresh vault — be stored verbatim. + uint256 internal constant REQUEST_ID_EMPTY = type(uint256).max; + + // --- Storage (all new slots; nothing from any parent is relocated) ----- + + /// @notice OToken-vault queue handle, stored verbatim (the vault's `requestId`). + /// `REQUEST_ID_EMPTY` (= `type(uint256).max`) means "no outstanding (unclaimed) queue + /// request"; initialised to it in `initialize` since the storage default of 0 is a + /// valid vault id. Reset to `REQUEST_ID_EMPTY` 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; + // Storage default is 0, which is a valid vault requestId — start at the empty sentinel. + outstandingRequestId = REQUEST_ID_EMPTY; + // 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); + // Defensive / unreachable in normal flow: Master validates amount > 0 before shipping + // WITHDRAW_REQUEST — this only guards a malformed/forged inbound message. + require(amount > 0, "Remote: zero withdraw"); + require( + outstandingRequestId == REQUEST_ID_EMPTY, + "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 the vault id verbatim — `REQUEST_ID_EMPTY` is the "no request" sentinel, + // so a real id of 0 (first withdrawal on a fresh vault) is unambiguous. + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = id; + // 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 != REQUEST_ID_EMPTY || + 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 empty here (the guard NACKs otherwise); cleared defensively. + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = REQUEST_ID_EMPTY; + 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 { + // `outstandingRequestId` stores the vault id verbatim. + uint256 vaultRequestIdMem = outstandingRequestId; + if (vaultRequestIdMem == REQUEST_ID_EMPTY) { + return; + } + // 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(vaultRequestIdMem) returns ( + uint256 _claimed + ) { + claimed = _claimed; + // slither-disable-next-line reentrancy-no-eth + outstandingRequestId = REQUEST_ID_EMPTY; + // 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(vaultRequestIdMem, 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 != REQUEST_ID_EMPTY + ? _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 **while + /// (w)OTokens are up-only** — the only thing that pushes it a few wei negative is the + /// wOToken 4626 rounding against the strategy (~1 wei per BRIDGE_IN floor / BRIDGE_OUT + /// ceil) once a `withdrawAll` drains it near 0. Reverting on that dust would freeze the + /// serialized yield channel, so we clamp (matches checkBalance-never-reverts). + /// CAVEAT: a real negative rebase (loss/slashing) would make this genuinely negative; the + /// clamp then masks it, and because Master re-adds its OWN `bridgeAdjustment`, + /// `checkBalance` OVER-reports by ~`bridgeAdjustment` (not 0). Out of scope while + /// (w)OTokens never negative-rebase; revisit (signed baseline) if that changes. See DESIGN §3.10. + 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..a90898e658 --- /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 CreateX/CREATE2 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. CreateX/CREATE2 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 + * CreateX/CREATE2 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 + * CreateX/CREATE2 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 CreateX/CREATE2 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..591e16caaf --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol @@ -0,0 +1,169 @@ +// 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 + * (CreateX/CREATE2 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; + } + + /// @dev Inbound liveness: if the CCIP DON never auto-executes a delivered message, anyone can + /// trigger manual execution. That path still runs `OffRamp.manuallyExecute` -> + /// `Router.routeMessage` -> `ccipReceive`, so `msg.sender` is the Router here even on a + /// manual replay and this guard passes — no governance escape hatch is needed. + /// https://docs.chain.link/ccip/tutorials/evm/manual-execution#trigger-manual-execution + 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 (CreateX/CREATE2 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..15409d420f --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol @@ -0,0 +1,465 @@ +// 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 (CreateX/CREATE2 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, + uint32 finalityThresholdExecuted, + 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 (CreateX/CREATE2 + // 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)) { + // Burn messages bypass CCTP's IMessageHandlerV2 finality callbacks (the transport + // recipient is the destination TokenMessenger, not us), so enforce the same + // receive-side finality floor the pure-message path gets via + // handleReceiveUnfinalizedMessage. For the finalised default (executed = 2000) this + // always passes; it only rejects a burn attested below the configured floor. + require(minFinalityThreshold > 0, "CCTP: threshold not set"); + require( + finalityThresholdExecuted >= minFinalityThreshold, + "CCTP: insufficient finality" + ); + _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 mintRecipient, + uint256 amount, + address msgSender, + uint256 feeExecuted, + bytes memory hookData + ) = CCTPMessageHelper.decodeBurnBody(body); + // The source-chain burn token is intentionally not extracted or checked: it's the + // SOURCE-chain USDC address (differs from this chain's `usdcToken` for cross-chain + // transfers), CCTP's MessageTransmitter validates the burn record cryptographically via + // the attestation, and what gets minted here is always the local USDC by protocol design. + // 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 (CreateX/CREATE2 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..2fb3142e8e --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/adapters/SuperbridgeAdapter.sol @@ -0,0 +1,339 @@ +// 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"); + // Re-assert the incident-response levers on the deferred path too. The atomic path + // gates on these in `_validateInbound`; a `pauseLane`/`revoke` issued after the message + // was stored must block deferred delivery as well, otherwise the pause is ineffective + // for exactly the split-delivery window it exists to cover. Check `authorised` first — + // `revoke` leaves `paused` untouched. A held slot isn't dropped: its WETH stays on the + // adapter until unpause / re-authorise (recoverable via `transferToken`). + require(authorised[_target], "Super: not authorised"); + require(!laneConfig[_target].paused, "Super: lane paused"); + 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..261068eeff --- /dev/null +++ b/contracts/contracts/strategies/crosschainV3/libraries/CCTPMessageHelper.sol @@ -0,0 +1,135 @@ +// 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 FINALITY_THRESHOLD_EXECUTED_INDEX = 144; + 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`). + * + * Also returns `finalityThresholdExecuted` — the finality Circle actually attested + * this message at (2000 = finalised, 1000–1999 = fast). Callers gate delivery against + * their configured floor; the burn path has no `IMessageHandlerV2` callback to do it. + * @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, + uint32 finalityThresholdExecuted, + bytes memory messageBody + ) + { + version = message.extractUint32(VERSION_INDEX); + sourceDomain = message.extractUint32(SOURCE_DOMAIN_INDEX); + sender = message.extractAddress(SENDER_INDEX); + recipient = message.extractAddress(RECIPIENT_INDEX); + finalityThresholdExecuted = message.extractUint32( + FINALITY_THRESHOLD_EXECUTED_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 CreateX/CREATE2 + * 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 CreateX/CREATE2 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/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..bba29d2a0e --- /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; + +// CreateX/CREATE2 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` (CreateX/CREATE2 → 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 CreateX/CREATE2 --- + 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..2426be33b6 --- /dev/null +++ b/contracts/deploy/base/102_oethb_v3_woeth_v2_upgrade.js @@ -0,0 +1,80 @@ +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.) + // `bridgeToRemote` is governor-or-strategist gated, so the strategist can drive it + // directly — no separate operator authorisation needed. + { + contract: cMigration, + signature: "setMaxPerBridge(uint256)", + args: [MAX_PER_BRIDGE], + }, + ], + }; + } +); 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..56f0134b93 --- /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; + +// CreateX/CREATE2 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` (CreateX/CREATE2 → 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 CreateX/CREATE2 (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/docs/AdaptersHierarchies.svg b/contracts/docs/AdaptersHierarchies.svg new file mode 100644 index 0000000000..b6627e783d --- /dev/null +++ b/contracts/docs/AdaptersHierarchies.svg @@ -0,0 +1,61 @@ + + + + + + +UmlClassDiagram + + + +40 + +<<Abstract>> +Governable +../contracts/governance/Governable.sol + + + +449 + +<<Abstract>> +AbstractAdapter +../contracts/strategies/crosschainV3/adapters/AbstractAdapter.sol + + + +449->40 + + + + + +451 + +CCIPAdapter +../contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol + + + +451->449 + + + + + +452 + +CCTPAdapter +../contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol + + + +452->449 + + + + + diff --git a/contracts/docs/BridgedWOETHMigrationStrategyHierarchy.svg b/contracts/docs/BridgedWOETHMigrationStrategyHierarchy.svg new file mode 100644 index 0000000000..d277efc7e4 --- /dev/null +++ b/contracts/docs/BridgedWOETHMigrationStrategyHierarchy.svg @@ -0,0 +1,75 @@ + + + + + + +UmlClassDiagram + + + +40 + +<<Abstract>> +Governable +../contracts/governance/Governable.sol + + + +216 + +BridgedWOETHMigrationStrategy +../contracts/strategies/BridgedWOETHMigrationStrategy.sol + + + +217 + +BridgedWOETHStrategy +../contracts/strategies/BridgedWOETHStrategy.sol + + + +216->217 + + + + + +263 + +<<Abstract>> +InitializableAbstractStrategy +../contracts/utils/InitializableAbstractStrategy.sol + + + +217->263 + + + + + +262 + +<<Abstract>> +Initializable +../contracts/utils/Initializable.sol + + + +263->40 + + + + + +263->262 + + + + + diff --git a/contracts/docs/BridgedWOETHMigrationStrategyInteractions.svg b/contracts/docs/BridgedWOETHMigrationStrategyInteractions.svg new file mode 100644 index 0000000000..b859ceddae --- /dev/null +++ b/contracts/docs/BridgedWOETHMigrationStrategyInteractions.svg @@ -0,0 +1,266 @@ + + + + + + +UmlClassDiagram + + + +82 + +<<Interface>> +IOracle + + + +87 + +<<Interface>> +IStrategy + + + +90 + +<<Interface>> +IVault + + + +276 + +<<Abstract>> +VaultStorage + + + +90->276 + + + + + +91 + +<<Interface>> +IWETH9 + + + +216 + +BridgedWOETHMigrationStrategy + + + +216->82 + + +price + + + +216->87 + + +checkBalance + + + +216->90 + + +burnForStrategy +checkBalance +mintForStrategy +strategistAddr + + + +216->91 + + +balanceOf +transfer +transferFrom + + + +456 + +<<Library>> +CCIPMessageBuilder + + + +216->456 + + +build + + + +458 + +<<Library>> +NativeFeeHelper + + + +216->458 + + +consume + + + +267 + +<<Library>> +StableMath + + + +216->267 + + + + + +1666 + +<<Interface>> +IRouterClient + + + +216->1666 + + +getFee + + + +1668 + +<<Library>> +Client + + + +216->1668 + + + + + +1324 + +<<Interface>> +IERC20 + + + +216->1324 + + +balanceOf +transfer +transferFrom + + + +1606 + +<<Library>> +SafeERC20 + + + +216->1606 + + +safeApprove +safeTransfer + + + +1349 + +<<Library>> +SafeCast + + + +216->1349 + + +toUint128 + + + +456->1668 + + +EVM2AnyMessage +EVMExtraArgsV1 +EVMTokenAmount +_argsToBytes + + + +1350 + +<<Library>> +SafeMath + + + +267->1350 + + +add +div +mul +sub + + + +276->1606 + + + + + +1666->1668 + + + + + +1606->1324 + + +allowance + + + +1057 + +<<Library>> +Address + + + +1606->1057 + + +functionCall + + + diff --git a/contracts/docs/BridgedWOETHMigrationStrategySquashed.svg b/contracts/docs/BridgedWOETHMigrationStrategySquashed.svg new file mode 100644 index 0000000000..d6dbb98485 --- /dev/null +++ b/contracts/docs/BridgedWOETHMigrationStrategySquashed.svg @@ -0,0 +1,126 @@ + + + + + + +UmlClassDiagram + + + +216 + +BridgedWOETHMigrationStrategy +../contracts/strategies/BridgedWOETHMigrationStrategy.sol + +Private: +   initialized: bool <<Initializable>> +   initializing: bool <<Initializable>> +   ______gap: uint256[50] <<Initializable>> +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   _deprecated_platformAddress: address <<InitializableAbstractStrategy>> +   _deprecated_vaultAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardTokenAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardLiquidationThreshold: uint256 <<InitializableAbstractStrategy>> +   _reserved: int256[98] <<InitializableAbstractStrategy>> +   __gap: uint256[47] <<BridgedWOETHMigrationStrategy>> +Internal: +   assetsMapped: address[] <<InitializableAbstractStrategy>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   platformAddress: address <<InitializableAbstractStrategy>> +   vaultAddress: address <<InitializableAbstractStrategy>> +   assetToPToken: mapping(address=>address) <<InitializableAbstractStrategy>> +   harvesterAddress: address <<InitializableAbstractStrategy>> +   rewardTokenAddresses: address[] <<InitializableAbstractStrategy>> +   weth: IWETH9 <<BridgedWOETHStrategy>> +   bridgedWOETH: IERC20 <<BridgedWOETHStrategy>> +   oethb: IERC20 <<BridgedWOETHStrategy>> +   oracle: IOracle <<BridgedWOETHStrategy>> +   lastOraclePrice: uint128 <<BridgedWOETHStrategy>> +   maxPriceDiffBps: uint128 <<BridgedWOETHStrategy>> +   master: address <<BridgedWOETHMigrationStrategy>> +   ccipRouter: IRouterClient <<BridgedWOETHMigrationStrategy>> +   ccipChainSelectorMainnet: uint64 <<BridgedWOETHMigrationStrategy>> +   totalBridged: uint256 <<BridgedWOETHMigrationStrategy>> +   maxPerBridge: uint256 <<BridgedWOETHMigrationStrategy>> +   operator: address <<BridgedWOETHMigrationStrategy>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _initialize(_rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<InitializableAbstractStrategy>> +    _collectRewardTokens() <<InitializableAbstractStrategy>> +    _setPTokenAddress(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    _abstractSetPToken(address, address) <<BridgedWOETHStrategy>> +    _setMaxPriceDiffBps(_maxPriceDiffBps: uint128) <<BridgedWOETHStrategy>> +    _updateWOETHOraclePrice(): uint256 <<BridgedWOETHStrategy>> +    _setMaxPerBridge(_maxPerBridge: uint256) <<BridgedWOETHMigrationStrategy>> +External: +    <<payable>> bridgeToRemote(_amount: uint256) <<onlyOperatorGovernorOrStrategist, nonReentrant>> <<BridgedWOETHMigrationStrategy>> +    <<payable>> null() <<BridgedWOETHMigrationStrategy>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    collectRewardTokens() <<BridgedWOETHStrategy>> +    setRewardTokenAddresses(_rewardTokenAddresses: address[]) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    getRewardTokenAddresses(): address[] <<InitializableAbstractStrategy>> +    setPTokenAddress(_asset: address, _pToken: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    removePToken(uint256) <<BridgedWOETHStrategy>> +    setHarvesterAddress(_harvesterAddress: address) <<onlyGovernorOrStrategist>> <<InitializableAbstractStrategy>> +    safeApproveAllTokens() <<BridgedWOETHStrategy>> +    deposit(address, uint256) <<onlyVault, nonReentrant>> <<BridgedWOETHStrategy>> +    depositAll() <<onlyVault, nonReentrant>> <<BridgedWOETHStrategy>> +    withdraw(_recipient: address, _asset: address, _amount: uint256) <<onlyVault, nonReentrant>> <<BridgedWOETHStrategy>> +    withdrawAll() <<onlyVaultOrGovernor, nonReentrant>> <<BridgedWOETHStrategy>> +    checkBalance(_asset: address): uint256 <<BridgedWOETHMigrationStrategy>> +    initialize(_maxPriceDiffBps: uint128) <<onlyGovernor, initializer>> <<BridgedWOETHStrategy>> +    setMaxPriceDiffBps(_maxPriceDiffBps: uint128) <<onlyGovernor>> <<BridgedWOETHStrategy>> +    updateWOETHOraclePrice(): uint256 <<nonReentrant>> <<BridgedWOETHStrategy>> +    depositBridgedWOETH(woethAmount: uint256) <<onlyGovernorOrStrategist, nonReentrant>> <<BridgedWOETHStrategy>> +    withdrawBridgedWOETH(oethToBurn: uint256) <<onlyGovernorOrStrategist, nonReentrant>> <<BridgedWOETHStrategy>> +    setOperator(_operator: address) <<onlyGovernor>> <<BridgedWOETHMigrationStrategy>> +    setMaxPerBridge(_maxPerBridge: uint256) <<onlyGovernorOrStrategist>> <<BridgedWOETHMigrationStrategy>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> PTokenAdded(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> PTokenRemoved(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> Deposit(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> Withdrawal(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenCollected(recipient: address, rewardToken: address, amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenAddressesUpdated(_oldAddresses: address[], _newAddresses: address[]) <<InitializableAbstractStrategy>> +    <<event>> HarvesterAddressesUpdated(_oldHarvesterAddress: address, _newHarvesterAddress: address) <<InitializableAbstractStrategy>> +    <<event>> MaxPriceDiffBpsUpdated(oldValue: uint128, newValue: uint128) <<BridgedWOETHStrategy>> +    <<event>> WOETHPriceUpdated(oldValue: uint128, newValue: uint128) <<BridgedWOETHStrategy>> +    <<event>> MaxPerBridgeSet(maxPerBridge: uint256) <<BridgedWOETHMigrationStrategy>> +    <<event>> OperatorUpdated(oldOperator: address, newOperator: address) <<BridgedWOETHMigrationStrategy>> +    <<event>> WOETHBridgedToRemote(amount: uint256, totalBridged: uint256) <<BridgedWOETHMigrationStrategy>> +    <<modifier>> initializer() <<Initializable>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVault() <<InitializableAbstractStrategy>> +    <<modifier>> onlyHarvesterOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernor() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyOperatorGovernorOrStrategist() <<BridgedWOETHMigrationStrategy>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor(_config: BaseStrategyConfig) <<InitializableAbstractStrategy>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<BridgedWOETHStrategy>> +    supportsAsset(_asset: address): bool <<BridgedWOETHStrategy>> +    constructor(_stratConfig: BaseStrategyConfig, _weth: address, _bridgedWOETH: address, _oethb: address, _oracle: address) <<BridgedWOETHStrategy>> +    getBridgedWOETHValue(woethAmount: uint256): uint256 <<BridgedWOETHStrategy>> +    constructor(_stratConfig: BaseStrategyConfig, _weth: address, _bridgedWOETH: address, _oethb: address, _oracle: address, _master: address, _ccipRouter: address, _ccipChainSelectorMainnet: uint64) <<BridgedWOETHMigrationStrategy>> + + + diff --git a/contracts/docs/BridgedWOETHMigrationStrategyStorage.svg b/contracts/docs/BridgedWOETHMigrationStrategyStorage.svg new file mode 100644 index 0000000000..9f9f33b56b --- /dev/null +++ b/contracts/docs/BridgedWOETHMigrationStrategyStorage.svg @@ -0,0 +1,149 @@ + + + + + + +StorageDiagram + + + +3 + +BridgedWOETHMigrationStrategy <<Contract>> + +slot + +0 + +1-50 + +51 + +52 + +53 + +54 + +55 + +56 + +57 + +58 + +59-156 + +157 + +158 + +159 + +160 + +161-207 + +type: <inherited contract>.variable (bytes) + +unallocated (30) + +bool: Initializable.initializing (1) + +bool: Initializable.initialized (1) + +uint256[50]: Initializable.______gap (1600) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_platformAddress (20) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_vaultAddress (20) + +mapping(address=>address): InitializableAbstractStrategy.assetToPToken (32) + +address[]: InitializableAbstractStrategy.assetsMapped (32) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_rewardTokenAddress (20) + +uint256: InitializableAbstractStrategy._deprecated_rewardLiquidationThreshold (32) + +unallocated (12) + +address: InitializableAbstractStrategy.harvesterAddress (20) + +address[]: InitializableAbstractStrategy.rewardTokenAddresses (32) + +int256[98]: InitializableAbstractStrategy._reserved (3136) + +uint128: BridgedWOETHStrategy.maxPriceDiffBps (16) + +uint128: BridgedWOETHStrategy.lastOraclePrice (16) + +uint256: totalBridged (32) + +uint256: maxPerBridge (32) + +unallocated (12) + +address: operator (20) + +uint256[47]: __gap (1504) + + + +1 + +address[]: assetsMapped <<Array>> +0x4a11f94e20a93c79f6ec743a1954ec4fc2c08429ae2122118bf234b2185c81b8 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +3:8->1 + + + + + +2 + +address[]: rewardTokenAddresses <<Array>> +0xa2999d817b6757290b50e8ecf3fa939673403dd35c97de392fdb343b4015ce9e + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +3:13->2 + + + + + diff --git a/contracts/docs/CCIPAdapterInteractions.svg b/contracts/docs/CCIPAdapterInteractions.svg new file mode 100644 index 0000000000..3b6a68140c --- /dev/null +++ b/contracts/docs/CCIPAdapterInteractions.svg @@ -0,0 +1,140 @@ + + + + + + +UmlClassDiagram + + + +321 + +<<Interface>> +IBridgeReceiver + + + +451 + +CCIPAdapter + + + +451->321 + + +receiveMessage + + + +456 + +<<Library>> +CCIPMessageBuilder + + + +451->456 + + +build + + + +1666 + +<<Interface>> +IRouterClient + + + +451->1666 + + +getFee + + + +1668 + +<<Library>> +Client + + + +451->1668 + + + + + +1324 + +<<Interface>> +IERC20 + + + +451->1324 + + + + + +1606 + +<<Library>> +SafeERC20 + + + +451->1606 + + +safeApprove +safeTransfer +safeTransferFrom + + + +456->1668 + + +EVM2AnyMessage +EVMExtraArgsV1 +EVMTokenAmount +_argsToBytes + + + +1666->1668 + + + + + +1606->1324 + + +allowance + + + +1057 + +<<Library>> +Address + + + +1606->1057 + + +functionCall + + + diff --git a/contracts/docs/CCIPAdapterSquashed.svg b/contracts/docs/CCIPAdapterSquashed.svg new file mode 100644 index 0000000000..4008165f2d --- /dev/null +++ b/contracts/docs/CCIPAdapterSquashed.svg @@ -0,0 +1,92 @@ + + + + + + +UmlClassDiagram + + + +451 + +CCIPAdapter +../contracts/strategies/crosschainV3/adapters/CCIPAdapter.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   __gap: uint256[50] <<AbstractAdapter>> +Internal: +   _maxTransferAmount: uint256 <<AbstractAdapter>> +   HEADER_LENGTH: uint256 <<AbstractAdapter>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   authorised: mapping(address=>bool) <<AbstractAdapter>> +   laneConfig: mapping(address=>ChainConfig) <<AbstractAdapter>> +   strategists: mapping(address=>bool) <<AbstractAdapter>> +   ccipRouter: IRouterClient <<CCIPAdapter>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _sendMessage(envelope: bytes, cfg: ChainConfig, fee: uint256) <<CCIPAdapter>> +    _sendMessageAndTokens(token: address, amount: uint256, envelope: bytes, cfg: ChainConfig, fee: uint256) <<CCIPAdapter>> +    _quoteFee(envelope: bytes, cfg: ChainConfig, token: address, amount: uint256): (fee: uint256, feeToken: address, requiresExternalPayment: bool) <<CCIPAdapter>> +    _validateInbound(srcChain: uint64, transportSender: address, envelope: bytes): (envelopeSender: address, intendedAmount: uint256, payload: bytes) <<AbstractAdapter>> +    _deliver(envelopeSender: address, token: address, amountReceived: uint256, feePaid: uint256, payload: bytes) <<AbstractAdapter>> +    _wrap(sender: address, intendedAmount: uint256, payload: bytes): bytes <<AbstractAdapter>> +    _unwrap(envelope: bytes): (sender: address, intendedAmount: uint256, payload: bytes) <<AbstractAdapter>> +External: +    <<payable>> sendMessage(payload: bytes) <<onlyAuthorised>> <<AbstractAdapter>> +    <<payable>> sendMessageAndTokens(token: address, amount: uint256, payload: bytes) <<onlyAuthorised>> <<AbstractAdapter>> +    <<payable>> null() <<AbstractAdapter>> +    quoteFee(token: address, amount: uint256, payload: bytes): (fee: uint256, feeToken: address, requiresExternalPayment: bool) <<AbstractAdapter>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    addStrategist(who: address) <<onlyGovernor>> <<AbstractAdapter>> +    removeStrategist(who: address) <<onlyGovernor>> <<AbstractAdapter>> +    authorise(sender: address, cfg: ChainConfig) <<onlyGovernor>> <<AbstractAdapter>> +    revoke(sender: address) <<onlyGovernor>> <<AbstractAdapter>> +    setLaneConfig(sender: address, cfg: ChainConfig) <<onlyGovernor>> <<AbstractAdapter>> +    setMaxTransferAmount(_amount: uint256) <<onlyGovernor>> <<AbstractAdapter>> +    pauseLane(sender: address) <<onlyStrategistOrGovernor>> <<AbstractAdapter>> +    unpauseLane(sender: address) <<onlyStrategistOrGovernor>> <<AbstractAdapter>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<AbstractAdapter>> +    ccipReceive(message: Client.Any2EVMMessage) <<onlyRouter>> <<CCIPAdapter>> +    supportsInterface(interfaceId: bytes4): bool <<CCIPAdapter>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> Authorised(sender: address, cfg: ChainConfig) <<AbstractAdapter>> +    <<event>> Revoked(sender: address) <<AbstractAdapter>> +    <<event>> LaneConfigUpdated(sender: address, cfg: ChainConfig) <<AbstractAdapter>> +    <<event>> LanePaused(sender: address) <<AbstractAdapter>> +    <<event>> LaneUnpaused(sender: address) <<AbstractAdapter>> +    <<event>> StrategistAdded(who: address) <<AbstractAdapter>> +    <<event>> StrategistRemoved(who: address) <<AbstractAdapter>> +    <<event>> MaxTransferAmountUpdated(oldAmount: uint256, newAmount: uint256) <<AbstractAdapter>> +    <<event>> MessageSent(sender: address, token: address, amount: uint256, feeCharged: uint256) <<AbstractAdapter>> +    <<event>> MessageDelivered(target: address, token: address, amountReceived: uint256, feePaid: uint256) <<AbstractAdapter>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyAuthorised() <<AbstractAdapter>> +    <<modifier>> onlyStrategistOrGovernor() <<AbstractAdapter>> +    <<modifier>> onlyRouter() <<CCIPAdapter>> +    maxTransferAmount(): uint256 <<AbstractAdapter>> +    minTransferAmount(): uint256 <<AbstractAdapter>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor() <<AbstractAdapter>> +    constructor(_ccipRouter: IRouterClient) <<CCIPAdapter>> + + + diff --git a/contracts/docs/CCIPAdapterStorage.svg b/contracts/docs/CCIPAdapterStorage.svg new file mode 100644 index 0000000000..58dcfd9382 --- /dev/null +++ b/contracts/docs/CCIPAdapterStorage.svg @@ -0,0 +1,105 @@ + + + + + + +StorageDiagram + + + +3 + +CCIPAdapter <<Contract>> + +slot + +0 + +1 + +2 + +3 + +4-53 + +type: <inherited contract>.variable (bytes) + +mapping(address=>bool): AbstractAdapter.authorised (32) + +mapping(address=>ChainConfig): AbstractAdapter.laneConfig (32) + +mapping(address=>bool): AbstractAdapter.strategists (32) + +uint256: AbstractAdapter._maxTransferAmount (32) + +uint256[50]: AbstractAdapter.__gap (1600) + + + +1 + +ChainConfig <<Struct>> + +offset + +0 + +type: variable (bytes) + +unallocated (19) + +uint32: destGasLimit (4) + +uint64: chainSelector (8) + +bool: paused (1) + + + +3:5->1 + + + + + +2 + +uint256[50]: __gap <<Array>> + +slot + +4 + +5 + +6-51 + +52 + +53 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +3:13->2 + + + + + diff --git a/contracts/docs/CCTPAdapterInteractions.svg b/contracts/docs/CCTPAdapterInteractions.svg new file mode 100644 index 0000000000..2d3c48450c --- /dev/null +++ b/contracts/docs/CCTPAdapterInteractions.svg @@ -0,0 +1,145 @@ + + + + + + +UmlClassDiagram + + + +316 + +<<Interface>> +ICCTPTokenMessenger + + + +317 + +<<Interface>> +ICCTPMessageTransmitter + + + +321 + +<<Interface>> +IBridgeReceiver + + + +452 + +CCTPAdapter + + + +452->316 + + +depositForBurnWithHook + + + +452->317 + + +receiveMessage +sendMessage + + + +452->321 + + +receiveMessage + + + +457 + +<<Library>> +CCTPMessageHelper + + + +452->457 + + +decodeBurnBody +decodeMessageHeader + + + +1324 + +<<Interface>> +IERC20 + + + +452->1324 + + +balanceOf + + + +1606 + +<<Library>> +SafeERC20 + + + +452->1606 + + +safeApprove +safeTransfer +safeTransferFrom + + + +259 + +<<Library>> +BytesHelper + + + +457->259 + + +extractAddress +extractSlice +extractUint256 +extractUint32 + + + +1606->1324 + + +allowance + + + +1057 + +<<Library>> +Address + + + +1606->1057 + + +functionCall + + + diff --git a/contracts/docs/CCTPAdapterSquashed.svg b/contracts/docs/CCTPAdapterSquashed.svg new file mode 100644 index 0000000000..de04735394 --- /dev/null +++ b/contracts/docs/CCTPAdapterSquashed.svg @@ -0,0 +1,115 @@ + + + + + + +UmlClassDiagram + + + +452 + +CCTPAdapter +../contracts/strategies/crosschainV3/adapters/CCTPAdapter.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   __gap: uint256[50] <<AbstractAdapter>> +Internal: +   _maxTransferAmount: uint256 <<AbstractAdapter>> +   HEADER_LENGTH: uint256 <<AbstractAdapter>> +   _minTransferAmount: uint256 <<CCTPAdapter>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   authorised: mapping(address=>bool) <<AbstractAdapter>> +   laneConfig: mapping(address=>ChainConfig) <<AbstractAdapter>> +   strategists: mapping(address=>bool) <<AbstractAdapter>> +   MAX_TRANSFER_AMOUNT: uint256 <<CCTPAdapter>> +   usdcToken: address <<CCTPAdapter>> +   tokenMessenger: ICCTPTokenMessenger <<CCTPAdapter>> +   messageTransmitter: ICCTPMessageTransmitter <<CCTPAdapter>> +   minFinalityThreshold: uint32 <<CCTPAdapter>> +   operator: address <<CCTPAdapter>> + +Private: +    _minFeeOrZero(amount: uint256): uint256 <<CCTPAdapter>> +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _sendMessage(envelope: bytes, cfg: ChainConfig, uint256) <<CCTPAdapter>> +    _sendMessageAndTokens(token: address, amount: uint256, envelope: bytes, cfg: ChainConfig, uint256) <<CCTPAdapter>> +    _quoteFee(bytes, ChainConfig, token: address, amount: uint256): (fee: uint256, feeToken: address, requiresExternalPayment: bool) <<CCTPAdapter>> +    _validateInbound(srcChain: uint64, transportSender: address, envelope: bytes): (envelopeSender: address, intendedAmount: uint256, payload: bytes) <<AbstractAdapter>> +    _deliver(envelopeSender: address, token: address, amountReceived: uint256, feePaid: uint256, payload: bytes) <<AbstractAdapter>> +    _wrap(sender: address, intendedAmount: uint256, payload: bytes): bytes <<AbstractAdapter>> +    _unwrap(envelope: bytes): (sender: address, intendedAmount: uint256, payload: bytes) <<AbstractAdapter>> +    _relayBurn(sourceDomain: uint32, body: bytes, message: bytes, attestation: bytes) <<CCTPAdapter>> +    _landedAmount(minted: uint256, expected: uint256): uint256 <<CCTPAdapter>> +    _dispatchBurn(sourceDomain: uint32, msgSender: address, hookData: bytes, landed: uint256, feeExecuted: uint256, burnAmount: uint256) <<CCTPAdapter>> +    _addressToBytes32(_addr: address): bytes32 <<CCTPAdapter>> +    _bytes32ToAddress(_b: bytes32): address <<CCTPAdapter>> +    _handleInbound(sourceDomain: uint32, sender: bytes32, messageBody: bytes) <<CCTPAdapter>> +External: +    <<payable>> sendMessage(payload: bytes) <<onlyAuthorised>> <<AbstractAdapter>> +    <<payable>> sendMessageAndTokens(token: address, amount: uint256, payload: bytes) <<onlyAuthorised>> <<AbstractAdapter>> +    <<payable>> null() <<AbstractAdapter>> +    quoteFee(token: address, amount: uint256, payload: bytes): (fee: uint256, feeToken: address, requiresExternalPayment: bool) <<AbstractAdapter>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    addStrategist(who: address) <<onlyGovernor>> <<AbstractAdapter>> +    removeStrategist(who: address) <<onlyGovernor>> <<AbstractAdapter>> +    authorise(sender: address, cfg: ChainConfig) <<onlyGovernor>> <<AbstractAdapter>> +    revoke(sender: address) <<onlyGovernor>> <<AbstractAdapter>> +    setLaneConfig(sender: address, cfg: ChainConfig) <<onlyGovernor>> <<AbstractAdapter>> +    setMaxTransferAmount(_amount: uint256) <<onlyGovernor>> <<AbstractAdapter>> +    pauseLane(sender: address) <<onlyStrategistOrGovernor>> <<AbstractAdapter>> +    unpauseLane(sender: address) <<onlyStrategistOrGovernor>> <<AbstractAdapter>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<AbstractAdapter>> +    handleReceiveFinalizedMessage(sourceDomain: uint32, sender: bytes32, uint32, messageBody: bytes): bool <<onlyCCTP>> <<CCTPAdapter>> +    handleReceiveUnfinalizedMessage(sourceDomain: uint32, sender: bytes32, finalityThresholdExecuted: uint32, messageBody: bytes): bool <<onlyCCTP>> <<CCTPAdapter>> +    setMinFinalityThreshold(_t: uint32) <<onlyGovernor>> <<CCTPAdapter>> +    setMinTransferAmount(_amount: uint256) <<onlyGovernor>> <<CCTPAdapter>> +    setOperator(_operator: address) <<onlyGovernor>> <<CCTPAdapter>> +    relay(message: bytes, attestation: bytes) <<onlyOperator>> <<CCTPAdapter>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> Authorised(sender: address, cfg: ChainConfig) <<AbstractAdapter>> +    <<event>> Revoked(sender: address) <<AbstractAdapter>> +    <<event>> LaneConfigUpdated(sender: address, cfg: ChainConfig) <<AbstractAdapter>> +    <<event>> LanePaused(sender: address) <<AbstractAdapter>> +    <<event>> LaneUnpaused(sender: address) <<AbstractAdapter>> +    <<event>> StrategistAdded(who: address) <<AbstractAdapter>> +    <<event>> StrategistRemoved(who: address) <<AbstractAdapter>> +    <<event>> MaxTransferAmountUpdated(oldAmount: uint256, newAmount: uint256) <<AbstractAdapter>> +    <<event>> MessageSent(sender: address, token: address, amount: uint256, feeCharged: uint256) <<AbstractAdapter>> +    <<event>> MessageDelivered(target: address, token: address, amountReceived: uint256, feePaid: uint256) <<AbstractAdapter>> +    <<event>> MinFinalityThresholdUpdated(oldThreshold: uint32, newThreshold: uint32) <<CCTPAdapter>> +    <<event>> MinTransferAmountUpdated(oldAmount: uint256, newAmount: uint256) <<CCTPAdapter>> +    <<event>> OperatorUpdated(oldOperator: address, newOperator: address) <<CCTPAdapter>> +    <<event>> MessageRelayed(by: address, sourceDomain: uint32) <<CCTPAdapter>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyAuthorised() <<AbstractAdapter>> +    <<modifier>> onlyStrategistOrGovernor() <<AbstractAdapter>> +    <<modifier>> onlyCCTP() <<CCTPAdapter>> +    <<modifier>> onlyOperator() <<CCTPAdapter>> +    maxTransferAmount(): uint256 <<CCTPAdapter>> +    minTransferAmount(): uint256 <<CCTPAdapter>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor() <<AbstractAdapter>> +    constructor(_usdcToken: address, _tokenMessenger: ICCTPTokenMessenger, _messageTransmitter: ICCTPMessageTransmitter) <<CCTPAdapter>> + + + diff --git a/contracts/docs/CCTPAdapterStorage.svg b/contracts/docs/CCTPAdapterStorage.svg new file mode 100644 index 0000000000..b61aecc1e9 --- /dev/null +++ b/contracts/docs/CCTPAdapterStorage.svg @@ -0,0 +1,121 @@ + + + + + + +StorageDiagram + + + +3 + +CCTPAdapter <<Contract>> + +slot + +0 + +1 + +2 + +3 + +4-53 + +54 + +55 + +56 + +type: <inherited contract>.variable (bytes) + +mapping(address=>bool): AbstractAdapter.authorised (32) + +mapping(address=>ChainConfig): AbstractAdapter.laneConfig (32) + +mapping(address=>bool): AbstractAdapter.strategists (32) + +uint256: AbstractAdapter._maxTransferAmount (32) + +uint256[50]: AbstractAdapter.__gap (1600) + +unallocated (28) + +uint32: minFinalityThreshold (4) + +uint256: _minTransferAmount (32) + +unallocated (12) + +address: operator (20) + + + +1 + +ChainConfig <<Struct>> + +offset + +0 + +type: variable (bytes) + +unallocated (19) + +uint32: destGasLimit (4) + +uint64: chainSelector (8) + +bool: paused (1) + + + +3:5->1 + + + + + +2 + +uint256[50]: __gap <<Array>> + +slot + +4 + +5 + +6-51 + +52 + +53 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +3:13->2 + + + + + diff --git a/contracts/docs/MasterWOTokenStrategyInteractions.svg b/contracts/docs/MasterWOTokenStrategyInteractions.svg new file mode 100644 index 0000000000..d7ab1130f1 --- /dev/null +++ b/contracts/docs/MasterWOTokenStrategyInteractions.svg @@ -0,0 +1,189 @@ + + + + + + +UmlClassDiagram + + + +58 + +<<Interface>> +IBasicToken + + + +90 + +<<Interface>> +IVault + + + +276 + +<<Abstract>> +VaultStorage + + + +90->276 + + + + + +320 + +<<Interface>> +IBridgeAdapter + + + +439 + +<<Library>> +CrossChainV3Helper + + + +441 + +MasterWOTokenStrategy + + + +441->58 + + +decimals + + + +441->90 + + +burnForStrategy +mintForStrategy +strategistAddr + + + +441->320 + + +maxTransferAmount +minTransferAmount +quoteFee + + + +441->439 + + +BridgeUserPayload +decodeBalanceCheckResponsePayload +decodeBridgeUserPayload +decodeUint256 +decodeWithdrawClaimAckPayload +decodeWithdrawRequestAckPayload +encodeBridgeUserPayload +encodeUint256 +packPayload +unpackPayload + + + +267 + +<<Library>> +StableMath + + + +441->267 + + +scaleBy + + + +1324 + +<<Interface>> +IERC20 + + + +441->1324 + + +balanceOf + + + +1606 + +<<Library>> +SafeERC20 + + + +441->1606 + + +safeApprove +safeTransfer +safeTransferFrom + + + +1350 + +<<Library>> +SafeMath + + + +267->1350 + + +add +div +mul +sub + + + +276->1606 + + + + + +1606->1324 + + +allowance + + + +1057 + +<<Library>> +Address + + + +1606->1057 + + +functionCall + + + diff --git a/contracts/docs/MasterWOTokenStrategySquashed.svg b/contracts/docs/MasterWOTokenStrategySquashed.svg new file mode 100644 index 0000000000..350c7a7343 --- /dev/null +++ b/contracts/docs/MasterWOTokenStrategySquashed.svg @@ -0,0 +1,180 @@ + + + + + + +UmlClassDiagram + + + +441 + +MasterWOTokenStrategy +../contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   __gap: uint256[50] <<AbstractCrossChainV3Strategy>> +   initialized: bool <<Initializable>> +   initializing: bool <<Initializable>> +   ______gap: uint256[50] <<Initializable>> +   _deprecated_platformAddress: address <<InitializableAbstractStrategy>> +   _deprecated_vaultAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardTokenAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardLiquidationThreshold: uint256 <<InitializableAbstractStrategy>> +   _reserved: int256[98] <<InitializableAbstractStrategy>> +   __gap: uint256[50] <<AbstractWOTokenStrategy>> +   __gap: uint256[41] <<MasterWOTokenStrategy>> +Internal: +   assetsMapped: address[] <<InitializableAbstractStrategy>> +   BPS_DENOMINATOR: uint256 <<AbstractWOTokenStrategy>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   outboundAdapter: address <<AbstractCrossChainV3Strategy>> +   inboundAdapter: address <<AbstractCrossChainV3Strategy>> +   operator: address <<AbstractCrossChainV3Strategy>> +   lastYieldNonce: uint64 <<AbstractCrossChainV3Strategy>> +   nonceProcessed: mapping(uint64=>bool) <<AbstractCrossChainV3Strategy>> +   lastBalanceCheckTimestamp: uint256 <<AbstractCrossChainV3Strategy>> +   platformAddress: address <<InitializableAbstractStrategy>> +   vaultAddress: address <<InitializableAbstractStrategy>> +   assetToPToken: mapping(address=>address) <<InitializableAbstractStrategy>> +   harvesterAddress: address <<InitializableAbstractStrategy>> +   rewardTokenAddresses: address[] <<InitializableAbstractStrategy>> +   MAX_BRIDGE_CALL_GAS: uint32 <<AbstractWOTokenStrategy>> +   MAX_BRIDGE_FEE_BPS: uint256 <<AbstractWOTokenStrategy>> +   bridgeAsset: address <<AbstractWOTokenStrategy>> +   oToken: address <<AbstractWOTokenStrategy>> +   bridgeAssetDecimals: uint8 <<AbstractWOTokenStrategy>> +   oTokenDecimals: uint8 <<AbstractWOTokenStrategy>> +   bridgeAdjustment: int256 <<AbstractWOTokenStrategy>> +   consumedBridgeIds: mapping(bytes32=>bool) <<AbstractWOTokenStrategy>> +   bridgeIdCounter: uint256 <<AbstractWOTokenStrategy>> +   bridgeFeeBps: uint256 <<AbstractWOTokenStrategy>> +   remoteStrategyBalance: uint256 <<MasterWOTokenStrategy>> +   pendingDepositAmount: uint256 <<MasterWOTokenStrategy>> +   pendingWithdrawalAmount: uint256 <<MasterWOTokenStrategy>> +   settlementSnapshot: int256 <<MasterWOTokenStrategy>> + +Private: +    _bridgeOutboundExec(_amount: uint256, _recipient: address, _callData: bytes, _callGasLimit: uint32) <<AbstractWOTokenStrategy>> +    _postDeliveryCall(p: CrossChainV3Helper.BridgeUserPayload) <<AbstractWOTokenStrategy>> +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _setOutboundAdapter(_outboundAdapter: address) <<AbstractWOTokenStrategy>> +    _getNextYieldNonce(): uint64 <<AbstractCrossChainV3Strategy>> +    _markYieldNonceProcessed(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    _acceptYieldNonce(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    _handleBridgeMessage(amountReceived: uint256, msgType: uint32, nonce: uint64, body: bytes) <<MasterWOTokenStrategy>> +    _send(token: address, amount: uint256, msgType: uint32, nonce: uint64, body: bytes, userFunded: bool) <<AbstractCrossChainV3Strategy>> +    _initialize(_rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<InitializableAbstractStrategy>> +    _collectRewardTokens() <<InitializableAbstractStrategy>> +    _setPTokenAddress(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    _abstractSetPToken(address, address) <<AbstractWOTokenStrategy>> +    _initWithPToken(pToken: address) <<AbstractWOTokenStrategy>> +    _toOToken(assetAmount: uint256): uint256 <<AbstractWOTokenStrategy>> +    _toAsset(oTokenAmount: uint256): uint256 <<AbstractWOTokenStrategy>> +    _handleInboundBridgeMessage(msgType: uint32, amount: uint256, body: bytes) <<nonReentrant>> <<AbstractWOTokenStrategy>> +    _applyBridgeAdjustment(msgType: uint32, amount: uint256) <<AbstractWOTokenStrategy>> +    _nextBridgeId(): bytes32 <<AbstractWOTokenStrategy>> +    _bridgeOutboundMsgType(): uint32 <<MasterWOTokenStrategy>> +    _consumeOTokenForBridge(amount: uint256) <<MasterWOTokenStrategy>> +    _deliverOTokenForBridge(amount: uint256, recipient: address) <<MasterWOTokenStrategy>> +    _depositToRemote(_asset: address, _amount: uint256) <<MasterWOTokenStrategy>> +    _withdrawRequest(_asset: address, _amount: uint256) <<MasterWOTokenStrategy>> +    _processBalanceCheckResponse(nonce: uint64, payload: bytes) <<MasterWOTokenStrategy>> +    _processSettlementAck(nonce: uint64, payload: bytes) <<MasterWOTokenStrategy>> +    _processWithdrawRequestAck(nonce: uint64, payload: bytes) <<MasterWOTokenStrategy>> +    _processWithdrawClaimAck(nonce: uint64, amount: uint256, payload: bytes) <<MasterWOTokenStrategy>> +    _processDepositAck(nonce: uint64, payload: bytes) <<MasterWOTokenStrategy>> +    _drawableRemoteBalance(): uint256 <<MasterWOTokenStrategy>> +External: +    <<payable>> null() <<AbstractCrossChainV3Strategy>> +    <<payable>> bridgeOTokenToPeer(_amount: uint256, _recipient: address, _callData: bytes, _callGasLimit: uint32) <<nonReentrant>> <<AbstractWOTokenStrategy>> +    <<payable>> triggerClaim() <<nonReentrant, onlyOperatorGovernorOrStrategist>> <<MasterWOTokenStrategy>> +    <<payable>> requestBalanceCheck() <<nonReentrant, onlyOperatorGovernorOrStrategist>> <<MasterWOTokenStrategy>> +    <<payable>> requestSettlement() <<nonReentrant, onlyOperatorGovernorOrStrategist>> <<MasterWOTokenStrategy>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    receiveMessage(address, address, amountReceived: uint256, payload: bytes) <<onlyInboundAdapter>> <<AbstractCrossChainV3Strategy>> +    setOutboundAdapter(_outboundAdapter: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    setInboundAdapter(_inboundAdapter: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    setOperator(_operator: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    transferNative(amount: uint256) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    collectRewardTokens() <<onlyHarvesterOrStrategist, nonReentrant>> <<AbstractWOTokenStrategy>> +    setRewardTokenAddresses(_rewardTokenAddresses: address[]) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    getRewardTokenAddresses(): address[] <<InitializableAbstractStrategy>> +    setPTokenAddress(_asset: address, _pToken: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    removePToken(_assetIndex: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    setHarvesterAddress(_harvesterAddress: address) <<onlyGovernorOrStrategist>> <<InitializableAbstractStrategy>> +    safeApproveAllTokens() <<onlyGovernor, nonReentrant>> <<MasterWOTokenStrategy>> +    deposit(_asset: address, _amount: uint256) <<onlyVault, nonReentrant>> <<MasterWOTokenStrategy>> +    depositAll() <<onlyVault, nonReentrant>> <<MasterWOTokenStrategy>> +    withdraw(_recipient: address, _asset: address, _amount: uint256) <<onlyVault, nonReentrant>> <<MasterWOTokenStrategy>> +    withdrawAll() <<onlyVaultOrGovernor, nonReentrant>> <<MasterWOTokenStrategy>> +    checkBalance(_asset: address): uint256 <<MasterWOTokenStrategy>> +    setBridgeFeeBps(_bps: uint256) <<onlyGovernor>> <<AbstractWOTokenStrategy>> +    initialize(_operator: address) <<onlyGovernor, initializer>> <<MasterWOTokenStrategy>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> OutboundAdapterUpdated(oldAdapter: address, newAdapter: address) <<AbstractCrossChainV3Strategy>> +    <<event>> InboundAdapterUpdated(oldAdapter: address, newAdapter: address) <<AbstractCrossChainV3Strategy>> +    <<event>> OperatorUpdated(oldOperator: address, newOperator: address) <<AbstractCrossChainV3Strategy>> +    <<event>> YieldNonceAdvanced(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    <<event>> YieldNonceProcessed(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    <<event>> PTokenAdded(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> PTokenRemoved(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> Deposit(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> Withdrawal(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenCollected(recipient: address, rewardToken: address, amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenAddressesUpdated(_oldAddresses: address[], _newAddresses: address[]) <<InitializableAbstractStrategy>> +    <<event>> HarvesterAddressesUpdated(_oldHarvesterAddress: address, _newHarvesterAddress: address) <<InitializableAbstractStrategy>> +    <<event>> BridgeRequested(bridgeId: bytes32, sender: address, recipient: address, amount: uint256, fee: uint256, callData: bytes, callGasLimit: uint32) <<AbstractWOTokenStrategy>> +    <<event>> BridgeDelivered(bridgeId: bytes32, recipient: address, amount: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeFeeBpsUpdated(oldBps: uint256, newBps: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeCallSucceeded(bridgeId: bytes32, recipient: address, amount: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeCallFailed(bridgeId: bytes32, recipient: address, amount: uint256, returnData: bytes) <<AbstractWOTokenStrategy>> +    <<event>> RemoteStrategyBalanceUpdated(yieldBaseline: uint256) <<MasterWOTokenStrategy>> +    <<event>> DepositRequested(nonce: uint64, amount: uint256) <<MasterWOTokenStrategy>> +    <<event>> DepositAcked(nonce: uint64, yieldBaseline: uint256) <<MasterWOTokenStrategy>> +    <<event>> WithdrawRequested(nonce: uint64, amount: uint256) <<MasterWOTokenStrategy>> +    <<event>> WithdrawRequestAcked(nonce: uint64, yieldBaseline: uint256) <<MasterWOTokenStrategy>> +    <<event>> WithdrawClaimTriggered(nonce: uint64, amount: uint256) <<MasterWOTokenStrategy>> +    <<event>> WithdrawClaimAcked(nonce: uint64, yieldBaseline: uint256, success: bool) <<MasterWOTokenStrategy>> +    <<event>> BalanceCheckRequested(nonce: uint64, timestamp: uint256) <<MasterWOTokenStrategy>> +    <<event>> BalanceCheckResponded(nonce: uint64, yieldBaseline: uint256, remoteTimestamp: uint256) <<MasterWOTokenStrategy>> +    <<event>> SettlementRequested(nonce: uint64, bridgeAdjustmentSnapshot: int256) <<MasterWOTokenStrategy>> +    <<event>> SettlementAcked(nonce: uint64, yieldBaseline: uint256) <<MasterWOTokenStrategy>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyInboundAdapter() <<AbstractCrossChainV3Strategy>> +    <<modifier>> initializer() <<Initializable>> +    <<modifier>> onlyGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVault() <<InitializableAbstractStrategy>> +    <<modifier>> onlyHarvesterOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernor() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyOperatorGovernorOrStrategist() <<AbstractWOTokenStrategy>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    isYieldOpInFlight(): bool <<AbstractCrossChainV3Strategy>> +    constructor(_config: BaseStrategyConfig) <<InitializableAbstractStrategy>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    supportsAsset(_asset: address): bool <<AbstractWOTokenStrategy>> +    constructor(_stratConfig: BaseStrategyConfig, _bridgeAsset: address, _oToken: address) <<MasterWOTokenStrategy>> +    availableBridgeLiquidity(): uint256 <<MasterWOTokenStrategy>> + + + diff --git a/contracts/docs/MasterWOTokenStrategyStorage.svg b/contracts/docs/MasterWOTokenStrategyStorage.svg new file mode 100644 index 0000000000..be0bb5b3ad --- /dev/null +++ b/contracts/docs/MasterWOTokenStrategyStorage.svg @@ -0,0 +1,305 @@ + + + + + + +StorageDiagram + + + +6 + +MasterWOTokenStrategy <<Contract>> + +slot + +0 + +1 + +2 + +3 + +4 + +5-54 + +55 + +56-105 + +106 + +107 + +108 + +109 + +110 + +111 + +112 + +113 + +114-211 + +212 + +213 + +214 + +215 + +216-265 + +266 + +267 + +268 + +269 + +270-310 + +type: <inherited contract>.variable (bytes) + +unallocated (12) + +address: AbstractCrossChainV3Strategy.outboundAdapter (20) + +unallocated (12) + +address: AbstractCrossChainV3Strategy.inboundAdapter (20) + +unallocated (4) + +uint64: AbstractCrossChainV3Strategy.lastYieldNonce (8) + +address: AbstractCrossChainV3Strategy.operator (20) + +mapping(uint64=>bool): AbstractCrossChainV3Strategy.nonceProcessed (32) + +uint256: AbstractCrossChainV3Strategy.lastBalanceCheckTimestamp (32) + +uint256[50]: AbstractCrossChainV3Strategy.__gap (1600) + +unallocated (30) + +bool: Initializable.initializing (1) + +bool: Initializable.initialized (1) + +uint256[50]: Initializable.______gap (1600) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_platformAddress (20) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_vaultAddress (20) + +mapping(address=>address): InitializableAbstractStrategy.assetToPToken (32) + +address[]: InitializableAbstractStrategy.assetsMapped (32) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_rewardTokenAddress (20) + +uint256: InitializableAbstractStrategy._deprecated_rewardLiquidationThreshold (32) + +unallocated (12) + +address: InitializableAbstractStrategy.harvesterAddress (20) + +address[]: InitializableAbstractStrategy.rewardTokenAddresses (32) + +int256[98]: InitializableAbstractStrategy._reserved (3136) + +int256: AbstractWOTokenStrategy.bridgeAdjustment (32) + +mapping(bytes32=>bool): AbstractWOTokenStrategy.consumedBridgeIds (32) + +uint256: AbstractWOTokenStrategy.bridgeIdCounter (32) + +uint256: AbstractWOTokenStrategy.bridgeFeeBps (32) + +uint256[50]: AbstractWOTokenStrategy.__gap (1600) + +uint256: remoteStrategyBalance (32) + +uint256: pendingDepositAmount (32) + +uint256: pendingWithdrawalAmount (32) + +int256: settlementSnapshot (32) + +uint256[41]: __gap (1312) + + + +1 + +uint256[50]: __gap <<Array>> + +slot + +5 + +6 + +7-52 + +53 + +54 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +6:12->1 + + + + + +2 + +address[]: assetsMapped <<Array>> +0x5006b838207c6a9ae9b84d68f467dd4bb5c305fbfb6b04eab8faaabeec1e18d8 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:20->2 + + + + + +3 + +address[]: rewardTokenAddresses <<Array>> +0xa1fcd19bfe8c32a61095b6bfbb2664842857e148fcbb5188386c8cd40348d5b6 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:25->3 + + + + + +4 + +uint256[50]: __gap <<Array>> + +slot + +216 + +217 + +218-263 + +264 + +265 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +6:36->4 + + + + + +5 + +uint256[41]: __gap <<Array>> + +slot + +270 + +271 + +272-308 + +309 + +310 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1184) + +uint256 (32) + +uint256 (32) + + + +6:46->5 + + + + + diff --git a/contracts/docs/OTokenStrategiesHierarchy.svg b/contracts/docs/OTokenStrategiesHierarchy.svg new file mode 100644 index 0000000000..fcf552a925 --- /dev/null +++ b/contracts/docs/OTokenStrategiesHierarchy.svg @@ -0,0 +1,109 @@ + + + + + + +UmlClassDiagram + + + +40 + +<<Abstract>> +Governable +../contracts/governance/Governable.sol + + + +437 + +<<Abstract>> +AbstractCrossChainV3Strategy +../contracts/strategies/crosschainV3/AbstractCrossChainV3Strategy.sol + + + +437->40 + + + + + +438 + +<<Abstract>> +AbstractWOTokenStrategy +../contracts/strategies/crosschainV3/AbstractWOTokenStrategy.sol + + + +438->437 + + + + + +263 + +<<Abstract>> +InitializableAbstractStrategy +../contracts/utils/InitializableAbstractStrategy.sol + + + +438->263 + + + + + +441 + +MasterWOTokenStrategy +../contracts/strategies/crosschainV3/MasterWOTokenStrategy.sol + + + +441->438 + + + + + +442 + +RemoteWOTokenStrategy +../contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol + + + +442->438 + + + + + +262 + +<<Abstract>> +Initializable +../contracts/utils/Initializable.sol + + + +263->40 + + + + + +263->262 + + + + + diff --git a/contracts/docs/RemoteWOTokenStrategyInteractions.svg b/contracts/docs/RemoteWOTokenStrategyInteractions.svg new file mode 100644 index 0000000000..8913770b6c --- /dev/null +++ b/contracts/docs/RemoteWOTokenStrategyInteractions.svg @@ -0,0 +1,237 @@ + + + + + + +UmlClassDiagram + + + +58 + +<<Interface>> +IBasicToken + + + +90 + +<<Interface>> +IVault + + + +276 + +<<Abstract>> +VaultStorage + + + +90->276 + + + + + +320 + +<<Interface>> +IBridgeAdapter + + + +439 + +<<Library>> +CrossChainV3Helper + + + +442 + +RemoteWOTokenStrategy + + + +442->58 + + +decimals + + + +442->90 + + +mint +strategistAddr + + + +442->320 + + +maxTransferAmount +minTransferAmount +quoteFee + + + +442->439 + + +BridgeUserPayload +decodeBridgeUserPayload +decodeUint256 +encodeBalanceCheckResponsePayload +encodeBridgeUserPayload +encodeUint256 +encodeWithdrawClaimAckPayload +encodeWithdrawRequestAckPayload +packPayload +unpackPayload + + + +267 + +<<Library>> +StableMath + + + +442->267 + + +scaleBy + + + +448 + +<<Interface>> +IERC4626 + + + +442->448 + + +deposit +mint +previewWithdraw +withdraw + + + +1324 + +<<Interface>> +IERC20 + + + +442->1324 + + +balanceOf + + + +1606 + +<<Library>> +SafeERC20 + + + +442->1606 + + +safeApprove +safeTransfer +safeTransferFrom + + + +1350 + +<<Library>> +SafeMath + + + +267->1350 + + +add +div +mul +sub + + + +1601 + +<<Interface>> +IERC20Metadata + + + +276->1601 + + +decimals + + + +276->1606 + + + + + +448->1324 + + + + + +448->1601 + + + + + +1601->1324 + + + + + +1606->1324 + + +allowance + + + +1057 + +<<Library>> +Address + + + +1606->1057 + + +functionCall + + + diff --git a/contracts/docs/RemoteWOTokenStrategySquashed.svg b/contracts/docs/RemoteWOTokenStrategySquashed.svg new file mode 100644 index 0000000000..4ef06ee135 --- /dev/null +++ b/contracts/docs/RemoteWOTokenStrategySquashed.svg @@ -0,0 +1,180 @@ + + + + + + +UmlClassDiagram + + + +442 + +RemoteWOTokenStrategy +../contracts/strategies/crosschainV3/RemoteWOTokenStrategy.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   __gap: uint256[50] <<AbstractCrossChainV3Strategy>> +   initialized: bool <<Initializable>> +   initializing: bool <<Initializable>> +   ______gap: uint256[50] <<Initializable>> +   _deprecated_platformAddress: address <<InitializableAbstractStrategy>> +   _deprecated_vaultAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardTokenAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardLiquidationThreshold: uint256 <<InitializableAbstractStrategy>> +   _reserved: int256[98] <<InitializableAbstractStrategy>> +   __gap: uint256[50] <<AbstractWOTokenStrategy>> +   __gap: uint256[43] <<RemoteWOTokenStrategy>> +Internal: +   assetsMapped: address[] <<InitializableAbstractStrategy>> +   BPS_DENOMINATOR: uint256 <<AbstractWOTokenStrategy>> +   REQUEST_ID_EMPTY: uint256 <<RemoteWOTokenStrategy>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   outboundAdapter: address <<AbstractCrossChainV3Strategy>> +   inboundAdapter: address <<AbstractCrossChainV3Strategy>> +   operator: address <<AbstractCrossChainV3Strategy>> +   lastYieldNonce: uint64 <<AbstractCrossChainV3Strategy>> +   nonceProcessed: mapping(uint64=>bool) <<AbstractCrossChainV3Strategy>> +   lastBalanceCheckTimestamp: uint256 <<AbstractCrossChainV3Strategy>> +   platformAddress: address <<InitializableAbstractStrategy>> +   vaultAddress: address <<InitializableAbstractStrategy>> +   assetToPToken: mapping(address=>address) <<InitializableAbstractStrategy>> +   harvesterAddress: address <<InitializableAbstractStrategy>> +   rewardTokenAddresses: address[] <<InitializableAbstractStrategy>> +   MAX_BRIDGE_CALL_GAS: uint32 <<AbstractWOTokenStrategy>> +   MAX_BRIDGE_FEE_BPS: uint256 <<AbstractWOTokenStrategy>> +   bridgeAsset: address <<AbstractWOTokenStrategy>> +   oToken: address <<AbstractWOTokenStrategy>> +   bridgeAssetDecimals: uint8 <<AbstractWOTokenStrategy>> +   oTokenDecimals: uint8 <<AbstractWOTokenStrategy>> +   bridgeAdjustment: int256 <<AbstractWOTokenStrategy>> +   consumedBridgeIds: mapping(bytes32=>bool) <<AbstractWOTokenStrategy>> +   bridgeIdCounter: uint256 <<AbstractWOTokenStrategy>> +   bridgeFeeBps: uint256 <<AbstractWOTokenStrategy>> +   woToken: address <<RemoteWOTokenStrategy>> +   oTokenVault: address <<RemoteWOTokenStrategy>> +   outstandingRequestId: uint256 <<RemoteWOTokenStrategy>> +   outstandingRequestAmount: uint256 <<RemoteWOTokenStrategy>> + +Private: +    _bridgeOutboundExec(_amount: uint256, _recipient: address, _callData: bytes, _callGasLimit: uint32) <<AbstractWOTokenStrategy>> +    _postDeliveryCall(p: CrossChainV3Helper.BridgeUserPayload) <<AbstractWOTokenStrategy>> +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _setOutboundAdapter(_outboundAdapter: address) <<AbstractWOTokenStrategy>> +    _getNextYieldNonce(): uint64 <<AbstractCrossChainV3Strategy>> +    _markYieldNonceProcessed(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    _acceptYieldNonce(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    _handleBridgeMessage(amountReceived: uint256, msgType: uint32, nonce: uint64, body: bytes) <<RemoteWOTokenStrategy>> +    _send(token: address, amount: uint256, msgType: uint32, nonce: uint64, body: bytes, userFunded: bool) <<AbstractCrossChainV3Strategy>> +    _initialize(_rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<InitializableAbstractStrategy>> +    _collectRewardTokens() <<InitializableAbstractStrategy>> +    _setPTokenAddress(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    _abstractSetPToken(address, address) <<AbstractWOTokenStrategy>> +    _initWithPToken(pToken: address) <<AbstractWOTokenStrategy>> +    _toOToken(assetAmount: uint256): uint256 <<AbstractWOTokenStrategy>> +    _toAsset(oTokenAmount: uint256): uint256 <<AbstractWOTokenStrategy>> +    _handleInboundBridgeMessage(msgType: uint32, amount: uint256, body: bytes) <<nonReentrant>> <<AbstractWOTokenStrategy>> +    _applyBridgeAdjustment(msgType: uint32, amount: uint256) <<AbstractWOTokenStrategy>> +    _nextBridgeId(): bytes32 <<AbstractWOTokenStrategy>> +    _bridgeOutboundMsgType(): uint32 <<RemoteWOTokenStrategy>> +    _consumeOTokenForBridge(amount: uint256) <<RemoteWOTokenStrategy>> +    _deliverOTokenForBridge(amount: uint256, recipient: address) <<RemoteWOTokenStrategy>> +    _processBalanceCheckRequest(nonce: uint64, payload: bytes) <<RemoteWOTokenStrategy>> +    _processSettlement(nonce: uint64, body: bytes) <<RemoteWOTokenStrategy>> +    _processWithdrawRequest(nonce: uint64, payload: bytes) <<RemoteWOTokenStrategy>> +    _processWithdrawClaim(nonce: uint64) <<RemoteWOTokenStrategy>> +    _opportunisticClaim() <<RemoteWOTokenStrategy>> +    _processDeposit(nonce: uint64, amount: uint256) <<RemoteWOTokenStrategy>> +    _mintAndWrap(mintAmount: uint256) <<RemoteWOTokenStrategy>> +    _viewCheckBalance(): uint256 <<RemoteWOTokenStrategy>> +    _yieldOnlyBaseline(): uint256 <<RemoteWOTokenStrategy>> +    _yieldOnlyBaselineAfter(oTokenAmount: uint256): uint256 <<RemoteWOTokenStrategy>> +External: +    <<payable>> null() <<AbstractCrossChainV3Strategy>> +    <<payable>> bridgeOTokenToPeer(_amount: uint256, _recipient: address, _callData: bytes, _callGasLimit: uint32) <<nonReentrant>> <<AbstractWOTokenStrategy>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    receiveMessage(address, address, amountReceived: uint256, payload: bytes) <<onlyInboundAdapter>> <<AbstractCrossChainV3Strategy>> +    setOutboundAdapter(_outboundAdapter: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    setInboundAdapter(_inboundAdapter: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    setOperator(_operator: address) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    transferNative(amount: uint256) <<onlyGovernor>> <<AbstractCrossChainV3Strategy>> +    collectRewardTokens() <<onlyHarvesterOrStrategist, nonReentrant>> <<AbstractWOTokenStrategy>> +    setRewardTokenAddresses(_rewardTokenAddresses: address[]) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    getRewardTokenAddresses(): address[] <<InitializableAbstractStrategy>> +    setPTokenAddress(_asset: address, _pToken: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    removePToken(_assetIndex: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    setHarvesterAddress(_harvesterAddress: address) <<onlyGovernorOrStrategist>> <<InitializableAbstractStrategy>> +    safeApproveAllTokens() <<onlyGovernor, nonReentrant>> <<RemoteWOTokenStrategy>> +    deposit(address, uint256) <<onlyVaultOrGovernor>> <<RemoteWOTokenStrategy>> +    depositAll() <<onlyVaultOrGovernor>> <<RemoteWOTokenStrategy>> +    withdraw(address, address, uint256) <<onlyVaultOrGovernor>> <<RemoteWOTokenStrategy>> +    withdrawAll() <<onlyVaultOrGovernor>> <<RemoteWOTokenStrategy>> +    checkBalance(_asset: address): uint256 <<RemoteWOTokenStrategy>> +    setBridgeFeeBps(_bps: uint256) <<onlyGovernor>> <<AbstractWOTokenStrategy>> +    initialize(_operator: address) <<onlyGovernor, initializer>> <<RemoteWOTokenStrategy>> +    claimRemoteWithdrawal() <<nonReentrant>> <<RemoteWOTokenStrategy>> +    retryDeposit() <<onlyOperatorGovernorOrStrategist, nonReentrant>> <<RemoteWOTokenStrategy>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> OutboundAdapterUpdated(oldAdapter: address, newAdapter: address) <<AbstractCrossChainV3Strategy>> +    <<event>> InboundAdapterUpdated(oldAdapter: address, newAdapter: address) <<AbstractCrossChainV3Strategy>> +    <<event>> OperatorUpdated(oldOperator: address, newOperator: address) <<AbstractCrossChainV3Strategy>> +    <<event>> YieldNonceAdvanced(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    <<event>> YieldNonceProcessed(nonce: uint64) <<AbstractCrossChainV3Strategy>> +    <<event>> PTokenAdded(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> PTokenRemoved(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> Deposit(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> Withdrawal(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenCollected(recipient: address, rewardToken: address, amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenAddressesUpdated(_oldAddresses: address[], _newAddresses: address[]) <<InitializableAbstractStrategy>> +    <<event>> HarvesterAddressesUpdated(_oldHarvesterAddress: address, _newHarvesterAddress: address) <<InitializableAbstractStrategy>> +    <<event>> BridgeRequested(bridgeId: bytes32, sender: address, recipient: address, amount: uint256, fee: uint256, callData: bytes, callGasLimit: uint32) <<AbstractWOTokenStrategy>> +    <<event>> BridgeDelivered(bridgeId: bytes32, recipient: address, amount: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeFeeBpsUpdated(oldBps: uint256, newBps: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeCallSucceeded(bridgeId: bytes32, recipient: address, amount: uint256) <<AbstractWOTokenStrategy>> +    <<event>> BridgeCallFailed(bridgeId: bytes32, recipient: address, amount: uint256, returnData: bytes) <<AbstractWOTokenStrategy>> +    <<event>> DepositProcessed(nonce: uint64, amount: uint256, yieldBaseline: uint256) <<RemoteWOTokenStrategy>> +    <<event>> WithdrawRequestProcessed(nonce: uint64, amount: uint256, requestId: uint256) <<RemoteWOTokenStrategy>> +    <<event>> WithdrawClaimDelivered(nonce: uint64, amount: uint256, yieldBaseline: uint256) <<RemoteWOTokenStrategy>> +    <<event>> WithdrawClaimNack(nonce: uint64, yieldBaseline: uint256) <<RemoteWOTokenStrategy>> +    <<event>> RemoteWithdrawalClaimed(requestId: uint256, amount: uint256) <<RemoteWOTokenStrategy>> +    <<event>> DepositUnderlyingFailed(nonce: uint64, amount: uint256, reason: bytes) <<RemoteWOTokenStrategy>> +    <<event>> WithdrawRequestUnderlyingFailed(nonce: uint64, amount: uint256, reason: bytes) <<RemoteWOTokenStrategy>> +    <<event>> IdleDepositRetried(mintedBridgeAsset: uint256, wrappedOToken: uint256) <<RemoteWOTokenStrategy>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyInboundAdapter() <<AbstractCrossChainV3Strategy>> +    <<modifier>> initializer() <<Initializable>> +    <<modifier>> onlyGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVault() <<InitializableAbstractStrategy>> +    <<modifier>> onlyHarvesterOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernor() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyOperatorGovernorOrStrategist() <<AbstractWOTokenStrategy>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    isYieldOpInFlight(): bool <<AbstractCrossChainV3Strategy>> +    constructor(_config: BaseStrategyConfig) <<InitializableAbstractStrategy>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<RemoteWOTokenStrategy>> +    supportsAsset(_asset: address): bool <<AbstractWOTokenStrategy>> +    constructor(_stratConfig: BaseStrategyConfig, _bridgeAsset: address, _oToken: address) <<AbstractWOTokenStrategy>> +    availableBridgeLiquidity(): uint256 <<RemoteWOTokenStrategy>> +    constructor(_stratConfig: BaseStrategyConfig, _bridgeAsset: address, _oToken: address, _woToken: address, _oTokenVault: address) <<RemoteWOTokenStrategy>> + + + diff --git a/contracts/docs/RemoteWOTokenStrategyStorage.svg b/contracts/docs/RemoteWOTokenStrategyStorage.svg new file mode 100644 index 0000000000..db443b8eec --- /dev/null +++ b/contracts/docs/RemoteWOTokenStrategyStorage.svg @@ -0,0 +1,297 @@ + + + + + + +StorageDiagram + + + +6 + +RemoteWOTokenStrategy <<Contract>> + +slot + +0 + +1 + +2 + +3 + +4 + +5-54 + +55 + +56-105 + +106 + +107 + +108 + +109 + +110 + +111 + +112 + +113 + +114-211 + +212 + +213 + +214 + +215 + +216-265 + +266 + +267 + +268-310 + +type: <inherited contract>.variable (bytes) + +unallocated (12) + +address: AbstractCrossChainV3Strategy.outboundAdapter (20) + +unallocated (12) + +address: AbstractCrossChainV3Strategy.inboundAdapter (20) + +unallocated (4) + +uint64: AbstractCrossChainV3Strategy.lastYieldNonce (8) + +address: AbstractCrossChainV3Strategy.operator (20) + +mapping(uint64=>bool): AbstractCrossChainV3Strategy.nonceProcessed (32) + +uint256: AbstractCrossChainV3Strategy.lastBalanceCheckTimestamp (32) + +uint256[50]: AbstractCrossChainV3Strategy.__gap (1600) + +unallocated (30) + +bool: Initializable.initializing (1) + +bool: Initializable.initialized (1) + +uint256[50]: Initializable.______gap (1600) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_platformAddress (20) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_vaultAddress (20) + +mapping(address=>address): InitializableAbstractStrategy.assetToPToken (32) + +address[]: InitializableAbstractStrategy.assetsMapped (32) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_rewardTokenAddress (20) + +uint256: InitializableAbstractStrategy._deprecated_rewardLiquidationThreshold (32) + +unallocated (12) + +address: InitializableAbstractStrategy.harvesterAddress (20) + +address[]: InitializableAbstractStrategy.rewardTokenAddresses (32) + +int256[98]: InitializableAbstractStrategy._reserved (3136) + +int256: AbstractWOTokenStrategy.bridgeAdjustment (32) + +mapping(bytes32=>bool): AbstractWOTokenStrategy.consumedBridgeIds (32) + +uint256: AbstractWOTokenStrategy.bridgeIdCounter (32) + +uint256: AbstractWOTokenStrategy.bridgeFeeBps (32) + +uint256[50]: AbstractWOTokenStrategy.__gap (1600) + +uint256: outstandingRequestId (32) + +uint256: outstandingRequestAmount (32) + +uint256[43]: __gap (1376) + + + +1 + +uint256[50]: __gap <<Array>> + +slot + +5 + +6 + +7-52 + +53 + +54 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +6:12->1 + + + + + +2 + +address[]: assetsMapped <<Array>> +0x5006b838207c6a9ae9b84d68f467dd4bb5c305fbfb6b04eab8faaabeec1e18d8 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:20->2 + + + + + +3 + +address[]: rewardTokenAddresses <<Array>> +0xa1fcd19bfe8c32a61095b6bfbb2664842857e148fcbb5188386c8cd40348d5b6 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +6:25->3 + + + + + +4 + +uint256[50]: __gap <<Array>> + +slot + +216 + +217 + +218-263 + +264 + +265 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1472) + +uint256 (32) + +uint256 (32) + + + +6:36->4 + + + + + +5 + +uint256[43]: __gap <<Array>> + +slot + +268 + +269 + +270-308 + +309 + +310 + +type: variable (bytes) + +uint256 (32) + +uint256 (32) + +---- (1248) + +uint256 (32) + +uint256 (32) + + + +6:44->5 + + + + + diff --git a/contracts/docs/generate.sh b/contracts/docs/generate.sh index e990239a23..fb0331d5e1 100644 --- a/contracts/docs/generate.sh +++ b/contracts/docs/generate.sh @@ -73,10 +73,36 @@ sol2uml storage .. -c BaseCurveAMOStrategy -i prettier-plugin-solidity -o BaseCu # contracts/strategies/algebra sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b OETHSupernovaAMOStrategy -o OETHSupernovaAMOStrategyHierarchy.svg -sol2uml .. -v -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b OETHSupernovaAMOStrategy -o OETHSupernovaAMOStrategyInteractions.svg -sol2uml .. -s -d 0 -b OETHSupernovaAMOStrategy -i prettier-plugin-solidity -o OETHSupernovaAMOStrategySquashed.svg +sol2uml .. -v -s -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b OETHSupernovaAMOStrategy -o OETHSupernovaAMOStrategyInteractions.svg +sol2uml .. -v -s -d 0 -b OETHSupernovaAMOStrategy -i prettier-plugin-solidity -o OETHSupernovaAMOStrategySquashed.svg sol2uml storage .. -c OETHSupernovaAMOStrategy -i prettier-plugin-solidity -o OETHSupernovaAMOStrategyStorage.svg --hideExpand ______gap,_reserved +# contracts/strategies/crosschainV3 +sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b MasterWOTokenStrategy,RemoteWOTokenStrategy -o OTokenStrategiesHierarchy.svg +sol2uml .. -v -s -d 1 -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b MasterWOTokenStrategy -o MasterWOTokenStrategyInteractions.svg +sol2uml .. -v -s -d 0 -b MasterWOTokenStrategy -i prettier-plugin-solidity -o MasterWOTokenStrategySquashed.svg +sol2uml storage .. -c MasterWOTokenStrategy -i prettier-plugin-solidity -o MasterWOTokenStrategyStorage.svg --hideExpand ______gap,_reserved + +sol2uml .. -v -s -d 1 -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b RemoteWOTokenStrategy -o RemoteWOTokenStrategyInteractions.svg +sol2uml .. -v -s -d 0 -b RemoteWOTokenStrategy -i prettier-plugin-solidity -o RemoteWOTokenStrategySquashed.svg +sol2uml storage .. -c RemoteWOTokenStrategy -i prettier-plugin-solidity -o RemoteWOTokenStrategyStorage.svg --hideExpand ______gap,_reserved + +sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b BridgedWOETHMigrationStrategy -o BridgedWOETHMigrationStrategyHierarchy.svg +sol2uml .. -v -s -d 1 -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b BridgedWOETHMigrationStrategy -o BridgedWOETHMigrationStrategyInteractions.svg +sol2uml .. -v -s -d 0 -b BridgedWOETHMigrationStrategy -i prettier-plugin-solidity -o BridgedWOETHMigrationStrategySquashed.svg +sol2uml storage .. -c BridgedWOETHMigrationStrategy -i prettier-plugin-solidity -o BridgedWOETHMigrationStrategyStorage.svg --hideExpand ______gap,_reserved,__gap + +# contracts/strategies/crosschainV3/adapters +sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b CCIPAdapter,CCTPAdapter -o AdaptersHierarchies.svg + +sol2uml .. -v -s -d 1 -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b CCIPAdapter -o CCIPAdapterInteractions.svg +sol2uml .. -v -s -d 0 -b CCIPAdapter -i prettier-plugin-solidity -o CCIPAdapterSquashed.svg +sol2uml storage .. -c CCIPAdapter -i prettier-plugin-solidity -o CCIPAdapterStorage.svg --hideExpand ______gap,_reserved + +sol2uml .. -v -s -d 1 -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b CCTPAdapter -o CCTPAdapterInteractions.svg +sol2uml .. -s -d 0 -b CCTPAdapter -i prettier-plugin-solidity -o CCTPAdapterSquashed.svg +sol2uml storage .. -c CCTPAdapter -i prettier-plugin-solidity -o CCTPAdapterStorage.svg --hideExpand ______gap,_reserved + # contracts/strategies/sonic sol2uml .. -v -hv -hf -he -hs -hl -hi -b SonicStakingStrategy -o SonicStakingStrategyHierarchy.svg sol2uml .. -s -d 0 -b SonicStakingStrategy -o SonicStakingStrategySquashed.svg diff --git a/contracts/pnpm-lock.yaml b/contracts/pnpm-lock.yaml index bbf934260b..f9ad514a03 100644 --- a/contracts/pnpm-lock.yaml +++ b/contracts/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: 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': {} @@ -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..2c71dff0c9 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/cctp-burn-relay.js @@ -0,0 +1,391 @@ +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, + finalityThresholdExecuted = 2000, + }) { + 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 + 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"); + }); + + it("rejects a burn attested below the configured finality floor", async () => { + // Adapter wants finalised (2000, set in beforeEach); the burn is attested fast (1000). + 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: adapter.address, // peer adapter under CREATE3 parity + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + finalityThresholdExecuted: 1000, // fast finality, below the 2000 floor + }); + + await expect( + adapter.connect(operator).relay(message, "0x") + ).to.be.revertedWith("CCTP: insufficient finality"); + }); + + it("accepts a fast-finality burn when the floor allows it", async () => { + // Lower the floor to fast finality; a burn attested at 1000 now delivers. + await adapter.connect(governor).setMinFinalityThreshold(1000); + + 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: adapter.address, // peer adapter under CREATE3 parity + feeExecuted: 0, + hookData, + }); + const message = buildTransportMessage({ + sourceDomain: SOURCE_DOMAIN, + sender: tokenMessenger.address, + recipient: tokenMessenger.address, + body: burnBody, + finalityThresholdExecuted: 1000, + }); + + await expect(adapter.connect(operator).relay(message, "0x")).to.emit( + adapter, + "MessageDelivered" + ); + expect(await usdc.balanceOf(strategy.address)).to.equal(amount); + }); +}); 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..dfdb273daa --- /dev/null +++ b/contracts/test/strategies/crosschainV3/failure-recovery.js @@ -0,0 +1,333 @@ +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); +// Sentinel for "no outstanding queue request" (RemoteWOTokenStrategy.REQUEST_ID_EMPTY). +const EMPTY = ethers.constants.MaxUint256; + +/** + * 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(EMPTY); + 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(EMPTY); + }); + + 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(EMPTY); + }); + + it("post-delivery callback cannot re-enter (nonReentrant blocks it; delivery still completes)", async () => { + const SEED = usdc("1000"); + const AMT = oToken18("100"); + + // Seed Remote with shares so a BRIDGE_OUT can be delivered there. + await bridgeAsset.mintTo(master.address, SEED); + await mockL2Vault.callDeposit(master.address, bridgeAsset.address, SEED); + + // Malicious recipient, armed to re-enter Remote.claimRemoteWithdrawal() (permissionless + + // nonReentrant) from the post-delivery callback. Called normally it would succeed (no-op); + // under re-entry it must fail because the bridge dispatch holds the shared nonReentrant lock. + const receiver = await ( + await ethers.getContractFactory("MockReentrantReceiver") + ).deploy(); + await receiver.arm( + remote.address, + remote.interface.encodeFunctionData("claimRemoteWithdrawal") + ); + + // alice BRIDGE_OUTs OToken to the malicious receiver on Remote, with the callback. + await mockL2Vault.mintOTokenTo(alice.address, AMT); + await oTokenL2.connect(alice).approve(master.address, AMT); + await master + .connect(alice) + .bridgeOTokenToPeer( + AMT, + receiver.address, + receiver.interface.encodeFunctionData("attack"), + 500000 + ); + + // The callback ran, but the re-entry was blocked by the nonReentrant guard. + expect(await receiver.reentered()).to.equal(true); + expect(await receiver.reentrySucceeded()).to.equal(false); + + // Delivery still completed (tokens are sent before the callback, per CEI) and accounting is + // applied exactly once. + expect(await oTokenEth.balanceOf(receiver.address)).to.equal(AMT); + expect(await remote.bridgeAdjustment()).to.equal(AMT.mul(-1)); + }); +}); 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..6a1684315b --- /dev/null +++ b/contracts/test/strategies/crosschainV3/remote-v3.mainnet.fork-test.js @@ -0,0 +1,249 @@ +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"); + +// Sentinel for "no outstanding queue request" (RemoteWOTokenStrategy.REQUEST_ID_EMPTY). +const EMPTY = ethers.constants.MaxUint256; + +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(EMPTY); + 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..2a6e9a0da6 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/split-inbound-adapter.js @@ -0,0 +1,330 @@ +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("stored message honours a pause/revoke issued after it was stored (incident response)", async () => { + const amount = ethers.utils.parseUnits("250", 6); + const data = wrapEnvelope(strategy.address, amount, packPayload("pending")); + const sRouter = await impersonateAndFund(await receiver.ccipRouter()); + + // Message stored while the lane is healthy (token leg not yet landed). + await receiver + .connect(sRouter) + .ccipReceive( + buildAny2EvmMessage({ data, transportSender: receiver.address }) + ); + expect(await receiver.hasPendingMessage(strategy.address)).to.equal(true); + + // Canonical-bridge ETH lands and `receive()` wraps it to WETH. + await deliverBridgeEth(amount); + + // Incident: governance pauses the lane. Deferred delivery must be blocked too, not just + // the atomic ccipReceive path. + await receiver.connect(governor).pauseLane(strategy.address); + await expect( + receiver.processStoredMessage(strategy.address) + ).to.be.revertedWith("Super: lane paused"); + + // Revoke leaves `paused` untouched, so this exercises the `authorised` check. + await receiver.connect(governor).unpauseLane(strategy.address); + await receiver.connect(governor).revoke(strategy.address); + await expect( + receiver.processStoredMessage(strategy.address) + ).to.be.revertedWith("Super: not authorised"); + + // The message + its WETH were held, not lost: re-authorise + unpause and delivery resumes. + await receiver.connect(governor).authorise(strategy.address, { + paused: false, + chainSelector: PEER_CHAIN, + destGasLimit: DEST_GAS_LIMIT, + }); + 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); + }); + + 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..4940cd1174 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/transfer-caps.js @@ -0,0 +1,574 @@ +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 + + // Signals the skip so it isn't silently dropped. + await expect(mockL2Vault.callDepositAll(master.address)) + .to.emit(master, "DepositSkipped") + .withArgs(ONE_K.div(2), ONE_K); + + // 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 expect( + mockL2Vault.callDeposit( + master.address, + bridgeAsset.address, + ONE_K.div(2) + ) + ) + .to.emit(master, "DepositSkipped") + .withArgs(ONE_K.div(2), ONE_K); + + 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..e3819978d9 --- /dev/null +++ b/contracts/test/strategies/crosschainV3/withdrawal.js @@ -0,0 +1,533 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); + +// Sentinel for "no outstanding queue request" (RemoteWOTokenStrategy.REQUEST_ID_EMPTY). +const EMPTY = ethers.constants.MaxUint256; + +/** + * 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.not.equal(EMPTY); + 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(EMPTY); + 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 (REQUEST_ID_EMPTY sentinel)", async () => { + // Fresh OToken vault: the first-ever withdrawal returns requestId 0. A 0-sentinel scheme would + // make this indistinguishable from "no request" (dropping the queued value from checkBalance and + // NACK-looping leg-2). With the REQUEST_ID_EMPTY (= MaxUint256) sentinel, id 0 is stored verbatim + // and recognised as a live queue request, 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 verbatim as 0 (recognised as a live queue request). + expect(await remote.outstandingRequestId()).to.equal(0); + // 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(EMPTY); + 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(EMPTY); + 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.not.equal(EMPTY); + + // 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.not.equal(EMPTY); + // 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.not.equal(EMPTY); + + // 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.not.equal(EMPTY); // 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(EMPTY); + 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(EMPTY); + + // Second call is a no-op — does not revert. + await remote.claimRemoteWithdrawal(); + expect(await remote.outstandingRequestId()).to.equal(EMPTY); + 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("setInboundAdapter rejects the zero address", async () => { + // A withdrawal's leg-2 ack is delivered through the inbound adapter, so it must never be + // zeroed (even mid-flight). The "unset" state only exists pre-configuration (storage default). + await expect( + master.connect(governor).setInboundAdapter(ethers.constants.AddressZero) + ).to.be.revertedWith("V3: zero inbound adapter"); + }); + + 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(EMPTY); // 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..89484adddb --- /dev/null +++ b/contracts/test/strategies/crosschainV3/withdrawal.mainnet.fork-test.js @@ -0,0 +1,220 @@ +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"); + +// Sentinel for "no outstanding queue request" (RemoteWOTokenStrategy.REQUEST_ID_EMPTY). +const EMPTY = ethers.constants.MaxUint256; +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.not.equal(EMPTY); + + // 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.not.equal(EMPTY); + + // 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(EMPTY); + 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 the empty sentinel, 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";