From 9e4067e95076a6cd15b688bba2376dd6720da52a Mon Sep 17 00:00:00 2001 From: Mihai Date: Thu, 14 May 2026 19:54:36 +0400 Subject: [PATCH] feat(bridge): add LayerZero USDT0 support --- .openzeppelin/mainnet.json | 269 ++++++++++++++++ README.md | 67 ++-- SECURITY.md | 47 +-- contracts/BridgeAdaptor.sol | 211 +++++++++++- contracts/interfaces/ILayerZeroComposer.sol | 25 ++ contracts/libraries/OFTComposeMsgCodec.sol | 28 ++ hardhat.config.ts | 12 + package.json | 8 +- script/DeployBridgeProxy.s.sol | 36 +++ setup.config.json | 6 + tasks/adaptor/enable-layerzero.ts | 30 ++ tasks/adaptor/rescue-layerzero.ts | 56 ++++ tasks/adaptor/set-layerzero-endpoint.ts | 41 +++ tasks/adaptor/set-layerzero-fee.ts | 37 +++ tasks/adaptor/set-layerzero-oft-token.ts | 72 +++++ tasks/adaptor/set-layerzero-source.ts | 77 +++++ tasks/deploy/bridge-adaptor.ts | 6 +- tasks/deploy/upgrade-bridge-adaptor.ts | 2 + tasks/lib/config.ts | 6 + test/foundry/BridgeAdaptor.t.sol | 339 +++++++++++++++++++- test/foundry/BridgeAdaptorFork.t.sol | 116 ++++++- 21 files changed, 1433 insertions(+), 58 deletions(-) create mode 100644 .openzeppelin/mainnet.json create mode 100644 contracts/interfaces/ILayerZeroComposer.sol create mode 100644 contracts/libraries/OFTComposeMsgCodec.sol create mode 100644 script/DeployBridgeProxy.s.sol create mode 100644 tasks/adaptor/enable-layerzero.ts create mode 100644 tasks/adaptor/rescue-layerzero.ts create mode 100644 tasks/adaptor/set-layerzero-endpoint.ts create mode 100644 tasks/adaptor/set-layerzero-fee.ts create mode 100644 tasks/adaptor/set-layerzero-oft-token.ts create mode 100644 tasks/adaptor/set-layerzero-source.ts diff --git a/.openzeppelin/mainnet.json b/.openzeppelin/mainnet.json new file mode 100644 index 0000000..9d3c41f --- /dev/null +++ b/.openzeppelin/mainnet.json @@ -0,0 +1,269 @@ +{ + "manifestVersion": "3.2", + "proxies": [ + { + "address": "0xafdA017307D611C088aa439a8eC706fe38f18d5f", + "kind": "transparent" + } + ], + "impls": { + "3b17060e402b973ee8c581225d95be0484e7b2dd2d73589d5777e040103ef901": { + "address": "0x9F586F5Ca51728D348CEf6604694E17fF523486E", + "layout": { + "solcVersion": "0.8.35", + "storage": [ + { + "label": "safe", + "offset": 0, + "slot": "0", + "type": "t_contract(IERC20Safe)4468", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:49" + }, + { + "label": "_admin", + "offset": 0, + "slot": "1", + "type": "t_address", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:51" + }, + { + "label": "_pendingAdmin", + "offset": 0, + "slot": "2", + "type": "t_address", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:53" + }, + { + "label": "wormhole", + "offset": 0, + "slot": "3", + "type": "t_contract(ICoreBridge)45176", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:57" + }, + { + "label": "wormholeTokenBridge", + "offset": 0, + "slot": "4", + "type": "t_contract(ITokenBridge)45344", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:59" + }, + { + "label": "circleMessageTransmitter", + "offset": 0, + "slot": "5", + "type": "t_contract(IMessageTransmitter)45514", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:63" + }, + { + "label": "wormholeEnabled", + "offset": 20, + "slot": "5", + "type": "t_bool", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:65" + }, + { + "label": "_paused", + "offset": 21, + "slot": "5", + "type": "t_bool", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:67" + }, + { + "label": "cctpFlatFee", + "offset": 22, + "slot": "5", + "type": "t_uint64", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:69" + }, + { + "label": "wormholeFeeBps", + "offset": 30, + "slot": "5", + "type": "t_uint16", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:71" + }, + { + "label": "cctpEnabled", + "offset": 0, + "slot": "6", + "type": "t_bool", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:74" + }, + { + "label": "_layerZero", + "offset": 0, + "slot": "7", + "type": "t_struct(LayerZeroState)2269_storage", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:87" + }, + { + "label": "__gap", + "offset": 0, + "slot": "11", + "type": "t_array(t_uint256)45_storage", + "contract": "BridgeAdaptor", + "src": "project/contracts/BridgeAdaptor.sol:90" + } + ], + "types": { + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_struct(InitializableStorage)287_storage": { + "label": "struct Initializable.InitializableStorage", + "members": [ + { + "label": "_initialized", + "type": "t_uint64", + "offset": 0, + "slot": "0" + }, + { + "label": "_initializing", + "type": "t_bool", + "offset": 8, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)45_storage": { + "label": "uint256[45]", + "numberOfBytes": "1440" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_contract(ICoreBridge)45176": { + "label": "contract ICoreBridge", + "numberOfBytes": "20" + }, + "t_contract(IERC20Safe)4468": { + "label": "contract IERC20Safe", + "numberOfBytes": "20" + }, + "t_contract(IMessageTransmitter)45514": { + "label": "contract IMessageTransmitter", + "numberOfBytes": "20" + }, + "t_contract(ITokenBridge)45344": { + "label": "contract ITokenBridge", + "numberOfBytes": "20" + }, + "t_mapping(t_address,t_address)": { + "label": "mapping(address => address)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_mapping(t_uint32,t_bool))": { + "label": "mapping(address => mapping(uint32 => bool))", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes32,t_bool)": { + "label": "mapping(bytes32 => bool)", + "numberOfBytes": "32" + }, + "t_mapping(t_uint32,t_bool)": { + "label": "mapping(uint32 => bool)", + "numberOfBytes": "32" + }, + "t_struct(LayerZeroState)2269_storage": { + "label": "struct BridgeAdaptor.LayerZeroState", + "members": [ + { + "label": "endpoint", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "enabled", + "type": "t_bool", + "offset": 20, + "slot": "0" + }, + { + "label": "feeBps", + "type": "t_uint16", + "offset": 21, + "slot": "0" + }, + { + "label": "oftTokens", + "type": "t_mapping(t_address,t_address)", + "offset": 0, + "slot": "1" + }, + { + "label": "allowedSrcEids", + "type": "t_mapping(t_address,t_mapping(t_uint32,t_bool))", + "offset": 0, + "slot": "2" + }, + { + "label": "composeProcessed", + "type": "t_mapping(t_bytes32,t_bool)", + "offset": 0, + "slot": "3" + } + ], + "numberOfBytes": "128" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint32": { + "label": "uint32", + "numberOfBytes": "4" + } + }, + "namespaces": { + "erc7201:openzeppelin.storage.Initializable": [ + { + "contract": "Initializable", + "label": "_initialized", + "type": "t_uint64", + "src": "npm/@openzeppelin/contracts@5.6.1/proxy/utils/Initializable.sol:69", + "offset": 0, + "slot": "0" + }, + { + "contract": "Initializable", + "label": "_initializing", + "type": "t_bool", + "src": "npm/@openzeppelin/contracts@5.6.1/proxy/utils/Initializable.sol:73", + "offset": 8, + "slot": "0" + } + ] + } + } + } + } +} diff --git a/README.md b/README.md index 41e227a..463462a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mx-bridge-sol -Upgradeable Solidity adaptor that forwards Wormhole Token Bridge and Circle CCTP V2 inbound transfers into the MultiversX `ERC20Safe`. **Ethereum mainnet only.** +Upgradeable Solidity adaptor that forwards Wormhole Token Bridge, Circle CCTP V2, and LayerZero OFT composed inbound transfers into the MultiversX `ERC20Safe`. **Ethereum mainnet only.** | Address | | | ------------------- | ----------------------------------------------------- | @@ -23,7 +23,7 @@ Upgradeable Solidity adaptor that forwards Wormhole Token Bridge and Circle CCTP nvm use yarn install # also activates the husky pre-commit hook yarn build # forge build && hardhat compile -yarn test # forge test -vv (98 tests) +yarn test # forge test -vv (129 tests) yarn coverage # forge coverage summary yarn lint # solhint + eslint + prettier + forge fmt --check ``` @@ -58,7 +58,7 @@ Requires `ETHERSCAN_API_KEY` in `.env` (free at ) ## Fork integration tests -A separate suite under `test/foundry/BridgeAdaptorFork.t.sol` runs against a real Ethereum mainnet fork (real `ERC20Safe`, real `MessageTransmitterV2`, real USDC): +A separate suite under `test/foundry/BridgeAdaptorFork.t.sol` runs against a real Ethereum mainnet fork (real `ERC20Safe`, real `MessageTransmitterV2`, real USDC/USDT, real LayerZero EndpointV2 + USDT0 OFTs): ```bash export MAINNET_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/" @@ -71,31 +71,54 @@ The suite skips cleanly when `MAINNET_RPC_URL` is unset. CI runs it as a separat All scripts default to `--network mainnet_eth`. Append `--price ` to set gas price; `--limit ` to override gas limit. Any other flag passes through to the underlying hardhat task. -| Script | Calls | Notes | -| -------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------- | -| `yarn bridge:pause` / `yarn bridge:unpause` | `pause()` / `unpause()` | Admin only | -| `yarn bridge:enable-wormhole --enabled true\|false` | `setWormholeEnabled` | Per-protocol kill-switch | -| `yarn bridge:enable-cctp --enabled true\|false` | `setCCTPEnabled` | Per-protocol kill-switch | -| `yarn bridge:set-fee --cctpFlatFee 1000000 --wormholeFeeBps 5` | `setFeeConfig` | Reads on-chain caps + validates | -| `yarn bridge:set-circle` | `setCircleTransmitter` | Defaults to `cctp.messageTransmitterV2` from config; pause-gated | -| `yarn bridge:update-wormhole` | `updateWormholeContracts` | Defaults to `wormhole.coreBridge` + `wormhole.tokenBridge` from config; pause-gated | -| `yarn bridge:transfer-admin --admin 0x...` | `transferAdmin` | Step 1 of two-step transfer | -| `yarn bridge:accept-admin` | `acceptAdmin` | Step 2; signer must equal pending admin | -| `yarn bridge:cancel-admin` | `cancelAdminTransfer` | Aborts a pending transfer | -| `yarn bridge:deposit-cctp --txhash --source ` | `depositFromCCTPV2` | Fetches message + attestation from Circle Iris | -| `yarn bridge:settle-wormhole --vaa 0x...` | `settleOutOfLimitsWormhole` | Permissionless; routes funds to admin | -| `yarn bridge:settle-cctp --txhash --source ` | `settleOutOfLimitsCCTP` | Permissionless; routes funds to admin | -| `yarn bridge:rescue-cctp --txhash --source ` | `rescueAndForwardCCTP` | Admin rescue for direct-redeemed CCTP USDC; auto-decodes hookData and verifies `mintRecipient == adaptor` | -| `yarn bridge:recover-tokens --token 0x... --all true` | `recoverTokens` | Sweep stuck balance; use `--amount` for partial | -| `yarn bridge:verify` | Etherscan verify | Reads proxy from `setup.config.json#bridgeAdaptor`; requires `ETHERSCAN_API_KEY` in `.env` | +| Script | Calls | Notes | +| ---------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------- | +| `yarn bridge:pause` / `yarn bridge:unpause` | `pause()` / `unpause()` | Admin only | +| `yarn bridge:enable-wormhole --enabled true\|false` | `setWormholeEnabled` | Per-protocol kill-switch | +| `yarn bridge:enable-cctp --enabled true\|false` | `setCCTPEnabled` | Per-protocol kill-switch | +| `yarn bridge:enable-layerzero --enabled true\|false` | `setLayerZeroEnabled` | Per-protocol kill-switch | +| `yarn bridge:set-fee --cctpFlatFee 1000000 --wormholeFeeBps 5` | `setFeeConfig` | Reads on-chain caps + validates | +| `yarn bridge:set-layerzero-fee --fee-bps 5` | `setLayerZeroFeeBps` | Reads on-chain cap + validates | +| `yarn bridge:set-circle` | `setCircleTransmitter` | Defaults to `cctp.messageTransmitterV2` from config; pause-gated | +| `yarn bridge:update-wormhole` | `updateWormholeContracts` | Defaults to `wormhole.coreBridge` + `wormhole.tokenBridge` from config; pause-gated | +| `yarn bridge:set-layerzero-endpoint` | `setLayerZeroEndpoint` | Defaults to `layerZero.endpointV2` from config; pause-gated | +| `yarn bridge:set-layerzero-oft-token --mesh native\|legacy` | `setLayerZeroOFTToken` | Defaults to USDT0 native/Legacy Mesh OFT → canonical USDT from config; pause-gated | +| `yarn bridge:set-layerzero-source --mesh native --src-eid 30110` | `setLayerZeroSource` | Allows a source LayerZero EID for the configured OFT; pause-gated | +| `yarn bridge:transfer-admin --admin 0x...` | `transferAdmin` | Step 1 of two-step transfer | +| `yarn bridge:accept-admin` | `acceptAdmin` | Step 2; signer must equal pending admin | +| `yarn bridge:cancel-admin` | `cancelAdminTransfer` | Aborts a pending transfer | +| `yarn bridge:deposit-cctp --txhash --source ` | `depositFromCCTPV2` | Fetches message + attestation from Circle Iris | +| `yarn bridge:settle-wormhole --vaa 0x...` | `settleOutOfLimitsWormhole` | Permissionless; routes funds to admin | +| `yarn bridge:settle-cctp --txhash --source ` | `settleOutOfLimitsCCTP` | Permissionless; routes funds to admin | +| `yarn bridge:rescue-cctp --txhash --source ` | `rescueAndForwardCCTP` | Admin rescue for direct-redeemed CCTP USDC; auto-decodes hookData and verifies `mintRecipient == adaptor` | +| `yarn bridge:rescue-layerzero --recipient 0x... --amount ` | `rescueAndForwardLayerZero` | Admin rescue for LayerZero-credited tokens stranded before Safe deposit | +| `yarn bridge:recover-tokens --token 0x... --all true` | `recoverTokens` | Sweep stuck balance; use `--amount` for partial | +| `yarn bridge:verify` | Etherscan verify | Reads proxy from `setup.config.json#bridgeAdaptor`; requires `ETHERSCAN_API_KEY` in `.env` | Hardware-wallet flow: re-target any task by calling `yarn hardhat --network mainnet_eth_ledger ...` directly. +### LayerZero USDT0 setup + +The LayerZero path is disabled by default. For USDT0 → Ethereum → MultiversX, pause the adaptor, set: + +- `layerZero.endpointV2`: Ethereum EndpointV2, `0x1a44076050125825900e736c501f859c50fE728c` +- `layerZero.usdt0OftAdapter`: Ethereum native USDT0 OFT Adapter, `0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee` +- `layerZero.usdt0LegacyMeshOft`: Ethereum Legacy Mesh OFT, `0x1F748c76dE468e9D11bd340fA9D5CBADf315dFB0` +- `layerZero.usdt`: canonical Ethereum USDT, `0xdAC17F958D2ee523a2206206994597C13D831ec7` +- allowed source EIDs on the native OFT for website-supported USDT0 chains: `30110` Arbitrum, `30367` HyperEVM, `30339` Ink, `30111` Optimism, `30109` Polygon, `30390` Monad, `30280` Sei, `30320` Unichain +- allowed source EID on the Legacy Mesh OFT for Solana: `30168` + +For the Solana USDT0 website path, configure both Ethereum OFTs: native for the EVM USDT0 mesh, and Legacy Mesh for Solana. + +The source-chain `USDT0.send(...)` must target the BridgeAdaptor proxy as `to` and include `composeMsg = abi.encode(bytes32 mvxRecipient, bytes callData)`. LayerZero delivers the OFT credit first, then executes `lzCompose`; failed compose execution should be retried through LayerZero tooling before using `rescueAndForwardLayerZero`. + ## Layout ``` -contracts/BridgeAdaptor.sol production contract (Solidity 0.8.35) -contracts/interfaces/IERC20Safe.sol Safe interface +contracts/BridgeAdaptor.sol production contract (Solidity 0.8.35) +contracts/interfaces/IERC20Safe.sol Safe interface +contracts/interfaces/ILayerZeroComposer.sol LayerZero composer interface +contracts/libraries/OFTComposeMsgCodec.sol LayerZero OFT compose decoder contracts/test/ Foundry mocks (not deployed) test/foundry/BridgeAdaptor.t.sol test suite (unit + 5000-run fuzz + storage-layout pins) tasks/ Hardhat 3 tasks diff --git a/SECURITY.md b/SECURITY.md index 4e68681..34fb596 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,7 @@ Out of scope (depended on, not maintained here): - The Wormhole Core Bridge + Token Bridge - Circle's `MessageTransmitterV2` +- LayerZero EndpointV2 + USDT0 OFT Adapter / trusted OFTs - The MultiversX `ERC20Safe` and the off-chain MultiversX bridge validators - OpenZeppelin upgradeable / SafeERC20 / ReentrancyGuard @@ -21,15 +22,17 @@ Out of scope (depended on, not maintained here): The adaptor inherits trust from every party below. -| Actor | Authority over the adaptor | Worst-case failure | Mitigation in this code | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Wormhole guardian set** | Signs VAAs that the adaptor accepts as proof of a remote transfer | Forged VAA mints non-existent funds and they end up on MultiversX | None at this layer. We rely on Wormhole's 13/19 quorum. Per-protocol kill switch (`setWormholeEnabled(false)`) lets admin freeze the path within one tx | -| **Wormhole Token Bridge** | Releases tokens to the adaptor on `completeTransferWithPayload` | Releases wrong amount or wrong token | Post-call balance-delta check in `_receiveWormhole` (`amount = balanceAfter - balanceBefore`); whitelist + post-pull delta in `_depositToSafe` | -| **Circle attester** | Signs CCTP V2 messages | Forged attestation mints USDC to the adaptor | None at this layer. Same kill-switch pattern via `setCCTPEnabled(false)` | -| **Circle MessageTransmitterV2** | Mints USDC to the adaptor on `receiveMessage` | Mints wrong amount | Post-call balance-delta check in `_receiveCCTP`; CCTP V2 message version assertion (`messageVersion != 1` reverts) | -| **MultiversX ERC20Safe** | Pulls tokens from the adaptor on `deposit()` / `depositWithSCExecution()` | Pulls less than `netAmount` (silent fail / fee-on-transfer / blacklist) | Post-pull balance assertion in `_depositToSafe` reverts with `UnexpectedSafePullDelta` if delta ≠ `netAmount` | -| **BridgeAdaptor admin** | Pause, set fees (capped), kill switches, rotate admin (2-step), update Wormhole/Circle refs (pause-gated), `rescueAndForwardCCTP`, `recoverTokens` | Drains stuck balances; routes settled out-of-limits funds to itself | Two-step admin transfer (`transferAdmin` → `acceptAdmin`); fee caps (`MAX_WORMHOLE_FEE_BPS=1000`, `MAX_CCTP_FLAT_FEE=100e6`); `recoverTokens` is admin-only and emits `TokensRecovered`. **Recommended deployment: multisig as admin.** | -| **Permissionless callers** | Call `depositFromWormhole`, `depositFromCCTPV2`, `settleOutOfLimitsWormhole`, `settleOutOfLimitsCCTP` | None — they pay gas to forward already-attested transfers | All entry points are `nonReentrant` and `whenNotPaused`; settlements route to `admin()`, not the caller | +| Actor | Authority over the adaptor | Worst-case failure | Mitigation in this code | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wormhole guardian set** | Signs VAAs that the adaptor accepts as proof of a remote transfer | Forged VAA mints non-existent funds and they end up on MultiversX | None at this layer. We rely on Wormhole's 13/19 quorum. Per-protocol kill switch (`setWormholeEnabled(false)`) lets admin freeze the path within one tx | +| **Wormhole Token Bridge** | Releases tokens to the adaptor on `completeTransferWithPayload` | Releases wrong amount or wrong token | Post-call balance-delta check in `_receiveWormhole` (`amount = balanceAfter - balanceBefore`); whitelist + post-pull delta in `_depositToSafe` | +| **Circle attester** | Signs CCTP V2 messages | Forged attestation mints USDC to the adaptor | None at this layer. Same kill-switch pattern via `setCCTPEnabled(false)` | +| **Circle MessageTransmitterV2** | Mints USDC to the adaptor on `receiveMessage` | Mints wrong amount | Post-call balance-delta check in `_receiveCCTP`; CCTP V2 message version assertion (`messageVersion != 1` reverts) | +| **LayerZero EndpointV2** | Calls `lzCompose` with an OFT composed message after the trusted OFT credited tokens to the adaptor | Delivers a forged or malformed compose message | `msg.sender == layerZeroEndpoint`, trusted OFT mapping, per-OFT source EID allowlist, compose GUID replay guard, per-protocol kill switch (`setLayerZeroEnabled(false)`) | +| **LayerZero OFT / USDT0 Adapter** | Credits local ERC20 tokens to the adaptor before compose execution | Credits wrong token/amount or queues a malicious compose payload | Admin maps each trusted OFT/OFT Adapter to one local ERC20 token; Safe whitelist + post-pull delta guard still apply. Amount is taken from LayerZero's OFT compose message, so this path inherits OFT trust | +| **MultiversX ERC20Safe** | Pulls tokens from the adaptor on `deposit()` / `depositWithSCExecution()` | Pulls less than `netAmount` (silent fail / fee-on-transfer / blacklist) | Post-pull balance assertion in `_depositToSafe` reverts with `UnexpectedSafePullDelta` if delta ≠ `netAmount` | +| **BridgeAdaptor admin** | Pause, set fees (capped), kill switches, rotate admin (2-step), update Wormhole/Circle/LayerZero refs (pause-gated), rescue paths, `recoverTokens` | Drains stuck balances; routes settled out-of-limits funds to itself | Two-step admin transfer (`transferAdmin` → `acceptAdmin`); fee caps (`MAX_WORMHOLE_FEE_BPS=1000`, `MAX_CCTP_FLAT_FEE=100e6`, `MAX_LAYERZERO_FEE_BPS=1000`); **Recommended deployment: multisig as admin.** | +| **Permissionless callers** | Call `depositFromWormhole`, `depositFromCCTPV2`, `settleOutOfLimitsWormhole`, `settleOutOfLimitsCCTP` | None — they pay gas to forward already-attested transfers | All entry points are `nonReentrant` and `whenNotPaused`; settlements route to `admin()`, not the caller. LayerZero compose is restricted to the configured EndpointV2 | ## Threat model @@ -41,9 +44,11 @@ The adaptor inherits trust from every party below. | Misconfigured fees draining users | Hard caps enforced at write-time (`FeeExceedsMaxBps`, `FeeExceedsMaxFlat`) | | Hostile admin handover | Two-step transfer with cancel; `_pendingAdmin` cleared on accept | | Replayed VAA / CCTP message | Wormhole + Circle each ship their own replay protection; relying on it intentionally | +| Replayed LayerZero compose | `layerZeroComposeProcessed[guid]` is set after a successful forward and checked on every `lzCompose` call | | Wrong-network deploy | `initialize` requires `block.chainid == 1` (`WrongChain`) | | CCTP V1 message smuggled into V2 path | `_extractAndDecodeHookData` asserts message version 1 (V2) | | Direct `receiveMessage` strands USDC in the adaptor | `rescueAndForwardCCTP` (admin-only); admin matches `(recipient, callData, amount)` to original burn off-chain | +| LayerZero compose omitted/fails after token credit | Retry through LayerZero tooling first; `rescueAndForwardLayerZero` lets admin forward stranded tokens | | Out-of-limits VAA stuck forever | `settleOutOfLimitsWormhole` / `settleOutOfLimitsCCTP` route net-of-fee to admin | | Fee-on-transfer / blacklist tokens silently breaking deposits | Whitelist check + post-pull balance delta in `_depositToSafe` | | Silent storage drift on upgrade | Storage layout is pinned by Foundry tests via `vm.load` (`test/foundry/BridgeAdaptor.t.sol`) | @@ -51,21 +56,23 @@ The adaptor inherits trust from every party below. ### What this code does NOT defend against -| Threat | Why it's out of scope | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Compromised Wormhole guardian quorum | Out of our control; mitigated only by `setWormholeEnabled(false)` after detection | -| Compromised Circle attester | Same; mitigated only by `setCCTPEnabled(false)` | -| Compromised admin private key | Mitigated operationally — deploy admin as a multisig with a sane threshold | -| Bugs inside the Safe, Wormhole, or Circle contracts | These are independently audited dependencies | -| Off-chain MultiversX validator failures (no mint, double mint) | Lives in the MultiversX bridge, not here | -| MEV / front-running of `depositFromX` | Calls are idempotent (Wormhole/Circle replay protection prevents re-execution); no economic incentive to front-run | +| Threat | Why it's out of scope | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Compromised Wormhole guardian quorum | Out of our control; mitigated only by `setWormholeEnabled(false)` after detection | +| Compromised Circle attester | Same; mitigated only by `setCCTPEnabled(false)` | +| Compromised LayerZero pathway / DVN / Executor / trusted OFT | Same trust-boundary class; mitigated only by `setLayerZeroEnabled(false)` after detection | +| Compromised admin private key | Mitigated operationally — deploy admin as a multisig with a sane threshold | +| Bugs inside the Safe, Wormhole, Circle, LayerZero, or OFT contracts | These are independently audited dependencies | +| Off-chain MultiversX validator failures (no mint, double mint) | Lives in the MultiversX bridge, not here | +| MEV / front-running of `depositFromX` | Calls are idempotent (Wormhole/Circle replay protection prevents re-execution); no economic incentive to front-run | ## Known limitations - **Single admin role.** No granular roles (e.g., separate "pauser" vs "fee setter"). Deliberate for v1 simplicity. If finer-grained control is needed, wrap with OZ `AccessControl` in a future major version. - **No on-chain timelock.** `setFeeConfig` and `setWormholeEnabled` / `setCCTPEnabled` are immediate. Use a Timelock-controlled multisig as admin if delayed governance is required. - **`recoverTokens` covers any ERC20.** Including wrapped versions of bridged tokens. Admin can sweep stuck balances; this is a feature for ops, not a back door — it cannot redirect in-flight transfers because every deposit forwards to the Safe atomically. -- **No formal verification.** Critical invariants (pulled-amount conservation, admin-only rescue) are covered by 93 unit tests + 5 mainnet-fork tests, not Halmos / Certora rules. +- **LayerZero compose is two-step.** A trusted OFT can credit tokens to the adaptor while `lzCompose` fails later because of gas/options or Safe limits. Operators should retry compose before using `rescueAndForwardLayerZero`. +- **No formal verification.** Critical invariants (pulled-amount conservation, admin-only rescue) are covered by 120 unit tests + 9 mainnet-fork tests, not Halmos / Certora rules. - **Static-analyzer suppressions.** Slither has 4 documented false-positive exclusions (see comments in `.github/workflows/ci.yml` slither step). Each is justified inline. ## Audit history @@ -92,8 +99,8 @@ We will acknowledge within 72 hours. Please do not open public GitHub issues for Before unpausing on mainnet: - [ ] Admin is a multisig (not an EOA) -- [ ] `wormholeEnabled` and `cctpEnabled` start `true` only after both protocols are smoke-tested with small amounts on the deployed proxy +- [ ] `wormholeEnabled`, `cctpEnabled`, and `layerZeroEnabled` start `true` only after enabled protocols are smoke-tested with small amounts on the deployed proxy - [ ] `cctpFlatFee` and `wormholeFeeBps` are set to non-zero values that cover infrastructure costs without blocking small transfers - [ ] Storage layout pin tests pass against the deployed implementation - [ ] Etherscan verification has succeeded (`yarn bridge:verify`) -- [ ] Monitoring alerts on `Pause`, `AdminTransferred`, `FeeConfigUpdated`, `CCTPRescueForwarded`, `TokensRecovered` +- [ ] Monitoring alerts on `Pause`, `AdminTransferred`, `FeeConfigUpdated`, `LayerZeroFeeUpdated`, `CCTPRescueForwarded`, `LayerZeroRescueForwarded`, `TokensRecovered` diff --git a/contracts/BridgeAdaptor.sol b/contracts/BridgeAdaptor.sol index 1d440fc..2474c7d 100644 --- a/contracts/BridgeAdaptor.sol +++ b/contracts/BridgeAdaptor.sol @@ -11,6 +11,8 @@ import {ITokenBridge} from "wormhole-sdk/interfaces/ITokenBridge.sol"; import {IMessageTransmitter} from "wormhole-sdk/interfaces/cctp/IMessageTransmitter.sol"; import {TokenBridgeTransferWithPayload, TokenBridgeMessageLib} from "wormhole-sdk/libraries/TokenBridgeMessages.sol"; import {IERC20Safe} from "./interfaces/IERC20Safe.sol"; +import {ILayerZeroComposer} from "./interfaces/ILayerZeroComposer.sol"; +import {OFTComposeMsgCodec} from "./libraries/OFTComposeMsgCodec.sol"; /** * @title BridgeAdaptor @@ -19,7 +21,7 @@ import {IERC20Safe} from "./interfaces/IERC20Safe.sol"; * @dev Upgrade-safe: append new fields by consuming `__gap` slots; never reorder. Storage * layout is pinned by Foundry tests to catch silent dependency drift. */ -contract BridgeAdaptor is Initializable, ReentrancyGuard { +contract BridgeAdaptor is Initializable, ReentrancyGuard, ILayerZeroComposer { using SafeERC20 for IERC20; // ============ Constants ============ @@ -71,12 +73,32 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { /// @notice CCTP integration kill-switch bool public cctpEnabled; + struct LayerZeroState { + address endpoint; + bool enabled; + uint16 feeBps; + mapping(address oft => address token) oftTokens; + mapping(address oft => mapping(uint32 srcEid => bool allowed)) allowedSrcEids; + mapping(bytes32 guid => bool processed) composeProcessed; + } + + // ============ LayerZero Storage ============ + /// @notice LayerZero EndpointV2, kill-switch, fee, trusted OFTs, source allowlist, and replay guard + LayerZeroState private _layerZero; + /// @dev Reserved for future upgrades - uint256[49] private __gap; + uint256[45] private __gap; + + enum FeeMode { + Wormhole, + CCTP, + LayerZero + } // ============ Custom Errors ============ error WormholeDisabled(); error CCTPDisabled(); + error LayerZeroDisabled(); error InvalidVAA(string reason); error InvalidCCTPVersion(uint32 expected, uint32 actual); error CircleCCTPNotConfigured(); @@ -99,6 +121,12 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { error TokenNotWhitelisted(address token); error WrongChain(uint256 expected, uint256 actual); error UnexpectedSafePullDelta(uint256 expected, uint256 actual); + error InvalidLayerZeroEndpoint(address expected, address actual); + error LayerZeroOFTNotConfigured(address oft); + error LayerZeroSourceNotAllowed(address oft, uint32 srcEid); + error LayerZeroComposeAlreadyProcessed(bytes32 guid); + error InvalidLayerZeroComposeMessage(); + error InvalidLayerZeroSource(); // ============ Events ============ event Pause(bool isPause); @@ -107,10 +135,27 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { event AdminSet(address indexed previousAdmin, address indexed newAdmin); event WormholeEnabledChanged(bool enabled); event CCTPEnabledChanged(bool enabled); + event LayerZeroEnabledChanged(bool enabled); event WormholeContractsUpdated(address indexed wormhole, address indexed tokenBridge); event CircleCCTPUpdated(address indexed messageTransmitter); + event LayerZeroEndpointUpdated(address indexed endpoint); + event LayerZeroOFTTokenUpdated(address indexed oft, address indexed token); + event LayerZeroSourceUpdated(address indexed oft, uint32 indexed srcEid, bool allowed); event FeeConfigUpdated(uint64 cctpFlatFee, uint16 wormholeFeeBps); + event LayerZeroFeeUpdated(uint16 layerZeroFeeBps); event CCTPRescueForwarded(bytes32 indexed mvxRecipient, uint256 amount, uint256 callDataLen); + event LayerZeroComposeForwarded( + address indexed oft, + uint32 indexed srcEid, + bytes32 indexed guid, + address token, + bytes32 mvxRecipient, + uint256 amount, + uint256 callDataLen + ); + event LayerZeroRescueForwarded( + address indexed token, bytes32 indexed mvxRecipient, uint256 amount, uint256 callDataLen + ); // ============ Modifiers ============ @@ -232,6 +277,12 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { emit CCTPEnabledChanged(enabled); } + /// @notice Enable/disable LayerZero integration (per-protocol kill-switch) + function setLayerZeroEnabled(bool enabled) external onlyAdmin { + _layerZero.enabled = enabled; + emit LayerZeroEnabledChanged(enabled); + } + /// @notice Update Wormhole core + token-bridge addresses. Both must be non-zero. Pause-gated. function updateWormholeContracts(address _wormhole, address _tokenBridge) external onlyAdmin whenPaused { if (_wormhole == address(0) || _tokenBridge == address(0)) revert InvalidAddress(); @@ -247,6 +298,29 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { emit CircleCCTPUpdated(_messageTransmitter); } + /// @notice Set the local LayerZero EndpointV2 address. Pause-gated. + function setLayerZeroEndpoint(address _endpoint) external onlyAdmin whenPaused { + if (_endpoint == address(0)) revert InvalidAddress(); + _layerZero.endpoint = _endpoint; + emit LayerZeroEndpointUpdated(_endpoint); + } + + /// @notice Map a trusted LayerZero OFT/OFT Adapter to the ERC20 token it credits locally. + /// @dev Set `token = address(0)` to remove trust for `oft`. Pause-gated. + function setLayerZeroOFTToken(address oft, address token) external onlyAdmin whenPaused { + if (oft == address(0)) revert InvalidAddress(); + _layerZero.oftTokens[oft] = token; + emit LayerZeroOFTTokenUpdated(oft, token); + } + + /// @notice Allow or disallow a source LayerZero endpoint for a trusted OFT/OFT Adapter. + function setLayerZeroSource(address oft, uint32 srcEid, bool allowed) external onlyAdmin whenPaused { + if (oft == address(0)) revert InvalidAddress(); + if (srcEid == 0) revert InvalidLayerZeroSource(); + _layerZero.allowedSrcEids[oft][srcEid] = allowed; + emit LayerZeroSourceUpdated(oft, srcEid, allowed); + } + /// @notice Set the CCTP flat fee and Wormhole bps fee, each bounded by its cap. /// @dev Not pause-gated by design; admin may retune fees while the contract is live. /// @param _cctpFlatFee Flat fee charged on each CCTP deposit, in USDC base units. @@ -255,6 +329,11 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { _setFeeConfig(_cctpFlatFee, _wormholeFeeBps); } + /// @notice Set the LayerZero bps fee, bounded by its cap. + function setLayerZeroFeeBps(uint16 _layerZeroFeeBps) external onlyAdmin { + _setLayerZeroFeeBps(_layerZeroFeeBps); + } + function _setFeeConfig(uint64 _cctpFlatFee, uint16 _wormholeFeeBps) internal { if (_wormholeFeeBps > MAX_WORMHOLE_FEE_BPS) revert FeeExceedsMaxBps(); if (_cctpFlatFee > MAX_CCTP_FLAT_FEE) revert FeeExceedsMaxFlat(); @@ -263,6 +342,12 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { emit FeeConfigUpdated(_cctpFlatFee, _wormholeFeeBps); } + function _setLayerZeroFeeBps(uint16 _layerZeroFeeBps) internal { + if (_layerZeroFeeBps > MAX_WORMHOLE_FEE_BPS) revert FeeExceedsMaxBps(); + _layerZero.feeBps = _layerZeroFeeBps; + emit LayerZeroFeeUpdated(_layerZeroFeeBps); + } + // ============ Wormhole Token Bridge Deposits ============ /// @notice Redeem a Wormhole Token Bridge Type-3 VAA and forward the tokens to the Safe. @@ -279,7 +364,7 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { (bytes32 recipient, bytes memory callData) = abi.decode(innerPayload, (bytes32, bytes)); if (recipient == bytes32(0)) revert InvalidRecipient(); - _depositToSafe(token, amount, recipient, callData, false); + _depositToSafe(token, amount, recipient, callData, FeeMode.Wormhole); } /// @notice Permissionless settlement of a Wormhole VAA outside Safe limits to `admin()`. @@ -291,7 +376,7 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { if (!safe.whitelistedTokens(token)) revert TokenNotWhitelisted(token); _requireAmountOutsideSafeLimits(token, amount); - uint256 fee = _calculateFee(amount, false); + uint256 fee = _calculateFee(amount, FeeMode.Wormhole); if (fee >= amount) revert InsufficientAmountForFee(); IERC20(token).safeTransfer(_admin, amount - fee); } @@ -343,7 +428,7 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { (address token, uint256 amount) = _receiveCCTP(cctpMessage, cctpAttestation); if (amount == 0) revert ZeroAmount(); - _depositToSafe(token, amount, mvxRecipient, callData, true); + _depositToSafe(token, amount, mvxRecipient, callData, FeeMode.CCTP); } /// @notice Permissionless settlement of a CCTP V2 message outside Safe limits to `admin()`. @@ -358,7 +443,7 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { if (!safe.whitelistedTokens(token)) revert TokenNotWhitelisted(token); _requireAmountOutsideSafeLimits(token, amount); - uint256 fee = _calculateFee(amount, true); + uint256 fee = _calculateFee(amount, FeeMode.CCTP); if (fee >= amount) revert InsufficientAmountForFee(); IERC20(token).safeTransfer(_admin, amount - fee); } @@ -383,7 +468,7 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { if (IERC20(USDC).balanceOf(address(this)) < amount) revert InsufficientBalance(); emit CCTPRescueForwarded(mvxRecipient, amount, callData.length); - _depositToSafe(USDC, amount, mvxRecipient, callData, true); + _depositToSafe(USDC, amount, mvxRecipient, callData, FeeMode.CCTP); } /// @dev Decode `(bytes32 mvxRecipient, bytes callData)` from CCTP V2 hookData; asserts version. @@ -416,24 +501,90 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { amount = IERC20(token).balanceOf(address(this)) - balanceBefore; } + // ============ LayerZero OFT Compose Deposits ============ + + /// @notice Receive a LayerZero OFT composed message and forward credited tokens to the Safe. + /// @dev `message.composeMsg()` decodes as `abi.encode(bytes32 mvxRecipient, bytes callData)`. + function lzCompose(address _from, bytes32 _guid, bytes calldata _message, address, bytes calldata) + external + payable + whenNotPaused + nonReentrant + { + if (!_layerZero.enabled) revert LayerZeroDisabled(); + if (msg.sender != _layerZero.endpoint) revert InvalidLayerZeroEndpoint(_layerZero.endpoint, msg.sender); + if (safe.paused()) revert SafePaused(); + if (_layerZero.composeProcessed[_guid]) revert LayerZeroComposeAlreadyProcessed(_guid); + + (address token, uint32 srcEid, uint256 amount, bytes32 mvxRecipient, bytes memory callData) = + _decodeLayerZeroCompose(_from, _message); + + _layerZero.composeProcessed[_guid] = true; + emit LayerZeroComposeForwarded(_from, srcEid, _guid, token, mvxRecipient, amount, callData.length); + _depositToSafe(token, amount, mvxRecipient, callData, FeeMode.LayerZero); + } + + function _decodeLayerZeroCompose(address _from, bytes calldata _message) + internal + view + returns (address token, uint32 srcEid, uint256 amount, bytes32 mvxRecipient, bytes memory callData) + { + token = _layerZero.oftTokens[_from]; + if (token == address(0)) revert LayerZeroOFTNotConfigured(_from); + if (_message.length < OFTComposeMsgCodec.COMPOSE_MSG_OFFSET + MIN_ABI_ENCODED_HOOK_DATA) { + revert InvalidLayerZeroComposeMessage(); + } + + srcEid = OFTComposeMsgCodec.srcEid(_message); + if (!_layerZero.allowedSrcEids[_from][srcEid]) revert LayerZeroSourceNotAllowed(_from, srcEid); + + amount = OFTComposeMsgCodec.amountLD(_message); + if (amount == 0) revert ZeroAmount(); + + (mvxRecipient, callData) = abi.decode(OFTComposeMsgCodec.composeMsg(_message), (bytes32, bytes)); + if (mvxRecipient == bytes32(0)) revert InvalidRecipient(); + } + + /// @notice Forward LayerZero-credited tokens that are stranded in the adaptor to the Safe. + /// @dev Intended for operational recovery when compose execution was omitted or cannot be retried. + function rescueAndForwardLayerZero(address token, bytes32 mvxRecipient, bytes calldata callData, uint256 amount) + external + onlyAdmin + whenNotPaused + nonReentrant + { + if (!_layerZero.enabled) revert LayerZeroDisabled(); + if (safe.paused()) revert SafePaused(); + if (token == address(0)) revert InvalidAddress(); + if (mvxRecipient == bytes32(0)) revert InvalidRecipient(); + if (amount == 0) revert ZeroAmount(); + if (IERC20(token).balanceOf(address(this)) < amount) revert InsufficientBalance(); + + emit LayerZeroRescueForwarded(token, mvxRecipient, amount, callData.length); + _depositToSafe(token, amount, mvxRecipient, callData, FeeMode.LayerZero); + } + // ============ Internal Functions ============ - /// @dev CCTP = flat fee; Wormhole = bps of amount. - function _calculateFee(uint256 amount, bool isCCTP) internal view returns (uint256) { - if (isCCTP) { + /// @dev CCTP = flat fee; Wormhole/LayerZero = bps of amount. + function _calculateFee(uint256 amount, FeeMode feeMode) internal view returns (uint256) { + if (feeMode == FeeMode.CCTP) { return cctpFlatFee; } + if (feeMode == FeeMode.LayerZero) { + return (amount * _layerZero.feeBps) / BPS_DENOMINATOR; + } return (amount * wormholeFeeBps) / BPS_DENOMINATOR; } /// @dev Forward `amount - fee` to the Safe. Whitelist + post-pull balance assertion guard /// against non-whitelisted tokens and fee-on-transfer / blacklist / silent-fail behaviour. - function _depositToSafe(address token, uint256 amount, bytes32 recipient, bytes memory callData, bool isCCTP) + function _depositToSafe(address token, uint256 amount, bytes32 recipient, bytes memory callData, FeeMode feeMode) internal { if (!safe.whitelistedTokens(token)) revert TokenNotWhitelisted(token); - uint256 fee = _calculateFee(amount, isCCTP); + uint256 fee = _calculateFee(amount, feeMode); if (fee >= amount) revert InsufficientAmountForFee(); uint256 netAmount = amount - fee; @@ -466,6 +617,42 @@ contract BridgeAdaptor is Initializable, ReentrancyGuard { return _pendingAdmin; } + /// @notice LayerZero percentage fee cap (10%). + // solhint-disable-next-line func-name-mixedcase + function MAX_LAYERZERO_FEE_BPS() external pure returns (uint16) { + return MAX_WORMHOLE_FEE_BPS; + } + + /// @notice Get the configured LayerZero EndpointV2 address. + function layerZeroEndpoint() external view returns (address) { + return _layerZero.endpoint; + } + + /// @notice Check whether LayerZero integration is enabled. + function layerZeroEnabled() external view returns (bool) { + return _layerZero.enabled; + } + + /// @notice Get the LayerZero fee in basis points. + function layerZeroFeeBps() external view returns (uint16) { + return _layerZero.feeBps; + } + + /// @notice Get the local ERC20 credited by a trusted LayerZero OFT/OFT Adapter. + function layerZeroOftTokens(address oft) external view returns (address) { + return _layerZero.oftTokens[oft]; + } + + /// @notice Check whether a source LayerZero EID is allowed for an OFT/OFT Adapter. + function layerZeroAllowedSrcEids(address oft, uint32 srcEid) external view returns (bool) { + return _layerZero.allowedSrcEids[oft][srcEid]; + } + + /// @notice Check whether a LayerZero compose GUID has already been forwarded. + function layerZeroComposeProcessed(bytes32 guid) external view returns (bool) { + return _layerZero.composeProcessed[guid]; + } + // ============ Admin Recovery Functions ============ /// @notice Sweep stuck tokens from this contract to the admin. diff --git a/contracts/interfaces/ILayerZeroComposer.sol b/contracts/interfaces/ILayerZeroComposer.sol new file mode 100644 index 0000000..b4ebd1b --- /dev/null +++ b/contracts/interfaces/ILayerZeroComposer.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.35; + +/** + * @title ILayerZeroComposer + * @notice Minimal LayerZero V2 composer interface called by the local EndpointV2. + */ +interface ILayerZeroComposer { + /** + * @notice Composes a LayerZero message from an OApp. + * @param _from OApp/OFT address that queued the composed message on this chain. + * @param _guid Unique identifier for the corresponding LayerZero message. + * @param _message Composed message payload. + * @param _executor Executor address. + * @param _extraData Additional executor data. + */ + function lzCompose( + address _from, + bytes32 _guid, + bytes calldata _message, + address _executor, + bytes calldata _extraData + ) external payable; +} diff --git a/contracts/libraries/OFTComposeMsgCodec.sol b/contracts/libraries/OFTComposeMsgCodec.sol new file mode 100644 index 0000000..38fb01c --- /dev/null +++ b/contracts/libraries/OFTComposeMsgCodec.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.35; + +/** + * @title OFTComposeMsgCodec + * @notice Minimal decoder for LayerZero OFT composed messages. + * @dev Layout: nonce(8) | srcEid(4) | amountLD(32) | composeFrom(32) | composeMsg. + */ +library OFTComposeMsgCodec { + uint256 internal constant COMPOSE_MSG_OFFSET = 76; + + function srcEid(bytes calldata message) internal pure returns (uint32) { + return uint32(bytes4(message[8:12])); + } + + function amountLD(bytes calldata message) internal pure returns (uint256) { + return uint256(bytes32(message[12:44])); + } + + function composeFrom(bytes calldata message) internal pure returns (bytes32) { + return bytes32(message[44:76]); + } + + function composeMsg(bytes calldata message) internal pure returns (bytes memory) { + return message[COMPOSE_MSG_OFFSET:]; + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index a528e4a..e53764c 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -27,6 +27,12 @@ import settleWormhole from "./tasks/adaptor/settle-wormhole.js"; import rescueCctp from "./tasks/adaptor/rescue-cctp.js"; import recoverTokens from "./tasks/adaptor/recover-tokens.js"; import setFeeConfig from "./tasks/adaptor/set-fee-config.js"; +import enableLayerZero from "./tasks/adaptor/enable-layerzero.js"; +import setLayerZeroEndpoint from "./tasks/adaptor/set-layerzero-endpoint.js"; +import setLayerZeroFee from "./tasks/adaptor/set-layerzero-fee.js"; +import setLayerZeroOftToken from "./tasks/adaptor/set-layerzero-oft-token.js"; +import setLayerZeroSource from "./tasks/adaptor/set-layerzero-source.js"; +import rescueLayerZero from "./tasks/adaptor/rescue-layerzero.js"; const initialIndex = Number(process.env.INITIAL_INDEX ?? 0); @@ -62,6 +68,12 @@ const config: HardhatUserConfig = { rescueCctp, recoverTokens, setFeeConfig, + enableLayerZero, + setLayerZeroEndpoint, + setLayerZeroFee, + setLayerZeroOftToken, + setLayerZeroSource, + rescueLayerZero, ], solidity: { profiles: { diff --git a/package.json b/package.json index 6e5b547..25d259d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xoxno/mx-bridge-sol", - "description": "BridgeAdaptor smart contract for the MultiversX <> EVM bridge (Wormhole + CCTP V2)", + "description": "BridgeAdaptor smart contract for the MultiversX <> EVM bridge (Wormhole + CCTP V2 + LayerZero OFT)", "version": "1.0.0", "private": true, "type": "module", @@ -73,16 +73,22 @@ "bridge:unpause": "hardhat adaptor-unpause --network mainnet_eth", "bridge:enable-wormhole": "hardhat adaptor-enable-wormhole --network mainnet_eth", "bridge:enable-cctp": "hardhat adaptor-enable-cctp --network mainnet_eth", + "bridge:enable-layerzero": "hardhat adaptor-enable-layerzero --network mainnet_eth", "bridge:set-fee": "hardhat adaptor-set-fee-config --network mainnet_eth", + "bridge:set-layerzero-fee": "hardhat adaptor-set-layerzero-fee --network mainnet_eth", "bridge:transfer-admin": "hardhat adaptor-transfer-admin --network mainnet_eth", "bridge:accept-admin": "hardhat adaptor-accept-admin --network mainnet_eth", "bridge:cancel-admin": "hardhat adaptor-cancel-admin-transfer --network mainnet_eth", "bridge:update-wormhole": "hardhat adaptor-update-wormhole --network mainnet_eth", "bridge:set-circle": "hardhat adaptor-set-circle-transmitter --network mainnet_eth", + "bridge:set-layerzero-endpoint": "hardhat adaptor-set-layerzero-endpoint --network mainnet_eth", + "bridge:set-layerzero-oft-token": "hardhat adaptor-set-layerzero-oft-token --network mainnet_eth", + "bridge:set-layerzero-source": "hardhat adaptor-set-layerzero-source --network mainnet_eth", "bridge:deposit-cctp": "hardhat deposit-cctp-v2 --network mainnet_eth", "bridge:settle-wormhole": "hardhat adaptor-settle-wormhole --network mainnet_eth", "bridge:settle-cctp": "hardhat claim-cctp-v2-to-admin --network mainnet_eth", "bridge:rescue-cctp": "hardhat adaptor-rescue-cctp --network mainnet_eth", + "bridge:rescue-layerzero": "hardhat adaptor-rescue-layerzero --network mainnet_eth", "bridge:recover-tokens": "hardhat adaptor-recover-tokens --network mainnet_eth" }, "packageManager": "yarn@1.22.22" diff --git a/script/DeployBridgeProxy.s.sol b/script/DeployBridgeProxy.s.sol new file mode 100644 index 0000000..82e25ec --- /dev/null +++ b/script/DeployBridgeProxy.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.35; + +import "forge-std/Script.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "../contracts/BridgeAdaptor.sol"; + +/// @notice One-shot proxy deployer used to recover from a partial OZ Upgrades deploy. +/// @dev Reads the already-deployed implementation from env (`IMPL_ADDRESS`) and constructs a +/// `TransparentUpgradeableProxy(impl, initialOwner=ADMIN, data=initialize(...))`. The proxy's +/// constructor internally creates a `ProxyAdmin` owned by `ADMIN` (OZ v5 contract behaviour). +/// Run with: `forge script script/DeployBridgeProxy.s.sol --rpc-url $MAINNET_RPC_URL +/// --ledger --sender 0xb741... --broadcast --gas-price 1gwei`. +contract DeployBridgeProxy is Script { + // Mainnet constants — must mirror setup.config.json. + address constant SAFE = 0xC3c144d86c8840FD405acd637A548E850C636138; + address constant WORMHOLE_CORE = 0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B; + address constant WORMHOLE_TOKEN_BRIDGE = 0x3ee18B2214AFF97000D974cf647E7C347E8fa585; + address constant CIRCLE_MT_V2 = 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64; + address constant ADMIN = 0xb741a35956AA2365c767734a5Ad6b8b60a41F8DD; + + function run() external { + address impl = vm.envAddress("IMPL_ADDRESS"); + require(impl.code.length > 0, "IMPL_ADDRESS has no code"); + + bytes memory initData = abi.encodeWithSelector( + BridgeAdaptor.initialize.selector, SAFE, WORMHOLE_CORE, WORMHOLE_TOKEN_BRIDGE, CIRCLE_MT_V2 + ); + + vm.startBroadcast(); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(impl, ADMIN, initData); + vm.stopBroadcast(); + + console.log("BridgeAdaptor proxy deployed to:", address(proxy)); + } +} diff --git a/setup.config.json b/setup.config.json index ee78411..25dc09c 100644 --- a/setup.config.json +++ b/setup.config.json @@ -10,5 +10,11 @@ "messageTransmitterV2": "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64", "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, + "layerZero": { + "endpointV2": "0x1a44076050125825900e736c501f859c50fE728c", + "usdt0OftAdapter": "0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee", + "usdt0LegacyMeshOft": "0x1F748c76dE468e9D11bd340fA9D5CBADf315dFB0", + "usdt": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + }, "bridgeAdaptor": "0xafdA017307D611C088aa439a8eC706fe38f18d5f" } diff --git a/tasks/adaptor/enable-layerzero.ts b/tasks/adaptor/enable-layerzero.ts new file mode 100644 index 0000000..bba3d32 --- /dev/null +++ b/tasks/adaptor/enable-layerzero.ts @@ -0,0 +1,30 @@ +import { task } from "hardhat/config"; + +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +interface Args extends CommonTaskArgs { + enabled: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-enable-layerzero", "Enable or disable LayerZero integration on BridgeAdaptor").addOption({ + name: "enabled", + description: "true to enable, false to disable", + defaultValue: "true", + }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + if (args.enabled !== "true" && args.enabled !== "false") { + throw new Error(`--enabled must be "true" or "false" (got "${args.enabled}")`); + } + const enabled = args.enabled === "true"; + const { adaptor, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + const tx = await adaptor.setLayerZeroEnabled(enabled, getTxOverrides(args)); + await confirmTx(tx, "setLayerZeroEnabled"); + console.log("LayerZero integration", enabled ? "enabled" : "disabled"); + }) + .build(); diff --git a/tasks/adaptor/rescue-layerzero.ts b/tasks/adaptor/rescue-layerzero.ts new file mode 100644 index 0000000..49b8358 --- /dev/null +++ b/tasks/adaptor/rescue-layerzero.ts @@ -0,0 +1,56 @@ +import { task } from "hardhat/config"; + +import { pick } from "../lib/config.js"; +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +interface Args extends CommonTaskArgs { + token: string; + recipient: string; + calldata: string; + amount: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-rescue-layerzero", "Forward stranded LayerZero-credited tokens to the MultiversX Safe") + .addOption({ + name: "token", + description: "Credited ERC20 token (defaults to setup.config.json#layerZero.usdt)", + defaultValue: "", + }) + .addOption({ name: "recipient", description: "MultiversX recipient bytes32 (0x...)", defaultValue: "" }) + .addOption({ name: "calldata", description: "Optional MvX SC execution calldata", defaultValue: "0x" }) + .addOption({ name: "amount", description: "Token amount in base units", defaultValue: "" }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + if (!args.recipient) throw new Error("--recipient is required"); + if (!args.amount) throw new Error("--amount is required"); + if (!/^0x[0-9a-fA-F]{64}$/.test(args.recipient)) throw new Error("--recipient must be bytes32 hex"); + if (!/^0x([0-9a-fA-F]{2})*$/.test(args.calldata)) throw new Error("--calldata must be hex bytes"); + + const { adaptor, cfg, connection, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + const tokenRaw = pick(args.token, cfg.layerZero?.usdt); + if (!tokenRaw) throw new Error("--token is required (or set layerZero.usdt in setup.config.json)"); + const token = connection.ethers.getAddress(tokenRaw); + if (token === "0x0000000000000000000000000000000000000000") throw new Error("--token cannot be the zero address"); + const amount = BigInt(args.amount); + + console.log("Static-call simulating rescueAndForwardLayerZero..."); + await adaptor.rescueAndForwardLayerZero.staticCall(token, args.recipient, args.calldata, amount); + + const tx = await adaptor.rescueAndForwardLayerZero( + token, + args.recipient, + args.calldata, + amount, + getTxOverrides(args), + ); + await confirmTx(tx, "rescueAndForwardLayerZero"); + console.log("Token:", token); + console.log("Recipient:", args.recipient); + console.log("Amount:", amount.toString()); + }) + .build(); diff --git a/tasks/adaptor/set-layerzero-endpoint.ts b/tasks/adaptor/set-layerzero-endpoint.ts new file mode 100644 index 0000000..476783a --- /dev/null +++ b/tasks/adaptor/set-layerzero-endpoint.ts @@ -0,0 +1,41 @@ +import { task } from "hardhat/config"; + +import { pick } from "../lib/config.js"; +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +interface Args extends CommonTaskArgs { + endpoint: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-set-layerzero-endpoint", "Set LayerZero EndpointV2 on BridgeAdaptor (pause-gated)").addOption({ + name: "endpoint", + description: "LayerZero EndpointV2 (defaults to setup.config.json#layerZero.endpointV2)", + defaultValue: "", + }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + const { adaptor, cfg, connection, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + const endpointRaw = pick(args.endpoint, cfg.layerZero?.endpointV2); + if (!endpointRaw) throw new Error("--endpoint is required (or set layerZero.endpointV2 in setup.config.json)"); + const endpoint = connection.ethers.getAddress(endpointRaw); + if (endpoint === "0x0000000000000000000000000000000000000000") { + throw new Error("--endpoint cannot be the zero address"); + } + const code = (await connection.ethers.provider.getCode(endpoint)) as string; + if (!code || code === "0x") throw new Error(`No contract code at ${endpoint}`); + + const paused = (await adaptor.paused()) as boolean; + if (!paused) { + throw new Error("BridgeAdaptor must be paused before setting LayerZero EndpointV2. Run adaptor-pause first."); + } + + const tx = await adaptor.setLayerZeroEndpoint(endpoint, getTxOverrides(args)); + await confirmTx(tx, "setLayerZeroEndpoint"); + console.log("LayerZero EndpointV2 set to:", endpoint); + }) + .build(); diff --git a/tasks/adaptor/set-layerzero-fee.ts b/tasks/adaptor/set-layerzero-fee.ts new file mode 100644 index 0000000..c1b133e --- /dev/null +++ b/tasks/adaptor/set-layerzero-fee.ts @@ -0,0 +1,37 @@ +import { task } from "hardhat/config"; + +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +interface Args extends CommonTaskArgs { + feeBps: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-set-layerzero-fee", "Set LayerZero fee in basis points").addOption({ + name: "feeBps", + description: "LayerZero fee basis points (5 = 0.05%; capped on-chain)", + defaultValue: "", + }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + if (!args.feeBps) throw new Error("--fee-bps is required"); + const feeBps = Number(args.feeBps); + if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 65_535) { + throw new Error("--fee-bps must be an integer uint16"); + } + + const { adaptor, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + const maxBps: bigint = await adaptor.MAX_LAYERZERO_FEE_BPS(); + if (BigInt(feeBps) > maxBps) { + throw new Error(`LayerZero fee ${feeBps} bps exceeds on-chain cap of ${maxBps} bps`); + } + + const tx = await adaptor.setLayerZeroFeeBps(feeBps, getTxOverrides(args)); + await confirmTx(tx, "setLayerZeroFeeBps"); + console.log("LayerZero fee set to:", feeBps, "bps"); + }) + .build(); diff --git a/tasks/adaptor/set-layerzero-oft-token.ts b/tasks/adaptor/set-layerzero-oft-token.ts new file mode 100644 index 0000000..4f9234a --- /dev/null +++ b/tasks/adaptor/set-layerzero-oft-token.ts @@ -0,0 +1,72 @@ +import { task } from "hardhat/config"; + +import { pick } from "../lib/config.js"; +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +const ZERO = "0x0000000000000000000000000000000000000000"; + +interface Args extends CommonTaskArgs { + oft: string; + token: string; + mesh: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-set-layerzero-oft-token", "Map a trusted LayerZero OFT/OFT Adapter to its local token") + .addOption({ + name: "oft", + description: "LayerZero OFT/OFT Adapter (defaults to setup.config.json#layerZero.usdt0OftAdapter)", + defaultValue: "", + }) + .addOption({ + name: "mesh", + description: "USDT0 mesh default when --oft is omitted: native or legacy", + defaultValue: "native", + }) + .addOption({ + name: "token", + description: "Credited ERC20 token (defaults to setup.config.json#layerZero.usdt; zero removes trust)", + defaultValue: "", + }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + const { adaptor, cfg, connection, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + if (args.mesh !== "native" && args.mesh !== "legacy") { + throw new Error(`--mesh must be "native" or "legacy" (got "${args.mesh}")`); + } + const defaultOft = args.mesh === "legacy" ? cfg.layerZero?.usdt0LegacyMeshOft : cfg.layerZero?.usdt0OftAdapter; + const oftRaw = pick(args.oft, defaultOft); + const tokenRaw = pick(args.token, cfg.layerZero?.usdt); + if (!oftRaw) { + throw new Error( + `--oft is required (or set ${args.mesh === "legacy" ? "layerZero.usdt0LegacyMeshOft" : "layerZero.usdt0OftAdapter"} in setup.config.json)`, + ); + } + if (!tokenRaw) throw new Error("--token is required (or set layerZero.usdt in setup.config.json)"); + + const oft = connection.ethers.getAddress(oftRaw); + const token = connection.ethers.getAddress(tokenRaw); + if (oft === ZERO) throw new Error("--oft cannot be the zero address"); + + const [oftCode, tokenCode] = await Promise.all([ + connection.ethers.provider.getCode(oft), + token === ZERO ? "0x01" : connection.ethers.provider.getCode(token), + ]); + if (!oftCode || oftCode === "0x") throw new Error(`No contract code at OFT ${oft}`); + if (!tokenCode || tokenCode === "0x") throw new Error(`No contract code at token ${token}`); + + const paused = (await adaptor.paused()) as boolean; + if (!paused) { + throw new Error("BridgeAdaptor must be paused before mapping a LayerZero OFT. Run adaptor-pause first."); + } + + const tx = await adaptor.setLayerZeroOFTToken(oft, token, getTxOverrides(args)); + await confirmTx(tx, "setLayerZeroOFTToken"); + console.log("LayerZero OFT:", oft); + console.log("Credited token:", token); + }) + .build(); diff --git a/tasks/adaptor/set-layerzero-source.ts b/tasks/adaptor/set-layerzero-source.ts new file mode 100644 index 0000000..533ff6c --- /dev/null +++ b/tasks/adaptor/set-layerzero-source.ts @@ -0,0 +1,77 @@ +import { task } from "hardhat/config"; + +import { pick } from "../lib/config.js"; +import { confirmTx, loadAdaptor } from "../lib/loadAdaptor.js"; +import { type CommonTaskArgs, getTxOverrides, withCommonAdaptorOptions } from "../lib/options.js"; + +interface Args extends CommonTaskArgs { + oft: string; + srcEid: string; + allowed: string; + mesh: string; +} + +export default withCommonAdaptorOptions( + task("adaptor-set-layerzero-source", "Allow or disallow a LayerZero source EID for a trusted OFT") + .addOption({ + name: "oft", + description: "LayerZero OFT/OFT Adapter (defaults to setup.config.json#layerZero.usdt0OftAdapter)", + defaultValue: "", + }) + .addOption({ + name: "mesh", + description: "USDT0 mesh default when --oft is omitted: native or legacy", + defaultValue: "native", + }) + .addOption({ + name: "srcEid", + description: "Source LayerZero endpoint ID, e.g. 30110 for Arbitrum", + defaultValue: "", + }) + .addOption({ name: "allowed", description: "true to allow, false to disallow", defaultValue: "true" }), +) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .setInlineAction(async (args: Args, hre: any) => { + if (!args.srcEid) throw new Error("--src-eid is required"); + if (args.allowed !== "true" && args.allowed !== "false") { + throw new Error(`--allowed must be "true" or "false" (got "${args.allowed}")`); + } + const srcEid = Number(args.srcEid); + if (!Number.isInteger(srcEid) || srcEid <= 0 || srcEid > 4_294_967_295) { + throw new Error("--src-eid must be a uint32 greater than zero"); + } + + const { adaptor, cfg, connection, signer } = await loadAdaptor(args, hre); + console.log("Signer:", await signer.getAddress()); + + if (args.mesh !== "native" && args.mesh !== "legacy") { + throw new Error(`--mesh must be "native" or "legacy" (got "${args.mesh}")`); + } + const defaultOft = args.mesh === "legacy" ? cfg.layerZero?.usdt0LegacyMeshOft : cfg.layerZero?.usdt0OftAdapter; + const oftRaw = pick(args.oft, defaultOft); + if (!oftRaw) { + throw new Error( + `--oft is required (or set ${args.mesh === "legacy" ? "layerZero.usdt0LegacyMeshOft" : "layerZero.usdt0OftAdapter"} in setup.config.json)`, + ); + } + const oft = connection.ethers.getAddress(oftRaw); + if (oft === "0x0000000000000000000000000000000000000000") throw new Error("--oft cannot be the zero address"); + + const code = (await connection.ethers.provider.getCode(oft)) as string; + if (!code || code === "0x") throw new Error(`No contract code at OFT ${oft}`); + + const paused = (await adaptor.paused()) as boolean; + if (!paused) { + throw new Error( + "BridgeAdaptor must be paused before changing LayerZero source allowlist. Run adaptor-pause first.", + ); + } + + const allowed = args.allowed === "true"; + const tx = await adaptor.setLayerZeroSource(oft, srcEid, allowed, getTxOverrides(args)); + await confirmTx(tx, "setLayerZeroSource"); + console.log("LayerZero OFT:", oft); + console.log("Source EID:", srcEid); + console.log("Allowed:", allowed); + }) + .build(); diff --git a/tasks/deploy/bridge-adaptor.ts b/tasks/deploy/bridge-adaptor.ts index d6dcb7b..c127c8a 100644 --- a/tasks/deploy/bridge-adaptor.ts +++ b/tasks/deploy/bridge-adaptor.ts @@ -77,7 +77,11 @@ export default withCommonAdaptorOptions( const adaptorContract = await upgrades.deployProxy( BridgeAdaptor, [safe, wormhole, tokenBridge, circleTransmitter], - { kind: "transparent", txOverrides: getTxOverrides(args) }, + { + kind: "transparent", + txOverrides: getTxOverrides(args), + unsafeAllow: ["constructor"], + }, ); await adaptorContract.waitForDeployment(); diff --git a/tasks/deploy/upgrade-bridge-adaptor.ts b/tasks/deploy/upgrade-bridge-adaptor.ts index 136ac4a..9b39c46 100644 --- a/tasks/deploy/upgrade-bridge-adaptor.ts +++ b/tasks/deploy/upgrade-bridge-adaptor.ts @@ -28,6 +28,8 @@ export default withCommonAdaptorOptions( const BridgeAdaptor = await connection.ethers.getContractFactory("BridgeAdaptor", adminWallet); const upgraded = await upgrades.upgradeProxy(adaptorAddress, BridgeAdaptor, { txOverrides: getTxOverrides(args), + unsafeAllow: ["constructor"], + redeployImplementation: "always", }); const newAddress = await upgraded.getAddress(); console.log("BridgeAdaptor upgraded at:", newAddress); diff --git a/tasks/lib/config.ts b/tasks/lib/config.ts index 3ecaaa8..b000f1c 100644 --- a/tasks/lib/config.ts +++ b/tasks/lib/config.ts @@ -14,6 +14,12 @@ export interface SetupConfig { messageTransmitterV2?: string; usdc?: string; }; + layerZero?: { + endpointV2?: string; + usdt0OftAdapter?: string; + usdt0LegacyMeshOft?: string; + usdt?: string; + }; } export function readSetupConfig(path: string): SetupConfig { diff --git a/test/foundry/BridgeAdaptor.t.sol b/test/foundry/BridgeAdaptor.t.sol index 8d9047f..cb8a72f 100644 --- a/test/foundry/BridgeAdaptor.t.sol +++ b/test/foundry/BridgeAdaptor.t.sol @@ -31,10 +31,15 @@ contract BridgeAdaptorTest is Test { address public admin; address public user; address public attacker; + address public layerZeroEndpoint; + address public layerZeroOft; bytes32 public constant MVX_RECIPIENT = bytes32(uint256(0xc0f0058cea88a2bc1240b60361efb965957038d05f916c42b3f23a2c38ced81e)); bytes32 public constant SOLANA_EMITTER = bytes32(uint256(uint160(0x1234567890123456789012345678901234567890))); + bytes32 public constant LAYERZERO_COMPOSE_FROM = + bytes32(uint256(uint160(0x7777777777777777777777777777777777777777))); + uint32 public constant ARBITRUM_LZ_EID = 30110; uint256 public constant DEFAULT_MIN_LIMIT = 100; uint256 public constant DEFAULT_MAX_LIMIT = 1_000_000; @@ -46,6 +51,8 @@ contract BridgeAdaptorTest is Test { admin = makeAddr("admin"); user = makeAddr("user"); attacker = makeAddr("attacker"); + layerZeroEndpoint = makeAddr("layerZeroEndpoint"); + layerZeroOft = makeAddr("layerZeroOft"); vm.startPrank(admin); @@ -106,6 +113,40 @@ contract BridgeAdaptorTest is Test { return buildCCTPV2MessageWithVersion(recipient, callData, 1); } + function buildLayerZeroComposeMessage( + uint64 nonce, + uint32 srcEid, + uint256 amount, + bytes32 composeFrom, + bytes32 recipient, + bytes memory callData + ) internal pure returns (bytes memory) { + return abi.encodePacked(nonce, srcEid, amount, composeFrom, abi.encode(recipient, callData)); + } + + function configureLayerZero(address oft, address token, uint32 srcEid) internal { + vm.startPrank(admin); + adaptor.pause(); + adaptor.setLayerZeroEndpoint(layerZeroEndpoint); + adaptor.setLayerZeroOFTToken(oft, token); + adaptor.setLayerZeroSource(oft, srcEid, true); + adaptor.setLayerZeroFeeBps(5); + adaptor.setLayerZeroEnabled(true); + adaptor.unpause(); + vm.stopPrank(); + } + + function configureLayerZeroWithoutSource(address oft, address token) internal { + vm.startPrank(admin); + adaptor.pause(); + adaptor.setLayerZeroEndpoint(layerZeroEndpoint); + adaptor.setLayerZeroOFTToken(oft, token); + adaptor.setLayerZeroFeeBps(5); + adaptor.setLayerZeroEnabled(true); + adaptor.unpause(); + vm.stopPrank(); + } + function buildCCTPV2MessageWithVersion(bytes32 recipient, bytes memory callData, uint32 version) internal pure @@ -161,6 +202,9 @@ contract BridgeAdaptorTest is Test { assertEq(adaptor.admin(), admin); assertEq(adaptor.cctpFlatFee(), 1e6); assertEq(adaptor.wormholeFeeBps(), 5); + assertEq(adaptor.layerZeroEndpoint(), address(0)); + assertFalse(adaptor.layerZeroEnabled()); + assertEq(adaptor.layerZeroFeeBps(), 0); } function test_Initialize_RevertsOnZeroSafe() public { @@ -307,6 +351,89 @@ contract BridgeAdaptorTest is Test { assertEq(address(adaptor.circleMessageTransmitter()), newMt); } + function test_SetLayerZeroEnabled_Success() public { + vm.prank(admin); + adaptor.setLayerZeroEnabled(true); + assertTrue(adaptor.layerZeroEnabled()); + } + + function test_SetLayerZeroEndpoint_RequiresPause() public { + vm.prank(admin); + vm.expectRevert(BridgeAdaptor.ContractNotPaused.selector); + adaptor.setLayerZeroEndpoint(layerZeroEndpoint); + } + + function test_SetLayerZeroEndpoint_RevertsOnZero() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectRevert(BridgeAdaptor.InvalidAddress.selector); + adaptor.setLayerZeroEndpoint(address(0)); + vm.stopPrank(); + } + + function test_SetLayerZeroEndpoint_SuccessWhenPaused() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectEmit(true, false, false, true, address(adaptor)); + emit BridgeAdaptor.LayerZeroEndpointUpdated(layerZeroEndpoint); + adaptor.setLayerZeroEndpoint(layerZeroEndpoint); + vm.stopPrank(); + assertEq(adaptor.layerZeroEndpoint(), layerZeroEndpoint); + } + + function test_SetLayerZeroOFTToken_SuccessAndRemoveWhenPaused() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectEmit(true, true, false, true, address(adaptor)); + emit BridgeAdaptor.LayerZeroOFTTokenUpdated(layerZeroOft, address(testToken)); + adaptor.setLayerZeroOFTToken(layerZeroOft, address(testToken)); + assertEq(adaptor.layerZeroOftTokens(layerZeroOft), address(testToken)); + + adaptor.setLayerZeroOFTToken(layerZeroOft, address(0)); + vm.stopPrank(); + assertEq(adaptor.layerZeroOftTokens(layerZeroOft), address(0)); + } + + function test_SetLayerZeroOFTToken_RequiresPause() public { + vm.prank(admin); + vm.expectRevert(BridgeAdaptor.ContractNotPaused.selector); + adaptor.setLayerZeroOFTToken(layerZeroOft, address(testToken)); + } + + function test_SetLayerZeroOFTToken_RevertsOnZeroOFT() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectRevert(BridgeAdaptor.InvalidAddress.selector); + adaptor.setLayerZeroOFTToken(address(0), address(testToken)); + vm.stopPrank(); + } + + function test_SetLayerZeroSource_SuccessWhenPaused() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectEmit(true, true, false, true, address(adaptor)); + emit BridgeAdaptor.LayerZeroSourceUpdated(layerZeroOft, ARBITRUM_LZ_EID, true); + adaptor.setLayerZeroSource(layerZeroOft, ARBITRUM_LZ_EID, true); + vm.stopPrank(); + assertTrue(adaptor.layerZeroAllowedSrcEids(layerZeroOft, ARBITRUM_LZ_EID)); + } + + function test_SetLayerZeroSource_RequiresPause() public { + vm.prank(admin); + vm.expectRevert(BridgeAdaptor.ContractNotPaused.selector); + adaptor.setLayerZeroSource(layerZeroOft, ARBITRUM_LZ_EID, true); + } + + function test_SetLayerZeroSource_RevertsOnInvalidInput() public { + vm.startPrank(admin); + adaptor.pause(); + vm.expectRevert(BridgeAdaptor.InvalidAddress.selector); + adaptor.setLayerZeroSource(address(0), ARBITRUM_LZ_EID, true); + vm.expectRevert(BridgeAdaptor.InvalidLayerZeroSource.selector); + adaptor.setLayerZeroSource(layerZeroOft, 0, true); + vm.stopPrank(); + } + // ============ Fee Caps ============ function test_SetFeeConfig_RevertsAboveBpsCap() public { @@ -332,6 +459,22 @@ contract BridgeAdaptorTest is Test { assertEq(adaptor.wormholeFeeBps(), maxBps); } + function test_SetLayerZeroFeeBps_RevertsAboveCap() public { + uint16 maxBps = adaptor.MAX_LAYERZERO_FEE_BPS(); + vm.prank(admin); + vm.expectRevert(BridgeAdaptor.FeeExceedsMaxBps.selector); + adaptor.setLayerZeroFeeBps(maxBps + 1); + } + + function test_SetLayerZeroFeeBps_AcceptsAtCap() public { + uint16 maxBps = adaptor.MAX_LAYERZERO_FEE_BPS(); + vm.prank(admin); + vm.expectEmit(false, false, false, true, address(adaptor)); + emit BridgeAdaptor.LayerZeroFeeUpdated(maxBps); + adaptor.setLayerZeroFeeBps(maxBps); + assertEq(adaptor.layerZeroFeeBps(), maxBps); + } + // ============ depositFromWormhole ============ function test_DepositFromWormhole_Success() public { @@ -474,6 +617,179 @@ contract BridgeAdaptorTest is Test { adaptor.depositFromCCTPV2(message, "attestation"); } + // ============ lzCompose ============ + + function test_LayerZeroCompose_Success() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + uint256 amount = 2000; + bytes32 guid = keccak256("lz-guid"); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, amount, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + testToken.mint(address(adaptor), amount); + + vm.prank(layerZeroEndpoint); + adaptor.lzCompose(layerZeroOft, guid, message, makeAddr("executor"), ""); + + assertTrue(adaptor.layerZeroComposeProcessed(guid)); + assertEq(safe.depositCount(), 1); + MockERC20Safe.DepositRecord memory record = safe.getDeposit(0); + assertEq(record.token, address(testToken)); + assertEq(record.amount, amount - (amount * 5) / 10_000); + assertEq(record.recipient, MVX_RECIPIENT); + } + + function test_LayerZeroCompose_WithSCExecution() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + uint256 amount = 3000; + bytes32 guid = keccak256("lz-guid-sc"); + bytes memory callData = hex"abcdef"; + bytes memory message = + buildLayerZeroComposeMessage(2, ARBITRUM_LZ_EID, amount, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, callData); + testToken.mint(address(adaptor), amount); + + vm.prank(layerZeroEndpoint); + adaptor.lzCompose(layerZeroOft, guid, message, makeAddr("executor"), ""); + + assertEq(safe.scDepositCount(), 1); + MockERC20Safe.DepositRecord memory record = safe.getSCDeposit(0); + assertEq(record.token, address(testToken)); + assertEq(record.amount, amount - (amount * 5) / 10_000); + assertEq(record.callData, callData); + } + + function test_LayerZeroCompose_RevertsWhenPaused() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + vm.prank(admin); + adaptor.pause(); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert(BridgeAdaptor.ContractPaused.selector); + adaptor.lzCompose(layerZeroOft, keccak256("paused"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsWhenDisabled() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + vm.prank(admin); + adaptor.setLayerZeroEnabled(false); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert(BridgeAdaptor.LayerZeroDisabled.selector); + adaptor.lzCompose(layerZeroOft, keccak256("disabled"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsFromWrongEndpoint() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + address wrongEndpoint = makeAddr("wrongEndpoint"); + vm.prank(wrongEndpoint); + vm.expectRevert( + abi.encodeWithSelector(BridgeAdaptor.InvalidLayerZeroEndpoint.selector, layerZeroEndpoint, wrongEndpoint) + ); + adaptor.lzCompose(layerZeroOft, keccak256("wrongEndpoint"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnUntrustedOFT() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + address untrustedOft = makeAddr("untrustedOft"); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert(abi.encodeWithSelector(BridgeAdaptor.LayerZeroOFTNotConfigured.selector, untrustedOft)); + adaptor.lzCompose(untrustedOft, keccak256("untrusted"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnDisallowedSource() public { + configureLayerZeroWithoutSource(layerZeroOft, address(testToken)); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert( + abi.encodeWithSelector(BridgeAdaptor.LayerZeroSourceNotAllowed.selector, layerZeroOft, ARBITRUM_LZ_EID) + ); + adaptor.lzCompose(layerZeroOft, keccak256("bad-source"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnReplay() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + uint256 amount = 2000; + bytes32 guid = keccak256("replay"); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, amount, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + testToken.mint(address(adaptor), amount); + + vm.prank(layerZeroEndpoint); + adaptor.lzCompose(layerZeroOft, guid, message, makeAddr("executor"), ""); + + testToken.mint(address(adaptor), amount); + vm.prank(layerZeroEndpoint); + vm.expectRevert(abi.encodeWithSelector(BridgeAdaptor.LayerZeroComposeAlreadyProcessed.selector, guid)); + adaptor.lzCompose(layerZeroOft, guid, message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnShortMessage() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + bytes memory shortMessage = new bytes(100); + vm.prank(layerZeroEndpoint); + vm.expectRevert(BridgeAdaptor.InvalidLayerZeroComposeMessage.selector); + adaptor.lzCompose(layerZeroOft, keccak256("short"), shortMessage, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnZeroAmount() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 0, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert(BridgeAdaptor.ZeroAmount.selector); + adaptor.lzCompose(layerZeroOft, keccak256("zero-amount"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnZeroRecipient() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, 2000, LAYERZERO_COMPOSE_FROM, bytes32(0), ""); + vm.prank(layerZeroEndpoint); + vm.expectRevert(BridgeAdaptor.InvalidRecipient.selector); + adaptor.lzCompose(layerZeroOft, keccak256("zero-recipient"), message, makeAddr("executor"), ""); + } + + function test_LayerZeroCompose_RevertsOnNonWhitelistedToken() public { + MockERC20 spam = new MockERC20("Spam", "SPM", 18); + configureLayerZero(layerZeroOft, address(spam), ARBITRUM_LZ_EID); + uint256 amount = 2000; + bytes memory message = + buildLayerZeroComposeMessage(1, ARBITRUM_LZ_EID, amount, LAYERZERO_COMPOSE_FROM, MVX_RECIPIENT, ""); + spam.mint(address(adaptor), amount); + + vm.prank(layerZeroEndpoint); + vm.expectRevert(abi.encodeWithSelector(BridgeAdaptor.TokenNotWhitelisted.selector, address(spam))); + adaptor.lzCompose(layerZeroOft, keccak256("spam"), message, makeAddr("executor"), ""); + } + + function test_RescueAndForwardLayerZero_Success() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + uint256 amount = 2000; + testToken.mint(address(adaptor), amount); + + vm.prank(admin); + adaptor.rescueAndForwardLayerZero(address(testToken), MVX_RECIPIENT, "", amount); + + assertEq(safe.depositCount(), 1); + MockERC20Safe.DepositRecord memory record = safe.getDeposit(0); + assertEq(record.token, address(testToken)); + assertEq(record.amount, amount - (amount * 5) / 10_000); + assertEq(record.recipient, MVX_RECIPIENT); + } + + function test_RescueAndForwardLayerZero_RevertsIfNotAdmin() public { + configureLayerZero(layerZeroOft, address(testToken), ARBITRUM_LZ_EID); + vm.prank(attacker); + vm.expectRevert(BridgeAdaptor.AccessControlSenderNotAdmin.selector); + adaptor.rescueAndForwardLayerZero(address(testToken), MVX_RECIPIENT, "", 2000); + } + // ============ settleOutOfLimitsWormhole ============ function test_SettleOutOfLimitsWormhole_BelowMin() public { @@ -853,7 +1169,9 @@ contract BridgeAdaptorTest is Test { // ============ NEW: storage layout pin ============ // Slot 0: safe; 1: _admin; 2: _pendingAdmin; 3: wormhole; 4: wormholeTokenBridge; // 5: packed (circleMessageTransmitter+wormholeEnabled+_paused+cctpFlatFee+wormholeFeeBps); - // 6: cctpEnabled; 7-55: __gap[49]. + // 6: cctpEnabled; 7: packed LayerZeroConfig(endpoint+enabled+feeBps); + // 8: layerZeroOftTokens; 9: layerZeroAllowedSrcEids; 10: layerZeroComposeProcessed; + // 11-55: __gap[45]. function test_StorageLayout_Slot0_Safe() public view { bytes32 v = vm.load(address(adaptor), bytes32(uint256(0))); @@ -897,6 +1215,25 @@ contract BridgeAdaptorTest is Test { assertEq(uint256(v) & 0xff, 1); } + function test_StorageLayout_Slot7_LayerZeroPacked() public { + vm.startPrank(admin); + adaptor.pause(); + adaptor.setLayerZeroEndpoint(layerZeroEndpoint); + adaptor.setLayerZeroFeeBps(7); + adaptor.setLayerZeroEnabled(true); + vm.stopPrank(); + + bytes32 v = vm.load(address(adaptor), bytes32(uint256(7))); + uint256 raw = uint256(v); + address endpoint = address(uint160(raw & ((1 << 160) - 1))); + bool layerZeroEnabled = ((raw >> 160) & 0xff) != 0; + uint16 bps = uint16((raw >> 168) & 0xffff); + + assertEq(endpoint, layerZeroEndpoint); + assertTrue(layerZeroEnabled); + assertEq(bps, 7); + } + // ============ NEW: Wormhole wrapped-asset path (BridgeAdaptor.sol:311 else branch) ============ /// @notice Cover the non-Ethereum-origin token branch where the adaptor calls diff --git a/test/foundry/BridgeAdaptorFork.t.sol b/test/foundry/BridgeAdaptorFork.t.sol index d63d4a8..e81e710 100644 --- a/test/foundry/BridgeAdaptorFork.t.sol +++ b/test/foundry/BridgeAdaptorFork.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.35; import "forge-std/Test.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "../../contracts/BridgeAdaptor.sol"; import "../../contracts/interfaces/IERC20Safe.sol"; import {IUSDC, UsdcDealer} from "wormhole-sdk/testing/UsdcDealer.sol"; @@ -16,11 +17,16 @@ import {IUSDC, UsdcDealer} from "wormhole-sdk/testing/UsdcDealer.sol"; /// - Real USDC safeTransfer (via OZ SafeERC20) end-to-end /// - Real Circle V2 MessageTransmitter ABI compatibility /// - Real address resolution (Wormhole core/token-bridge, Circle MT, Safe, USDC) +/// - Real LayerZero EndpointV2 / USDT0 OFT code presence +/// - Real USDT Safe acceptance through LayerZero compose/rescue adaptor paths /// /// Out-of-scope (deferred): /// - Full signed-message E2E for Wormhole VAA / CCTP V2 attestation. Wormhole-SDK's /// overrides target CCTP V1 message layout and our contract enforces V2; building /// V2 messages by hand is brittle. Add when Circle ships official V2 test helpers. +/// - Full LayerZero Executor delivery. The fork tests impersonate the real EndpointV2 +/// address to exercise BridgeAdaptor's endpoint/OFT/source checks and real Safe/USDT +/// integration without needing a committed cross-chain packet. contract BridgeAdaptorForkTest is Test { using UsdcDealer for IUSDC; @@ -30,10 +36,16 @@ contract BridgeAdaptorForkTest is Test { address constant WORMHOLE_TOKEN_BRIDGE = 0x3ee18B2214AFF97000D974cf647E7C347E8fa585; address constant CIRCLE_MT_V2 = 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64; address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDT_ADDRESS = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant LAYERZERO_ENDPOINT_V2 = 0x1a44076050125825900e736c501f859c50fE728c; + address constant USDT0_ETHEREUM_OFT_ADAPTER = 0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee; + address constant USDT0_LEGACY_MESH_ETHEREUM_OFT = 0x1F748c76dE468e9D11bd340fA9D5CBADf315dFB0; // CCTP V2 message offsets (must match BridgeAdaptor constants). uint256 constant CCTP_V2_HOOK_DATA_OFFSET = 376; uint256 constant MIN_ABI_ENCODED_HOOK_DATA = 96; + uint32 constant LZ_EID_ARBITRUM = 30110; + uint32 constant LZ_EID_SOLANA = 30168; BridgeAdaptor adaptor; address admin; @@ -53,7 +65,8 @@ contract BridgeAdaptorForkTest is Test { return; } // Pin to a recent finalized block for determinism. Bump every few months. - vm.createSelectFork(rpc, 22_500_000); + // This block is after the USDT0 Legacy Mesh Ethereum OFT deployment used by Solana. + vm.createSelectFork(rpc, 25_090_000); admin = makeAddr("fork-admin"); @@ -68,6 +81,35 @@ contract BridgeAdaptorForkTest is Test { vm.stopPrank(); } + function buildLayerZeroComposeMessage( + uint64 nonce, + uint32 srcEid, + uint256 amount, + bytes32 composeFrom, + bytes32 recipient, + bytes memory callData + ) internal pure returns (bytes memory) { + return abi.encodePacked(nonce, srcEid, amount, composeFrom, abi.encode(recipient, callData)); + } + + function configureLayerZero(address oft, uint32 srcEid) internal { + vm.startPrank(admin); + adaptor.pause(); + adaptor.setLayerZeroEndpoint(LAYERZERO_ENDPOINT_V2); + adaptor.setLayerZeroOFTToken(oft, USDT_ADDRESS); + adaptor.setLayerZeroSource(oft, srcEid, true); + adaptor.setLayerZeroFeeBps(5); + adaptor.setLayerZeroEnabled(true); + adaptor.unpause(); + vm.stopPrank(); + } + + function assumeUsdtSafeReady() internal { + if (!IERC20Safe(ERC20_SAFE).whitelistedTokens(USDT_ADDRESS)) { + vm.skip(true); + } + } + /// @notice Sanity: the proxy initialized with real-mainnet refs and admin matches. function test_Fork_RealAddresses() public view { assertEq(adaptor.getSafe(), ERC20_SAFE); @@ -80,6 +122,14 @@ contract BridgeAdaptorForkTest is Test { assertFalse(adaptor.paused()); } + /// @notice Sanity: the production LayerZero and USDT0 contracts exist at the pinned block. + function test_Fork_LayerZero_RealContractsHaveCode() public view { + assertGt(LAYERZERO_ENDPOINT_V2.code.length, 0); + assertGt(USDT0_ETHEREUM_OFT_ADAPTER.code.length, 0); + assertGt(USDT0_LEGACY_MESH_ETHEREUM_OFT.code.length, 0); + assertGt(USDT_ADDRESS.code.length, 0); + } + /// @notice End-to-end with the real USDC contract: deal in, recover out. function test_Fork_RecoverTokens_RealUSDC() public { IUSDC usdc = IUSDC(USDC_ADDRESS); @@ -129,6 +179,70 @@ contract BridgeAdaptorForkTest is Test { assertEq(usdc.balanceOf(address(adaptor)), fee); } + /// @notice Forward stranded USDT into the REAL ERC20Safe via the LayerZero rescue path. + function test_Fork_RescueAndForwardLayerZero_RealSafeUSDT() public { + assumeUsdtSafeReady(); + configureLayerZero(USDT0_ETHEREUM_OFT_ADAPTER, LZ_EID_ARBITRUM); + + uint256 amount = 100e6; // 100 USDT; clears the live Safe's USDT minimum at the pinned block. + deal(USDT_ADDRESS, address(adaptor), amount); + + uint256 safeBefore = IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE); + + vm.prank(admin); + adaptor.rescueAndForwardLayerZero(USDT_ADDRESS, MVX_RECIPIENT, "", amount); + + uint256 fee = (amount * adaptor.layerZeroFeeBps()) / 10_000; + assertEq(IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE), safeBefore + amount - fee); + assertEq(IERC20(USDT_ADDRESS).balanceOf(address(adaptor)), fee); + } + + /// @notice Native USDT0 mesh: EndpointV2 + trusted Ethereum OFT Adapter + source EID gates. + function test_Fork_LayerZeroCompose_NativeUSDT0_RealSafeUSDT() public { + assumeUsdtSafeReady(); + configureLayerZero(USDT0_ETHEREUM_OFT_ADAPTER, LZ_EID_ARBITRUM); + + uint256 amount = 100e6; + bytes32 guid = keccak256("native-usdt0-guid"); + bytes memory message = buildLayerZeroComposeMessage( + 1, LZ_EID_ARBITRUM, amount, bytes32(uint256(uint160(address(0xBEEF)))), MVX_RECIPIENT, "" + ); + deal(USDT_ADDRESS, address(adaptor), amount); + + uint256 safeBefore = IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE); + + vm.prank(LAYERZERO_ENDPOINT_V2); + adaptor.lzCompose(USDT0_ETHEREUM_OFT_ADAPTER, guid, message, address(0xCAFE), ""); + + uint256 fee = (amount * adaptor.layerZeroFeeBps()) / 10_000; + assertTrue(adaptor.layerZeroComposeProcessed(guid)); + assertEq(IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE), safeBefore + amount - fee); + assertEq(IERC20(USDT_ADDRESS).balanceOf(address(adaptor)), fee); + } + + /// @notice Solana USDT0 uses USDT0 Legacy Mesh and therefore a different Ethereum OFT. + function test_Fork_LayerZeroCompose_SolanaLegacyMesh_RealSafeUSDT() public { + assumeUsdtSafeReady(); + configureLayerZero(USDT0_LEGACY_MESH_ETHEREUM_OFT, LZ_EID_SOLANA); + + uint256 amount = 100e6; + bytes32 guid = keccak256("solana-legacy-usdt0-guid"); + bytes memory message = buildLayerZeroComposeMessage( + 1, LZ_EID_SOLANA, amount, bytes32(uint256(uint160(address(0x1234)))), MVX_RECIPIENT, "" + ); + deal(USDT_ADDRESS, address(adaptor), amount); + + uint256 safeBefore = IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE); + + vm.prank(LAYERZERO_ENDPOINT_V2); + adaptor.lzCompose(USDT0_LEGACY_MESH_ETHEREUM_OFT, guid, message, address(0xCAFE), ""); + + uint256 fee = (amount * adaptor.layerZeroFeeBps()) / 10_000; + assertTrue(adaptor.layerZeroComposeProcessed(guid)); + assertEq(IERC20(USDT_ADDRESS).balanceOf(ERC20_SAFE), safeBefore + amount - fee); + assertEq(IERC20(USDT_ADDRESS).balanceOf(address(adaptor)), fee); + } + /// @notice Real Circle V2 MessageTransmitter is the call target. We verify the version /// guard fires BEFORE the real transmitter is dispatched (cheap fail-fast path). function test_Fork_DepositFromCCTPV2_RevertsOnWrongVersion() public {