diff --git a/contracts/test/ArbitrumMocks.sol b/contracts/test/ArbitrumMocks.sol index 9971358b5..70aeea587 100644 --- a/contracts/test/ArbitrumMocks.sol +++ b/contracts/test/ArbitrumMocks.sol @@ -2,45 +2,94 @@ pragma solidity ^0.8.0; contract ArbitrumMockErc20GatewayRouter { + address public gateway; + + event OutboundTransferCalled( + address l1Token, + address to, + uint256 amount, + uint256 maxGas, + uint256 gasPriceBid, + bytes data + ); + + event OutboundTransferCustomRefundCalled( + address l1Token, + address refundTo, + address to, + uint256 amount, + uint256 maxGas, + uint256 gasPriceBid, + bytes data + ); + + function setGateway(address _gateway) external { + gateway = _gateway; + } + function outboundTransferCustomRefund( - address, - address, - address, - uint256, - uint256, - uint256, + address _l1Token, + address _refundTo, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, bytes calldata _data ) external payable returns (bytes memory) { + emit OutboundTransferCustomRefundCalled(_l1Token, _refundTo, _to, _amount, _maxGas, _gasPriceBid, _data); return _data; } function outboundTransfer( - address, - address, - uint256, - uint256, - uint256, + address _l1Token, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, bytes calldata _data ) external payable returns (bytes memory) { + emit OutboundTransferCalled(_l1Token, _to, _amount, _maxGas, _gasPriceBid, _data); return _data; } function getGateway(address) external view returns (address) { - return address(this); + // Return custom gateway if set, otherwise return self (original behavior) + return gateway != address(0) ? gateway : address(this); } } contract Inbox { + event RetryableTicketCreated( + address destAddr, + uint256 l2CallValue, + uint256 maxSubmissionCost, + address excessFeeRefundAddress, + address callValueRefundAddress, + uint256 maxGas, + uint256 gasPriceBid, + bytes data + ); + function createRetryableTicket( - address, - uint256, - uint256, - address, - address, - uint256, - uint256, - bytes memory - ) external pure returns (uint256) { + address _destAddr, + uint256 _l2CallValue, + uint256 _maxSubmissionCost, + address _excessFeeRefundAddress, + address _callValueRefundAddress, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes memory _data + ) external payable returns (uint256) { + emit RetryableTicketCreated( + _destAddr, + _l2CallValue, + _maxSubmissionCost, + _excessFeeRefundAddress, + _callValueRefundAddress, + _maxGas, + _gasPriceBid, + _data + ); return 0; } } diff --git a/contracts/test/MockCCTP.sol b/contracts/test/MockCCTP.sol index 29fe9b260..50d34bdf5 100644 --- a/contracts/test/MockCCTP.sol +++ b/contracts/test/MockCCTP.sol @@ -4,24 +4,35 @@ pragma solidity ^0.8.0; import "../libraries/CircleCCTPAdapter.sol"; contract MockCCTPMinter is ITokenMinter { - function burnLimitsPerMessage(address) external pure returns (uint256) { - return type(uint256).max; + uint256 private _burnLimit = type(uint256).max; + + function setBurnLimit(uint256 limit) external { + _burnLimit = limit; + } + + function burnLimitsPerMessage(address) external view returns (uint256) { + return _burnLimit; } } contract MockCCTPMessenger is ITokenMessenger { ITokenMinter private minter; + uint256 public depositForBurnCallCount; + + event DepositForBurnCalled(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken); constructor(ITokenMinter _minter) { minter = _minter; } function depositForBurn( - uint256, - uint32, - bytes32, - address - ) external pure returns (uint64 _nonce) { + uint256 _amount, + uint32 _destinationDomain, + bytes32 _mintRecipient, + address _burnToken + ) external returns (uint64 _nonce) { + depositForBurnCallCount++; + emit DepositForBurnCalled(_amount, _destinationDomain, _mintRecipient, _burnToken); return 0; } diff --git a/contracts/test/MockERC20.sol b/contracts/test/MockERC20.sol index 044f7d31e..d290606e8 100644 --- a/contracts/test/MockERC20.sol +++ b/contracts/test/MockERC20.sol @@ -6,6 +6,42 @@ import { ERC20Permit } from "@openzeppelin/contracts-v4/token/ERC20/extensions/E import { ERC20 } from "@openzeppelin/contracts-v4/token/ERC20/ERC20.sol"; import { SignatureChecker } from "@openzeppelin/contracts-v4/utils/cryptography/SignatureChecker.sol"; +/** + * @title MintableERC20 + * @notice Simple mintable ERC20 with configurable decimals for testing. + */ +contract MintableERC20 is ERC20 { + uint8 private _decimals; + + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _decimals = decimals_; + } + + function mint(address to, uint256 amount) external returns (bool) { + _mint(to, amount); + return true; + } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } + + function burnFrom(address from, uint256 amount) external returns (bool) { + _spendAllowance(from, msg.sender, amount); + _burn(from, amount); + return true; + } + + // ExpandedIERC20 compatibility + function addMinter(address) external {} + function addBurner(address) external {} + function resetOwner(address) external {} + + function decimals() public view override returns (uint8) { + return _decimals; + } +} + /** * @title MockERC20 * @notice Implements mocked ERC20 contract with various features. diff --git a/test/evm/foundry/local/Arbitrum_Adapter.t.sol b/test/evm/foundry/local/Arbitrum_Adapter.t.sol new file mode 100644 index 000000000..3a169d4ce --- /dev/null +++ b/test/evm/foundry/local/Arbitrum_Adapter.t.sol @@ -0,0 +1,609 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; + +// Test utilities +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; + +// Contract under test +import { Arbitrum_Adapter } from "../../../../contracts/chain-adapters/Arbitrum_Adapter.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; + +// External dependencies +import { ITokenMessenger } from "../../../../contracts/external/interfaces/CCTPInterfaces.sol"; +import { ArbitrumInboxLike, ArbitrumL1ERC20GatewayLike } from "../../../../contracts/interfaces/ArbitrumBridge.sol"; + +// Existing mocks +import { ArbitrumMockErc20GatewayRouter, Inbox } from "../../../../contracts/test/ArbitrumMocks.sol"; +import { MockCCTPMessenger, MockCCTPMinter } from "../../../../contracts/test/MockCCTP.sol"; +import { MockOFTMessenger } from "../../../../contracts/test/MockOFTMessenger.sol"; +import { AdapterStore, MessengerTypes } from "../../../../contracts/AdapterStore.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; + +/** + * @title Arbitrum_AdapterTest + * @notice Foundry tests for Arbitrum_Adapter, ported from Hardhat tests. + * @dev Tests relayMessage and relayTokens functionality via HubPool delegatecall. + */ +contract Arbitrum_AdapterTest is HubPoolTestBase { + // ============ Contracts ============ + + Arbitrum_Adapter adapter; + + // ============ Mocks ============ + + Inbox inbox; + ArbitrumMockErc20GatewayRouter gatewayRouter; + MockCCTPMinter cctpMinter; + MockCCTPMessenger cctpMessenger; + MockOFTMessenger oftMessenger; + AdapterStore adapterStore; + + // ============ Addresses ============ + + address refundAddress; + address mockSpoke; + address gateway; + + // ============ Chain Constants (loaded from constants.json) ============ + + uint256 ARBITRUM_CHAIN_ID; + uint32 ARBITRUM_OFT_EID; + uint32 ARBITRUM_CIRCLE_DOMAIN; + + // ============ Adapter Constants ============ + + uint256 constant OFT_FEE_CAP = 1 ether; + uint256 constant L2_MAX_SUBMISSION_COST = 0.01 ether; + uint256 constant L2_GAS_PRICE = 5 gwei; + uint32 constant RELAY_MESSAGE_L2_GAS_LIMIT = 2_000_000; + uint32 constant RELAY_TOKENS_L2_GAS_LIMIT = 300_000; + + // ============ Test Amounts ============ + + uint256 constant TOKENS_TO_SEND = 100 ether; + uint256 constant LP_FEES = 10 ether; + + // ============ Setup ============ + + function setUp() public { + // Load chain constants from constants.json + ARBITRUM_CHAIN_ID = getChainId("ARBITRUM"); + ARBITRUM_OFT_EID = uint32(getOftEid(ARBITRUM_CHAIN_ID)); + ARBITRUM_CIRCLE_DOMAIN = getCircleDomainId(ARBITRUM_CHAIN_ID); + + // Create HubPool fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test addresses + refundAddress = makeAddr("refundAddress"); + mockSpoke = makeAddr("mockSpoke"); + gateway = makeAddr("gateway"); + + // Deploy Arbitrum-specific mocks + inbox = new Inbox(); + gatewayRouter = new ArbitrumMockErc20GatewayRouter(); + gatewayRouter.setGateway(gateway); + + cctpMinter = new MockCCTPMinter(); + cctpMessenger = new MockCCTPMessenger(cctpMinter); + + adapterStore = new AdapterStore(); + oftMessenger = new MockOFTMessenger(address(fixture.usdt)); + + // Deploy Arbitrum Adapter + adapter = new Arbitrum_Adapter( + ArbitrumInboxLike(address(inbox)), + ArbitrumL1ERC20GatewayLike(address(gatewayRouter)), + refundAddress, + IERC20(address(fixture.usdc)), + ITokenMessenger(address(cctpMessenger)), + address(adapterStore), + ARBITRUM_OFT_EID, + OFT_FEE_CAP + ); + + // Configure HubPool with adapter + fixture.hubPool.setCrossChainContracts(ARBITRUM_CHAIN_ID, address(adapter), mockSpoke); + + // Enable tokens and set pool rebalance routes + enableToken(ARBITRUM_CHAIN_ID, address(fixture.dai), fixture.l2Dai); + enableToken(ARBITRUM_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + enableToken(ARBITRUM_CHAIN_ID, address(fixture.usdc), fixture.l2Usdc); + enableToken(ARBITRUM_CHAIN_ID, address(fixture.usdt), fixture.l2Usdt); + } + + // ============ relayMessage Tests ============ + + function test_relayMessage_CallsSpokePoolFunctions() public { + address newAdmin = makeAddr("newAdmin"); + bytes memory functionData = abi.encodeWithSignature("setCrossDomainAdmin(address)", newAdmin); + + vm.expectEmit(true, true, true, true, address(inbox)); + emit Inbox.RetryableTicketCreated( + mockSpoke, + 0, // l2CallValue + L2_MAX_SUBMISSION_COST, + refundAddress, + refundAddress, + RELAY_MESSAGE_L2_GAS_LIMIT, + L2_GAS_PRICE, + functionData + ); + + uint256 inboxBalanceBefore = address(inbox).balance; + fixture.hubPool.relaySpokePoolAdminFunction(ARBITRUM_CHAIN_ID, functionData); + uint256 inboxBalanceAfter = address(inbox).balance; + + uint256 expectedEth = L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * RELAY_MESSAGE_L2_GAS_LIMIT; + assertEq(inboxBalanceAfter - inboxBalanceBefore, expectedEth, "Inbox balance change mismatch"); + } + + // ============ relayTokens Tests (ERC20 via Gateway) ============ + + function test_relayTokens_ERC20_ViaArbitrumGateway() public { + addLiquidity(fixture.dai, TOKENS_TO_SEND); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.dai), + TOKENS_TO_SEND, + LP_FEES + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + // Expected data sent to gateway + bytes memory expectedData = abi.encode(L2_MAX_SUBMISSION_COST, ""); + + // Expect gateway call + vm.expectEmit(true, true, true, true, address(gatewayRouter)); + emit ArbitrumMockErc20GatewayRouter.OutboundTransferCustomRefundCalled( + address(fixture.dai), + refundAddress, + mockSpoke, + TOKENS_TO_SEND, + RELAY_TOKENS_L2_GAS_LIMIT, + L2_GAS_PRICE, + expectedData + ); + + // Expect relayRootBundle message to SpokePool + vm.expectEmit(true, true, true, true, address(inbox)); + emit Inbox.RetryableTicketCreated( + mockSpoke, + 0, + L2_MAX_SUBMISSION_COST, + refundAddress, + refundAddress, + RELAY_MESSAGE_L2_GAS_LIMIT, + L2_GAS_PRICE, + abi.encodeWithSignature("relayRootBundle(bytes32,bytes32)", bytes32(0), bytes32(0)) + ); + + uint256 gatewayBalanceBefore = address(gatewayRouter).balance; + + // Execute + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + uint256 gatewayBalanceAfter = address(gatewayRouter).balance; + uint256 expectedEth = L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * RELAY_TOKENS_L2_GAS_LIMIT; + assertEq(gatewayBalanceAfter - gatewayBalanceBefore, expectedEth, "GatewayRouter balance change mismatch"); + + // Verify allowance was set (HubPool approved gateway via delegatecall context) + assertEq( + fixture.dai.allowance(address(fixture.hubPool), gateway), + TOKENS_TO_SEND, + "Gateway allowance mismatch" + ); + } + + // ============ relayTokens Tests (USDC via CCTP) ============ + + function test_relayTokens_USDC_ViaCCTP() public { + uint256 usdcAmount = 100e6; // 100 USDC (6 decimals) + addLiquidity(fixture.usdc, usdcAmount); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdc), + usdcAmount, + 10e6 // LP fees + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32 expectedRecipient = bytes32(uint256(uint160(mockSpoke))); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + usdcAmount, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // Verify CCTP messenger allowance + assertEq( + fixture.usdc.allowance(address(fixture.hubPool), address(cctpMessenger)), + usdcAmount, + "CCTP allowance mismatch" + ); + } + + function test_relayTokens_USDC_SplitsWhenOverLimit() public { + uint256 usdcAmount = 100e6; + addLiquidity(fixture.usdc, usdcAmount * 2); + + // 1) Set limit below amount to send and where amount does not divide evenly into limit. + uint256 burnLimit = usdcAmount / 2 - 1; + cctpMinter.setBurnLimit(burnLimit); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdc), + usdcAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32 expectedRecipient = bytes32(uint256(uint160(mockSpoke))); + + // Expect 3 calls: 2 * burnLimit + remainder + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + burnLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + burnLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + 2, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // Should have called depositForBurn 3 times (2 full + 1 remainder) + assertEq(cctpMessenger.depositForBurnCallCount(), 3, "Should split into 3 CCTP calls"); + + // 2) Set limit below amount to send and where amount divides evenly into limit. + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + uint256 newLimit = usdcAmount / 2; + cctpMinter.setBurnLimit(newLimit); + + // Expect 2 more calls: 2 * newLimit + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + newLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + newLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // 2 more calls added to prior 3. + assertEq(cctpMessenger.depositForBurnCallCount(), 5, "Should have 5 total CCTP calls"); + } + + // ============ relayTokens Tests (USDT via OFT) ============ + + function test_relayTokens_USDT_ViaOFT() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + // Configure OFT messenger in AdapterStore + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + // Set fees to return (within cap) + uint256 nativeFee = 0.1 ether; + oftMessenger.setFeesToReturn(nativeFee, 0); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // Verify OFT messenger was called + assertEq(oftMessenger.sendCallCount(), 1, "OFT send should be called once"); + + // Verify send params (public struct getter returns tuple) + (uint32 dstEid, bytes32 to, uint256 amountLD, , , , ) = oftMessenger.lastSendParam(); + assertEq(dstEid, ARBITRUM_OFT_EID, "Destination EID mismatch"); + assertEq(amountLD, usdtAmount, "Amount mismatch"); + assertEq(to, bytes32(uint256(uint160(mockSpoke))), "Recipient mismatch"); + + // Verify allowance was set + assertEq( + fixture.usdt.allowance(address(fixture.hubPool), address(oftMessenger)), + usdtAmount, + "OFT allowance mismatch" + ); + } + + // ============ OFT Error Cases ============ + + function test_relayTokens_OFT_RevertIf_LzTokenFeeNotZero() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + // Set non-zero lzTokenFee + oftMessenger.setFeesToReturn(0.1 ether, 1); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + vm.expectRevert(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + function test_relayTokens_OFT_RevertIf_NativeFeeExceedsCap() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + // Set native fee higher than cap (1 ether) + oftMessenger.setFeesToReturn(2 ether, 0); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + vm.expectRevert(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + function test_relayTokens_OFT_RevertIf_InsufficientEthForFee() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + // Set a valid fee within cap + oftMessenger.setFeesToReturn(0.5 ether, 0); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + // Drain HubPool's ETH balance (leave 1 wei to avoid zero balance issues) + uint256 hubPoolBalance = address(fixture.hubPool).balance; + vm.prank(address(fixture.hubPool)); + (bool success, ) = address(this).call{ value: hubPoolBalance - 1 }(""); + require(success, "ETH transfer failed"); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + vm.expectRevert(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + function test_relayTokens_OFT_RevertIf_IncorrectAmountReceived() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + oftMessenger.setFeesToReturn(0, 0); + // Set mismatched amounts in receipt (amountSentLD correct, amountReceivedLD wrong) + oftMessenger.setLDAmountsToReturn(usdtAmount, usdtAmount - 1); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + vm.expectRevert(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + function test_relayTokens_OFT_RevertIf_IncorrectAmountSent() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + oftMessenger.setFeesToReturn(0, 0); + // Set mismatched sent amount in receipt (amountSentLD wrong, amountReceivedLD correct) + oftMessenger.setLDAmountsToReturn(usdtAmount - 1, usdtAmount); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + vm.expectRevert(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + // ============ Receive ETH ============ + + receive() external payable {} +} diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol new file mode 100644 index 000000000..dd6c82153 --- /dev/null +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; + +import { HubPool } from "../../../../contracts/HubPool.sol"; +import { WETH9 } from "../../../../contracts/external/WETH9.sol"; +import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9Interface.sol"; +import { LpTokenFactoryInterface } from "../../../../contracts/interfaces/LpTokenFactoryInterface.sol"; +import { FinderInterface } from "../../../../contracts/external/uma/core/contracts/data-verification-mechanism/interfaces/FinderInterface.sol"; +import { OracleInterfaces } from "../../../../contracts/external/uma/core/contracts/data-verification-mechanism/implementation/Constants.sol"; +import { Constants } from "../../../../script/utils/Constants.sol"; + +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; + +// ============ UMA Ecosystem Mocks ============ + +/** + * @title MockLpTokenFactory + * @notice Factory that creates MintableERC20 instances for HubPool. + */ +contract MockLpTokenFactory is LpTokenFactoryInterface { + function createLpToken(address) external override returns (address) { + return address(new MintableERC20("LP Token", "LPT", 18)); + } +} + +/** + * @title MockFinder + * @notice Minimal UMA Finder mock for registering interface addresses. + */ +contract MockFinder is FinderInterface { + mapping(bytes32 => address) public interfaces; + + function changeImplementationAddress(bytes32 interfaceName, address implementationAddress) external override { + interfaces[interfaceName] = implementationAddress; + } + + function getImplementationAddress(bytes32 interfaceName) external view override returns (address) { + return interfaces[interfaceName]; + } +} + +/** + * @title MockAddressWhitelist + * @notice Mock collateral whitelist that approves all tokens. + */ +contract MockAddressWhitelist { + function addToWhitelist(address) external {} + function removeFromWhitelist(address) external {} + function isOnWhitelist(address) external pure returns (bool) { + return true; + } + function getWhitelist() external pure returns (address[] memory) { + return new address[](0); + } +} + +/** + * @title MockStore + * @notice Mock UMA Store that returns zero final fees. + */ +contract MockStore { + struct FinalFee { + uint256 rawValue; + } + + function payOracleFees() external payable {} + function payOracleFeesErc20(address, uint256) external {} + function computeFinalFee(address) external pure returns (FinalFee memory) { + return FinalFee(0); + } +} + +// ============ Fixture Data Struct ============ + +/** + * @title HubPoolFixtureData + * @notice Contains all deployed contracts and addresses from the fixture. + */ +struct HubPoolFixtureData { + // Core contracts + HubPool hubPool; + WETH9 weth; + // Tokens + MintableERC20 dai; + MintableERC20 usdc; + MintableERC20 usdt; + // UMA mocks + MockLpTokenFactory lpTokenFactory; + MockFinder finder; + MockAddressWhitelist addressWhitelist; + MockStore store; + // L2 token addresses + address l2Weth; + address l2Dai; + address l2Usdc; + address l2Usdt; +} + +/** + * @title HubPoolTestBase + * @notice Base test contract providing HubPool fixture setup similar to Hardhat fixtures. + * @dev Extend this contract in your tests and call `createHubPoolFixture()` in setUp(). + * Inherits from Constants to provide access to chain IDs, Circle domains, OFT EIDs, etc. + */ +abstract contract HubPoolTestBase is Test, Constants { + // ============ Constants ============ + + uint256 public constant BOND_AMOUNT = 5 ether; + uint256 public constant INITIAL_ETH = 100 ether; + uint256 public constant LP_ETH_FUNDING = 10 ether; + + // ============ Internal Storage ============ + + HubPoolFixtureData internal fixture; + + // ============ Fixture Creation ============ + + /** + * @notice Deploys and configures a HubPool with all necessary mocks. + * @dev Call this in your setUp() function. The caller becomes the owner. + * @return data The fixture data containing all deployed contracts + */ + function createHubPoolFixture() internal returns (HubPoolFixtureData memory data) { + // Deploy UMA ecosystem mocks + data.lpTokenFactory = new MockLpTokenFactory(); + data.finder = new MockFinder(); + data.addressWhitelist = new MockAddressWhitelist(); + data.store = new MockStore(); + + // Configure finder with UMA ecosystem addresses + data.finder.changeImplementationAddress(OracleInterfaces.CollateralWhitelist, address(data.addressWhitelist)); + data.finder.changeImplementationAddress(OracleInterfaces.Store, address(data.store)); + + // Deploy WETH and tokens + data.weth = new WETH9(); + data.dai = new MintableERC20("DAI", "DAI", 18); + data.usdc = new MintableERC20("USDC", "USDC", 6); + data.usdt = new MintableERC20("USDT", "USDT", 6); + + // Create L2 token addresses + data.l2Weth = makeAddr("l2Weth"); + data.l2Dai = makeAddr("l2Dai"); + data.l2Usdc = makeAddr("l2Usdc"); + data.l2Usdt = makeAddr("l2Usdt"); + + // Deploy HubPool + data.hubPool = new HubPool(data.lpTokenFactory, data.finder, WETH9Interface(address(data.weth)), address(0)); + + // Set bond token + data.hubPool.setBond(IERC20(address(data.weth)), BOND_AMOUNT); + + // Fund caller with ETH and WETH for bond + vm.deal(address(this), INITIAL_ETH); + data.weth.deposit{ value: INITIAL_ETH / 2 }(); + data.weth.approve(address(data.hubPool), type(uint256).max); + + // Fund HubPool with ETH for L2 calls + vm.deal(address(data.hubPool), LP_ETH_FUNDING); + + // Store in internal storage for convenience + fixture = data; + + return data; + } + + /** + * @notice Enables a token for LP and sets up pool rebalance route. + * @param chainId The destination chain ID + * @param l1Token The L1 token address + * @param l2Token The L2 token address + */ + function enableToken(uint256 chainId, address l1Token, address l2Token) internal { + fixture.hubPool.setPoolRebalanceRoute(chainId, l1Token, l2Token); + fixture.hubPool.enableL1TokenForLiquidityProvision(l1Token); + } + + /** + * @notice Adds liquidity for a token to the HubPool. + * @param token The token to provide liquidity for + * @param amount The amount of liquidity to add + */ + function addLiquidity(MintableERC20 token, uint256 amount) internal { + token.mint(address(this), amount); + token.approve(address(fixture.hubPool), amount); + fixture.hubPool.addLiquidity(address(token), amount); + } + + /** + * @notice Proposes a root bundle and warps past liveness period. + * @param poolRebalanceRoot The pool rebalance merkle root + * @param relayerRefundRoot The relayer refund merkle root (use bytes32(0) if not needed) + * @param slowRelayRoot The slow relay merkle root (use bytes32(0) if not needed) + */ + function proposeAndExecuteBundle( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot + ) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 1, + poolRebalanceRoot, + relayerRefundRoot, + slowRelayRoot + ); + + // Warp past liveness period + vm.warp(block.timestamp + fixture.hubPool.liveness() + 1); + } +} diff --git a/test/evm/foundry/utils/MerkleTreeUtils.sol b/test/evm/foundry/utils/MerkleTreeUtils.sol new file mode 100644 index 000000000..940788dd1 --- /dev/null +++ b/test/evm/foundry/utils/MerkleTreeUtils.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; + +/** + * @title MerkleTreeUtils + * @notice Utility functions for building Merkle trees in Foundry tests. + * @dev For simple single-leaf trees, the root is just the hash of the leaf with an empty proof. + */ +library MerkleTreeUtils { + /** + * @notice Builds a single-token pool rebalance leaf and its merkle root. + * @param chainId The destination chain ID + * @param token The L1 token address + * @param netSendAmount Amount to send to L2 (positive = send to L2) + * @param lpFee LP fee for this rebalance + * @return leaf The pool rebalance leaf struct + * @return root The merkle root (hash of the single leaf) + */ + function buildSingleTokenLeaf( + uint256 chainId, + address token, + uint256 netSendAmount, + uint256 lpFee + ) internal pure returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) { + uint256[] memory bundleLpFees = new uint256[](1); + bundleLpFees[0] = lpFee; + + int256[] memory netSendAmounts = new int256[](1); + netSendAmounts[0] = int256(netSendAmount); + + int256[] memory runningBalances = new int256[](1); + runningBalances[0] = int256(netSendAmount); + + address[] memory l1Tokens = new address[](1); + l1Tokens[0] = token; + + leaf = HubPoolInterface.PoolRebalanceLeaf({ + chainId: chainId, + groupIndex: 0, + bundleLpFees: bundleLpFees, + netSendAmounts: netSendAmounts, + runningBalances: runningBalances, + leafId: 0, + l1Tokens: l1Tokens + }); + + root = keccak256(abi.encode(leaf)); + } + + /** + * @notice Builds a multi-token pool rebalance leaf and its merkle root. + * @param chainId The destination chain ID + * @param tokens Array of L1 token addresses + * @param netSendAmounts_ Array of amounts to send to L2 (positive = send to L2) + * @param lpFees Array of LP fees for each token + * @return leaf The pool rebalance leaf struct + * @return root The merkle root (hash of the single leaf) + */ + function buildMultiTokenLeaf( + uint256 chainId, + address[] memory tokens, + uint256[] memory netSendAmounts_, + uint256[] memory lpFees + ) internal pure returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) { + require(tokens.length == netSendAmounts_.length && tokens.length == lpFees.length, "Array length mismatch"); + + int256[] memory netSendAmounts = new int256[](tokens.length); + int256[] memory runningBalances = new int256[](tokens.length); + + for (uint256 i = 0; i < tokens.length; i++) { + netSendAmounts[i] = int256(netSendAmounts_[i]); + runningBalances[i] = int256(netSendAmounts_[i]); + } + + leaf = HubPoolInterface.PoolRebalanceLeaf({ + chainId: chainId, + groupIndex: 0, + bundleLpFees: lpFees, + netSendAmounts: netSendAmounts, + runningBalances: runningBalances, + leafId: 0, + l1Tokens: tokens + }); + + root = keccak256(abi.encode(leaf)); + } + + /** + * @notice Returns an empty proof array for single-leaf trees. + */ + function emptyProof() internal pure returns (bytes32[] memory) { + return new bytes32[](0); + } +}