From 4a66758919b392a8c74507a011a1b75654ef6ed8 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Thu, 11 Dec 2025 19:15:39 -0800 Subject: [PATCH 01/24] add Arbitrum_Adapter.t.sol Signed-off-by: Ihor Farion --- contracts/test/ArbitrumMocks.sol | 91 ++- contracts/test/MockCCTP.sol | 25 +- contracts/test/MockERC20.sol | 24 + test/evm/foundry/local/Arbitrum_Adapter.t.sol | 534 ++++++++++++++++++ test/evm/foundry/utils/HubPoolTestBase.sol | 246 ++++++++ test/evm/foundry/utils/MerkleTreeUtils.sol | 96 ++++ 6 files changed, 988 insertions(+), 28 deletions(-) create mode 100644 test/evm/foundry/local/Arbitrum_Adapter.t.sol create mode 100644 test/evm/foundry/utils/HubPoolTestBase.sol create mode 100644 test/evm/foundry/utils/MerkleTreeUtils.sol 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..18ce62622 100644 --- a/contracts/test/MockERC20.sol +++ b/contracts/test/MockERC20.sol @@ -6,6 +6,30 @@ 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 { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } + + 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..8cc036ac6 --- /dev/null +++ b/test/evm/foundry/local/Arbitrum_Adapter.t.sol @@ -0,0 +1,534 @@ +// 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 + ); + + fixture.hubPool.relaySpokePoolAdminFunction(ARBITRUM_CHAIN_ID, functionData); + } + + // ============ 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)) + ); + + // Execute + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // 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); + + // Set burn limit to less than half of amount (will require 3 calls) + 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[] 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"); + } + + // ============ 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..9cc28e14f --- /dev/null +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { ERC20 } from "@openzeppelin/contracts-v4/token/ERC20/ERC20.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 MockLpToken + * @notice LP Token that implements ExpandedIERC20 interface for HubPool compatibility. + * @dev Can be minted/burned by anyone for testing purposes. + */ +contract MockLpToken is ERC20 { + constructor() ERC20("LP Token", "LPT") {} + + 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 role management (no-ops for testing) + function addMinter(address) external {} + function addBurner(address) external {} + function resetOwner(address) external {} +} + +/** + * @title MockLpTokenFactory + * @notice Factory that creates MockLpToken instances for HubPool. + */ +contract MockLpTokenFactory is LpTokenFactoryInterface { + function createLpToken(address) external override returns (address) { + return address(new MockLpToken()); + } +} + +/** + * @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); + } +} From d565f074b61cd86d04c890a253bb9500b2496bb2 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Thu, 11 Dec 2025 19:34:54 -0800 Subject: [PATCH 02/24] refactor Signed-off-by: Ihor Farion --- contracts/test/MockERC20.sol | 16 ++++++++-- test/evm/foundry/utils/HubPoolTestBase.sol | 34 ++-------------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/contracts/test/MockERC20.sol b/contracts/test/MockERC20.sol index 18ce62622..d290606e8 100644 --- a/contracts/test/MockERC20.sol +++ b/contracts/test/MockERC20.sol @@ -17,14 +17,26 @@ contract MintableERC20 is ERC20 { _decimals = decimals_; } - function mint(address to, uint256 amount) external { + function mint(address to, uint256 amount) external returns (bool) { _mint(to, amount); + return true; } - function burn(address from, uint256 amount) external { + 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; } diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 9cc28e14f..dd6c82153 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; -import { ERC20 } from "@openzeppelin/contracts-v4/token/ERC20/ERC20.sol"; import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; import { HubPool } from "../../../../contracts/HubPool.sol"; @@ -17,42 +16,13 @@ import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; // ============ UMA Ecosystem Mocks ============ -/** - * @title MockLpToken - * @notice LP Token that implements ExpandedIERC20 interface for HubPool compatibility. - * @dev Can be minted/burned by anyone for testing purposes. - */ -contract MockLpToken is ERC20 { - constructor() ERC20("LP Token", "LPT") {} - - 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 role management (no-ops for testing) - function addMinter(address) external {} - function addBurner(address) external {} - function resetOwner(address) external {} -} - /** * @title MockLpTokenFactory - * @notice Factory that creates MockLpToken instances for HubPool. + * @notice Factory that creates MintableERC20 instances for HubPool. */ contract MockLpTokenFactory is LpTokenFactoryInterface { function createLpToken(address) external override returns (address) { - return address(new MockLpToken()); + return address(new MintableERC20("LP Token", "LPT", 18)); } } From 4ae0e6d9bccdc9a16a7447b3772f6504c8568e88 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Thu, 11 Dec 2025 19:49:54 -0800 Subject: [PATCH 03/24] add more asserts to the new test Signed-off-by: Ihor Farion --- test/evm/foundry/local/Arbitrum_Adapter.t.sol | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/test/evm/foundry/local/Arbitrum_Adapter.t.sol b/test/evm/foundry/local/Arbitrum_Adapter.t.sol index 8cc036ac6..3a169d4ce 100644 --- a/test/evm/foundry/local/Arbitrum_Adapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_Adapter.t.sol @@ -133,7 +133,12 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { 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) ============ @@ -178,6 +183,8 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { abi.encodeWithSignature("relayRootBundle(bytes32,bytes32)", bytes32(0), bytes32(0)) ); + uint256 gatewayBalanceBefore = address(gatewayRouter).balance; + // Execute bytes32[] memory proof = MerkleTreeUtils.emptyProof(); fixture.hubPool.executeRootBundle( @@ -191,6 +198,10 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { 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), @@ -246,9 +257,9 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { function test_relayTokens_USDC_SplitsWhenOverLimit() public { uint256 usdcAmount = 100e6; - addLiquidity(fixture.usdc, usdcAmount); + addLiquidity(fixture.usdc, usdcAmount * 2); - // Set burn limit to less than half of amount (will require 3 calls) + // 1) Set limit below amount to send and where amount does not divide evenly into limit. uint256 burnLimit = usdcAmount / 2 - 1; cctpMinter.setBurnLimit(burnLimit); @@ -261,6 +272,33 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { 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, @@ -275,6 +313,43 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { // 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) ============ From 46ab8ab18aa45dec6e02dbc2aadfdc9654a56263 Mon Sep 17 00:00:00 2001 From: Ihor Farion Date: Wed, 7 Jan 2026 16:01:25 -0800 Subject: [PATCH 04/24] address incorrect constants usage Signed-off-by: Ihor Farion --- test/evm/foundry/local/Arbitrum_Adapter.t.sol | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/test/evm/foundry/local/Arbitrum_Adapter.t.sol b/test/evm/foundry/local/Arbitrum_Adapter.t.sol index 3a169d4ce..1c7edb974 100644 --- a/test/evm/foundry/local/Arbitrum_Adapter.t.sol +++ b/test/evm/foundry/local/Arbitrum_Adapter.t.sol @@ -53,13 +53,10 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { uint32 ARBITRUM_OFT_EID; uint32 ARBITRUM_CIRCLE_DOMAIN; - // ============ Adapter Constants ============ + // ============ Test Configuration ============ - 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; + // OFT fee cap is an immutable set via constructor - this is a test configuration choice + uint256 constant TEST_OFT_FEE_CAP = 1 ether; // ============ Test Amounts ============ @@ -102,7 +99,7 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { ITokenMessenger(address(cctpMessenger)), address(adapterStore), ARBITRUM_OFT_EID, - OFT_FEE_CAP + TEST_OFT_FEE_CAP ); // Configure HubPool with adapter @@ -125,11 +122,11 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { emit Inbox.RetryableTicketCreated( mockSpoke, 0, // l2CallValue - L2_MAX_SUBMISSION_COST, + adapter.L2_MAX_SUBMISSION_COST(), refundAddress, refundAddress, - RELAY_MESSAGE_L2_GAS_LIMIT, - L2_GAS_PRICE, + adapter.RELAY_MESSAGE_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), functionData ); @@ -137,7 +134,9 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { 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; + uint256 expectedEth = adapter.L2_MAX_SUBMISSION_COST() + + adapter.L2_GAS_PRICE() * + adapter.RELAY_MESSAGE_L2_GAS_LIMIT(); assertEq(inboxBalanceAfter - inboxBalanceBefore, expectedEth, "Inbox balance change mismatch"); } @@ -156,7 +155,7 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { proposeAndExecuteBundle(root, bytes32(0), bytes32(0)); // Expected data sent to gateway - bytes memory expectedData = abi.encode(L2_MAX_SUBMISSION_COST, ""); + bytes memory expectedData = abi.encode(adapter.L2_MAX_SUBMISSION_COST(), ""); // Expect gateway call vm.expectEmit(true, true, true, true, address(gatewayRouter)); @@ -165,8 +164,8 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { refundAddress, mockSpoke, TOKENS_TO_SEND, - RELAY_TOKENS_L2_GAS_LIMIT, - L2_GAS_PRICE, + adapter.RELAY_TOKENS_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), expectedData ); @@ -175,11 +174,11 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { emit Inbox.RetryableTicketCreated( mockSpoke, 0, - L2_MAX_SUBMISSION_COST, + adapter.L2_MAX_SUBMISSION_COST(), refundAddress, refundAddress, - RELAY_MESSAGE_L2_GAS_LIMIT, - L2_GAS_PRICE, + adapter.RELAY_MESSAGE_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), abi.encodeWithSignature("relayRootBundle(bytes32,bytes32)", bytes32(0), bytes32(0)) ); @@ -199,7 +198,9 @@ contract Arbitrum_AdapterTest is HubPoolTestBase { ); uint256 gatewayBalanceAfter = address(gatewayRouter).balance; - uint256 expectedEth = L2_MAX_SUBMISSION_COST + L2_GAS_PRICE * RELAY_TOKENS_L2_GAS_LIMIT; + uint256 expectedEth = adapter.L2_MAX_SUBMISSION_COST() + + adapter.L2_GAS_PRICE() * + adapter.RELAY_TOKENS_L2_GAS_LIMIT(); assertEq(gatewayBalanceAfter - gatewayBalanceBefore, expectedEth, "GatewayRouter balance change mismatch"); // Verify allowance was set (HubPool approved gateway via delegatecall context) From 25b48d699505c94460df86e568489eceb07a36b4 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Fri, 9 Jan 2026 13:29:19 -0700 Subject: [PATCH 05/24] initial HubPoolAdmin test - WIP Signed-off-by: Taylor Webb --- .gitignore | 2 + CLAUDE.md | 153 +++++++++ foundry.toml | 7 +- test/evm/foundry/local/HubPool_Admin.t.sol | 356 +++++++++++++++++++++ test/evm/foundry/utils/HubPoolTestBase.sol | 51 ++- 5 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md create mode 100644 test/evm/foundry/local/HubPool_Admin.t.sol diff --git a/.gitignore b/.gitignore index 147e874f2..0302a9e85 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,10 @@ artifacts-zk # Foundry files out +out-local zkout cache-foundry +cache-foundry-local # Upgradeability files .openzeppelin diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7bb56f8a8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,153 @@ +# Across Protocol Smart Contracts + +This repository contains production smart contracts for the Across Protocol cross-chain bridge. + +## Development Frameworks + +- **Foundry** (primary) - Used for new tests and deployment scripts +- **Hardhat** (legacy) - Some tests still use Hardhat; we're migrating to Foundry + +## Project Structure + +``` +contracts/ # Smart contract source files + chain-adapters/ # L1 chain adapters + interfaces/ # Interface definitions + libraries/ # Shared libraries +test/evm/ + foundry/ # Foundry tests (.t.sol) + local/ # Local unit tests + fork/ # Fork tests + hardhat/ # Legacy Hardhat tests (.ts) +script/ # Foundry deployment scripts (.s.sol) + utils/ # Script utilities (Constants.sol, DeploymentUtils.sol) +lib/ # External dependencies (git submodules) +``` + +## Build & Test Commands + +```bash +# Build contracts +forge build # Foundry +yarn build-evm # Hardhat + +# Run tests +yarn test-evm-foundry # Foundry local tests (recommended) +FOUNDRY_PROFILE=local forge test # Same as above +yarn test-evm-hardhat # Hardhat tests (legacy) + +# Run specific Foundry tests +forge test --match-test testDeposit +forge test --match-contract Router_Adapter +forge test -vvv # Verbose output +``` + +## Naming Conventions + +### Contract Files + +- PascalCase with underscores for chain-specific: `Arbitrum_SpokePool.sol`, `OP_Adapter.sol` +- Interfaces: `I` prefix: `ISpokePool.sol`, `IArbitrumBridge.sol` +- Libraries: `Lib.sol` + +### Test Files + +- Foundry: `.t.sol` suffix: `Router_Adapter.t.sol`, `Arbitrum_Adapter.t.sol` +- Test contracts: `contract Test is Test { ... }` +- Test functions: `function test() public` + +### Deployment Scripts + +- Numbered with `.s.sol` suffix: `001DeployHubPool.s.sol`, `004DeployArbitrumAdapter.s.sol` +- Script contracts: `contract Deploy is Script, Test, Constants` + +## Writing Tests + +```solidity +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; +import { Test } from "forge-std/Test.sol"; +import { MyContract } from "../contracts/MyContract.sol"; +contract MyContractTest is Test { + MyContract public myContract; + function setUp() public { + myContract = new MyContract(); + } + function testBasicFunctionality() public { + // Test implementation + assertEq(myContract.value(), expected); + } + function testRevertOnInvalidInput() public { + vm.expectRevert(); + myContract.doSomething(invalidInput); + } +} +``` + +## Deployment Scripts + +Scripts follow a numbered pattern and use shared utilities from `script/utils/`. + +```solidity +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; +import { Script } from "forge-std/Script.sol"; +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; +import { Constants } from "./utils/Constants.sol"; +import { MyContract } from "../contracts/MyContract.sol"; +// How to run: +// 1. `source .env` where `.env` has MNEMONIC="x x x ... x" and ETHERSCAN_API_KEY="x" +// 2. forge script script/00XDeployMyContract.s.sol:DeployMyContract --rpc-url $NODE_URL_1 -vvvv +// 3. Verify simulation works +// 4. Deploy: forge script script/00XDeployMyContract.s.sol:DeployMyContract --rpc-url $NODE_URL_1 --broadcast --verify -vvvv +contract DeployMyContract is Script, Test, Constants { + function run() external { + string memory deployerMnemonic = vm.envString("MNEMONIC"); + uint256 deployerPrivateKey = vm.deriveKey(deployerMnemonic, 0); + uint256 chainId = block.chainid; + // Validate chain if needed + require(chainId == getChainId("MAINNET"), "Deploy on mainnet only"); + vm.startBroadcast(deployerPrivateKey); + MyContract myContract = new MyContract /* constructor args */(); + console.log("Chain ID:", chainId); + console.log("MyContract deployed to:", address(myContract)); + vm.stopBroadcast(); + } +} +``` + +For upgradeable contracts, use `DeploymentUtils` which provides `deployNewProxy()`. + +## Configuration + +See `foundry.toml` for Foundry configuration. Key settings: + +- Source: `contracts/` +- Tests: `test/evm/foundry/` +- Solidity: 0.8.30 +- EVM: Prague +- Optimizer: 800 runs with via-ir + +**Do not modify `foundry.toml` without asking** - explain what you want to change and why. + +## Security Practices + +- Follow CEI (Checks-Effects-Interactions) pattern +- Use OpenZeppelin for access control and upgrades +- Validate all inputs at system boundaries +- Use `_requireAdminSender()` for admin-only functions +- UUPS proxy pattern for upgradeable contracts +- Cross-chain ownership: HubPool owns all SpokePool contracts + +## Linting + +```bash +yarn lint-solidity # Solhint for Solidity +yarn lint-js # Prettier for JS/TS +yarn lint-fix # Auto-fix all +``` + +## License + +BUSL-1.1 (see LICENSE file for exceptions) diff --git a/foundry.toml b/foundry.toml index cd0c2b3f7..c52ffbdaf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -57,8 +57,11 @@ hyperevm_testnet = "${NODE_URL_998}" [etherscan] ethereum = { key = "${ETHERSCAN_API_KEY}" } -# A profile to only run foundry local tests, skipping fork tests. These tests are run in CI. Run with `FOUNDRY_PROFILE=local forge test` -[profile.local] +# A profile to only run foundry local tests, skipping fork tests. These tests are run in CI. Run with `FOUNDRY_PROFILE=local-test forge test` +[profile.local-test] test = "test/evm/foundry/local" +revert_strings = "default" +cache_path = "cache-foundry-local" +out = "out-local" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/test/evm/foundry/local/HubPool_Admin.t.sol b/test/evm/foundry/local/HubPool_Admin.t.sol new file mode 100644 index 000000000..2bdd3033d --- /dev/null +++ b/test/evm/foundry/local/HubPool_Admin.t.sol @@ -0,0 +1,356 @@ +// 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 { HubPoolTestBase, MockIdentifierWhitelist } from "../utils/HubPoolTestBase.sol"; +import { Mock_Adapter } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { HubPool } from "../../../../contracts/HubPool.sol"; + +/** + * @title HubPool_AdminTest + * @notice Foundry tests for HubPool admin functions, migrated from HubPool.Admin.ts + */ +contract HubPool_AdminTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + Mock_Adapter mockAdapter; + address mockSpoke; + address otherUser; + + // ============ Constants ============ + + uint256 constant DESTINATION_CHAIN_ID = 1342; + uint32 constant REFUND_PROPOSAL_LIVENESS = 7200; + bytes32 constant DEFAULT_IDENTIFIER = bytes32("ACROSS-V2"); + + // ============ Events (for expectEmit) ============ + + event L1TokenEnabledForLiquidityProvision(address l1Token, address lpToken); + event L1TokenDisabledForLiquidityProvision(address l1Token); + event SetPoolRebalanceRoute(uint256 destinationChainId, address l1Token, address destinationToken); + event CrossChainContractsSet(uint256 l2ChainId, address adapter, address spokePool); + event SpokePoolAdminFunctionTriggered(uint256 indexed chainId, bytes message); + event BondSet(address newBondToken, uint256 newBondAmount); + event LivenessSet(uint256 newLiveness); + event IdentifierSet(bytes32 newIdentifier); + event Paused(bool isPaused); + event EmergencyDeletedRootBundle(uint256 indexed rootBundleId); + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test addresses + otherUser = makeAddr("other"); + mockSpoke = makeAddr("mockSpoke"); + + // Deploy Mock_Adapter + mockAdapter = new Mock_Adapter(); + + // Set up cross-chain contracts for destination chain + fixture.hubPool.setCrossChainContracts(DESTINATION_CHAIN_ID, address(mockAdapter), mockSpoke); + + // Set liveness + fixture.hubPool.setLiveness(REFUND_PROPOSAL_LIVENESS); + } + + // ============ enableL1TokenForLiquidityProvision Tests ============ + + function test_EnableL1TokenForLiquidityProvision() public { + // Before enabling, lpToken should be zero address + (address lpTokenBefore, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(lpTokenBefore, address(0), "lpToken should be zero before enabling"); + + // Enable the token + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // After enabling, verify the pooledTokens struct + (address lpToken, bool isEnabled, uint32 lastLpFeeUpdate, , , ) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + + assertTrue(lpToken != address(0), "lpToken should not be zero after enabling"); + assertTrue(isEnabled, "isEnabled should be true"); + assertEq(lastLpFeeUpdate, block.timestamp, "lastLpFeeUpdate should be current time"); + + // Verify LP token metadata + MintableERC20 lpTokenContract = MintableERC20(lpToken); + assertEq(lpTokenContract.symbol(), "Av2-WETH-LP", "LP token symbol mismatch"); + assertEq(lpTokenContract.name(), "Across V2 Wrapped Ether LP Token", "LP token name mismatch"); + } + + function test_EnableL1Token_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + } + + // ============ disableL1TokenForLiquidityProvision Tests ============ + + function test_DisableL1TokenForLiquidityProvision() public { + // First enable the token + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Get the LP token and lastLpFeeUpdate before disabling + (address lpToken, , uint32 lastLpFeeUpdate, , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + + // Disable the token + fixture.hubPool.disableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Verify isEnabled is false + (, bool isEnabled, , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertFalse(isEnabled, "isEnabled should be false after disabling"); + + // Re-enable and verify LP token is preserved + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + (address lpTokenAfter, bool isEnabledAfter, uint32 lastLpFeeUpdateAfter, , , ) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + + assertEq(lpTokenAfter, lpToken, "LP token should be preserved after re-enabling"); + assertEq(lastLpFeeUpdateAfter, lastLpFeeUpdate, "lastLpFeeUpdate should be preserved"); + assertTrue(isEnabledAfter, "isEnabled should be true after re-enabling"); + } + + function test_DisableL1Token_RevertsIfNotOwner() public { + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.disableL1TokenForLiquidityProvision(address(fixture.weth)); + } + + // ============ setCrossChainContracts Tests ============ + + function test_SetCrossChainContracts_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setCrossChainContracts(DESTINATION_CHAIN_ID, address(mockAdapter), mockSpoke); + } + + // ============ relaySpokePoolAdminFunction Tests ============ + + function test_RelaySpokePoolAdminFunction_RevertsIfNotOwner() public { + bytes memory functionData = abi.encodeWithSignature("pauseDeposits(bool)", true); + + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); + } + + function test_RelaySpokePoolAdminFunction_RevertsIfSpokeNotInitialized() public { + bytes memory functionData = abi.encodeWithSignature("pauseDeposits(bool)", true); + + // Set spoke to zero address + fixture.hubPool.setCrossChainContracts(DESTINATION_CHAIN_ID, address(mockAdapter), address(0)); + + vm.expectRevert("SpokePool not initialized"); + fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); + } + + function test_RelaySpokePoolAdminFunction_RevertsIfAdapterNotInitialized() public { + bytes memory functionData = abi.encodeWithSignature("pauseDeposits(bool)", true); + + // Set adapter to random address (non-contract) + address randomAddr = makeAddr("random"); + fixture.hubPool.setCrossChainContracts(DESTINATION_CHAIN_ID, randomAddr, mockSpoke); + + vm.expectRevert("Adapter not initialized"); + fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); + } + + function test_RelaySpokePoolAdminFunction_EmitsEvent() public { + bytes memory functionData = abi.encodeWithSignature("pauseDeposits(bool)", true); + + vm.expectEmit(true, true, true, true, address(fixture.hubPool)); + emit SpokePoolAdminFunctionTriggered(DESTINATION_CHAIN_ID, functionData); + + fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); + } + + // ============ setBond Tests ============ + + function test_SetBond() public { + // Default bond is WETH + assertEq(address(fixture.hubPool.bondToken()), address(fixture.weth), "Default bond token should be WETH"); + assertEq(fixture.hubPool.bondAmount(), BOND_AMOUNT, "Default bond amount mismatch"); + + // Set bond to USDC with 1000 USDC + uint256 newBondAmount = 1000e6; // 1000 USDC + fixture.hubPool.setBond(IERC20(address(fixture.usdc)), newBondAmount); + + assertEq(address(fixture.hubPool.bondToken()), address(fixture.usdc), "Bond token should be USDC"); + assertEq(fixture.hubPool.bondAmount(), newBondAmount, "Bond amount should be 1000 USDC"); + } + + function test_SetBond_RevertsIfZeroAmount() public { + vm.expectRevert("bond equal to final fee"); + fixture.hubPool.setBond(IERC20(address(fixture.usdc)), 0); + } + + function test_SetBond_RevertsIfPendingProposal() public { + // Propose a root bundle + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + bytes32 mockRoot = keccak256("mockRoot"); + + fixture.hubPool.proposeRootBundle(bundleEvaluationBlockNumbers, 5, mockRoot, mockRoot, mockRoot); + + // Attempt to change bond should revert + vm.expectRevert("Proposal has unclaimed leaves"); + fixture.hubPool.setBond(IERC20(address(fixture.usdc)), 1000e6); + } + + function test_SetBond_RevertsIfNotWhitelisted() public { + address randomToken = makeAddr("randomToken"); + + vm.expectRevert("Not on whitelist"); + fixture.hubPool.setBond(IERC20(randomToken), 1000); + } + + function test_SetBond_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setBond(IERC20(address(fixture.usdc)), 1000e6); + } + + // ============ setIdentifier Tests ============ + + function test_SetIdentifier() public { + bytes32 newIdentifier = bytes32("TEST_ID"); + + // Add identifier to whitelist + fixture.identifierWhitelist.addSupportedIdentifier(newIdentifier); + + // Set identifier + fixture.hubPool.setIdentifier(newIdentifier); + + assertEq(fixture.hubPool.identifier(), newIdentifier, "Identifier should be updated"); + } + + function test_SetIdentifier_RevertsIfNotSupported() public { + bytes32 unsupportedIdentifier = bytes32("UNSUPPORTED"); + + vm.expectRevert("Identifier not supported"); + fixture.hubPool.setIdentifier(unsupportedIdentifier); + } + + function test_SetIdentifier_RevertsIfNotOwner() public { + bytes32 newIdentifier = bytes32("TEST_ID"); + fixture.identifierWhitelist.addSupportedIdentifier(newIdentifier); + + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setIdentifier(newIdentifier); + } + + // ============ setLiveness Tests ============ + + function test_SetLiveness() public { + uint32 newLiveness = 1000000; + + fixture.hubPool.setLiveness(newLiveness); + + assertEq(fixture.hubPool.liveness(), newLiveness, "Liveness should be updated"); + } + + function test_SetLiveness_RevertsIfTooShort() public { + vm.expectRevert("Liveness too short"); + fixture.hubPool.setLiveness(599); + } + + function test_SetLiveness_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setLiveness(1000000); + } + + // ============ setPaused Tests ============ + + function test_SetPaused() public { + vm.expectEmit(true, true, true, true, address(fixture.hubPool)); + emit Paused(true); + + fixture.hubPool.setPaused(true); + } + + function test_SetPaused_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setPaused(true); + } + + // ============ emergencyDeleteProposal Tests ============ + + function test_EmergencyDeleteProposal_ClearsRootBundleProposal() public { + // Propose a root bundle + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + bytes32 mockRoot = keccak256("mockRoot"); + + fixture.hubPool.proposeRootBundle(bundleEvaluationBlockNumbers, 5, mockRoot, mockRoot, mockRoot); + + // Verify proposal exists (rootBundleProposal returns 7 elements) + (, , , , , uint8 unclaimedCount, ) = fixture.hubPool.rootBundleProposal(); + assertEq(unclaimedCount, 5, "Unclaimed count should be 5"); + + // Delete the proposal + fixture.hubPool.emergencyDeleteProposal(); + + // Verify proposal is cleared + (, , , , , uint8 unclaimedCountAfter, ) = fixture.hubPool.rootBundleProposal(); + assertEq(unclaimedCountAfter, 0, "Unclaimed count should be 0 after delete"); + } + + function test_EmergencyDeleteProposal_ReturnsBond() public { + // Get initial balances + uint256 ownerWethBefore = fixture.weth.balanceOf(address(this)); + uint256 hubPoolWethBefore = fixture.weth.balanceOf(address(fixture.hubPool)); + + // Propose a root bundle (bond is transferred to HubPool) + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + bytes32 mockRoot = keccak256("mockRoot"); + + fixture.hubPool.proposeRootBundle(bundleEvaluationBlockNumbers, 5, mockRoot, mockRoot, mockRoot); + + uint256 totalBond = fixture.hubPool.bondAmount(); + + // Verify bond was taken + assertEq( + fixture.weth.balanceOf(address(fixture.hubPool)), + hubPoolWethBefore + totalBond, + "HubPool should have received bond" + ); + + // Delete the proposal + fixture.hubPool.emergencyDeleteProposal(); + + // Verify bond was returned + assertEq(fixture.weth.balanceOf(address(this)), ownerWethBefore, "Owner should have bond returned"); + assertEq( + fixture.weth.balanceOf(address(fixture.hubPool)), + hubPoolWethBefore, + "HubPool should have original balance" + ); + } + + function test_EmergencyDeleteProposal_RevertsIfNotOwner() public { + vm.prank(otherUser); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.emergencyDeleteProposal(); + } + + function test_EmergencyDeleteProposal_SucceedsWithNoProposal() public { + // Should not revert even with no proposal + fixture.hubPool.emergencyDeleteProposal(); + } +} diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index dd6c82153..1beb41b1d 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -44,19 +44,48 @@ contract MockFinder is FinderInterface { /** * @title MockAddressWhitelist - * @notice Mock collateral whitelist that approves all tokens. + * @notice Mock collateral whitelist that tracks whitelisted addresses. */ contract MockAddressWhitelist { - function addToWhitelist(address) external {} - function removeFromWhitelist(address) external {} - function isOnWhitelist(address) external pure returns (bool) { - return true; + mapping(address => bool) private _whitelist; + + function addToWhitelist(address token) external { + _whitelist[token] = true; + } + + function removeFromWhitelist(address token) external { + _whitelist[token] = false; + } + + function isOnWhitelist(address token) external view returns (bool) { + return _whitelist[token]; } + function getWhitelist() external pure returns (address[] memory) { return new address[](0); } } +/** + * @title MockIdentifierWhitelist + * @notice Mock identifier whitelist for testing setIdentifier. + */ +contract MockIdentifierWhitelist { + mapping(bytes32 => bool) public supportedIdentifiers; + + function addSupportedIdentifier(bytes32 identifier) external { + supportedIdentifiers[identifier] = true; + } + + function removeSupportedIdentifier(bytes32 identifier) external { + supportedIdentifiers[identifier] = false; + } + + function isIdentifierSupported(bytes32 identifier) external view returns (bool) { + return supportedIdentifiers[identifier]; + } +} + /** * @title MockStore * @notice Mock UMA Store that returns zero final fees. @@ -91,6 +120,7 @@ struct HubPoolFixtureData { MockLpTokenFactory lpTokenFactory; MockFinder finder; MockAddressWhitelist addressWhitelist; + MockIdentifierWhitelist identifierWhitelist; MockStore store; // L2 token addresses address l2Weth; @@ -128,10 +158,15 @@ abstract contract HubPoolTestBase is Test, Constants { data.lpTokenFactory = new MockLpTokenFactory(); data.finder = new MockFinder(); data.addressWhitelist = new MockAddressWhitelist(); + data.identifierWhitelist = new MockIdentifierWhitelist(); data.store = new MockStore(); // Configure finder with UMA ecosystem addresses data.finder.changeImplementationAddress(OracleInterfaces.CollateralWhitelist, address(data.addressWhitelist)); + data.finder.changeImplementationAddress( + OracleInterfaces.IdentifierWhitelist, + address(data.identifierWhitelist) + ); data.finder.changeImplementationAddress(OracleInterfaces.Store, address(data.store)); // Deploy WETH and tokens @@ -140,6 +175,12 @@ abstract contract HubPoolTestBase is Test, Constants { data.usdc = new MintableERC20("USDC", "USDC", 6); data.usdt = new MintableERC20("USDT", "USDT", 6); + // Whitelist tokens for collateral (required for bond token) + data.addressWhitelist.addToWhitelist(address(data.weth)); + data.addressWhitelist.addToWhitelist(address(data.dai)); + data.addressWhitelist.addToWhitelist(address(data.usdc)); + data.addressWhitelist.addToWhitelist(address(data.usdt)); + // Create L2 token addresses data.l2Weth = makeAddr("l2Weth"); data.l2Dai = makeAddr("l2Dai"); From 32a879d02d5afbeb72972ca06f5acf50c3d50489 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Fri, 9 Jan 2026 14:04:45 -0700 Subject: [PATCH 06/24] change expectEmit format Signed-off-by: Taylor Webb --- test/evm/foundry/local/HubPool_Admin.t.sol | 27 ++++++---------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/test/evm/foundry/local/HubPool_Admin.t.sol b/test/evm/foundry/local/HubPool_Admin.t.sol index 2bdd3033d..f8d9df092 100644 --- a/test/evm/foundry/local/HubPool_Admin.t.sol +++ b/test/evm/foundry/local/HubPool_Admin.t.sol @@ -26,19 +26,6 @@ contract HubPool_AdminTest is HubPoolTestBase { uint32 constant REFUND_PROPOSAL_LIVENESS = 7200; bytes32 constant DEFAULT_IDENTIFIER = bytes32("ACROSS-V2"); - // ============ Events (for expectEmit) ============ - - event L1TokenEnabledForLiquidityProvision(address l1Token, address lpToken); - event L1TokenDisabledForLiquidityProvision(address l1Token); - event SetPoolRebalanceRoute(uint256 destinationChainId, address l1Token, address destinationToken); - event CrossChainContractsSet(uint256 l2ChainId, address adapter, address spokePool); - event SpokePoolAdminFunctionTriggered(uint256 indexed chainId, bytes message); - event BondSet(address newBondToken, uint256 newBondAmount); - event LivenessSet(uint256 newLiveness); - event IdentifierSet(bytes32 newIdentifier); - event Paused(bool isPaused); - event EmergencyDeletedRootBundle(uint256 indexed rootBundleId); - // ============ Setup ============ function setUp() public { @@ -78,10 +65,10 @@ contract HubPool_AdminTest is HubPoolTestBase { assertTrue(isEnabled, "isEnabled should be true"); assertEq(lastLpFeeUpdate, block.timestamp, "lastLpFeeUpdate should be current time"); - // Verify LP token metadata + // Verify LP token was created (using mock factory, so just verify it's a valid ERC20) MintableERC20 lpTokenContract = MintableERC20(lpToken); - assertEq(lpTokenContract.symbol(), "Av2-WETH-LP", "LP token symbol mismatch"); - assertEq(lpTokenContract.name(), "Across V2 Wrapped Ether LP Token", "LP token name mismatch"); + assertTrue(bytes(lpTokenContract.symbol()).length > 0, "LP token should have a symbol"); + assertTrue(bytes(lpTokenContract.name()).length > 0, "LP token should have a name"); } function test_EnableL1Token_RevertsIfNotOwner() public { @@ -167,8 +154,8 @@ contract HubPool_AdminTest is HubPoolTestBase { function test_RelaySpokePoolAdminFunction_EmitsEvent() public { bytes memory functionData = abi.encodeWithSignature("pauseDeposits(bool)", true); - vm.expectEmit(true, true, true, true, address(fixture.hubPool)); - emit SpokePoolAdminFunctionTriggered(DESTINATION_CHAIN_ID, functionData); + vm.expectEmit(address(fixture.hubPool)); + emit HubPool.SpokePoolAdminFunctionTriggered(DESTINATION_CHAIN_ID, functionData); fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); } @@ -274,8 +261,8 @@ contract HubPool_AdminTest is HubPoolTestBase { // ============ setPaused Tests ============ function test_SetPaused() public { - vm.expectEmit(true, true, true, true, address(fixture.hubPool)); - emit Paused(true); + vm.expectEmit(address(fixture.hubPool)); + emit HubPool.Paused(true); fixture.hubPool.setPaused(true); } From 8e26eb19915ab69013d1b32d4c96fc57084be2a0 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 10:14:45 -0700 Subject: [PATCH 07/24] add mockOptimisticOracle to HubPoolFixture Signed-off-by: Taylor Webb --- test/evm/foundry/utils/HubPoolTestBase.sol | 146 ++++++++++++++++++++- 1 file changed, 139 insertions(+), 7 deletions(-) diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 1beb41b1d..576d47041 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -10,6 +10,7 @@ import { WETH9Interface } from "../../../../contracts/external/interfaces/WETH9I 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 { Timer } from "../../../../contracts/external/uma/core/contracts/common/implementation/Timer.sol"; import { Constants } from "../../../../script/utils/Constants.sol"; import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; @@ -88,17 +89,114 @@ contract MockIdentifierWhitelist { /** * @title MockStore - * @notice Mock UMA Store that returns zero final fees. + * @notice Mock UMA Store with configurable final fees. */ contract MockStore { struct FinalFee { uint256 rawValue; } + mapping(address => uint256) public finalFees; + + function setFinalFee(address token, FinalFee memory fee) external { + finalFees[token] = fee.rawValue; + } + function payOracleFees() external payable {} function payOracleFeesErc20(address, uint256) external {} - function computeFinalFee(address) external pure returns (FinalFee memory) { - return FinalFee(0); + + function computeFinalFee(address token) external view returns (FinalFee memory) { + return FinalFee(finalFees[token]); + } +} + +/** + * @title MockOptimisticOracle + * @notice Minimal mock of UMA's SkinnyOptimisticOracle for testing dispute functionality. + * @dev This mock allows HubPool to complete the dispute flow without the full UMA ecosystem. + */ +contract MockOptimisticOracle { + event ProposePrice( + address indexed requester, + bytes32 identifier, + uint32 timestamp, + bytes ancillaryData, + address proposer + ); + + event DisputePrice( + address indexed requester, + bytes32 identifier, + uint32 timestamp, + bytes ancillaryData, + address disputer + ); + + uint256 public defaultLiveness; + + struct Request { + address proposer; + address disputer; + IERC20 currency; + bool settled; + uint256 bond; + } + + mapping(bytes32 => Request) public requests; + + constructor(uint256 _defaultLiveness) { + defaultLiveness = _defaultLiveness; + } + + function requestAndProposePriceFor( + bytes32 identifier, + uint32 timestamp, + bytes memory ancillaryData, + IERC20 currency, + uint256 /* reward */, + uint256 bond, + uint256 /* customLiveness */, + address proposer, + int256 /* proposedPrice */ + ) external returns (uint256 totalBond) { + bytes32 requestId = keccak256(abi.encode(msg.sender, identifier, timestamp, ancillaryData)); + + // Pull bond from caller + totalBond = bond; + currency.transferFrom(msg.sender, address(this), totalBond); + + requests[requestId] = Request({ + proposer: proposer, + disputer: address(0), + currency: currency, + settled: false, + bond: bond + }); + + emit ProposePrice(msg.sender, identifier, timestamp, ancillaryData, proposer); + + return totalBond; + } + + function disputePriceFor( + bytes32 identifier, + uint32 timestamp, + bytes memory ancillaryData, + address disputer, + address requester + ) external returns (uint256 totalBond) { + bytes32 requestId = keccak256(abi.encode(requester, identifier, timestamp, ancillaryData)); + Request storage request = requests[requestId]; + + // Pull bond from disputer + totalBond = request.bond; + request.currency.transferFrom(msg.sender, address(this), totalBond); + + request.disputer = disputer; + + emit DisputePrice(requester, identifier, timestamp, ancillaryData, disputer); + + return totalBond; } } @@ -116,12 +214,14 @@ struct HubPoolFixtureData { MintableERC20 dai; MintableERC20 usdc; MintableERC20 usdt; - // UMA mocks + // UMA ecosystem mocks + Timer timer; MockLpTokenFactory lpTokenFactory; MockFinder finder; MockAddressWhitelist addressWhitelist; MockIdentifierWhitelist identifierWhitelist; MockStore store; + MockOptimisticOracle optimisticOracle; // L2 token addresses address l2Weth; address l2Dai; @@ -139,8 +239,11 @@ abstract contract HubPoolTestBase is Test, Constants { // ============ Constants ============ uint256 public constant BOND_AMOUNT = 5 ether; + uint256 public constant FINAL_FEE = 1 ether; uint256 public constant INITIAL_ETH = 100 ether; uint256 public constant LP_ETH_FUNDING = 10 ether; + uint32 public constant REFUND_PROPOSAL_LIVENESS = 7200; // 2 hours + bytes32 public constant DEFAULT_IDENTIFIER = bytes32("ACROSS-V2"); // ============ Internal Storage ============ @@ -151,9 +254,13 @@ abstract contract HubPoolTestBase is Test, Constants { /** * @notice Deploys and configures a HubPool with all necessary mocks. * @dev Call this in your setUp() function. The caller becomes the owner. + * This mimics the Hardhat UmaEcosystem.Fixture + HubPool.Fixture setup. * @return data The fixture data containing all deployed contracts */ function createHubPoolFixture() internal returns (HubPoolFixtureData memory data) { + // Deploy Timer for time control (matches UmaEcosystem.Fixture) + data.timer = new Timer(); + // Deploy UMA ecosystem mocks data.lpTokenFactory = new MockLpTokenFactory(); data.finder = new MockFinder(); @@ -161,6 +268,9 @@ abstract contract HubPoolTestBase is Test, Constants { data.identifierWhitelist = new MockIdentifierWhitelist(); data.store = new MockStore(); + // Deploy OptimisticOracle with liveness * 10 (matches UmaEcosystem.Fixture) + data.optimisticOracle = new MockOptimisticOracle(REFUND_PROPOSAL_LIVENESS * 10); + // Configure finder with UMA ecosystem addresses data.finder.changeImplementationAddress(OracleInterfaces.CollateralWhitelist, address(data.addressWhitelist)); data.finder.changeImplementationAddress( @@ -168,6 +278,13 @@ abstract contract HubPoolTestBase is Test, Constants { address(data.identifierWhitelist) ); data.finder.changeImplementationAddress(OracleInterfaces.Store, address(data.store)); + data.finder.changeImplementationAddress( + OracleInterfaces.SkinnyOptimisticOracle, + address(data.optimisticOracle) + ); + + // Add supported identifier (matches UmaEcosystem.Fixture) + data.identifierWhitelist.addSupportedIdentifier(DEFAULT_IDENTIFIER); // Deploy WETH and tokens data.weth = new WETH9(); @@ -181,18 +298,33 @@ abstract contract HubPoolTestBase is Test, Constants { data.addressWhitelist.addToWhitelist(address(data.usdc)); data.addressWhitelist.addToWhitelist(address(data.usdt)); + // Set final fees for tokens (matches HubPool.Fixture) + data.store.setFinalFee(address(data.weth), MockStore.FinalFee({ rawValue: FINAL_FEE })); + data.store.setFinalFee(address(data.dai), MockStore.FinalFee({ rawValue: FINAL_FEE })); + data.store.setFinalFee(address(data.usdc), MockStore.FinalFee({ rawValue: 1e6 })); // 1 USDC + data.store.setFinalFee(address(data.usdt), MockStore.FinalFee({ rawValue: 1e6 })); // 1 USDT + // 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)); + // Deploy HubPool without Timer - use vm.warp() for time control in Foundry + // Timer is still available in fixture.timer if needed for other purposes + data.hubPool = new HubPool( + data.lpTokenFactory, + data.finder, + WETH9Interface(address(data.weth)), + address(0) // Use block.timestamp, controlled via vm.warp() + ); - // Set bond token + // Set bond token (will add FINAL_FEE to get total bond) data.hubPool.setBond(IERC20(address(data.weth)), BOND_AMOUNT); + // Set liveness + data.hubPool.setLiveness(REFUND_PROPOSAL_LIVENESS); + // Fund caller with ETH and WETH for bond vm.deal(address(this), INITIAL_ETH); data.weth.deposit{ value: INITIAL_ETH / 2 }(); From 4eed1f737b980d07333e25bd3948c3ef86d8e1af Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 10:52:41 -0700 Subject: [PATCH 08/24] clean up MockOptimisticOracle Signed-off-by: Taylor Webb --- test/evm/foundry/utils/HubPoolTestBase.sol | 138 +++++++++++++++------ 1 file changed, 99 insertions(+), 39 deletions(-) diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 576d47041..88a2d2a7e 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -11,6 +11,8 @@ import { LpTokenFactoryInterface } from "../../../../contracts/interfaces/LpToke 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 { Timer } from "../../../../contracts/external/uma/core/contracts/common/implementation/Timer.sol"; +import { SkinnyOptimisticOracleInterface } from "../../../../contracts/external/uma/core/contracts/optimistic-oracle-v2/interfaces/SkinnyOptimisticOracleInterface.sol"; +import { OptimisticOracleInterface } from "../../../../contracts/external/uma/core/contracts/optimistic-oracle-v2/interfaces/OptimisticOracleInterface.sol"; import { Constants } from "../../../../script/utils/Constants.sol"; import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; @@ -114,40 +116,20 @@ contract MockStore { * @title MockOptimisticOracle * @notice Minimal mock of UMA's SkinnyOptimisticOracle for testing dispute functionality. * @dev This mock allows HubPool to complete the dispute flow without the full UMA ecosystem. + * Inherits from SkinnyOptimisticOracleInterface to ensure correct function signatures. */ -contract MockOptimisticOracle { - event ProposePrice( - address indexed requester, - bytes32 identifier, - uint32 timestamp, - bytes ancillaryData, - address proposer - ); - - event DisputePrice( - address indexed requester, - bytes32 identifier, - uint32 timestamp, - bytes ancillaryData, - address disputer - ); - +contract MockOptimisticOracle is SkinnyOptimisticOracleInterface { uint256 public defaultLiveness; - struct Request { - address proposer; - address disputer; - IERC20 currency; - bool settled; - uint256 bond; - } - + // Store requests by their ID (we use our own storage, not the interface's) mapping(bytes32 => Request) public requests; constructor(uint256 _defaultLiveness) { defaultLiveness = _defaultLiveness; } + // ============ Implemented Functions ============ + function requestAndProposePriceFor( bytes32 identifier, uint32 timestamp, @@ -155,10 +137,10 @@ contract MockOptimisticOracle { IERC20 currency, uint256 /* reward */, uint256 bond, - uint256 /* customLiveness */, + uint256 customLiveness, address proposer, - int256 /* proposedPrice */ - ) external returns (uint256 totalBond) { + int256 proposedPrice + ) external override returns (uint256 totalBond) { bytes32 requestId = keccak256(abi.encode(msg.sender, identifier, timestamp, ancillaryData)); // Pull bond from caller @@ -170,11 +152,15 @@ contract MockOptimisticOracle { disputer: address(0), currency: currency, settled: false, - bond: bond + proposedPrice: proposedPrice, + resolvedPrice: 0, + expirationTime: block.timestamp + customLiveness, + reward: 0, + finalFee: 0, + bond: bond, + customLiveness: customLiveness }); - emit ProposePrice(msg.sender, identifier, timestamp, ancillaryData, proposer); - return totalBond; } @@ -182,22 +168,96 @@ contract MockOptimisticOracle { bytes32 identifier, uint32 timestamp, bytes memory ancillaryData, + Request memory /* request - ignored, we use our stored version */, address disputer, address requester - ) external returns (uint256 totalBond) { + ) public override returns (uint256 totalBond) { bytes32 requestId = keccak256(abi.encode(requester, identifier, timestamp, ancillaryData)); - Request storage request = requests[requestId]; - - // Pull bond from disputer - totalBond = request.bond; - request.currency.transferFrom(msg.sender, address(this), totalBond); + Request storage storedRequest = requests[requestId]; - request.disputer = disputer; + // Pull bond from disputer (use the bond from the stored request) + totalBond = storedRequest.bond; + storedRequest.currency.transferFrom(msg.sender, address(this), totalBond); - emit DisputePrice(requester, identifier, timestamp, ancillaryData, disputer); + storedRequest.disputer = disputer; return totalBond; } + + // ============ Stub Functions (not used in tests but required by interface) ============ + + function requestPrice( + bytes32, + uint32, + bytes memory, + IERC20, + uint256, + uint256, + uint256 + ) external pure override returns (uint256) { + revert("Not implemented"); + } + + function proposePriceFor( + address, + bytes32, + uint32, + bytes memory, + Request memory, + address, + int256 + ) public pure override returns (uint256) { + revert("Not implemented"); + } + + function proposePrice( + address, + bytes32, + uint32, + bytes memory, + Request memory, + int256 + ) external pure override returns (uint256) { + revert("Not implemented"); + } + + function disputePrice( + address, + bytes32, + uint32, + bytes memory, + Request memory + ) external pure override returns (uint256) { + revert("Not implemented"); + } + + function settle( + address, + bytes32, + uint32, + bytes memory, + Request memory + ) external pure override returns (uint256, int256) { + revert("Not implemented"); + } + + function getState( + address, + bytes32, + uint32, + bytes memory, + Request memory + ) external pure override returns (OptimisticOracleInterface.State) { + revert("Not implemented"); + } + + function hasPrice(address, bytes32, uint32, bytes memory, Request memory) public pure override returns (bool) { + revert("Not implemented"); + } + + function stampAncillaryData(bytes memory, address) public pure override returns (bytes memory) { + revert("Not implemented"); + } } // ============ Fixture Data Struct ============ From 9e4dc2ef8255f2562a1f1de5005ea19144532c9b Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 11:18:49 -0700 Subject: [PATCH 09/24] Clean up HubPool Admin test Signed-off-by: Taylor Webb --- test/evm/foundry/local/HubPool_Admin.t.sol | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/evm/foundry/local/HubPool_Admin.t.sol b/test/evm/foundry/local/HubPool_Admin.t.sol index f8d9df092..b8d9ff360 100644 --- a/test/evm/foundry/local/HubPool_Admin.t.sol +++ b/test/evm/foundry/local/HubPool_Admin.t.sol @@ -23,13 +23,12 @@ contract HubPool_AdminTest is HubPoolTestBase { // ============ Constants ============ uint256 constant DESTINATION_CHAIN_ID = 1342; - uint32 constant REFUND_PROPOSAL_LIVENESS = 7200; - bytes32 constant DEFAULT_IDENTIFIER = bytes32("ACROSS-V2"); // ============ Setup ============ function setUp() public { // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + // Also sets liveness and identifier in the HubPool createHubPoolFixture(); // Create test addresses @@ -41,9 +40,6 @@ contract HubPool_AdminTest is HubPoolTestBase { // Set up cross-chain contracts for destination chain fixture.hubPool.setCrossChainContracts(DESTINATION_CHAIN_ID, address(mockAdapter), mockSpoke); - - // Set liveness - fixture.hubPool.setLiveness(REFUND_PROPOSAL_LIVENESS); } // ============ enableL1TokenForLiquidityProvision Tests ============ @@ -163,16 +159,22 @@ contract HubPool_AdminTest is HubPoolTestBase { // ============ setBond Tests ============ function test_SetBond() public { - // Default bond is WETH + // Default bond is WETH (bondAmount = BOND_AMOUNT + FINAL_FEE for WETH) assertEq(address(fixture.hubPool.bondToken()), address(fixture.weth), "Default bond token should be WETH"); - assertEq(fixture.hubPool.bondAmount(), BOND_AMOUNT, "Default bond amount mismatch"); + assertEq(fixture.hubPool.bondAmount(), BOND_AMOUNT + FINAL_FEE, "Default bond amount mismatch"); // Set bond to USDC with 1000 USDC + // Note: setBond adds the final fee to the bond amount uint256 newBondAmount = 1000e6; // 1000 USDC + uint256 usdcFinalFee = fixture.store.finalFees(address(fixture.usdc)); fixture.hubPool.setBond(IERC20(address(fixture.usdc)), newBondAmount); assertEq(address(fixture.hubPool.bondToken()), address(fixture.usdc), "Bond token should be USDC"); - assertEq(fixture.hubPool.bondAmount(), newBondAmount, "Bond amount should be 1000 USDC"); + assertEq( + fixture.hubPool.bondAmount(), + newBondAmount + usdcFinalFee, + "Bond amount should be 1001 USDC (1000 + finalFee)" + ); } function test_SetBond_RevertsIfZeroAmount() public { From 3dcba73c1c9f0672d94b0ab52005b472f0157c5a Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 11:21:30 -0700 Subject: [PATCH 10/24] add DisputeRootBundle test Signed-off-by: Taylor Webb --- .../local/HubPool_DisputeRootBundle.t.sol | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol diff --git a/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol new file mode 100644 index 000000000..f155895b7 --- /dev/null +++ b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { HubPoolTestBase, MockStore, MockOptimisticOracle } from "../utils/HubPoolTestBase.sol"; + +import { HubPool } from "../../../../contracts/HubPool.sol"; +import { OracleInterfaces } from "../../../../contracts/external/uma/core/contracts/data-verification-mechanism/implementation/Constants.sol"; +import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; + +/** + * @title HubPool_DisputeRootBundleTest + * @notice Foundry tests for HubPool.disputeRootBundle, ported from Hardhat tests. + * @dev Some tests that require full UMA ecosystem integration are simplified or skipped. + */ +contract HubPool_DisputeRootBundleTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + MockOptimisticOracle mockOptimisticOracle; + MockStore mockStore; + address dataWorker; + + // ============ Constants ============ + // Note: REFUND_PROPOSAL_LIVENESS, BOND_AMOUNT, FINAL_FEE, DEFAULT_IDENTIFIER + // are inherited from HubPoolTestBase + + uint256 constant AMOUNT_TO_LP = 1000 ether; + + bytes32 constant MOCK_POOL_REBALANCE_ROOT = bytes32(uint256(0xabc)); + bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + + // ============ Setup ============ + + function setUp() public { + createHubPoolFixture(); + + dataWorker = makeAddr("dataWorker"); + + // Deploy mocks with fees support + mockStore = new MockStore(); + mockOptimisticOracle = new MockOptimisticOracle(REFUND_PROPOSAL_LIVENESS * 10); + + // Update finder to use our mocks + fixture.finder.changeImplementationAddress(OracleInterfaces.Store, address(mockStore)); + fixture.finder.changeImplementationAddress( + OracleInterfaces.SkinnyOptimisticOracle, + address(mockOptimisticOracle) + ); + + // Set final fee for WETH + mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: FINAL_FEE })); + + // Note: liveness is already set by createHubPoolFixture() + + // Fund data worker with WETH for bonds + uint256 totalBond = BOND_AMOUNT + FINAL_FEE; + vm.deal(dataWorker, totalBond * 3); // Enough for multiple proposals/disputes + vm.startPrank(dataWorker); + fixture.weth.deposit{ value: totalBond * 2 }(); + fixture.weth.approve(address(fixture.hubPool), type(uint256).max); + vm.stopPrank(); + + // Add liquidity as LP - need more ETH first + vm.deal(address(this), AMOUNT_TO_LP + 10 ether); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + fixture.hubPool.addLiquidity{ value: AMOUNT_TO_LP }(address(fixture.weth), AMOUNT_TO_LP); + } + + // ============ Helper Functions ============ + + function _proposeRootBundle() internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](3); + bundleEvaluationBlockNumbers[0] = 1; + bundleEvaluationBlockNumbers[1] = 2; + bundleEvaluationBlockNumbers[2] = 3; + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 5, // poolRebalanceLeafCount + MOCK_POOL_REBALANCE_ROOT, + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + } + + // ============ Tests ============ + + function test_DisputeRootBundle_DeletesActiveProposal() public { + _proposeRootBundle(); + + // Increment time slightly to avoid weirdness + vm.warp(block.timestamp + 15); + + // Approve OO to spend disputer's bond + vm.startPrank(dataWorker); + fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); + fixture.hubPool.disputeRootBundle(); + vm.stopPrank(); + + // Verify proposal is cleared + ( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot, + uint256 claimedBitMap, + address proposer, + uint8 unclaimedPoolRebalanceLeafCount, + uint32 challengePeriodEndTimestamp + ) = fixture.hubPool.rootBundleProposal(); + + assertEq(poolRebalanceRoot, bytes32(0), "poolRebalanceRoot should be cleared"); + assertEq(relayerRefundRoot, bytes32(0), "relayerRefundRoot should be cleared"); + assertEq(slowRelayRoot, bytes32(0), "slowRelayRoot should be cleared"); + assertEq(claimedBitMap, 0, "claimedBitMap should be 0"); + assertEq(proposer, address(0), "proposer should be cleared"); + assertEq(unclaimedPoolRebalanceLeafCount, 0, "unclaimedPoolRebalanceLeafCount should be 0"); + assertEq(challengePeriodEndTimestamp, 0, "challengePeriodEndTimestamp should be 0"); + } + + function test_DisputeRootBundle_RevertsAfterLiveness() public { + _proposeRootBundle(); + + // Warp past liveness period + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + vm.prank(dataWorker); + vm.expectRevert("Request passed liveness"); + fixture.hubPool.disputeRootBundle(); + } + + function test_DisputeRootBundle_CancelsWhenFinalFeeEqualsBond() public { + // Set final fee equal to totalBond (bond + finalFee) + uint256 totalBond = BOND_AMOUNT + FINAL_FEE; + mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: totalBond })); + + _proposeRootBundle(); + + // Record balances before dispute + uint256 dataWorkerBalanceBefore = fixture.weth.balanceOf(dataWorker); + uint256 hubPoolBalanceBefore = fixture.weth.balanceOf(address(fixture.hubPool)); + + vm.prank(dataWorker); + fixture.hubPool.disputeRootBundle(); + + // When finalFee >= totalBond, the dispute is cancelled and proposer's bond is returned + // The disputer doesn't need to pay anything + uint256 dataWorkerBalanceAfter = fixture.weth.balanceOf(dataWorker); + uint256 hubPoolBalanceAfter = fixture.weth.balanceOf(address(fixture.hubPool)); + + // Proposer should get their bond back + assertEq( + dataWorkerBalanceAfter, + dataWorkerBalanceBefore + totalBond, + "Proposer should receive bond back on cancellation" + ); + assertEq(hubPoolBalanceAfter, hubPoolBalanceBefore - totalBond, "HubPool should release the bond"); + + // Verify proposal is cleared + (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); + assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared"); + } + + function test_DisputeRootBundle_WorksWithDecreasedFinalFee() public { + _proposeRootBundle(); + + // Decrease final fee to half + uint256 newFinalFee = FINAL_FEE / 2; + mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: newFinalFee })); + + // Approve OO to spend disputer's bond + vm.startPrank(dataWorker); + fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); + fixture.hubPool.disputeRootBundle(); + vm.stopPrank(); + + // Verify proposal is cleared + (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); + assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared"); + } + + function test_DisputeRootBundle_AnyoneCanDispute() public { + _proposeRootBundle(); + + address randomDisputer = makeAddr("randomDisputer"); + + // Fund disputer with WETH + vm.deal(randomDisputer, 10 ether); + vm.startPrank(randomDisputer); + fixture.weth.deposit{ value: BOND_AMOUNT + FINAL_FEE }(); + fixture.weth.approve(address(fixture.hubPool), type(uint256).max); + fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); + fixture.hubPool.disputeRootBundle(); + vm.stopPrank(); + + // Verify proposal is cleared + (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); + assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared by random disputer"); + } + + function test_DisputeRootBundle_RevertsIfNoProposal() public { + // No proposal has been made + // Attempting to dispute should fail because challengePeriodEndTimestamp is 0 + // and currentTime > 0 + vm.prank(dataWorker); + vm.expectRevert("Request passed liveness"); + fixture.hubPool.disputeRootBundle(); + } +} From b7a1686707cf7d0196efaf7a28b8480b453f6fe6 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 14:34:22 -0700 Subject: [PATCH 11/24] replicate SkinnyOptimisticOracle functionality Signed-off-by: Taylor Webb --- test/evm/foundry/utils/HubPoolTestBase.sol | 43 +++++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 88a2d2a7e..3c1a22179 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -114,18 +114,20 @@ contract MockStore { /** * @title MockOptimisticOracle - * @notice Minimal mock of UMA's SkinnyOptimisticOracle for testing dispute functionality. - * @dev This mock allows HubPool to complete the dispute flow without the full UMA ecosystem. + * @notice Mock of UMA's SkinnyOptimisticOracle matching the real implementation behavior. + * @dev This mock replicates the real oracle's behavior for testing dispute functionality. * Inherits from SkinnyOptimisticOracleInterface to ensure correct function signatures. */ contract MockOptimisticOracle is SkinnyOptimisticOracleInterface { uint256 public defaultLiveness; + MockStore public store; // Store requests by their ID (we use our own storage, not the interface's) mapping(bytes32 => Request) public requests; - constructor(uint256 _defaultLiveness) { + constructor(uint256 _defaultLiveness, MockStore _store) { defaultLiveness = _defaultLiveness; + store = _store; } // ============ Implemented Functions ============ @@ -143,8 +145,11 @@ contract MockOptimisticOracle is SkinnyOptimisticOracleInterface { ) external override returns (uint256 totalBond) { bytes32 requestId = keccak256(abi.encode(msg.sender, identifier, timestamp, ancillaryData)); - // Pull bond from caller - totalBond = bond; + // Get final fee from store (matching real oracle behavior) + uint256 finalFee = store.computeFinalFee(address(currency)).rawValue; + + // Pull bond + finalFee from caller (matching real oracle: bond + finalFee) + totalBond = bond + finalFee; currency.transferFrom(msg.sender, address(this), totalBond); requests[requestId] = Request({ @@ -156,7 +161,7 @@ contract MockOptimisticOracle is SkinnyOptimisticOracleInterface { resolvedPrice: 0, expirationTime: block.timestamp + customLiveness, reward: 0, - finalFee: 0, + finalFee: finalFee, bond: bond, customLiveness: customLiveness }); @@ -168,17 +173,34 @@ contract MockOptimisticOracle is SkinnyOptimisticOracleInterface { bytes32 identifier, uint32 timestamp, bytes memory ancillaryData, - Request memory /* request - ignored, we use our stored version */, + Request memory request, address disputer, address requester ) public override returns (uint256 totalBond) { bytes32 requestId = keccak256(abi.encode(requester, identifier, timestamp, ancillaryData)); Request storage storedRequest = requests[requestId]; - // Pull bond from disputer (use the bond from the stored request) - totalBond = storedRequest.bond; + // Pull full bondAmount from disputer (bond + finalFee from the Request parameter) + // This matches what HubPool approves: bondAmount = bond + finalFee + // Matching real oracle: totalBond = request.requestSettings.bond.add(request.finalFee) + totalBond = request.bond + request.finalFee; storedRequest.currency.transferFrom(msg.sender, address(this), totalBond); + // Compute burned bond: floor(bond / 2) + // Matching real oracle: _computeBurnedBond() + uint256 burnedBond = request.bond / 2; + + // The total fee is the burned bond and the final fee added together + // Matching real oracle: totalFee = request.finalFee.add(burnedBond) + uint256 totalFee = request.finalFee + burnedBond; + + // Send totalFee to store (matching real oracle behavior) + if (totalFee > 0) { + storedRequest.currency.transfer(address(store), totalFee); + store.payOracleFeesErc20(address(storedRequest.currency), totalFee); + } + + // Update stored request with disputer storedRequest.disputer = disputer; return totalBond; @@ -329,7 +351,8 @@ abstract contract HubPoolTestBase is Test, Constants { data.store = new MockStore(); // Deploy OptimisticOracle with liveness * 10 (matches UmaEcosystem.Fixture) - data.optimisticOracle = new MockOptimisticOracle(REFUND_PROPOSAL_LIVENESS * 10); + // Pass store reference so oracle can distribute final fees (matching real oracle) + data.optimisticOracle = new MockOptimisticOracle(REFUND_PROPOSAL_LIVENESS * 10, data.store); // Configure finder with UMA ecosystem addresses data.finder.changeImplementationAddress(OracleInterfaces.CollateralWhitelist, address(data.addressWhitelist)); From 1a72b864625e1533aa5762f5440a0e1ed5a27a54 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 14:34:45 -0700 Subject: [PATCH 12/24] fix all disputeRootBundle tests Signed-off-by: Taylor Webb --- .../local/HubPool_DisputeRootBundle.t.sol | 178 +++++++++++++----- 1 file changed, 126 insertions(+), 52 deletions(-) diff --git a/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol index f155895b7..4be9328f7 100644 --- a/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol +++ b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import { HubPoolTestBase, MockStore, MockOptimisticOracle } from "../utils/HubPoolTestBase.sol"; +import { HubPoolTestBase, MockStore } from "../utils/HubPoolTestBase.sol"; import { HubPool } from "../../../../contracts/HubPool.sol"; -import { OracleInterfaces } from "../../../../contracts/external/uma/core/contracts/data-verification-mechanism/implementation/Constants.sol"; import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; +import { SkinnyOptimisticOracleInterface } from "../../../../contracts/external/uma/core/contracts/optimistic-oracle-v2/interfaces/SkinnyOptimisticOracleInterface.sol"; /** * @title HubPool_DisputeRootBundleTest @@ -15,15 +15,12 @@ import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; contract HubPool_DisputeRootBundleTest is HubPoolTestBase { // ============ Test Infrastructure ============ - MockOptimisticOracle mockOptimisticOracle; - MockStore mockStore; address dataWorker; + address liquidityProvider; // ============ Constants ============ - // Note: REFUND_PROPOSAL_LIVENESS, BOND_AMOUNT, FINAL_FEE, DEFAULT_IDENTIFIER - // are inherited from HubPoolTestBase - uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant TOTAL_BOND = BOND_AMOUNT + FINAL_FEE; bytes32 constant MOCK_POOL_REBALANCE_ROOT = bytes32(uint256(0xabc)); bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); @@ -35,35 +32,25 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { createHubPoolFixture(); dataWorker = makeAddr("dataWorker"); - - // Deploy mocks with fees support - mockStore = new MockStore(); - mockOptimisticOracle = new MockOptimisticOracle(REFUND_PROPOSAL_LIVENESS * 10); - - // Update finder to use our mocks - fixture.finder.changeImplementationAddress(OracleInterfaces.Store, address(mockStore)); - fixture.finder.changeImplementationAddress( - OracleInterfaces.SkinnyOptimisticOracle, - address(mockOptimisticOracle) - ); - - // Set final fee for WETH - mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: FINAL_FEE })); - - // Note: liveness is already set by createHubPoolFixture() + liquidityProvider = makeAddr("liquidityProvider"); // Fund data worker with WETH for bonds - uint256 totalBond = BOND_AMOUNT + FINAL_FEE; - vm.deal(dataWorker, totalBond * 3); // Enough for multiple proposals/disputes + vm.deal(dataWorker, TOTAL_BOND * 3); // Enough for multiple proposals/disputes vm.startPrank(dataWorker); - fixture.weth.deposit{ value: totalBond * 2 }(); + fixture.weth.deposit{ value: TOTAL_BOND * 2 }(); fixture.weth.approve(address(fixture.hubPool), type(uint256).max); vm.stopPrank(); - // Add liquidity as LP - need more ETH first - vm.deal(address(this), AMOUNT_TO_LP + 10 ether); + // Enable token for LP (as owner, matching Hardhat test) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); - fixture.hubPool.addLiquidity{ value: AMOUNT_TO_LP }(address(fixture.weth), AMOUNT_TO_LP); + + // Fund liquidity provider and add liquidity (matching Hardhat test structure) + vm.deal(liquidityProvider, AMOUNT_TO_LP + 10 ether); + vm.startPrank(liquidityProvider); + fixture.weth.deposit{ value: AMOUNT_TO_LP }(); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + vm.stopPrank(); } // ============ Helper Functions ============ @@ -92,11 +79,9 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { // Increment time slightly to avoid weirdness vm.warp(block.timestamp + 15); - // Approve OO to spend disputer's bond - vm.startPrank(dataWorker); - fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); + // dataWorker already has approval to HubPool from setUp() + vm.prank(dataWorker); fixture.hubPool.disputeRootBundle(); - vm.stopPrank(); // Verify proposal is cleared ( @@ -116,6 +101,24 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { assertEq(proposer, address(0), "proposer should be cleared"); assertEq(unclaimedPoolRebalanceLeafCount, 0, "unclaimedPoolRebalanceLeafCount should be 0"); assertEq(challengePeriodEndTimestamp, 0, "challengePeriodEndTimestamp should be 0"); + + // Verify optimistic oracle was called correctly + // RequestId is computed as keccak256(abi.encode(requester, identifier, timestamp, ancillaryData)) + uint32 disputeTimestamp = uint32(block.timestamp); + bytes memory ancillaryData = ""; + bytes32 requestId = keccak256( + abi.encode(address(fixture.hubPool), DEFAULT_IDENTIFIER, disputeTimestamp, ancillaryData) + ); + + // Verify request exists in optimistic oracle + // Public mapping getter returns tuple of all struct fields + (address ooProposer, address disputer, , , int256 proposedPrice, , , , , uint256 bond, ) = fixture + .optimisticOracle + .requests(requestId); + + assertTrue(ooProposer != address(0), "Request should exist in optimistic oracle"); + assertEq(proposedPrice, int256(1e18), "Proposed price should be 1e18 (True)"); + assertEq(bond, TOTAL_BOND - FINAL_FEE, "Bond should be TOTAL_BOND - FINAL_FEE"); } function test_DisputeRootBundle_RevertsAfterLiveness() public { @@ -130,9 +133,8 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { } function test_DisputeRootBundle_CancelsWhenFinalFeeEqualsBond() public { - // Set final fee equal to totalBond (bond + finalFee) - uint256 totalBond = BOND_AMOUNT + FINAL_FEE; - mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: totalBond })); + // Set final fee equal to TOTAL_BOND (bond + finalFee) + fixture.store.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: TOTAL_BOND })); _proposeRootBundle(); @@ -143,7 +145,7 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { vm.prank(dataWorker); fixture.hubPool.disputeRootBundle(); - // When finalFee >= totalBond, the dispute is cancelled and proposer's bond is returned + // When finalFee >= TOTAL_BOND, the dispute is cancelled and proposer's bond is returned // The disputer doesn't need to pay anything uint256 dataWorkerBalanceAfter = fixture.weth.balanceOf(dataWorker); uint256 hubPoolBalanceAfter = fixture.weth.balanceOf(address(fixture.hubPool)); @@ -151,14 +153,29 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { // Proposer should get their bond back assertEq( dataWorkerBalanceAfter, - dataWorkerBalanceBefore + totalBond, + dataWorkerBalanceBefore + TOTAL_BOND, "Proposer should receive bond back on cancellation" ); - assertEq(hubPoolBalanceAfter, hubPoolBalanceBefore - totalBond, "HubPool should release the bond"); + assertEq(hubPoolBalanceAfter, hubPoolBalanceBefore - TOTAL_BOND, "HubPool should release the bond"); // Verify proposal is cleared - (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); - assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared"); + ( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot, + uint256 claimedBitMap, + address proposer, + uint8 unclaimedPoolRebalanceLeafCount, + uint32 challengePeriodEndTimestamp + ) = fixture.hubPool.rootBundleProposal(); + + assertEq(poolRebalanceRoot, bytes32(0), "poolRebalanceRoot should be cleared"); + assertEq(relayerRefundRoot, bytes32(0), "relayerRefundRoot should be cleared"); + assertEq(slowRelayRoot, bytes32(0), "slowRelayRoot should be cleared"); + assertEq(claimedBitMap, 0, "claimedBitMap should be 0"); + assertEq(proposer, address(0), "proposer should be cleared"); + assertEq(unclaimedPoolRebalanceLeafCount, 0, "unclaimedPoolRebalanceLeafCount should be 0"); + assertEq(challengePeriodEndTimestamp, 0, "challengePeriodEndTimestamp should be 0"); } function test_DisputeRootBundle_WorksWithDecreasedFinalFee() public { @@ -166,17 +183,60 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { // Decrease final fee to half uint256 newFinalFee = FINAL_FEE / 2; - mockStore.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: newFinalFee })); + uint256 newBond = TOTAL_BOND - newFinalFee; + fixture.store.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: newFinalFee })); - // Approve OO to spend disputer's bond - vm.startPrank(dataWorker); - fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); + // Record balances before dispute + uint256 dataWorkerBalanceBefore = fixture.weth.balanceOf(dataWorker); + uint256 hubPoolBalanceBefore = fixture.weth.balanceOf(address(fixture.hubPool)); + uint256 optimisticOracleBalanceBefore = fixture.weth.balanceOf(address(fixture.optimisticOracle)); + uint256 storeBalanceBefore = fixture.weth.balanceOf(address(fixture.store)); + + // dataWorker already has approval to HubPool from setUp() + vm.prank(dataWorker); fixture.hubPool.disputeRootBundle(); - vm.stopPrank(); + + // Verify token balance changes match expected values + // dataWorker: -TOTAL_BOND (pays the bond) + // hubPool: -TOTAL_BOND (releases the proposer's bond) + // optimisticOracle: +TOTAL_BOND + newBond/2 (receives bond + half of new bond) + // store: +newFinalFee + newBond/2 (receives final fee + half of new bond) + uint256 dataWorkerBalanceAfter = fixture.weth.balanceOf(dataWorker); + uint256 hubPoolBalanceAfter = fixture.weth.balanceOf(address(fixture.hubPool)); + uint256 optimisticOracleBalanceAfter = fixture.weth.balanceOf(address(fixture.optimisticOracle)); + uint256 storeBalanceAfter = fixture.weth.balanceOf(address(fixture.store)); + + assertEq(dataWorkerBalanceAfter, dataWorkerBalanceBefore - TOTAL_BOND, "dataWorker should pay TOTAL_BOND"); + assertEq(hubPoolBalanceAfter, hubPoolBalanceBefore - TOTAL_BOND, "hubPool should release TOTAL_BOND"); + assertEq( + optimisticOracleBalanceAfter, + optimisticOracleBalanceBefore + TOTAL_BOND + newBond / 2, + "optimisticOracle should receive TOTAL_BOND + newBond/2" + ); + assertEq( + storeBalanceAfter, + storeBalanceBefore + newFinalFee + newBond / 2, + "store should receive newFinalFee + newBond/2" + ); // Verify proposal is cleared - (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); - assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared"); + ( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot, + uint256 claimedBitMap, + address proposer, + uint8 unclaimedPoolRebalanceLeafCount, + uint32 challengePeriodEndTimestamp + ) = fixture.hubPool.rootBundleProposal(); + + assertEq(poolRebalanceRoot, bytes32(0), "poolRebalanceRoot should be cleared"); + assertEq(relayerRefundRoot, bytes32(0), "relayerRefundRoot should be cleared"); + assertEq(slowRelayRoot, bytes32(0), "slowRelayRoot should be cleared"); + assertEq(claimedBitMap, 0, "claimedBitMap should be 0"); + assertEq(proposer, address(0), "proposer should be cleared"); + assertEq(unclaimedPoolRebalanceLeafCount, 0, "unclaimedPoolRebalanceLeafCount should be 0"); + assertEq(challengePeriodEndTimestamp, 0, "challengePeriodEndTimestamp should be 0"); } function test_DisputeRootBundle_AnyoneCanDispute() public { @@ -187,15 +247,29 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { // Fund disputer with WETH vm.deal(randomDisputer, 10 ether); vm.startPrank(randomDisputer); - fixture.weth.deposit{ value: BOND_AMOUNT + FINAL_FEE }(); + fixture.weth.deposit{ value: TOTAL_BOND }(); fixture.weth.approve(address(fixture.hubPool), type(uint256).max); - fixture.weth.approve(address(mockOptimisticOracle), type(uint256).max); fixture.hubPool.disputeRootBundle(); vm.stopPrank(); // Verify proposal is cleared - (bytes32 poolRebalanceRoot, , , , , , ) = fixture.hubPool.rootBundleProposal(); - assertEq(poolRebalanceRoot, bytes32(0), "Proposal should be cleared by random disputer"); + ( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot, + uint256 claimedBitMap, + address proposer, + uint8 unclaimedPoolRebalanceLeafCount, + uint32 challengePeriodEndTimestamp + ) = fixture.hubPool.rootBundleProposal(); + + assertEq(poolRebalanceRoot, bytes32(0), "poolRebalanceRoot should be cleared"); + assertEq(relayerRefundRoot, bytes32(0), "relayerRefundRoot should be cleared"); + assertEq(slowRelayRoot, bytes32(0), "slowRelayRoot should be cleared"); + assertEq(claimedBitMap, 0, "claimedBitMap should be 0"); + assertEq(proposer, address(0), "proposer should be cleared"); + assertEq(unclaimedPoolRebalanceLeafCount, 0, "unclaimedPoolRebalanceLeafCount should be 0"); + assertEq(challengePeriodEndTimestamp, 0, "challengePeriodEndTimestamp should be 0"); } function test_DisputeRootBundle_RevertsIfNoProposal() public { From f52d94e47d6288e82586dccd80bc1b95b0f203fa Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 16:00:03 -0700 Subject: [PATCH 13/24] clean up ExecuteRootBundle tests Signed-off-by: Taylor Webb --- .../local/HubPool_ExecuteRootBundle.t.sol | 600 ++++++++++++++++++ 1 file changed, 600 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol diff --git a/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol b/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol new file mode 100644 index 000000000..51387ef0b --- /dev/null +++ b/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; + +import { Mock_Adapter, Mock_Bridge } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; +import { HubPool } from "../../../../contracts/HubPool.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; + +/** + * @title HubPool_ExecuteRootBundleTest + * @notice Foundry tests for HubPool.executeRootBundle, ported from Hardhat tests. + */ +contract HubPool_ExecuteRootBundleTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + Mock_Adapter mockAdapter; + address mockSpoke; + address dataWorker; + address liquidityProvider; + + // ============ Constants ============ + + uint256 constant REPAYMENT_CHAIN_ID = 777; + // REFUND_PROPOSAL_LIVENESS, BOND_AMOUNT, FINAL_FEE inherited from HubPoolTestBase + uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant WETH_TO_SEND = 100 ether; + uint256 constant DAI_TO_SEND = 1000 ether; + uint256 constant WETH_LP_FEE = 1 ether; + uint256 constant DAI_LP_FEE = 10 ether; + + bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + // Also sets liveness and identifier in the HubPool + createHubPoolFixture(); + + // Deploy Mock_Adapter and set up cross-chain contracts + mockAdapter = new Mock_Adapter(); + mockSpoke = makeAddr("mockSpoke"); + + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), mockSpoke); + + // Enable tokens and set pool rebalance routes + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.dai), fixture.l2Dai); + + // Enable WETH for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + // Enable DAI for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.dai)); + + // Create dataWorker and liquidityProvider accounts + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker wallet: DAI tokens and WETH (bondAmount + finalFee) * 2 + uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; + fixture.dai.mint(dataWorker, dataWorkerAmount); + vm.deal(dataWorker, dataWorkerAmount); + vm.prank(dataWorker); + fixture.weth.deposit{ value: dataWorkerAmount }(); + + // Seed liquidityProvider wallet: DAI tokens and WETH amountToLp * 10 + uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; + fixture.dai.mint(liquidityProvider, liquidityProviderAmount); + vm.deal(liquidityProvider, liquidityProviderAmount); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: liquidityProviderAmount }(); + + // Add liquidity for WETH from liquidityProvider + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Add liquidity for DAI from liquidityProvider + fixture.dai.mint(liquidityProvider, AMOUNT_TO_LP * 10); + vm.prank(liquidityProvider); + fixture.dai.approve(address(fixture.hubPool), AMOUNT_TO_LP * 10); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.dai), AMOUNT_TO_LP * 10); + + // Approve WETH for dataWorker (for bonding) + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), BOND_AMOUNT * 10); + } + + // ============ Helper Functions ============ + + /** + * @notice Constructs a simple 2-leaf merkle tree for testing. + * @dev Mirrors the constructSimpleTree function from Hardhat tests. + */ + function constructSimpleTree() + internal + view + returns (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) + { + leaves = new HubPoolInterface.PoolRebalanceLeaf[](2); + + // Leaf 0: Contains WETH and DAI, groupIndex = 0 (will relay root bundle) + { + uint256[] memory bundleLpFees = new uint256[](2); + bundleLpFees[0] = WETH_LP_FEE; + bundleLpFees[1] = DAI_LP_FEE; + + int256[] memory netSendAmounts = new int256[](2); + netSendAmounts[0] = int256(WETH_TO_SEND); + netSendAmounts[1] = int256(DAI_TO_SEND); + + int256[] memory runningBalances = new int256[](2); + runningBalances[0] = int256(WETH_TO_SEND); + runningBalances[1] = int256(DAI_TO_SEND); + + address[] memory l1Tokens = new address[](2); + l1Tokens[0] = address(fixture.weth); + l1Tokens[1] = address(fixture.dai); + + leaves[0] = HubPoolInterface.PoolRebalanceLeaf({ + chainId: REPAYMENT_CHAIN_ID, + groupIndex: 0, + bundleLpFees: bundleLpFees, + netSendAmounts: netSendAmounts, + runningBalances: runningBalances, + leafId: 0, + l1Tokens: l1Tokens + }); + } + + // Leaf 1: Empty leaf, groupIndex = 1 (will not relay root bundle) + { + leaves[1] = HubPoolInterface.PoolRebalanceLeaf({ + chainId: REPAYMENT_CHAIN_ID, + groupIndex: 1, + bundleLpFees: new uint256[](0), + netSendAmounts: new int256[](0), + runningBalances: new int256[](0), + leafId: 1, + l1Tokens: new address[](0) + }); + } + + // Build the merkle root from leaves + root = _buildMerkleRoot(leaves); + } + + /** + * @notice Builds a merkle root from pool rebalance leaves. + * @dev For 2 leaves: root = keccak256(leaf0Hash, leaf1Hash) + */ + function _buildMerkleRoot(HubPoolInterface.PoolRebalanceLeaf[] memory leaves) internal pure returns (bytes32) { + if (leaves.length == 1) { + return keccak256(abi.encode(leaves[0])); + } else if (leaves.length == 2) { + bytes32 leaf0Hash = keccak256(abi.encode(leaves[0])); + bytes32 leaf1Hash = keccak256(abi.encode(leaves[1])); + // Sort leaves for consistent ordering + if (leaf0Hash < leaf1Hash) { + return keccak256(abi.encodePacked(leaf0Hash, leaf1Hash)); + } else { + return keccak256(abi.encodePacked(leaf1Hash, leaf0Hash)); + } + } + revert("Only 1 or 2 leaves supported in test helper"); + } + + /** + * @notice Gets the merkle proof for a leaf at a given index. + */ + function _getMerkleProof( + HubPoolInterface.PoolRebalanceLeaf[] memory leaves, + uint256 index + ) internal pure returns (bytes32[] memory) { + if (leaves.length == 1) { + return new bytes32[](0); + } else if (leaves.length == 2) { + bytes32[] memory proof = new bytes32[](1); + uint256 siblingIndex = index == 0 ? 1 : 0; + proof[0] = keccak256(abi.encode(leaves[siblingIndex])); + return proof; + } + revert("Only 1 or 2 leaves supported in test helper"); + } + + /** + * @notice Proposes a root bundle with the given leaves. + */ + function _proposeRootBundle(bytes32 poolRebalanceRoot, uint8 leafCount) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](leafCount); + for (uint8 i = 0; i < leafCount; i++) { + bundleEvaluationBlockNumbers[i] = block.number + i; + } + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + leafCount, + poolRebalanceRoot, + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + } + + /** + * @notice Executes a leaf from the root bundle. + */ + function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + // ============ Tests ============ + + function test_ExecuteRootBundle_ProducesRelayCallsAndSendsTokens() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + + // Advance time past liveness + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // Record balances before execution + uint256 hubPoolWethBefore = fixture.weth.balanceOf(address(fixture.hubPool)); + uint256 hubPoolDaiBefore = fixture.dai.balanceOf(address(fixture.hubPool)); + + // Expect events for token relays + // Note: Mock_Adapter emits events when relayTokens/relayMessage are called + vm.expectEmit(address(fixture.hubPool)); + emit HubPool.RootBundleExecuted( + leaves[0].groupIndex, + leaves[0].leafId, + leaves[0].chainId, + leaves[0].l1Tokens, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + dataWorker + ); + + // Record logs to capture adapter events (since Mock_Adapter is delegatecalled, events emit from HubPool) + vm.recordLogs(); + + // Execute first leaf + bytes32[] memory proof = _getMerkleProof(leaves, 0); + _executeLeaf(leaves[0], proof); + + // Get recorded logs + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Check RelayMessageCalled events + // Event signature: RelayMessageCalled(address,bytes,address) + bytes32 relayMessageEventSig = keccak256("RelayMessageCalled(address,bytes,address)"); + uint256 relayMessageCount = 0; + address relayMessageTarget; + bytes memory relayMessageData; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == relayMessageEventSig && logs[i].emitter == address(fixture.hubPool)) { + relayMessageCount++; + // Decode event data: (address target, bytes message, address caller) + (relayMessageTarget, relayMessageData, ) = abi.decode(logs[i].data, (address, bytes, address)); + } + } + assertEq(relayMessageCount, 1, "Exactly one RelayMessageCalled event should be emitted"); + assertEq(relayMessageTarget, mockSpoke, "RelayMessage target should be mockSpoke"); + + // Expected message is the encoded relayRootBundle call + bytes memory expectedMessage = abi.encodeWithSignature( + "relayRootBundle(bytes32,bytes32)", + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + assertEq(relayMessageData, expectedMessage, "RelayMessage data should match relayRootBundle call"); + + // Check RelayTokensCalled events + // Event signature: RelayTokensCalled(address,address,uint256,address,address) + bytes32 relayTokensEventSig = keccak256("RelayTokensCalled(address,address,uint256,address,address)"); + uint256 relayTokensCount = 0; + address[] memory relayTokensL1Tokens = new address[](2); + address[] memory relayTokensL2Tokens = new address[](2); + uint256[] memory relayTokensAmounts = new uint256[](2); + address[] memory relayTokensTo = new address[](2); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == relayTokensEventSig && logs[i].emitter == address(fixture.hubPool)) { + // Decode event data: (address l1Token, address l2Token, uint256 amount, address to, address caller) + ( + relayTokensL1Tokens[relayTokensCount], + relayTokensL2Tokens[relayTokensCount], + relayTokensAmounts[relayTokensCount], + relayTokensTo[relayTokensCount], + + ) = abi.decode(logs[i].data, (address, address, uint256, address, address)); + relayTokensCount++; + } + } + assertEq(relayTokensCount, 2, "Exactly two RelayTokensCalled events should be emitted"); + + // First event should be WETH + assertEq(relayTokensL1Tokens[0], address(fixture.weth), "First RelayTokens l1Token should be WETH"); + assertEq(relayTokensL2Tokens[0], fixture.l2Weth, "First RelayTokens l2Token should be l2Weth"); + assertEq(relayTokensAmounts[0], WETH_TO_SEND, "First RelayTokens amount should be WETH_TO_SEND"); + assertEq(relayTokensTo[0], mockSpoke, "First RelayTokens to should be mockSpoke"); + + // Second event should be DAI + assertEq(relayTokensL1Tokens[1], address(fixture.dai), "Second RelayTokens l1Token should be DAI"); + assertEq(relayTokensL2Tokens[1], fixture.l2Dai, "Second RelayTokens l2Token should be l2Dai"); + assertEq(relayTokensAmounts[1], DAI_TO_SEND, "Second RelayTokens amount should be DAI_TO_SEND"); + assertEq(relayTokensTo[1], mockSpoke, "Second RelayTokens to should be mockSpoke"); + + // Verify tokens were sent to the mock bridge + Mock_Bridge bridge = mockAdapter.bridge(); + assertEq(fixture.weth.balanceOf(address(bridge)), WETH_TO_SEND, "Bridge should have received WETH"); + assertEq(fixture.dai.balanceOf(address(bridge)), DAI_TO_SEND, "Bridge should have received DAI"); + + // Verify HubPool balance decreased (note: bond is still held for unexecuted leaf) + assertEq( + fixture.weth.balanceOf(address(fixture.hubPool)), + hubPoolWethBefore - WETH_TO_SEND, + "HubPool WETH balance mismatch" + ); + assertEq( + fixture.dai.balanceOf(address(fixture.hubPool)), + hubPoolDaiBefore - DAI_TO_SEND, + "HubPool DAI balance mismatch" + ); + + // Verify leaf count decremented + (, , , , , uint8 unclaimedPoolRebalanceLeafCount, uint32 challengePeriodEndTimestamp) = fixture + .hubPool + .rootBundleProposal(); + assertEq(unclaimedPoolRebalanceLeafCount, 1, "Unclaimed leaf count should be 1"); + } + + function test_ExecuteRootBundle_TwoLeavesDoNotRelayRootTwice() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // Record logs to capture adapter events (since Mock_Adapter is delegatecalled, events emit from HubPool) + vm.recordLogs(); + + // Execute both leaves with same chain ID + _executeLeaf(leaves[0], _getMerkleProof(leaves, 0)); + _executeLeaf(leaves[1], _getMerkleProof(leaves, 1)); + + // Get recorded logs + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Check the mockAdapter was called with the correct arguments for each method. The event counts should be identical + // to the above test. + // Check RelayMessageCalled events + // Event signature: RelayMessageCalled(address,bytes,address) + bytes32 relayMessageEventSig = keccak256("RelayMessageCalled(address,bytes,address)"); + uint256 relayMessageCount = 0; + address relayMessageTarget; + bytes memory relayMessageData; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == relayMessageEventSig && logs[i].emitter == address(fixture.hubPool)) { + relayMessageCount++; + // Decode event data: (address target, bytes message, address caller) + (relayMessageTarget, relayMessageData, ) = abi.decode(logs[i].data, (address, bytes, address)); + } + } + assertEq(relayMessageCount, 1, "Exactly one message sent from L1->L2"); + + // Verify the event args match + assertEq(relayMessageTarget, mockSpoke, "RelayMessage target should be mockSpoke"); + + // Expected message is the encoded relayRootBundle call + bytes memory expectedMessage = abi.encodeWithSignature( + "relayRootBundle(bytes32,bytes32)", + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + assertEq(relayMessageData, expectedMessage, "RelayMessage data should match relayRootBundle call"); + + // Verify all leaves were executed + (, , , , , uint8 unclaimedPoolRebalanceLeafCount, uint32 challengePeriodEndTimestamp) = fixture + .hubPool + .rootBundleProposal(); + assertEq(unclaimedPoolRebalanceLeafCount, 0, "All leaves should be executed"); + } + + function test_ExecuteRootBundle_AllLeavesReturnsBond() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // Record balance before + uint256 bondAmount = fixture.hubPool.bondAmount(); + uint256 wethBefore = fixture.weth.balanceOf(dataWorker); + + // Execute first leaf (bond not returned yet) + _executeLeaf(leaves[0], _getMerkleProof(leaves, 0)); + assertEq(fixture.weth.balanceOf(dataWorker), wethBefore, "Bond should not be returned after first leaf"); + + // Execute second leaf (bond should be returned) + _executeLeaf(leaves[1], _getMerkleProof(leaves, 1)); + assertEq( + fixture.weth.balanceOf(dataWorker), + wethBefore + bondAmount, + "Bond should be returned after all leaves executed" + ); + } + + function test_ExecuteRootBundle_RevertsIfSpokePoolNotSet() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + + // Set spoke pool to zero address + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), address(0)); + + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + vm.expectRevert("SpokePool not initialized"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + } + + function test_ExecuteRootBundle_RevertsIfAdapterNotSet() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + + // Set adapter to a random (non-contract) address + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, makeAddr("random"), mockSpoke); + + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + vm.expectRevert("Adapter not initialized"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + } + + function test_ExecuteRootBundle_RevertsIfDestinationTokenIsZero() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + + // Set WETH pool rebalance route to zero address + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), address(0)); + + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + vm.expectRevert("Route not whitelisted"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + } + + function test_ExecuteRootBundle_RejectsBeforeLiveness() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + + // Warp to 10 seconds before liveness ends + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS - 10); + + vm.expectRevert("Not passed liveness"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + + // Warp past liveness - should work now + vm.warp(block.timestamp + 11); + _executeLeaf(leaves[0], _getMerkleProof(leaves, 0)); + } + + function test_ExecuteRootBundle_RejectsInvalidLeaves() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // Create a bad leaf with modified chainId + HubPoolInterface.PoolRebalanceLeaf memory badLeaf = leaves[0]; + badLeaf.chainId = 13371; + + vm.expectRevert("Bad Proof"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + badLeaf.chainId, + badLeaf.groupIndex, + badLeaf.bundleLpFees, + badLeaf.netSendAmounts, + badLeaf.runningBalances, + badLeaf.leafId, + badLeaf.l1Tokens, + _getMerkleProof(leaves, 0) + ); + } + + function test_ExecuteRootBundle_RejectsDoubleClaims() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // First claim should succeed + _executeLeaf(leaves[0], _getMerkleProof(leaves, 0)); + + // Second claim should fail + vm.expectRevert("Already claimed"); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + } + + function test_ExecuteRootBundle_CannotExecuteWhilePaused() public { + (HubPoolInterface.PoolRebalanceLeaf[] memory leaves, bytes32 root) = constructSimpleTree(); + + _proposeRootBundle(root, 2); + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + + // Pause the HubPool + fixture.hubPool.setPaused(true); + + vm.expectRevert(); + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaves[0].chainId, + leaves[0].groupIndex, + leaves[0].bundleLpFees, + leaves[0].netSendAmounts, + leaves[0].runningBalances, + leaves[0].leafId, + leaves[0].l1Tokens, + _getMerkleProof(leaves, 0) + ); + + // Unpause and verify execution works + fixture.hubPool.setPaused(false); + _executeLeaf(leaves[0], _getMerkleProof(leaves, 0)); + } + + // Allow contract to receive ETH + receive() external payable {} +} From 540c7e038cbe2857277f71d2fb0722e6288e5ad1 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 16:36:12 -0700 Subject: [PATCH 14/24] add HubPool LP test Signed-off-by: Taylor Webb --- .../local/HubPool_LiquidityProvision.t.sol | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_LiquidityProvision.t.sol diff --git a/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol new file mode 100644 index 000000000..f0845196a --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; + +/** + * @title HubPool_LiquidityProvisionTest + * @notice Foundry tests for HubPool liquidity provision, ported from Hardhat tests. + */ +contract HubPool_LiquidityProvisionTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address liquidityProvider; + address other; + + MintableERC20 wethLpToken; + MintableERC20 usdcLpToken; + MintableERC20 daiLpToken; + + // ============ Constants ============ + + uint256 constant AMOUNT_TO_SEED_WALLETS = 1500 ether; + uint256 constant AMOUNT_TO_LP = 1000 ether; + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + liquidityProvider = makeAddr("liquidityProvider"); + other = makeAddr("other"); + + // Enable tokens for LP (creates LP tokens) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.usdc)); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.dai)); + + // Get LP token addresses + (address wethLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + (address usdcLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.usdc)); + (address daiLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.dai)); + + wethLpToken = MintableERC20(wethLpTokenAddr); + usdcLpToken = MintableERC20(usdcLpTokenAddr); + daiLpToken = MintableERC20(daiLpTokenAddr); + + // Seed liquidity provider with tokens and ETH + fixture.usdc.mint(liquidityProvider, AMOUNT_TO_SEED_WALLETS); + fixture.dai.mint(liquidityProvider, AMOUNT_TO_SEED_WALLETS); + vm.deal(liquidityProvider, AMOUNT_TO_SEED_WALLETS); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: AMOUNT_TO_SEED_WALLETS }(); + } + + // ============ Tests ============ + + function test_AddingERC20Liquidity_CorrectlyPullsTokensAndMintsLPTokens() public { + // Balances of collateral before should equal the seed amount and there should be 0 outstanding LP tokens. + assertEq(fixture.dai.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS); + assertEq(daiLpToken.balanceOf(liquidityProvider), 0); + + vm.prank(liquidityProvider); + fixture.dai.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.dai), AMOUNT_TO_LP); + + // The balance of the collateral should be equal to the original amount minus the LPed amount. The balance of LP + // tokens should be equal to the amount of LP tokens divided by the exchange rate current. This rate starts at 1e18, + // so this should equal the amount minted. + assertEq(fixture.dai.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - AMOUNT_TO_LP); + assertEq(daiLpToken.balanceOf(liquidityProvider), AMOUNT_TO_LP); + assertEq(daiLpToken.totalSupply(), AMOUNT_TO_LP); + } + + function test_RemovingERC20Liquidity_BurnsLPTokensAndReturnsCollateral() public { + vm.prank(liquidityProvider); + fixture.dai.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.dai), AMOUNT_TO_LP); + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + daiLpToken.approve(address(fixture.hubPool), AMOUNT_TO_LP); + + // Next, try remove half the liquidity. This should modify the balances, as expected. + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.dai), AMOUNT_TO_LP / 2, false); + + assertEq(fixture.dai.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - AMOUNT_TO_LP / 2); + assertEq(daiLpToken.balanceOf(liquidityProvider), AMOUNT_TO_LP / 2); + assertEq(daiLpToken.totalSupply(), AMOUNT_TO_LP / 2); + + // Removing more than the total balance of LP tokens should throw. + vm.expectRevert(); + vm.prank(other); + fixture.hubPool.removeLiquidity(address(fixture.dai), AMOUNT_TO_LP, false); + + // Cant try receive ETH if the token is pool token is not WETH. + vm.expectRevert(); + vm.prank(other); + fixture.hubPool.removeLiquidity(address(fixture.dai), AMOUNT_TO_LP / 2, true); + + // Can remove the remaining LP tokens for a balance of 0. Use the same params as the above reverted call to show + // that only the sendEth param needs to be changed. + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.dai), AMOUNT_TO_LP / 2, false); + assertEq(fixture.dai.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS); // back to starting balance. + assertEq(daiLpToken.balanceOf(liquidityProvider), 0); // All LP tokens burnt. + assertEq(daiLpToken.totalSupply(), 0); + } + + function test_AddingETHLiquidity_CorrectlyWrapsToWETHAndMintsLPTokens() public { + // Depositor can send WETH, if they have. Explicitly set the value to 0 to ensure we dont send any eth with the tx. + uint256 initialWethTotalSupply = fixture.weth.totalSupply(); + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + assertEq(fixture.weth.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - AMOUNT_TO_LP); + assertEq(wethLpToken.balanceOf(liquidityProvider), AMOUNT_TO_LP); + + // Next, try depositing ETH with the transaction. No WETH should be sent. The ETH send with the TX should be + // wrapped for the user and LP tokens minted. Send the deposit and check the ether balance changes as expected. + uint256 wethEthBalanceBefore = address(fixture.weth).balance; + uint256 liquidityProviderEthBefore = liquidityProvider.balance; + + // Need to fund the liquidityProvider with ETH for the second deposit (they have 0 ETH since all was wrapped) + vm.deal(liquidityProvider, AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity{ value: AMOUNT_TO_LP }(address(fixture.weth), AMOUNT_TO_LP); + + // WETH's Ether balance should increase by the amount LPed. + assertEq(address(fixture.weth).balance, wethEthBalanceBefore + AMOUNT_TO_LP); + // The weth Token balance should have stayed the same as no weth was spent. + assertEq(fixture.weth.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - AMOUNT_TO_LP); + // However, the WETH LP token should have increase by the amount of LP tokens minted, as 2 x amountToLp. + assertEq(wethLpToken.balanceOf(liquidityProvider), AMOUNT_TO_LP * 2); + // Equally, the total WETH supply should have increased by the amount of LP tokens minted as they were deposited. + assertEq(wethLpToken.totalSupply(), AMOUNT_TO_LP * 2); + // WETH totalSupply should be initial supply + amount deposited in second addLiquidity (which wraps ETH to WETH) + assertEq(fixture.weth.totalSupply(), initialWethTotalSupply + AMOUNT_TO_LP); + } + + function test_RemovingETHLiquidity_CanSendBackWETHOrETHDependingOnUsersChoice() public { + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + wethLpToken.approve(address(fixture.hubPool), AMOUNT_TO_LP); + + // Remove half the liquidity as WETH (set sendETH = false). This should modify the weth bal and not the eth bal. + uint256 liquidityProviderEthBefore = liquidityProvider.balance; + uint256 liquidityProviderWethBefore = fixture.weth.balanceOf(liquidityProvider); + + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP / 2, false); + + assertEq(liquidityProvider.balance, liquidityProviderEthBefore); // ETH balance unchanged + assertEq(fixture.weth.balanceOf(liquidityProvider), liquidityProviderWethBefore + AMOUNT_TO_LP / 2); // WETH balance should increase by the amount removed. + + // Next, remove half the liquidity as ETH (set sendETH = true). This should modify the eth bal but not the weth bal. + liquidityProviderWethBefore = fixture.weth.balanceOf(liquidityProvider); + liquidityProviderEthBefore = liquidityProvider.balance; + + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP / 2, true); + + assertEq(liquidityProvider.balance, liquidityProviderEthBefore + AMOUNT_TO_LP / 2); // There should be ETH transferred, not WETH. + assertEq(fixture.weth.balanceOf(liquidityProvider), liquidityProviderWethBefore); // weth balance stayed the same. + + // There should be no LP tokens left outstanding: + assertEq(wethLpToken.balanceOf(liquidityProvider), 0); + } + + function test_AddingAndRemovingNon18DecimalCollateral_MintsCommensurateAmountOfLPTokens() public { + // USDC is 6 decimal places. Scale the amountToLp back to a normal number then up by 6 decimal places to get a 1e6 + // scaled number. i.e amountToLp is 1000 so this number will be 1e9. + // amountToLp = 1000 ether = 1000 * 1e18 = 1e21 + // fromWei(amountToLp) = 1000 + // scaledAmountToLp = 1000 * 1e6 = 1e9 + uint256 scaledAmountToLp = (AMOUNT_TO_LP / 1e12) * 1e6; // Convert from 18 decimals to 6 decimals + + vm.prank(liquidityProvider); + fixture.usdc.approve(address(fixture.hubPool), scaledAmountToLp); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.usdc), scaledAmountToLp); + + // Check the balances are correct. + assertEq(usdcLpToken.balanceOf(liquidityProvider), scaledAmountToLp); + assertEq(fixture.usdc.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - scaledAmountToLp); + assertEq(fixture.usdc.balanceOf(address(fixture.hubPool)), scaledAmountToLp); + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + usdcLpToken.approve(address(fixture.hubPool), scaledAmountToLp); + + // Redemption should work as normal, just scaled. + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.usdc), scaledAmountToLp / 2, false); + assertEq(usdcLpToken.balanceOf(liquidityProvider), scaledAmountToLp / 2); + assertEq(fixture.usdc.balanceOf(liquidityProvider), AMOUNT_TO_SEED_WALLETS - scaledAmountToLp / 2); + assertEq(fixture.usdc.balanceOf(address(fixture.hubPool)), scaledAmountToLp / 2); + } + + function test_Pause_DisablesAnyLiquidityAction() public { + fixture.hubPool.setPaused(true); + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.expectRevert("Contract is paused"); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + vm.expectRevert("Contract is paused"); + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP, false); + } +} From ea58ce6d4ae28b87dd04b4cee8c80fc95108e84e Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 17:10:47 -0700 Subject: [PATCH 15/24] Add liquidity provision fees test Signed-off-by: Taylor Webb --- .../HubPool_LiquidityProvisionFees.t.sol | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol diff --git a/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol new file mode 100644 index 000000000..4def89e2b --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { Mock_Adapter } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; + +/** + * @title HubPool_LiquidityProvisionFeesTest + * @notice Foundry tests for HubPool liquidity provision fees, ported from Hardhat tests. + */ +contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address dataWorker; + address liquidityProvider; + + MintableERC20 wethLpToken; + Mock_Adapter mockAdapter; + address mockSpoke; + + // ============ Constants ============ + + uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant REPAYMENT_CHAIN_ID = 777; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 ether; + + bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker with WETH (bondAmount + finalFee) * 2 + uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; + vm.deal(dataWorker, dataWorkerAmount); + vm.prank(dataWorker); + fixture.weth.deposit{ value: dataWorkerAmount }(); + + // Seed liquidityProvider with WETH amountToLp * 10 + uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; + vm.deal(liquidityProvider, liquidityProviderAmount); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: liquidityProviderAmount }(); + + // Enable WETH for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Get LP token address + (address wethLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + wethLpToken = MintableERC20(wethLpTokenAddr); + + // Add liquidity for WETH from liquidityProvider + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Approve WETH for dataWorker (for bonding) + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), BOND_AMOUNT * 10); + + // Deploy Mock_Adapter and set up cross-chain contracts + mockAdapter = new Mock_Adapter(); + mockSpoke = makeAddr("mockSpoke"); + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), mockSpoke); + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + } + + // ============ Helper Functions ============ + + /** + * @notice Constructs a single-chain tree for testing. + * @dev Mirrors the constructSingleChainTree function from Hardhat tests. + */ + function constructSingleChainTree() + internal + pure + returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) + { + (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( + REPAYMENT_CHAIN_ID, + address(0), // Will be set to WETH in tests + TOKENS_SEND_TO_L2, + REALIZED_LP_FEES + ); + } + + /** + * @notice Proposes a root bundle and warps past liveness period. + */ + function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 1, + poolRebalanceRoot, + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + + // Warp past liveness period + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + } + + /** + * @notice Executes a leaf from the root bundle. + */ + function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + // ============ Tests ============ + + function test_FeeTrackingVariables_AreCorrectlyUpdatedAtExecutionOfRefund() public { + // Before any execution happens liquidity trackers are set as expected. + ( + , + , + uint32 lastLpFeeUpdate, + int256 utilizedReserves, + uint256 liquidReserves, + uint256 undistributedLpFees + ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP); + assertEq(utilizedReserves, 0); + assertEq(undistributedLpFees, 0); + assertEq(lastLpFeeUpdate, block.timestamp); + + // Construct the tree with WETH + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // Validate the post execution values have updated as expected. Liquid reserves should be the original LPed amount + // minus the amount sent to L2. Utilized reserves should be the amount sent to L2 plus the attribute to LPs. + // Undistributed LP fees should be attribute to LPs. + (, , , utilizedReserves, liquidReserves, undistributedLpFees) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + // UtilizedReserves contains both the amount sent to L2 and the attributed LP fees. + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + assertEq(undistributedLpFees, REALIZED_LP_FEES); + } + + function test_ExchangeRateCurrent_CorrectlyAttributesFeesOverSmearPeriod() public { + // Construct the tree with WETH + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + // Exchange rate current before any fees are attributed execution should be 1. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // Exchange rate current right after the refund execution should be the amount deposited, grown by the 100 second + // liveness period. Of the 10 ETH attributed to LPs, a total of 10*0.0000015*7201=0.108015 was attributed to LPs. + // The exchange rate is therefore (1000+0.108015)/1000=1.000108015. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1000108015000000000); + + // Validate the state variables are updated accordingly. In particular, undistributedLpFees should have decremented + // by the amount allocated in the previous computation. This should be 10-0.108015=9.891985. + (, , , int256 utilizedReserves, uint256 liquidReserves, uint256 undistributedLpFees) = fixture + .hubPool + .pooledTokens(address(fixture.weth)); + assertEq(undistributedLpFees, 9891985000000000000); + + // Next, advance time 2 days. Compute the ETH attributed to LPs by multiplying the original amount allocated(10), + // minus the previous computation amount(0.108) by the smear rate, by the duration to get the second periods + // allocation of(10 - 0.108015) * 0.0000015 * (172800)=2.564002512.The exchange rate should be The sum of the + // liquidity provided and the fees added in both periods as (1000+0.108015+2.564002512)/1000=1.002672017512. + vm.warp(block.timestamp + 2 * 24 * 60 * 60); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1002672017512000000); + + // Again, we can validate that the undistributedLpFees have been updated accordingly. This should be set to the + // original amount (10) minus the two sets of attributed LP fees as 10-0.108015-2.564002512=7.327982488. + (, , , utilizedReserves, liquidReserves, undistributedLpFees) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + assertEq(undistributedLpFees, 7327982488000000000); + + // Finally, advance time past the end of the smear period by moving forward 10 days. At this point all LP fees + // should be attributed such that undistributedLpFees=0 and the exchange rate should simply be (1000+10)/1000=1.01. + vm.warp(block.timestamp + 10 * 24 * 60 * 60); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + (, , , utilizedReserves, liquidReserves, undistributedLpFees) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + assertEq(undistributedLpFees, 0); + } +} From 3fc337d4790d0e28ae1a87f7c73c8d86f80a7514 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Mon, 12 Jan 2026 19:22:23 -0700 Subject: [PATCH 16/24] LiquidityProvisionHaircut test WIP Signed-off-by: Taylor Webb --- .../HubPool_LiquidityProvisionHaircut.t.sol | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol diff --git a/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol new file mode 100644 index 000000000..27ba7455d --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { Mock_Adapter } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; + +/** + * @title HubPool_LiquidityProvisionHaircutTest + * @notice Foundry tests for HubPool liquidity provision haircut, ported from Hardhat tests. + */ +contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address dataWorker; + address liquidityProvider; + + MintableERC20 wethLpToken; + Mock_Adapter mockAdapter; + address mockSpoke; + + // ============ Constants ============ + + uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant REPAYMENT_CHAIN_ID = 777; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 ether; + + bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker with WETH (bondAmount + finalFee) * 2 + uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; + vm.deal(dataWorker, dataWorkerAmount); + vm.prank(dataWorker); + fixture.weth.deposit{ value: dataWorkerAmount }(); + + // Seed liquidityProvider with WETH amountToLp * 10 + uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; + vm.deal(liquidityProvider, liquidityProviderAmount); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: liquidityProviderAmount }(); + + // Enable WETH for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Get LP token address + (address wethLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + wethLpToken = MintableERC20(wethLpTokenAddr); + + // Add liquidity for WETH from liquidityProvider + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Approve WETH for dataWorker (for bonding) + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), BOND_AMOUNT * 10); + + // Deploy Mock_Adapter and set up cross-chain contracts + mockAdapter = new Mock_Adapter(); + mockSpoke = makeAddr("mockSpoke"); + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), mockSpoke); + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + } + + // ============ Helper Functions ============ + + /** + * @notice Constructs a single-chain tree for testing. + * @dev Mirrors the constructSingleChainTree function from Hardhat tests. + */ + function constructSingleChainTree() + internal + pure + returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) + { + (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( + REPAYMENT_CHAIN_ID, + address(0), // Will be set to WETH in tests + TOKENS_SEND_TO_L2, + REALIZED_LP_FEES + ); + } + + /** + * @notice Proposes a root bundle and warps past liveness period. + */ + function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 1, + poolRebalanceRoot, + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + + // Warp past liveness period + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + } + + /** + * @notice Executes a leaf from the root bundle. + */ + function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + // ============ Tests ============ + + function test_HaircutCanCorrectlyOffsetExchangeRateCurrentToEncapsulateLostTokens() public { + // Construct the tree with WETH + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // Exchange rate current right after the refund execution should be the amount deposited, grown by the 100 second + // liveness period. Of the 10 ETH attributed to LPs, a total of 10*0.0000015*7201=0.108015 was attributed to LPs. + // The exchange rate is therefore (1000+0.108015)/1000=1.000108015. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1000108015000000000); + + // At this point if all LP tokens are attempted to be redeemed at the provided exchange rate the call should fail + // as the hub pool is currently waiting for funds to come back over the canonical bridge. they are lent out. + // Approve LP tokens for burning (needed for burnFrom, even though the call will revert due to insufficient reserves) + vm.prank(liquidityProvider); + wethLpToken.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + vm.expectRevert(); + fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP, false); + + // Now, consider that the funds sent over the bridge (tokensSendToL2) are actually lost due to the L2 breaking. + // We now need to haircut the LPs be modifying the exchange rate current such that they get a commensurate + // redemption rate against the lost funds. + fixture.hubPool.haircutReserves(address(fixture.weth), int256(TOKENS_SEND_TO_L2)); + fixture.hubPool.sync(address(fixture.weth)); + + // The exchange rate current should now factor in the loss of funds and should now be less than 1. Taking the amount + // attributed to LPs in fees from the previous calculation and the 100 lost tokens, the exchangeRateCurrent should be: + // (1000+0.108015-100)/1000=0.900108015. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 900108015000000000); + + // Now, advance time such that all accumulated rewards are accumulated. + vm.warp(block.timestamp + 10 * 24 * 60 * 60); + fixture.hubPool.exchangeRateCurrent(address(fixture.weth)); // force state sync. + (, , , , , uint256 undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(undistributedLpFees, 0); + + // Exchange rate should now be the (LPAmount + fees - lostTokens) / LPTokenSupply = (1000+10-100)/1000=0.91 + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 910000000000000000); + } +} From 984ece58b12fbe077661cb87deebcc2ddc95cc07 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 10:07:55 -0700 Subject: [PATCH 17/24] add PooledTokenSync test Signed-off-by: Taylor Webb --- .../HubPool_PooledTokenSynchronization.t.sol | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol diff --git a/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol new file mode 100644 index 000000000..f8be5bbb8 --- /dev/null +++ b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { Mock_Adapter } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; + +/** + * @title HubPool_PooledTokenSynchronizationTest + * @notice Foundry tests for HubPool pooled token synchronization, ported from Hardhat tests. + */ +contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address dataWorker; + address liquidityProvider; + + MintableERC20 wethLpToken; + Mock_Adapter mockAdapter; + address mockSpoke; + + // ============ Constants ============ + + uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant REPAYMENT_CHAIN_ID = 3117; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 ether; + + bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + bytes32 constant MOCK_TREE_ROOT = bytes32(uint256(0xabcd)); + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker with WETH (bondAmount + finalFee) * 10 + extra for token transfers + // Need enough for bonds + large token transfers (500 ether for dropped tokens test) + uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 10 + 600 ether; + vm.deal(dataWorker, dataWorkerAmount); + vm.prank(dataWorker); + fixture.weth.deposit{ value: dataWorkerAmount }(); + + // Seed liquidityProvider with WETH amountToLp * 10 + uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; + vm.deal(liquidityProvider, liquidityProviderAmount); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: liquidityProviderAmount }(); + + // Enable WETH for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Get LP token address + (address wethLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + wethLpToken = MintableERC20(wethLpTokenAddr); + + // Add liquidity for WETH from liquidityProvider + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Approve WETH for dataWorker (for bonding) + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), BOND_AMOUNT * 10); + + // Deploy Mock_Adapter and set up cross-chain contracts + mockAdapter = new Mock_Adapter(); + mockSpoke = makeAddr("mockSpoke"); + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), mockSpoke); + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + } + + // ============ Helper Functions ============ + + /** + * @notice Constructs a single-chain tree for testing. + * @param scalingSize Scaling factor for amounts (default 1) + * @return leaf The pool rebalance leaf + * @return root The merkle root + */ + function constructSingleChainTree( + uint256 scalingSize + ) internal pure returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) { + uint256 tokensSendToL2 = TOKENS_SEND_TO_L2 * scalingSize; + uint256 realizedLpFees = REALIZED_LP_FEES * scalingSize; + + (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( + REPAYMENT_CHAIN_ID, + address(0), // Will be set to WETH in tests + tokensSendToL2, + realizedLpFees + ); + } + + /** + * @notice Proposes a root bundle and warps past liveness period. + */ + function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 1, + poolRebalanceRoot, + MOCK_RELAYER_REFUND_ROOT, + MOCK_SLOW_RELAY_ROOT + ); + + // Warp past liveness period + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + } + + /** + * @notice Executes a leaf from the root bundle. + */ + function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + /** + * @notice Forces a state sync by calling exchangeRateCurrent. + */ + function _forceSync(address token) internal { + fixture.hubPool.exchangeRateCurrent(token); + } + + // ============ Tests ============ + + function test_SyncUpdatesCountersCorrectlyThroughTheLifecycleOfARelay() public { + // Values start as expected. + (, , , int256 utilizedReserves, uint256 liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP); + assertEq(utilizedReserves, 0); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + + // Calling sync at this point should not change the counters. + _forceSync(address(fixture.weth)); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP); + assertEq(utilizedReserves, 0); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + + // Execute a relayer refund. Check counters move accordingly. + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(1); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + + // Bond being paid in should not impact liquid reserves. + _forceSync(address(fixture.weth)); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP); + + // Counters should move once the root bundle is executed. + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + + // Calling sync again does nothing. + _forceSync(address(fixture.weth)); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + + // Next, move time forward past the end of the 1 week L2 liveness, say 10 days. At this point all fees should also + // have been attributed to the LPs. The Exchange rate should update to (1000+10)/1000=1.01. Sync should still not + // change anything as no tokens have been sent directly to the contracts (yet). + vm.warp(block.timestamp + 10 * 24 * 60 * 60); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + _forceSync(address(fixture.weth)); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + + // Now, mimic the conclusion of the of the L2 -> l1 token transfer which pays back the LPs. The bundle of relays + // executed on L2 constituted a relayer repayment of 100 tokens. The LPs should now have received 100 tokens + the + // realizedLp fees of 10 tokens. i.e there should be a transfer of 110 tokens from L2->L1. This is represented by + // simply send the tokens to the hubPool. The sync method should correctly attribute this to the trackers + vm.prank(dataWorker); + fixture.weth.transfer(address(fixture.hubPool), TOKENS_SEND_TO_L2 + REALIZED_LP_FEES); + + _forceSync(address(fixture.weth)); + + // Liquid reserves should now be the sum of original LPed amount + the realized fees. This should equal the amount + // LPed minus the amount sent to L2, plus the amount sent back to L1 (they are equivalent). + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP + REALIZED_LP_FEES); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2 + TOKENS_SEND_TO_L2 + REALIZED_LP_FEES); + + // All funds have returned to L1. As a result, the utilizedReserves should now be 0. + assertEq(utilizedReserves, 0); + + // Finally, the exchangeRate should not have changed, even though the token balance of the contract has changed. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + } + + function test_TokenBalanceTrackersSyncCorrectlyWhenTokensAreDroppedOntoTheContract() public { + (, , , int256 utilizedReserves, uint256 liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP); + assertEq(utilizedReserves, 0); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + + uint256 amountToSend = 10 ether; + vm.prank(dataWorker); + fixture.weth.transfer(address(fixture.hubPool), amountToSend); + + // The token balances should now sync correctly. Liquid reserves should capture the new funds sent to the hubPool + // and the utilizedReserves should be negative in size equal to the tokens dropped onto the contract. + _forceSync(address(fixture.weth)); + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP + amountToSend); + assertEq(utilizedReserves, -int256(amountToSend)); + // Importantly the exchange rate should not have changed. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + } + + function test_LiquidityUtilizationCorrectlyTracksTheUtilizationOfLiquidity() public { + // Liquidity utilization starts off at 0 before any actions are done. + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + + // Execute a relayer refund. Check counters move accordingly. + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(1); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + + // Liquidity is not used until the relayerRefund is executed(i.e "pending" reserves are not considered). + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // Now that the liquidity is used (sent to L2) we should be able to find the utilization. This should simply be + // the utilizedReserves / (liquidReserves + utilizedReserves) = 110 / (900 + 110) = 0.108910891089108910 + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 108910891089108910); + + // Advance time such that all LP fees have been paid out. Liquidity utilization should not have changed. + vm.warp(block.timestamp + 10 * 24 * 60 * 60); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 108910891089108910); + _forceSync(address(fixture.weth)); + (, , , int256 utilizedReserves, uint256 liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + + // Now say that the LPs remove half their liquidity(withdraw 500 LP tokens). Removing half the LP tokens should send + // back 500*1.01=505 tokens to the liquidity provider. Validate that the expected tokens move. + uint256 amountToWithdraw = 500 ether; + uint256 tokensReturnedForWithdrawnLpTokens = (amountToWithdraw * 1010000000000000000) / 1e18; + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + wethLpToken.approve(address(fixture.hubPool), amountToWithdraw); + + uint256 balanceBefore = fixture.weth.balanceOf(liquidityProvider); + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), amountToWithdraw, false); + uint256 balanceAfter = fixture.weth.balanceOf(liquidityProvider); + assertEq(balanceAfter - balanceBefore, tokensReturnedForWithdrawnLpTokens); + + // Pool trackers should update accordingly. + _forceSync(address(fixture.weth)); + // Liquid reserves should now be the original LPed amount, minus that sent to l2, minus the fees removed from the + // pool due to redeeming the LP tokens as 1000-100-500*1.01=395. Utilized reserves should not change. + (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2 - tokensReturnedForWithdrawnLpTokens); + assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); + + // The associated liquidity utilization should be utilizedReserves / (liquidReserves + utilizedReserves) as + // (110) / (395 + 110) = 0.217821782178217821 + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 217821782178217821); + + // Now, mint tokens to mimic the finalization of the relay. The utilization should go back to 0. + vm.prank(dataWorker); + fixture.weth.transfer(address(fixture.hubPool), TOKENS_SEND_TO_L2 + REALIZED_LP_FEES); + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + } + + function test_LiquidityUtilizationIsAlwaysFlooredAt0EvenIfTokensAreDroppedOntoTheContract() public { + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + vm.prank(dataWorker); + fixture.weth.transfer(address(fixture.hubPool), 500 ether); + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + + // Seeing tokens were gifted onto the contract in size greater than the actual utilized reserves utilized reserves is + // floored to 0. The utilization equation is therefore relayedAmount / liquidReserves. For a relay of 100 units, + // the utilization should therefore be 100 / 1500 = 0.06666666666666667. + assertEq(fixture.hubPool.liquidityUtilizationPostRelay(address(fixture.weth), 100 ether), 66666666666666666); + + // A larger relay of 600 should be 600/ 1500 = 0.4 + assertEq(fixture.hubPool.liquidityUtilizationPostRelay(address(fixture.weth), 600 ether), 400000000000000000); + } + + function test_LiquidityUtilizationPostRelayCorrectlyComputesExpectedUtilizationForAGivenRelaySize() public { + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + assertEq(fixture.hubPool.liquidityUtilizationPostRelay(address(fixture.weth), 0), 0); + + // A relay of 100 Tokens should result in a liquidity utilization of 100 / (900 + 100) = 0.1. + assertEq(fixture.hubPool.liquidityUtilizationPostRelay(address(fixture.weth), 100 ether), 100000000000000000); + + // Execute a relay refund bundle to increase the liquidity utilization. + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(1); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + + // Liquidity is not used until the relayerRefund is executed(i.e "pending" reserves are not considered). + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // Now that the liquidity is used (sent to L2) we should be able to find the utilization. This should simply be + // the utilizedReserves / (liquidReserves + utilizedReserves) = 110 / (900 + 110) = 0.108910891089108910 + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 108910891089108910); + } + + function test_HighLiquidityUtilizationBlocksLPsFromWithdrawing() public { + // Execute a relayer refund bundle. Set the scalingSize to 5. This will use 500 ETH from the hubPool. + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(5); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + + // Liquidity utilization should now be (550) / (500 + 550) = 0.523809523809523809. I.e utilization is over 50%. + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 523809523809523809); + + // Now, trying to withdraw 51% of the liquidity in an LP position should revert. + vm.prank(liquidityProvider); + vm.expectRevert(); + fixture.hubPool.removeLiquidity(address(fixture.weth), 501 ether, false); + + // Can remove exactly at the 50% mark, removing all free liquidity. + uint256 currentExchangeRate = fixture.hubPool.exchangeRateCurrent(address(fixture.weth)); + assertEq(currentExchangeRate, 1050000000000000000); + // Calculate the absolute maximum LP tokens that can be redeemed as the 500 tokens that we know are liquid in the + // contract (we used 500 in the relayer refund) divided by the exchange rate. Add one wei as this operation will + // round down. We can check that this redemption amount will return exactly 500 tokens. + uint256 maxRedeemableLpTokens = (500 ether * 1e18) / currentExchangeRate + 1; + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + wethLpToken.approve(address(fixture.hubPool), maxRedeemableLpTokens); + + uint256 balanceBefore = fixture.weth.balanceOf(liquidityProvider); + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), maxRedeemableLpTokens, false); // redeem + uint256 balanceAfter = fixture.weth.balanceOf(liquidityProvider); + assertEq(balanceAfter - balanceBefore, 500 ether); // should send back exactly 500 tokens. + + // After this, the liquidity utilization should be exactly 100% with 0 tokens left in the contract. + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 1e18); + assertEq(fixture.weth.balanceOf(address(fixture.hubPool)), 0); + + // Trying to remove even 1 wei should fail. + vm.prank(liquidityProvider); + vm.expectRevert(); + fixture.hubPool.removeLiquidity(address(fixture.weth), 1, false); + } + + function test_RedeemingAllLPTokensAfterAccruingFeesIsHandledCorrectly() public { + HubPoolInterface.PoolRebalanceLeaf memory leaf; + bytes32 root; + (leaf, root) = constructSingleChainTree(1); + leaf.l1Tokens[0] = address(fixture.weth); + + // Recalculate root with correct token address + root = keccak256(abi.encode(leaf)); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + + // Send back to L1 the tokensSendToL2 + realizedLpFees, i.e to mimic the finalization of the relay. + vm.prank(dataWorker); + fixture.weth.transfer(address(fixture.hubPool), TOKENS_SEND_TO_L2 + REALIZED_LP_FEES); + + // Exchange rate should be 1.01 (accumulated 10 WETH on 1000 WETH worth of liquidity). Utilization should be 0. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); + + // Approve LP tokens for burning + vm.prank(liquidityProvider); + wethLpToken.approve(address(fixture.hubPool), AMOUNT_TO_LP); + + // Now, trying to all liquidity. + vm.prank(liquidityProvider); + fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP, false); + + // Exchange rate is now set to 1.0 as all fees have been withdrawn. + // Note: When LP token supply is 0, exchangeRateCurrent returns early without updating lastLpFeeUpdate + // So we skip the state-modifying call here and just verify the state + _forceSync(address(fixture.weth)); + (, , , int256 utilizedReserves, uint256 liquidReserves, uint256 undistributedLpFees) = fixture + .hubPool + .pooledTokens(address(fixture.weth)); + assertEq(liquidReserves, 0); + assertEq(utilizedReserves, 0); + assertEq(undistributedLpFees, 0); + + // Now, mint LP tokens again. The exchange rate should be re-set to 0 and have no memory of the previous deposits. + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Exchange rate should be 1.0 as all fees have been withdrawn. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + + // Going through a full refund lifecycle does returns to where we were before, with no memory of previous fees. + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + // Note: Use getCurrentTime() instead of block.timestamp because block.timestamp may not reflect + // the warp that happened inside _proposeRootBundle due to Foundry's handling of vm.warp in internal calls. + vm.warp(fixture.hubPool.getCurrentTime() + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + + // Exchange rate should be 1.01, with 1% accumulated on the back of refunds with no memory of the previous fees. + // Note: We don't send tokens back here (unlike first cycle) because we're testing that fees accumulate + // even when tokens are still on L2. The exchange rate should reflect accumulated fees. + // After warping 10 days, _updateAccumulatedLpFees should move all fees from undistributedLpFees, + // so exchange rate = (liquidReserves + utilizedReserves - 0) / lpTokenSupply = (900 + 110) / 1000 = 1.01 + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1010000000000000000); + } +} From d6893325124fe2f5c487c8222edbaec46485a9c5 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 12:57:22 -0700 Subject: [PATCH 18/24] add missing assertion Signed-off-by: Taylor Webb --- .../evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol index f8be5bbb8..64d431af7 100644 --- a/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol +++ b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol @@ -431,8 +431,7 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP, false); // Exchange rate is now set to 1.0 as all fees have been withdrawn. - // Note: When LP token supply is 0, exchangeRateCurrent returns early without updating lastLpFeeUpdate - // So we skip the state-modifying call here and just verify the state + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); _forceSync(address(fixture.weth)); (, , , int256 utilizedReserves, uint256 liquidReserves, uint256 undistributedLpFees) = fixture .hubPool From 74fbf8bd7f6c8338b55db7df1e9c3daecd1bbe4d Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 13:45:54 -0700 Subject: [PATCH 19/24] add ProposeRootBundle test Signed-off-by: Taylor Webb --- .../local/HubPool_ProposeRootBundle.t.sol | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_ProposeRootBundle.t.sol diff --git a/test/evm/foundry/local/HubPool_ProposeRootBundle.t.sol b/test/evm/foundry/local/HubPool_ProposeRootBundle.t.sol new file mode 100644 index 000000000..8139ef6b9 --- /dev/null +++ b/test/evm/foundry/local/HubPool_ProposeRootBundle.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { HubPool } from "../../../../contracts/HubPool.sol"; + +/** + * @title HubPool_ProposeRootBundleTest + * @notice Foundry tests for HubPool root bundle proposal, ported from Hardhat tests. + */ +contract HubPool_ProposeRootBundleTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address dataWorker; + + // ============ Constants ============ + + uint256[] mockBundleEvaluationBlockNumbers; + uint8 constant MOCK_POOL_REBALANCE_LEAF_COUNT = 5; + bytes32 mockPoolRebalanceRoot; + bytes32 mockRelayerRefundRoot; + bytes32 mockSlowRelayRoot; + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + dataWorker = makeAddr("dataWorker"); + + // Set up mock bundle data + mockBundleEvaluationBlockNumbers = new uint256[](3); + mockBundleEvaluationBlockNumbers[0] = 1; + mockBundleEvaluationBlockNumbers[1] = 2; + mockBundleEvaluationBlockNumbers[2] = 3; + + mockPoolRebalanceRoot = keccak256("poolRebalanceRoot"); + mockRelayerRefundRoot = keccak256("relayerRefundRoot"); + mockSlowRelayRoot = keccak256("slowRelayRoot"); + + // Seed dataWorker with WETH for totalBond (bondAmount + finalFee) + uint256 totalBond = BOND_AMOUNT + FINAL_FEE; + vm.deal(dataWorker, totalBond); + vm.prank(dataWorker); + fixture.weth.deposit{ value: totalBond }(); + } + + // ============ Tests ============ + + function test_ProposalOfRootBundleCorrectlyStoresDataEmitsEventsAndPullsTheBond() public { + uint256 totalBond = BOND_AMOUNT + FINAL_FEE; + uint32 expectedChallengePeriodEndTimestamp = uint32(block.timestamp + REFUND_PROPOSAL_LIVENESS); + + // Approve bond for HubPool + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), totalBond); + + uint256 dataWorkerWethBalanceBefore = fixture.weth.balanceOf(dataWorker); + + // Propose root bundle + vm.expectEmit(true, true, true, true); + emit HubPool.ProposeRootBundle( + expectedChallengePeriodEndTimestamp, + MOCK_POOL_REBALANCE_LEAF_COUNT, + mockBundleEvaluationBlockNumbers, + mockPoolRebalanceRoot, + mockRelayerRefundRoot, + mockSlowRelayRoot, + dataWorker + ); + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + mockBundleEvaluationBlockNumbers, + MOCK_POOL_REBALANCE_LEAF_COUNT, + mockPoolRebalanceRoot, + mockRelayerRefundRoot, + mockSlowRelayRoot + ); + + // Balances of the hubPool should have incremented by the bond and the dataWorker should have decremented by the bond. + assertEq(fixture.weth.balanceOf(address(fixture.hubPool)), totalBond); + assertEq(fixture.weth.balanceOf(dataWorker), dataWorkerWethBalanceBefore - totalBond); + + // Check root bundle proposal data + ( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot, + uint256 claimedBitMap, + address proposer, + uint8 unclaimedPoolRebalanceLeafCount, + uint32 challengePeriodEndTimestamp + ) = fixture.hubPool.rootBundleProposal(); + + assertEq(challengePeriodEndTimestamp, expectedChallengePeriodEndTimestamp); + assertEq(unclaimedPoolRebalanceLeafCount, MOCK_POOL_REBALANCE_LEAF_COUNT); + assertEq(poolRebalanceRoot, mockPoolRebalanceRoot); + assertEq(relayerRefundRoot, mockRelayerRefundRoot); + assertEq(slowRelayRoot, mockSlowRelayRoot); + assertEq(claimedBitMap, 0); // no claims yet so everything should be marked at 0. + assertEq(proposer, dataWorker); + + // Can not re-initialize if the previous bundle has unclaimed leaves. + // Need to re-approve since first proposal consumed the approval + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), totalBond); + + // Seed dataWorker with more WETH for second proposal attempt + vm.deal(dataWorker, totalBond); + vm.prank(dataWorker); + fixture.weth.deposit{ value: totalBond }(); + + vm.prank(dataWorker); + vm.expectRevert("Proposal has unclaimed leaves"); + fixture.hubPool.proposeRootBundle( + mockBundleEvaluationBlockNumbers, + MOCK_POOL_REBALANCE_LEAF_COUNT, + mockPoolRebalanceRoot, + mockRelayerRefundRoot, + mockSlowRelayRoot + ); + } + + function test_CannotProposeWhilePaused() public { + uint256 totalBond = BOND_AMOUNT + FINAL_FEE; + + // Seed owner with WETH for bond + vm.deal(owner, totalBond); + fixture.weth.deposit{ value: totalBond }(); + fixture.weth.approve(address(fixture.hubPool), totalBond); + + // Pause the contract + fixture.hubPool.setPaused(true); + + // Try to propose - should revert + uint256[] memory blockNumbers = new uint256[](3); + blockNumbers[0] = 1; + blockNumbers[1] = 2; + blockNumbers[2] = 3; + bytes32 mockTreeRoot = keccak256("mockTreeRoot"); + + vm.expectRevert("Contract is paused"); + fixture.hubPool.proposeRootBundle(blockNumbers, 5, mockTreeRoot, mockTreeRoot, mockSlowRelayRoot); + } +} From 0ec73dfa7bad27a92ff36876e0f57dce2d2fcd32 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 15:19:16 -0700 Subject: [PATCH 20/24] Add HubPool ProtocolFees test Signed-off-by: Taylor Webb --- .../foundry/local/HubPool_ProtocolFees.t.sol | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 test/evm/foundry/local/HubPool_ProtocolFees.t.sol diff --git a/test/evm/foundry/local/HubPool_ProtocolFees.t.sol b/test/evm/foundry/local/HubPool_ProtocolFees.t.sol new file mode 100644 index 000000000..e6f1732c5 --- /dev/null +++ b/test/evm/foundry/local/HubPool_ProtocolFees.t.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { Test } from "forge-std/Test.sol"; +import { HubPoolTestBase } from "../utils/HubPoolTestBase.sol"; +import { MerkleTreeUtils } from "../utils/MerkleTreeUtils.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; +import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { Mock_Adapter } from "../../../../contracts/chain-adapters/Mock_Adapter.sol"; + +/** + * @title HubPool_ProtocolFeesTest + * @notice Foundry tests for HubPool protocol fees, ported from Hardhat tests. + */ +contract HubPool_ProtocolFeesTest is HubPoolTestBase { + // ============ Test Infrastructure ============ + + address owner; + address dataWorker; + address liquidityProvider; + + MintableERC20 wethLpToken; + Mock_Adapter mockAdapter; + address mockSpoke; + + // ============ Constants ============ + + uint256 constant AMOUNT_TO_LP = 1000 ether; + uint256 constant REPAYMENT_CHAIN_ID = 3117; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 ether; + + bytes32 constant MOCK_TREE_ROOT = bytes32(uint256(0xabcd)); + + uint256 constant INITIAL_PROTOCOL_FEE_CAPTURE_PCT = 0.1 ether; // 10% + + // ============ Setup ============ + + function setUp() public { + // Create base fixture (deploys HubPool, WETH, tokens, UMA mocks) + createHubPoolFixture(); + + // Create test accounts + owner = address(this); // Test contract is owner + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker with WETH for bonds + uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; + vm.deal(dataWorker, dataWorkerAmount); + vm.prank(dataWorker); + fixture.weth.deposit{ value: dataWorkerAmount }(); + + // Seed liquidityProvider with WETH + uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; + vm.deal(liquidityProvider, liquidityProviderAmount); + vm.prank(liquidityProvider); + fixture.weth.deposit{ value: liquidityProviderAmount }(); + + // Enable WETH for LP (creates LP token) + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + + // Get LP token address + (address wethLpTokenAddr, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth)); + wethLpToken = MintableERC20(wethLpTokenAddr); + + // Add liquidity for WETH from liquidityProvider + vm.prank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + vm.prank(liquidityProvider); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + + // Approve WETH for dataWorker (for bonding) + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), BOND_AMOUNT * 10); + + // Deploy Mock_Adapter and set up cross-chain contracts + mockAdapter = new Mock_Adapter(); + mockSpoke = makeAddr("mockSpoke"); + fixture.hubPool.setCrossChainContracts(REPAYMENT_CHAIN_ID, address(mockAdapter), mockSpoke); + fixture.hubPool.setPoolRebalanceRoute(REPAYMENT_CHAIN_ID, address(fixture.weth), fixture.l2Weth); + + // Set initial protocol fee capture + fixture.hubPool.setProtocolFeeCapture(owner, INITIAL_PROTOCOL_FEE_CAPTURE_PCT); + } + + // ============ Helper Functions ============ + + /** + * @notice Constructs a single-chain tree for testing. + * @return leaf The pool rebalance leaf + * @return root The merkle root + */ + function constructSingleChainTree() + internal + view + returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) + { + (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( + REPAYMENT_CHAIN_ID, + address(fixture.weth), + TOKENS_SEND_TO_L2, + REALIZED_LP_FEES + ); + } + + /** + * @notice Proposes a root bundle and warps past liveness period. + */ + function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { + uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); + bundleEvaluationBlockNumbers[0] = block.number; + + vm.prank(dataWorker); + fixture.hubPool.proposeRootBundle( + bundleEvaluationBlockNumbers, + 1, + poolRebalanceRoot, + MOCK_TREE_ROOT, + MOCK_TREE_ROOT + ); + + // Warp past liveness period + vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); + } + + /** + * @notice Executes a leaf from the root bundle. + */ + function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + vm.prank(dataWorker); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + // ============ Tests ============ + + function test_OnlyOwnerCanSetProtocolFeeCapture() public { + vm.prank(liquidityProvider); + vm.expectRevert("Ownable: caller is not the owner"); + fixture.hubPool.setProtocolFeeCapture(liquidityProvider, 0.1 ether); + } + + function test_CanChangeProtocolFeeCaptureSettings() public { + assertEq(fixture.hubPool.protocolFeeCaptureAddress(), owner); + assertEq(fixture.hubPool.protocolFeeCapturePct(), INITIAL_PROTOCOL_FEE_CAPTURE_PCT); + + uint256 newPct = 0.1 ether; + + // Can't set to 0 address + vm.expectRevert("Bad protocolFeeCaptureAddress"); + fixture.hubPool.setProtocolFeeCapture(address(0), newPct); + + fixture.hubPool.setProtocolFeeCapture(liquidityProvider, newPct); + assertEq(fixture.hubPool.protocolFeeCaptureAddress(), liquidityProvider); + assertEq(fixture.hubPool.protocolFeeCapturePct(), newPct); + } + + function test_WhenFeeCaptureNotZeroFeesCorrectlyAttributeBetweenLPsAndProtocol() public { + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = constructSingleChainTree(); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + // 90% of the fees should be attributed to the LPs. + (, , , , , uint256 undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); + uint256 expectedLpFees = (REALIZED_LP_FEES * (1 ether - INITIAL_PROTOCOL_FEE_CAPTURE_PCT)) / 1 ether; + assertEq(undistributedLpFees, expectedLpFees); + + // 10% of the fees should be attributed to the protocol. + uint256 expectedProtocolFees = (REALIZED_LP_FEES * INITIAL_PROTOCOL_FEE_CAPTURE_PCT) / 1 ether; + assertEq(fixture.hubPool.unclaimedAccumulatedProtocolFees(address(fixture.weth)), expectedProtocolFees); + + // Protocol should be able to claim their fees. + uint256 ownerBalanceBefore = fixture.weth.balanceOf(owner); + fixture.hubPool.claimProtocolFeesCaptured(address(fixture.weth)); + uint256 ownerBalanceAfter = fixture.weth.balanceOf(owner); + assertEq(ownerBalanceAfter - ownerBalanceBefore, expectedProtocolFees); + + // After claiming, the protocol fees should be zero. + assertEq(fixture.hubPool.unclaimedAccumulatedProtocolFees(address(fixture.weth)), 0); + + // Once all the fees have been attributed the correct amount should be claimable by the LPs. + vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + fixture.hubPool.exchangeRateCurrent(address(fixture.weth)); // force state sync. + (, , , , , undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(undistributedLpFees, 0); + } + + function test_WhenFeeCaptureZeroAllFeesAccumulateToLPs() public { + fixture.hubPool.setProtocolFeeCapture(owner, 0); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = constructSingleChainTree(); + + _proposeRootBundle(root); + _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + + (, , , , , uint256 undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(undistributedLpFees, REALIZED_LP_FEES); + + vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. + fixture.hubPool.exchangeRateCurrent(address(fixture.weth)); // force state sync. + (, , , , , undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); + assertEq(undistributedLpFees, 0); + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1.01 ether); + } +} From c6503e187cf2821318965ec76fd510eed9af2352 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 15:52:31 -0700 Subject: [PATCH 21/24] make HubPoolStore tests more thorough Signed-off-by: Taylor Webb --- test/evm/foundry/local/HubPoolStore.t.sol | 250 +++++++++++++++++++++- 1 file changed, 242 insertions(+), 8 deletions(-) diff --git a/test/evm/foundry/local/HubPoolStore.t.sol b/test/evm/foundry/local/HubPoolStore.t.sol index 1d536aeb7..ed12c90bb 100644 --- a/test/evm/foundry/local/HubPoolStore.t.sol +++ b/test/evm/foundry/local/HubPoolStore.t.sol @@ -2,28 +2,262 @@ pragma solidity ^0.8.0; import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; import { HubPoolStore } from "../../../../contracts/chain-adapters/Universal_Adapter.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; + +/// @dev Simple mock that implements only what HubPoolStore needs from HubPool +contract MockHubPoolForStore { + HubPoolInterface.RootBundle public rootBundleProposal; + + function setPendingRootBundle(HubPoolInterface.RootBundle memory _rootBundle) external { + rootBundleProposal = _rootBundle; + } +} contract HubPoolStoreTest is Test { HubPoolStore store; - - address hubPool; + MockHubPoolForStore hubPool; bytes message = abi.encode("message"); address target = makeAddr("target"); + event StoredCallData(address indexed target, bytes data, uint256 indexed nonce); + function setUp() public { - hubPool = vm.addr(1); - store = new HubPoolStore(hubPool); + hubPool = new MockHubPoolForStore(); + store = new HubPoolStore(address(hubPool)); + } + + // ============ Constructor Tests ============ + + function testConstructor() public view { + assertEq(store.hubPool(), address(hubPool)); + } + + // ============ Access Control Tests ============ + + function testOnlyHubPoolCanStore() public { + vm.expectRevert(HubPoolStore.NotHubPool.selector); + store.storeRelayMessageCalldata(target, message, true); + } + + function testOnlyHubPoolCanStore_arbitraryAddress() public { + vm.prank(makeAddr("randomCaller")); + vm.expectRevert(HubPoolStore.NotHubPool.selector); + store.storeRelayMessageCalldata(target, message, true); } - function testStoreRelayMessageCalldata() public { - // Only hub pool can call this function. - vm.expectRevert(); + // ============ Admin Message Tests (isAdminSender = true) ============ + + function testStoreAdminMessage() public { + vm.prank(address(hubPool)); + store.storeRelayMessageCalldata(target, message, true); + + // First admin message should use nonce 0 + assertEq(store.relayMessageCallData(0), keccak256(abi.encode(target, message))); + } + + function testStoreAdminMessage_emitsEvent() public { + vm.prank(address(hubPool)); + vm.expectEmit(true, true, true, true); + emit StoredCallData(target, message, 0); store.storeRelayMessageCalldata(target, message, true); + } + + function testStoreAdminMessage_incrementsNonce() public { + vm.startPrank(address(hubPool)); - vm.prank(hubPool); + // First call uses nonce 0 store.storeRelayMessageCalldata(target, message, true); assertEq(store.relayMessageCallData(0), keccak256(abi.encode(target, message))); + + // Second call uses nonce 1 + bytes memory message2 = abi.encode("message2"); + store.storeRelayMessageCalldata(target, message2, true); + assertEq(store.relayMessageCallData(1), keccak256(abi.encode(target, message2))); + + // Third call uses nonce 2 + address target2 = makeAddr("target2"); + store.storeRelayMessageCalldata(target2, message, true); + assertEq(store.relayMessageCallData(2), keccak256(abi.encode(target2, message))); + + vm.stopPrank(); + } + + function testStoreAdminMessage_includesTargetInHash() public { + vm.startPrank(address(hubPool)); + + // Same message but different targets should produce different hashes + address target1 = makeAddr("target1"); + address target2 = makeAddr("target2"); + + store.storeRelayMessageCalldata(target1, message, true); + store.storeRelayMessageCalldata(target2, message, true); + + bytes32 hash1 = store.relayMessageCallData(0); + bytes32 hash2 = store.relayMessageCallData(1); + + // Verify the hashes are different + assertTrue(hash1 != hash2); + + // Verify each hash matches expected + assertEq(hash1, keccak256(abi.encode(target1, message))); + assertEq(hash2, keccak256(abi.encode(target2, message))); + + vm.stopPrank(); + } + + // ============ Non-Admin Message Tests (isAdminSender = false) ============ + + function testStoreNonAdminMessage() public { + uint32 challengePeriodTimestamp = uint32(block.timestamp); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: challengePeriodTimestamp, + poolRebalanceRoot: bytes32("poolRoot"), + relayerRefundRoot: bytes32("refundRoot"), + slowRelayRoot: bytes32("slowRoot"), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + vm.prank(address(hubPool)); + store.storeRelayMessageCalldata(target, message, false); + + // Non-admin message uses challengePeriodEndTimestamp as nonce + // Target is overwritten to address(0) in the hash + assertEq(store.relayMessageCallData(challengePeriodTimestamp), keccak256(abi.encode(address(0), message))); + } + + function testStoreNonAdminMessage_emitsEvent() public { + uint32 challengePeriodTimestamp = uint32(block.timestamp); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: challengePeriodTimestamp, + poolRebalanceRoot: bytes32(0), + relayerRefundRoot: bytes32(0), + slowRelayRoot: bytes32(0), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + vm.prank(address(hubPool)); + // Event should have address(0) as target for non-admin messages + vm.expectEmit(true, true, true, true); + emit StoredCallData(address(0), message, challengePeriodTimestamp); + store.storeRelayMessageCalldata(target, message, false); + } + + function testStoreNonAdminMessage_duplicateDoesNotOverwrite() public { + uint32 challengePeriodTimestamp = uint32(block.timestamp); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: challengePeriodTimestamp, + poolRebalanceRoot: bytes32(0), + relayerRefundRoot: bytes32(0), + slowRelayRoot: bytes32(0), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + vm.startPrank(address(hubPool)); + + // First call stores data + vm.recordLogs(); + store.storeRelayMessageCalldata(target, message, false); + + // Second call with same challengePeriodTimestamp should NOT emit event + // because data is already stored + store.storeRelayMessageCalldata(target, message, false); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + // Only one StoredCallData event should be emitted + uint256 storedCallDataCount = 0; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("StoredCallData(address,bytes,uint256)")) { + storedCallDataCount++; + } + } + assertEq(storedCallDataCount, 1); + + // Data should still be the original + assertEq(store.relayMessageCallData(challengePeriodTimestamp), keccak256(abi.encode(address(0), message))); + + vm.stopPrank(); + } + + function testStoreNonAdminMessage_differentTimestampCreatesNewEntry() public { + uint32 timestamp1 = uint32(block.timestamp); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: timestamp1, + poolRebalanceRoot: bytes32(0), + relayerRefundRoot: bytes32(0), + slowRelayRoot: bytes32(0), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + vm.startPrank(address(hubPool)); + + // Store first message + store.storeRelayMessageCalldata(target, message, false); + assertEq(store.relayMessageCallData(timestamp1), keccak256(abi.encode(address(0), message))); + + // Update to new timestamp + uint32 timestamp2 = timestamp1 + 1; + vm.warp(timestamp2); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: timestamp2, + poolRebalanceRoot: bytes32(0), + relayerRefundRoot: bytes32(0), + slowRelayRoot: bytes32(0), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + // Store second message with different data + bytes memory message2 = abi.encode("different message"); + store.storeRelayMessageCalldata(target, message2, false); + + // Both entries should exist + assertEq(store.relayMessageCallData(timestamp1), keccak256(abi.encode(address(0), message))); + assertEq(store.relayMessageCallData(timestamp2), keccak256(abi.encode(address(0), message2))); + + vm.stopPrank(); + } + + function testStoreNonAdminMessage_targetIgnoredInHash() public { + uint32 challengePeriodTimestamp = uint32(block.timestamp); + hubPool.setPendingRootBundle( + HubPoolInterface.RootBundle({ + challengePeriodEndTimestamp: challengePeriodTimestamp, + poolRebalanceRoot: bytes32(0), + relayerRefundRoot: bytes32(0), + slowRelayRoot: bytes32(0), + claimedBitMap: 0, + proposer: address(0), + unclaimedPoolRebalanceLeafCount: 0 + }) + ); + + vm.prank(address(hubPool)); + // Even though we pass a target, it should be replaced with address(0) + store.storeRelayMessageCalldata(makeAddr("someTarget"), message, false); + + // Verify the hash uses address(0) not the passed target + assertEq(store.relayMessageCallData(challengePeriodTimestamp), keccak256(abi.encode(address(0), message))); } } From b17f2b897d331838f86253c3380483270499a70a Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Wed, 14 Jan 2026 20:43:51 -0700 Subject: [PATCH 22/24] consolidate repeated functions and constants into HubPoolTestBase Signed-off-by: Taylor Webb --- .../local/HubPool_DisputeRootBundle.t.sol | 17 +--- .../local/HubPool_ExecuteRootBundle.t.sol | 19 +--- .../local/HubPool_LiquidityProvision.t.sol | 5 +- .../HubPool_LiquidityProvisionFees.t.sol | 75 +++------------ .../HubPool_LiquidityProvisionHaircut.t.sol | 58 ++---------- .../HubPool_PooledTokenSynchronization.t.sol | 92 +++++-------------- .../foundry/local/HubPool_ProtocolFees.t.sol | 60 ++---------- test/evm/foundry/utils/HubPoolTestBase.sol | 70 ++++++++++++++ 8 files changed, 132 insertions(+), 264 deletions(-) diff --git a/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol index 4be9328f7..ecc5ddd4e 100644 --- a/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol +++ b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol @@ -18,14 +18,6 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { address dataWorker; address liquidityProvider; - // ============ Constants ============ - uint256 constant AMOUNT_TO_LP = 1000 ether; - uint256 constant TOTAL_BOND = BOND_AMOUNT + FINAL_FEE; - - bytes32 constant MOCK_POOL_REBALANCE_ROOT = bytes32(uint256(0xabc)); - bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); - bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); - // ============ Setup ============ function setUp() public { @@ -35,19 +27,16 @@ contract HubPool_DisputeRootBundleTest is HubPoolTestBase { liquidityProvider = makeAddr("liquidityProvider"); // Fund data worker with WETH for bonds - vm.deal(dataWorker, TOTAL_BOND * 3); // Enough for multiple proposals/disputes - vm.startPrank(dataWorker); - fixture.weth.deposit{ value: TOTAL_BOND * 2 }(); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + vm.prank(dataWorker); fixture.weth.approve(address(fixture.hubPool), type(uint256).max); - vm.stopPrank(); // Enable token for LP (as owner, matching Hardhat test) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); // Fund liquidity provider and add liquidity (matching Hardhat test structure) - vm.deal(liquidityProvider, AMOUNT_TO_LP + 10 ether); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP); vm.startPrank(liquidityProvider); - fixture.weth.deposit{ value: AMOUNT_TO_LP }(); fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); vm.stopPrank(); diff --git a/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol b/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol index 51387ef0b..80fd99a4c 100644 --- a/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol +++ b/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol @@ -26,16 +26,11 @@ contract HubPool_ExecuteRootBundleTest is HubPoolTestBase { // ============ Constants ============ uint256 constant REPAYMENT_CHAIN_ID = 777; - // REFUND_PROPOSAL_LIVENESS, BOND_AMOUNT, FINAL_FEE inherited from HubPoolTestBase - uint256 constant AMOUNT_TO_LP = 1000 ether; uint256 constant WETH_TO_SEND = 100 ether; uint256 constant DAI_TO_SEND = 1000 ether; uint256 constant WETH_LP_FEE = 1 ether; uint256 constant DAI_LP_FEE = 10 ether; - bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); - bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); - // ============ Setup ============ function setUp() public { @@ -63,18 +58,12 @@ contract HubPool_ExecuteRootBundleTest is HubPoolTestBase { liquidityProvider = makeAddr("liquidityProvider"); // Seed dataWorker wallet: DAI tokens and WETH (bondAmount + finalFee) * 2 - uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; - fixture.dai.mint(dataWorker, dataWorkerAmount); - vm.deal(dataWorker, dataWorkerAmount); - vm.prank(dataWorker); - fixture.weth.deposit{ value: dataWorkerAmount }(); + fixture.dai.mint(dataWorker, TOTAL_BOND * 2); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); // Seed liquidityProvider wallet: DAI tokens and WETH amountToLp * 10 - uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; - fixture.dai.mint(liquidityProvider, liquidityProviderAmount); - vm.deal(liquidityProvider, liquidityProviderAmount); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: liquidityProviderAmount }(); + fixture.dai.mint(liquidityProvider, AMOUNT_TO_LP * 10); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); // Add liquidity for WETH from liquidityProvider vm.prank(liquidityProvider); diff --git a/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol index f0845196a..a3ef21b9d 100644 --- a/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol +++ b/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol @@ -24,7 +24,6 @@ contract HubPool_LiquidityProvisionTest is HubPoolTestBase { // ============ Constants ============ uint256 constant AMOUNT_TO_SEED_WALLETS = 1500 ether; - uint256 constant AMOUNT_TO_LP = 1000 ether; // ============ Setup ============ @@ -54,9 +53,7 @@ contract HubPool_LiquidityProvisionTest is HubPoolTestBase { // Seed liquidity provider with tokens and ETH fixture.usdc.mint(liquidityProvider, AMOUNT_TO_SEED_WALLETS); fixture.dai.mint(liquidityProvider, AMOUNT_TO_SEED_WALLETS); - vm.deal(liquidityProvider, AMOUNT_TO_SEED_WALLETS); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: AMOUNT_TO_SEED_WALLETS }(); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_SEED_WALLETS); } // ============ Tests ============ diff --git a/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol index 4def89e2b..e1f1fec9e 100644 --- a/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol @@ -25,13 +25,9 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { // ============ Constants ============ - uint256 constant AMOUNT_TO_LP = 1000 ether; uint256 constant REPAYMENT_CHAIN_ID = 777; - uint256 constant TOKENS_SEND_TO_L2 = 100 ether; - uint256 constant REALIZED_LP_FEES = 10 ether; - - bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); - bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + uint256 constant TOKENS_TO_SEND = 100 ether; + uint256 constant LP_FEES = 10 ether; // ============ Setup ============ @@ -45,16 +41,10 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { liquidityProvider = makeAddr("liquidityProvider"); // Seed dataWorker with WETH (bondAmount + finalFee) * 2 - uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; - vm.deal(dataWorker, dataWorkerAmount); - vm.prank(dataWorker); - fixture.weth.deposit{ value: dataWorkerAmount }(); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); // Seed liquidityProvider with WETH amountToLp * 10 - uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; - vm.deal(liquidityProvider, liquidityProviderAmount); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: liquidityProviderAmount }(); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); // Enable WETH for LP (creates LP token) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); @@ -94,45 +84,8 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( REPAYMENT_CHAIN_ID, address(0), // Will be set to WETH in tests - TOKENS_SEND_TO_L2, - REALIZED_LP_FEES - ); - } - - /** - * @notice Proposes a root bundle and warps past liveness period. - */ - function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { - uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); - bundleEvaluationBlockNumbers[0] = block.number; - - vm.prank(dataWorker); - fixture.hubPool.proposeRootBundle( - bundleEvaluationBlockNumbers, - 1, - poolRebalanceRoot, - MOCK_RELAYER_REFUND_ROOT, - MOCK_SLOW_RELAY_ROOT - ); - - // Warp past liveness period - vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); - } - - /** - * @notice Executes a leaf from the root bundle. - */ - function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { - vm.prank(dataWorker); - fixture.hubPool.executeRootBundle( - leaf.chainId, - leaf.groupIndex, - leaf.bundleLpFees, - leaf.netSendAmounts, - leaf.runningBalances, - leaf.leafId, - leaf.l1Tokens, - proof + TOKENS_TO_SEND, + LP_FEES ); } @@ -162,8 +115,9 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Validate the post execution values have updated as expected. Liquid reserves should be the original LPed amount // minus the amount sent to L2. Utilized reserves should be the amount sent to L2 plus the attribute to LPs. @@ -171,10 +125,10 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { (, , , utilizedReserves, liquidReserves, undistributedLpFees) = fixture.hubPool.pooledTokens( address(fixture.weth) ); - assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); + assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_TO_SEND); // UtilizedReserves contains both the amount sent to L2 and the attributed LP fees. - assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); - assertEq(undistributedLpFees, REALIZED_LP_FEES); + assertEq(utilizedReserves, int256(TOKENS_TO_SEND + LP_FEES)); + assertEq(undistributedLpFees, LP_FEES); } function test_ExchangeRateCurrent_CorrectlyAttributesFeesOverSmearPeriod() public { @@ -190,8 +144,9 @@ contract HubPool_LiquidityProvisionFeesTest is HubPoolTestBase { // Exchange rate current before any fees are attributed execution should be 1. assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Exchange rate current right after the refund execution should be the amount deposited, grown by the 100 second // liveness period. Of the 10 ETH attributed to LPs, a total of 10*0.0000015*7201=0.108015 was attributed to LPs. diff --git a/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol b/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol index 27ba7455d..5bc0c3bb3 100644 --- a/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol @@ -25,14 +25,10 @@ contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { // ============ Constants ============ - uint256 constant AMOUNT_TO_LP = 1000 ether; uint256 constant REPAYMENT_CHAIN_ID = 777; uint256 constant TOKENS_SEND_TO_L2 = 100 ether; uint256 constant REALIZED_LP_FEES = 10 ether; - bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); - bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); - // ============ Setup ============ function setUp() public { @@ -45,16 +41,10 @@ contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { liquidityProvider = makeAddr("liquidityProvider"); // Seed dataWorker with WETH (bondAmount + finalFee) * 2 - uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; - vm.deal(dataWorker, dataWorkerAmount); - vm.prank(dataWorker); - fixture.weth.deposit{ value: dataWorkerAmount }(); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); // Seed liquidityProvider with WETH amountToLp * 10 - uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; - vm.deal(liquidityProvider, liquidityProviderAmount); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: liquidityProviderAmount }(); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); // Enable WETH for LP (creates LP token) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); @@ -99,43 +89,6 @@ contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { ); } - /** - * @notice Proposes a root bundle and warps past liveness period. - */ - function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { - uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); - bundleEvaluationBlockNumbers[0] = block.number; - - vm.prank(dataWorker); - fixture.hubPool.proposeRootBundle( - bundleEvaluationBlockNumbers, - 1, - poolRebalanceRoot, - MOCK_RELAYER_REFUND_ROOT, - MOCK_SLOW_RELAY_ROOT - ); - - // Warp past liveness period - vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); - } - - /** - * @notice Executes a leaf from the root bundle. - */ - function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { - vm.prank(dataWorker); - fixture.hubPool.executeRootBundle( - leaf.chainId, - leaf.groupIndex, - leaf.bundleLpFees, - leaf.netSendAmounts, - leaf.runningBalances, - leaf.leafId, - leaf.l1Tokens, - proof - ); - } - // ============ Tests ============ function test_HaircutCanCorrectlyOffsetExchangeRateCurrentToEncapsulateLostTokens() public { @@ -148,8 +101,9 @@ contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Exchange rate current right after the refund execution should be the amount deposited, grown by the 100 second // liveness period. Of the 10 ETH attributed to LPs, a total of 10*0.0000015*7201=0.108015 was attributed to LPs. @@ -165,7 +119,7 @@ contract HubPool_LiquidityProvisionHaircutTest is HubPoolTestBase { vm.expectRevert(); fixture.hubPool.removeLiquidity(address(fixture.weth), AMOUNT_TO_LP, false); - // Now, consider that the funds sent over the bridge (tokensSendToL2) are actually lost due to the L2 breaking. + // Now, consider that the funds sent over the bridge (TOKENS_SEND_TO_L2) are actually lost due to the L2 breaking. // We now need to haircut the LPs be modifying the exchange rate current such that they get a commensurate // redemption rate against the lost funds. fixture.hubPool.haircutReserves(address(fixture.weth), int256(TOKENS_SEND_TO_L2)); diff --git a/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol index 64d431af7..fd518f64d 100644 --- a/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol +++ b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol @@ -25,15 +25,10 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // ============ Constants ============ - uint256 constant AMOUNT_TO_LP = 1000 ether; uint256 constant REPAYMENT_CHAIN_ID = 3117; uint256 constant TOKENS_SEND_TO_L2 = 100 ether; uint256 constant REALIZED_LP_FEES = 10 ether; - bytes32 constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); - bytes32 constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); - bytes32 constant MOCK_TREE_ROOT = bytes32(uint256(0xabcd)); - // ============ Setup ============ function setUp() public { @@ -47,16 +42,10 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Seed dataWorker with WETH (bondAmount + finalFee) * 10 + extra for token transfers // Need enough for bonds + large token transfers (500 ether for dropped tokens test) - uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 10 + 600 ether; - vm.deal(dataWorker, dataWorkerAmount); - vm.prank(dataWorker); - fixture.weth.deposit{ value: dataWorkerAmount }(); + seedUserWithWeth(dataWorker, TOTAL_BOND * 10 + 600 ether); // Seed liquidityProvider with WETH amountToLp * 10 - uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; - vm.deal(liquidityProvider, liquidityProviderAmount); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: liquidityProviderAmount }(); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); // Enable WETH for LP (creates LP token) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); @@ -93,51 +82,14 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { function constructSingleChainTree( uint256 scalingSize ) internal pure returns (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) { - uint256 tokensSendToL2 = TOKENS_SEND_TO_L2 * scalingSize; - uint256 realizedLpFees = REALIZED_LP_FEES * scalingSize; + uint256 tokensToSend = TOKENS_SEND_TO_L2 * scalingSize; + uint256 lpFees = REALIZED_LP_FEES * scalingSize; (leaf, root) = MerkleTreeUtils.buildSingleTokenLeaf( REPAYMENT_CHAIN_ID, address(0), // Will be set to WETH in tests - tokensSendToL2, - realizedLpFees - ); - } - - /** - * @notice Proposes a root bundle and warps past liveness period. - */ - function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { - uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); - bundleEvaluationBlockNumbers[0] = block.number; - - vm.prank(dataWorker); - fixture.hubPool.proposeRootBundle( - bundleEvaluationBlockNumbers, - 1, - poolRebalanceRoot, - MOCK_RELAYER_REFUND_ROOT, - MOCK_SLOW_RELAY_ROOT - ); - - // Warp past liveness period - vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); - } - - /** - * @notice Executes a leaf from the root bundle. - */ - function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { - vm.prank(dataWorker); - fixture.hubPool.executeRootBundle( - leaf.chainId, - leaf.groupIndex, - leaf.bundleLpFees, - leaf.netSendAmounts, - leaf.runningBalances, - leaf.leafId, - leaf.l1Tokens, - proof + tokensToSend, + lpFees ); } @@ -173,7 +125,8 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); // Bond being paid in should not impact liquid reserves. _forceSync(address(fixture.weth)); @@ -181,7 +134,7 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { assertEq(liquidReserves, AMOUNT_TO_LP); // Counters should move once the root bundle is executed. - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); (, , , utilizedReserves, liquidReserves, ) = fixture.hubPool.pooledTokens(address(fixture.weth)); assertEq(liquidReserves, AMOUNT_TO_LP - TOKENS_SEND_TO_L2); assertEq(utilizedReserves, int256(TOKENS_SEND_TO_L2 + REALIZED_LP_FEES)); @@ -257,11 +210,12 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); // Liquidity is not used until the relayerRefund is executed(i.e "pending" reserves are not considered). assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Now that the liquidity is used (sent to L2) we should be able to find the utilization. This should simply be // the utilizedReserves / (liquidReserves + utilizedReserves) = 110 / (900 + 110) = 0.108910891089108910 @@ -340,11 +294,12 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); // Liquidity is not used until the relayerRefund is executed(i.e "pending" reserves are not considered). assertEq(fixture.hubPool.liquidityUtilizationCurrent(address(fixture.weth)), 0); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Now that the liquidity is used (sent to L2) we should be able to find the utilization. This should simply be // the utilizedReserves / (liquidReserves + utilizedReserves) = 110 / (900 + 110) = 0.108910891089108910 @@ -361,8 +316,9 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. // Liquidity utilization should now be (550) / (500 + 550) = 0.523809523809523809. I.e utilization is over 50%. @@ -410,11 +366,12 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { // Recalculate root with correct token address root = keccak256(abi.encode(leaf)); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); vm.warp(block.timestamp + 10 * 24 * 60 * 60); // Move time to accumulate all fees. - // Send back to L1 the tokensSendToL2 + realizedLpFees, i.e to mimic the finalization of the relay. + // Send back to L1 the TOKENS_SEND_TO_L2 + REALIZED_LP_FEES, i.e to mimic the finalization of the relay. vm.prank(dataWorker); fixture.weth.transfer(address(fixture.hubPool), TOKENS_SEND_TO_L2 + REALIZED_LP_FEES); @@ -450,8 +407,9 @@ contract HubPool_PooledTokenSynchronizationTest is HubPoolTestBase { assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); // Going through a full refund lifecycle does returns to where we were before, with no memory of previous fees. - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // Note: Use getCurrentTime() instead of block.timestamp because block.timestamp may not reflect // the warp that happened inside _proposeRootBundle due to Foundry's handling of vm.warp in internal calls. vm.warp(fixture.hubPool.getCurrentTime() + 10 * 24 * 60 * 60); // Move time to accumulate all fees. diff --git a/test/evm/foundry/local/HubPool_ProtocolFees.t.sol b/test/evm/foundry/local/HubPool_ProtocolFees.t.sol index e6f1732c5..04f56ba36 100644 --- a/test/evm/foundry/local/HubPool_ProtocolFees.t.sol +++ b/test/evm/foundry/local/HubPool_ProtocolFees.t.sol @@ -25,13 +25,10 @@ contract HubPool_ProtocolFeesTest is HubPoolTestBase { // ============ Constants ============ - uint256 constant AMOUNT_TO_LP = 1000 ether; uint256 constant REPAYMENT_CHAIN_ID = 3117; uint256 constant TOKENS_SEND_TO_L2 = 100 ether; uint256 constant REALIZED_LP_FEES = 10 ether; - bytes32 constant MOCK_TREE_ROOT = bytes32(uint256(0xabcd)); - uint256 constant INITIAL_PROTOCOL_FEE_CAPTURE_PCT = 0.1 ether; // 10% // ============ Setup ============ @@ -46,16 +43,10 @@ contract HubPool_ProtocolFeesTest is HubPoolTestBase { liquidityProvider = makeAddr("liquidityProvider"); // Seed dataWorker with WETH for bonds - uint256 dataWorkerAmount = (BOND_AMOUNT + FINAL_FEE) * 2; - vm.deal(dataWorker, dataWorkerAmount); - vm.prank(dataWorker); - fixture.weth.deposit{ value: dataWorkerAmount }(); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); // Seed liquidityProvider with WETH - uint256 liquidityProviderAmount = AMOUNT_TO_LP * 10; - vm.deal(liquidityProvider, liquidityProviderAmount); - vm.prank(liquidityProvider); - fixture.weth.deposit{ value: liquidityProviderAmount }(); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); // Enable WETH for LP (creates LP token) fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); @@ -104,43 +95,6 @@ contract HubPool_ProtocolFeesTest is HubPoolTestBase { ); } - /** - * @notice Proposes a root bundle and warps past liveness period. - */ - function _proposeRootBundle(bytes32 poolRebalanceRoot) internal { - uint256[] memory bundleEvaluationBlockNumbers = new uint256[](1); - bundleEvaluationBlockNumbers[0] = block.number; - - vm.prank(dataWorker); - fixture.hubPool.proposeRootBundle( - bundleEvaluationBlockNumbers, - 1, - poolRebalanceRoot, - MOCK_TREE_ROOT, - MOCK_TREE_ROOT - ); - - // Warp past liveness period - vm.warp(block.timestamp + REFUND_PROPOSAL_LIVENESS + 1); - } - - /** - * @notice Executes a leaf from the root bundle. - */ - function _executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { - vm.prank(dataWorker); - fixture.hubPool.executeRootBundle( - leaf.chainId, - leaf.groupIndex, - leaf.bundleLpFees, - leaf.netSendAmounts, - leaf.runningBalances, - leaf.leafId, - leaf.l1Tokens, - proof - ); - } - // ============ Tests ============ function test_OnlyOwnerCanSetProtocolFeeCapture() public { @@ -167,8 +121,9 @@ contract HubPool_ProtocolFeesTest is HubPoolTestBase { function test_WhenFeeCaptureNotZeroFeesCorrectlyAttributeBetweenLPsAndProtocol() public { (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = constructSingleChainTree(); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); // 90% of the fees should be attributed to the LPs. (, , , , , uint256 undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); @@ -200,8 +155,9 @@ contract HubPool_ProtocolFeesTest is HubPoolTestBase { (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = constructSingleChainTree(); - _proposeRootBundle(root); - _executeLeaf(leaf, MerkleTreeUtils.emptyProof()); + vm.prank(dataWorker); + proposeBundleAndAdvanceTime(root, MOCK_RELAYER_REFUND_ROOT, MOCK_SLOW_RELAY_ROOT); + executeLeaf(leaf, MerkleTreeUtils.emptyProof()); (, , , , , uint256 undistributedLpFees) = fixture.hubPool.pooledTokens(address(fixture.weth)); assertEq(undistributedLpFees, REALIZED_LP_FEES); diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 3c1a22179..3e6226d30 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -16,6 +16,7 @@ import { OptimisticOracleInterface } from "../../../../contracts/external/uma/co import { Constants } from "../../../../script/utils/Constants.sol"; import { MintableERC20 } from "../../../../contracts/test/MockERC20.sol"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; // ============ UMA Ecosystem Mocks ============ @@ -322,11 +323,20 @@ abstract contract HubPoolTestBase is Test, Constants { uint256 public constant BOND_AMOUNT = 5 ether; uint256 public constant FINAL_FEE = 1 ether; + uint256 public constant TOTAL_BOND = BOND_AMOUNT + FINAL_FEE; uint256 public constant INITIAL_ETH = 100 ether; uint256 public constant LP_ETH_FUNDING = 10 ether; uint32 public constant REFUND_PROPOSAL_LIVENESS = 7200; // 2 hours bytes32 public constant DEFAULT_IDENTIFIER = bytes32("ACROSS-V2"); + // Common test amounts + uint256 public constant AMOUNT_TO_LP = 1000 ether; + + // Mock roots for bundle proposals + bytes32 public constant MOCK_POOL_REBALANCE_ROOT = bytes32(uint256(0xabc)); + bytes32 public constant MOCK_RELAYER_REFUND_ROOT = bytes32(uint256(0x1234)); + bytes32 public constant MOCK_SLOW_RELAY_ROOT = bytes32(uint256(0x5678)); + // ============ Internal Storage ============ HubPoolFixtureData internal fixture; @@ -469,4 +479,64 @@ abstract contract HubPoolTestBase is Test, Constants { // Warp past liveness period vm.warp(block.timestamp + fixture.hubPool.liveness() + 1); } + + /** + * @notice Proposes a root bundle and warps past liveness period. + * @param poolRebalanceRoot The pool rebalance merkle root + * @param relayerRefundRoot The relayer refund merkle root + * @param slowRelayRoot The slow relay merkle root + */ + function proposeBundleAndAdvanceTime( + bytes32 poolRebalanceRoot, + bytes32 relayerRefundRoot, + bytes32 slowRelayRoot + ) internal { + proposeAndExecuteBundle(poolRebalanceRoot, relayerRefundRoot, slowRelayRoot); + } + + /** + * @notice Executes a pool rebalance leaf from a root bundle. + * @param leaf The pool rebalance leaf to execute + * @param proof The merkle proof for the leaf + */ + function executeLeaf(HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32[] memory proof) internal { + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + } + + /** + * @notice Seeds a user with ETH and WETH. + * @param user The address to seed + * @param amount The amount of ETH/WETH to seed + */ + function seedUserWithWeth(address user, uint256 amount) internal { + vm.deal(user, amount); + vm.prank(user); + fixture.weth.deposit{ value: amount }(); + } + + /** + * @notice Sets up token routes for a destination chain. + * @param chainId The destination chain ID + * @param l2Weth The L2 WETH address + * @param l2Dai The L2 DAI address + * @param l2Usdc The L2 USDC address + */ + function setupTokenRoutes(uint256 chainId, address l2Weth, address l2Dai, address l2Usdc) internal { + fixture.hubPool.setPoolRebalanceRoute(chainId, address(fixture.weth), l2Weth); + fixture.hubPool.setPoolRebalanceRoute(chainId, address(fixture.dai), l2Dai); + fixture.hubPool.setPoolRebalanceRoute(chainId, address(fixture.usdc), l2Usdc); + + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth)); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.dai)); + fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.usdc)); + } } From 49ee84d8bdcd7c467b2a8e6ed44f74cf70e09ed6 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Thu, 15 Jan 2026 15:39:00 -0700 Subject: [PATCH 23/24] fix Multicall test to expect return data on revert Signed-off-by: Taylor Webb --- test/evm/foundry/local/MultiCallerUpgradeable.t.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/evm/foundry/local/MultiCallerUpgradeable.t.sol b/test/evm/foundry/local/MultiCallerUpgradeable.t.sol index 605ae03ca..e7d8cb3aa 100644 --- a/test/evm/foundry/local/MultiCallerUpgradeable.t.sol +++ b/test/evm/foundry/local/MultiCallerUpgradeable.t.sol @@ -102,10 +102,8 @@ contract MultiCallerUpgradeableTest is Test { assert(results[i].success); } else { assert(!results[i].success); - assertEq( - "", // Error messages are stripped. - results[i].returnData - ); + // Revert data is preserved by tryMulticall + assert(results[i].returnData.length > 0); } } } From 7d425bfb1940cb33f5fe043947b0ee1e83f8f821 Mon Sep 17 00:00:00 2001 From: Taylor Webb Date: Thu, 15 Jan 2026 16:31:14 -0700 Subject: [PATCH 24/24] update foundry test script Signed-off-by: Taylor Webb --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9bdb07ce6..6175ca6ec 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build-verified": "yarn build-evm && yarn build-svm-solana-verify && yarn generate-svm-artifacts && yarn build-ts", "test-evm": "yarn test-evm-hardhat && yarn test-evm-foundry", "test-evm-hardhat": "IS_TEST=true hardhat test", - "test-evm-foundry": "FOUNDRY_PROFILE=local forge test", + "test-evm-foundry": "FOUNDRY_PROFILE=local-test forge test", "test-svm": "IS_TEST=true yarn build-svm && yarn generate-svm-artifacts && anchor test --skip-build", "test-svm-solana-verify": "IS_TEST=true yarn build-svm-solana-verify && yarn generate-svm-artifacts && anchor test --skip-build", "test": "yarn test-evm && yarn test-svm",