diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c264a6a9f..aedb2e257 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -55,4 +55,4 @@ jobs: # "env": { # "NODE_ENV": "test" # } - # } \ No newline at end of file + # } diff --git a/.gitignore b/.gitignore index ac1bc0dae..d50851c3b 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 index 3f7a94ee0..6cf180669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,6 @@ contract MyContractTest is Test { // Test implementation assertEq(myContract.value(), expected); } - function testRevertOnInvalidInput() public { vm.expectRevert(); myContract.doSomething(invalidInput); @@ -108,19 +107,16 @@ 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"); diff --git a/broadcast/deployed-addresses.json b/broadcast/deployed-addresses.json index ddcffb845..da4fd36b1 100644 --- a/broadcast/deployed-addresses.json +++ b/broadcast/deployed-addresses.json @@ -614,7 +614,7 @@ } }, "4326": { - "chain_name": "MegaETH", + "chain_name": "Chain 4326", "contracts": { "SpokePool": { "address": "0x3db06da8f0a24a525f314eec954fc5c6a973d40e", diff --git a/broadcast/deployed-addresses.md b/broadcast/deployed-addresses.md index 17e4af498..a3c77cacd 100644 --- a/broadcast/deployed-addresses.md +++ b/broadcast/deployed-addresses.md @@ -193,16 +193,6 @@ This file contains the latest deployed smart contract addresses from the broadca | SpokePoolPeriphery | [0x89415a82d909a7238d69094C3Dd1dCC1aCbDa85C](https://soneium.blockscout.com/address/0x89415a82d909a7238d69094C3Dd1dCC1aCbDa85C) | | SpokePoolVerifier | [0x3Fb9cED51E968594C87963a371Ed90c39519f65A](https://soneium.blockscout.com/address/0x3Fb9cED51E968594C87963a371Ed90c39519f65A) | -## MegaETH (4326) - -| Contract Name | Address | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -| MulticallHandler | [0xFfc1285082deAB9bf0ECA5699e4930bb310aFbE4](https://megaeth.blockscout.com/address/0xFfc1285082deAB9bf0ECA5699e4930bb310aFbE4) | -| OP_SpokePool | [0x9b4A302A548c7e313c2b74C461db7b84d3074A84](https://megaeth.blockscout.com/address/0x9b4A302A548c7e313c2b74C461db7b84d3074A84) | -| SpokePool | [0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E](https://megaeth.blockscout.com/address/0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E) | -| SpokePool | [0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E](https://megaeth.blockscout.com/address/0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E) | -| SpokePoolPeriphery | [0xf0aBCe137a493185c5E768F275E7E931109f8981](https://megaeth.blockscout.com/address/0xf0aBCe137a493185c5E768F275E7E931109f8981) | - ## Base (8453) | Contract Name | Address | @@ -332,6 +322,16 @@ This file contains the latest deployed smart contract addresses from the broadca | MulticallHandler | [0x0F7Ae28dE1C8532170AD4ee566B5801485c13a0E](https://sepolia-blockscout.lisk.com/address/0x0F7Ae28dE1C8532170AD4ee566B5801485c13a0E) | | SpokePool | [0xeF684C38F94F48775959ECf2012D7E864ffb9dd4](https://sepolia-blockscout.lisk.com/address/0xeF684C38F94F48775959ECf2012D7E864ffb9dd4) | +## Chain 4326 (4326) + +| Contract Name | Address | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| MulticallHandler | [0xFfc1285082deAB9bf0ECA5699e4930bb310aFbE4](https://megaeth.blockscout.com/address/0xFfc1285082deAB9bf0ECA5699e4930bb310aFbE4) | +| OP_SpokePool | [0x9b4A302A548c7e313c2b74C461db7b84d3074A84](https://megaeth.blockscout.com/address/0x9b4A302A548c7e313c2b74C461db7b84d3074A84) | +| SpokePool | [0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E](https://megaeth.blockscout.com/address/0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E) | +| SpokePool | [0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E](https://megaeth.blockscout.com/address/0x3Db06DA8F0a24A525f314eeC954fC5c6a973d40E) | +| SpokePoolPeriphery | [0xf0aBCe137a493185c5E768F275E7E931109f8981](https://megaeth.blockscout.com/address/0xf0aBCe137a493185c5E768F275E7E931109f8981) | + ## Lens Sepolia (37111) | Contract Name | Address | @@ -357,11 +357,11 @@ This file contains the latest deployed smart contract addresses from the broadca ## Tatara (129399) -| Contract Name | Address | -| ----------------- | ------------------------------------------ | -| MulticallHandler | 0xAC537C12fE8f544D712d71ED4376a502EEa944d7 | -| SpokePool | 0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64 | -| SpokePoolVerifier | 0x630b76C7cA96164a5aCbC1105f8BA8B739C82570 | +| Contract Name | Address | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| MulticallHandler | [0xAC537C12fE8f544D712d71ED4376a502EEa944d7](https://explorer.tatara.katana.network/address/0xAC537C12fE8f544D712d71ED4376a502EEa944d7) | +| SpokePool | [0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64](https://explorer.tatara.katana.network/address/0x09aea4b2242abC8bb4BB78D537A67a245A7bEC64) | +| SpokePoolVerifier | [0x630b76C7cA96164a5aCbC1105f8BA8B739C82570](https://explorer.tatara.katana.network/address/0x630b76C7cA96164a5aCbC1105f8BA8B739C82570) | ## Arbitrum Sepolia (421614) diff --git a/foundry.toml b/foundry.toml index 1c7920c93..42958d1e7 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/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", 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..c0c4dd666 --- /dev/null +++ b/test/evm/foundry/local/Arbitrum_Adapter.t.sol @@ -0,0 +1,605 @@ +// 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; + + // ============ Test Configuration ============ + + // OFT fee cap is an immutable set via constructor - this is a test configuration choice + uint256 constant TEST_OFT_FEE_CAP = 1 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, + TEST_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 + adapter.L2_MAX_SUBMISSION_COST(), + refundAddress, + refundAddress, + adapter.RELAY_MESSAGE_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), + functionData + ); + + uint256 inboxBalanceBefore = address(inbox).balance; + fixture.hubPool.relaySpokePoolAdminFunction(ARBITRUM_CHAIN_ID, functionData); + uint256 inboxBalanceAfter = address(inbox).balance; + + 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"); + } + + // ============ 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 + ); + + proposeBundleAndAdvanceTime(root, bytes32(0), bytes32(0)); + + // Expected data sent to gateway + bytes memory expectedData = abi.encode(adapter.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, + adapter.RELAY_TOKENS_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), + expectedData + ); + + // Expect relayRootBundle message to SpokePool + vm.expectEmit(true, true, true, true, address(inbox)); + emit Inbox.RetryableTicketCreated( + mockSpoke, + 0, + adapter.L2_MAX_SUBMISSION_COST(), + refundAddress, + refundAddress, + adapter.RELAY_MESSAGE_L2_GAS_LIMIT(), + adapter.L2_GAS_PRICE(), + abi.encodeWithSignature("relayRootBundle(bytes32,bytes32)", bytes32(0), bytes32(0)) + ); + + uint256 gatewayBalanceBefore = address(gatewayRouter).balance; + + // Execute + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + uint256 gatewayBalanceAfter = address(gatewayRouter).balance; + uint256 expectedEth = 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) + 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 + ); + + proposeBundleAndAdvanceTime(root, bytes32(0), bytes32(0)); + + bytes32 expectedRecipient = bytes32(uint256(uint160(mockSpoke))); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + usdcAmount, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // Verify CCTP messenger allowance + assertEq( + fixture.usdc.allowance(address(fixture.hubPool), address(cctpMessenger)), + usdcAmount, + "CCTP allowance mismatch" + ); + } + + function test_relayTokens_USDC_SplitsWhenOverLimit() public { + uint256 usdcAmount = 100e6; + addLiquidity(fixture.usdc, usdcAmount * 2); + + // 1) Set limit below amount to send and where amount does not divide evenly into limit. + uint256 burnLimit = usdcAmount / 2 - 1; + cctpMinter.setBurnLimit(burnLimit); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdc), + usdcAmount, + 10e6 + ); + + proposeBundleAndAdvanceTime(root, bytes32(0), bytes32(0)); + + bytes32 expectedRecipient = bytes32(uint256(uint160(mockSpoke))); + + // Expect 3 calls: 2 * burnLimit + remainder + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + burnLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + burnLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + 2, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + bytes32[] memory proof = MerkleTreeUtils.emptyProof(); + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // Should have called depositForBurn 3 times (2 full + 1 remainder) + assertEq(cctpMessenger.depositForBurnCallCount(), 3, "Should split into 3 CCTP calls"); + + // 2) Set limit below amount to send and where amount divides evenly into limit. + proposeBundleAndAdvanceTime(root, bytes32(0), bytes32(0)); + + uint256 newLimit = usdcAmount / 2; + cctpMinter.setBurnLimit(newLimit); + + // Expect 2 more calls: 2 * newLimit + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + newLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + vm.expectEmit(true, true, true, true, address(cctpMessenger)); + emit MockCCTPMessenger.DepositForBurnCalled( + newLimit, + ARBITRUM_CIRCLE_DOMAIN, + expectedRecipient, + address(fixture.usdc) + ); + + fixture.hubPool.executeRootBundle( + leaf.chainId, + leaf.groupIndex, + leaf.bundleLpFees, + leaf.netSendAmounts, + leaf.runningBalances, + leaf.leafId, + leaf.l1Tokens, + proof + ); + + // 2 more calls added to prior 3. + assertEq(cctpMessenger.depositForBurnCallCount(), 5, "Should have 5 total CCTP calls"); + } + + // ============ relayTokens Tests (USDT via OFT) ============ + + function test_relayTokens_USDT_ViaOFT() public { + uint256 usdtAmount = 100e6; + addLiquidity(fixture.usdt, usdtAmount); + + // Configure OFT messenger in AdapterStore + adapterStore.setMessenger( + MessengerTypes.OFT_MESSENGER, + ARBITRUM_OFT_EID, + address(fixture.usdt), + address(oftMessenger) + ); + + // Set fees to return (within cap) + uint256 nativeFee = 0.1 ether; + oftMessenger.setFeesToReturn(nativeFee, 0); + + (HubPoolInterface.PoolRebalanceLeaf memory leaf, bytes32 root) = MerkleTreeUtils.buildSingleTokenLeaf( + ARBITRUM_CHAIN_ID, + address(fixture.usdt), + usdtAmount, + 10e6 + ); + + proposeBundleAndAdvanceTime(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 + ); + + proposeBundleAndAdvanceTime(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 + ); + + proposeBundleAndAdvanceTime(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 + ); + + proposeBundleAndAdvanceTime(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 + ); + + proposeBundleAndAdvanceTime(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 + ); + + proposeBundleAndAdvanceTime(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/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))); } } 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..b8d9ff360 --- /dev/null +++ b/test/evm/foundry/local/HubPool_Admin.t.sol @@ -0,0 +1,345 @@ +// 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; + + // ============ 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 + 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); + } + + // ============ 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 was created (using mock factory, so just verify it's a valid ERC20) + MintableERC20 lpTokenContract = MintableERC20(lpToken); + 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 { + 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(address(fixture.hubPool)); + emit HubPool.SpokePoolAdminFunctionTriggered(DESTINATION_CHAIN_ID, functionData); + + fixture.hubPool.relaySpokePoolAdminFunction(DESTINATION_CHAIN_ID, functionData); + } + + // ============ setBond Tests ============ + + function test_SetBond() public { + // 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 + 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 + usdcFinalFee, + "Bond amount should be 1001 USDC (1000 + finalFee)" + ); + } + + 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(address(fixture.hubPool)); + emit HubPool.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/local/HubPool_DisputeRootBundle.t.sol b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol new file mode 100644 index 000000000..ecc5ddd4e --- /dev/null +++ b/test/evm/foundry/local/HubPool_DisputeRootBundle.t.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { HubPoolTestBase, MockStore } from "../utils/HubPoolTestBase.sol"; + +import { HubPool } from "../../../../contracts/HubPool.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 + * @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 ============ + + address dataWorker; + address liquidityProvider; + + // ============ Setup ============ + + function setUp() public { + createHubPoolFixture(); + + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Fund data worker with WETH for bonds + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + vm.prank(dataWorker); + fixture.weth.approve(address(fixture.hubPool), type(uint256).max); + + // 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) + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP); + vm.startPrank(liquidityProvider); + fixture.weth.approve(address(fixture.hubPool), AMOUNT_TO_LP); + fixture.hubPool.addLiquidity(address(fixture.weth), AMOUNT_TO_LP); + vm.stopPrank(); + } + + // ============ 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); + + // dataWorker already has approval to HubPool from setUp() + vm.prank(dataWorker); + fixture.hubPool.disputeRootBundle(); + + // 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"); + + // 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 { + _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 TOTAL_BOND (bond + finalFee) + fixture.store.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: TOTAL_BOND })); + + _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 >= 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)); + + // Proposer should get their bond back + assertEq( + dataWorkerBalanceAfter, + dataWorkerBalanceBefore + TOTAL_BOND, + "Proposer should receive bond back on cancellation" + ); + assertEq(hubPoolBalanceAfter, hubPoolBalanceBefore - TOTAL_BOND, "HubPool should release the bond"); + + // 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_WorksWithDecreasedFinalFee() public { + _proposeRootBundle(); + + // Decrease final fee to half + uint256 newFinalFee = FINAL_FEE / 2; + uint256 newBond = TOTAL_BOND - newFinalFee; + fixture.store.setFinalFee(address(fixture.weth), MockStore.FinalFee({ rawValue: newFinalFee })); + + // 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(); + + // 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, + 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 { + _proposeRootBundle(); + + address randomDisputer = makeAddr("randomDisputer"); + + // Fund disputer with WETH + vm.deal(randomDisputer, 10 ether); + vm.startPrank(randomDisputer); + fixture.weth.deposit{ value: TOTAL_BOND }(); + fixture.weth.approve(address(fixture.hubPool), 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_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(); + } +} 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..80fd99a4c --- /dev/null +++ b/test/evm/foundry/local/HubPool_ExecuteRootBundle.t.sol @@ -0,0 +1,589 @@ +// 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; + 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; + + // ============ 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 + fixture.dai.mint(dataWorker, TOTAL_BOND * 2); + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + + // Seed liquidityProvider wallet: DAI tokens and WETH amountToLp * 10 + fixture.dai.mint(liquidityProvider, AMOUNT_TO_LP * 10); + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); + + // 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 {} +} 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..a3ef21b9d --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvision.t.sol @@ -0,0 +1,223 @@ +// 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; + + // ============ 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); + seedUserWithWeth(liquidityProvider, 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); + } +} 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..af5d12acb --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionFees.t.sol @@ -0,0 +1,184 @@ +// 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 REPAYMENT_CHAIN_ID = 777; + + // ============ 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 + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + + // Seed liquidityProvider with WETH amountToLp * 10 + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); + + // 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_TO_SEND, + LP_FEES + ); + } + + // ============ 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)); + + 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. + // Undistributed LP fees should be attribute to LPs. + (, , , utilizedReserves, liquidReserves, undistributedLpFees) = fixture.hubPool.pooledTokens( + address(fixture.weth) + ); + 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_TO_SEND + LP_FEES)); + assertEq(undistributedLpFees, 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); + + 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. + // 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); + } +} 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..5bc0c3bb3 --- /dev/null +++ b/test/evm/foundry/local/HubPool_LiquidityProvisionHaircut.t.sol @@ -0,0 +1,142 @@ +// 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 REPAYMENT_CHAIN_ID = 777; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 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 + dataWorker = makeAddr("dataWorker"); + liquidityProvider = makeAddr("liquidityProvider"); + + // Seed dataWorker with WETH (bondAmount + finalFee) * 2 + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + + // Seed liquidityProvider with WETH amountToLp * 10 + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); + + // 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 + ); + } + + // ============ 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)); + + 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. + // 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 (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)); + 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); + } +} 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..fd518f64d --- /dev/null +++ b/test/evm/foundry/local/HubPool_PooledTokenSynchronization.t.sol @@ -0,0 +1,424 @@ +// 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 REPAYMENT_CHAIN_ID = 3117; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 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 + 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) + seedUserWithWeth(dataWorker, TOTAL_BOND * 10 + 600 ether); + + // Seed liquidityProvider with WETH amountToLp * 10 + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); + + // 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 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 + tokensToSend, + lpFees + ); + } + + /** + * @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)); + + 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)); + (, , , 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)); + + 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()); + + // 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)); + + 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()); + + // 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)); + + 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%. + 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)); + + 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 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); + + // 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. + assertEq(fixture.hubPool.exchangeRateCurrent(address(fixture.weth)), 1e18); + _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. + 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. + + // 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); + } +} 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); + } +} 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..04f56ba36 --- /dev/null +++ b/test/evm/foundry/local/HubPool_ProtocolFees.t.sol @@ -0,0 +1,171 @@ +// 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 REPAYMENT_CHAIN_ID = 3117; + uint256 constant TOKENS_SEND_TO_L2 = 100 ether; + uint256 constant REALIZED_LP_FEES = 10 ether; + + 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 + seedUserWithWeth(dataWorker, TOTAL_BOND * 2); + + // Seed liquidityProvider with WETH + seedUserWithWeth(liquidityProvider, AMOUNT_TO_LP * 10); + + // 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 + ); + } + + // ============ 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(); + + 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)); + 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(); + + 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); + + 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); + } +} 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); } } } diff --git a/test/evm/foundry/local/chain-adapters/Arbitrum_SendTokensAdapter.t.sol b/test/evm/foundry/local/chain-adapters/Arbitrum_SendTokensAdapter.t.sol index 3233eeec2..dc7e1739b 100644 --- a/test/evm/foundry/local/chain-adapters/Arbitrum_SendTokensAdapter.t.sol +++ b/test/evm/foundry/local/chain-adapters/Arbitrum_SendTokensAdapter.t.sol @@ -34,10 +34,6 @@ contract Arbitrum_SendTokensAdapterTest is HubPoolTestBase { uint256 constant ARBITRUM_CHAIN_ID = 42161; - // ============ Test Amounts ============ - - uint256 constant AMOUNT_TO_LP = 1000 ether; - // ============ Setup ============ function setUp() public { diff --git a/test/evm/foundry/utils/HubPoolTestBase.sol b/test/evm/foundry/utils/HubPoolTestBase.sol index 95b7ed26f..e04b95bfe 100644 --- a/test/evm/foundry/utils/HubPoolTestBase.sol +++ b/test/evm/foundry/utils/HubPoolTestBase.sol @@ -11,9 +11,12 @@ 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 { 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"; +import { HubPoolInterface } from "../../../../contracts/interfaces/HubPoolInterface.sol"; import { MockSpokePool } from "../../../../contracts/test/MockSpokePool.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { MerkleTreeUtils } from "./MerkleTreeUtils.sol"; @@ -48,32 +51,238 @@ 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. + * @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 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, MockStore _store) { + defaultLiveness = _defaultLiveness; + store = _store; + } + + // ============ Implemented Functions ============ + + function requestAndProposePriceFor( + bytes32 identifier, + uint32 timestamp, + bytes memory ancillaryData, + IERC20 currency, + uint256 /* reward */, + uint256 bond, + uint256 customLiveness, + address proposer, + int256 proposedPrice + ) external override returns (uint256 totalBond) { + bytes32 requestId = keccak256(abi.encode(msg.sender, identifier, timestamp, ancillaryData)); + + // 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({ + proposer: proposer, + disputer: address(0), + currency: currency, + settled: false, + proposedPrice: proposedPrice, + resolvedPrice: 0, + expirationTime: block.timestamp + customLiveness, + reward: 0, + finalFee: finalFee, + bond: bond, + customLiveness: customLiveness + }); + + return totalBond; + } + + function disputePriceFor( + bytes32 identifier, + uint32 timestamp, + bytes memory ancillaryData, + 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 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; + } + + // ============ 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"); } } @@ -91,11 +300,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; @@ -113,8 +325,12 @@ abstract contract HubPoolTestBase is Test, Constants { // ============ 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 ============ @@ -125,12 +341,14 @@ abstract contract HubPoolTestBase is Test, Constants { uint256 public constant USDT_TO_SEND = 100e6; // USDT has 6 decimals uint256 public constant USDT_LP_FEES = 10e6; uint256 public constant BURN_LIMIT = 1_000_000e6; // 1M USDC per message + uint256 public constant AMOUNT_TO_LP = 1000 ether; // ============ Common Mock Roots ============ bytes32 public constant MOCK_TREE_ROOT = keccak256("mockTreeRoot"); bytes32 public constant MOCK_RELAYER_REFUND_ROOT = keccak256("mockRelayerRefundRoot"); bytes32 public constant MOCK_SLOW_RELAY_ROOT = keccak256("mockSlowRelayRoot"); + bytes32 public constant MOCK_POOL_REBALANCE_ROOT = keccak256("mockPoolRebalanceRoot"); // ============ Internal Storage ============ @@ -141,18 +359,38 @@ 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(); data.addressWhitelist = new MockAddressWhitelist(); + data.identifierWhitelist = new MockIdentifierWhitelist(); data.store = new MockStore(); + // Deploy OptimisticOracle with liveness * 10 (matches UmaEcosystem.Fixture) + // 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)); + data.finder.changeImplementationAddress( + OracleInterfaces.IdentifierWhitelist, + 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(); @@ -160,18 +398,39 @@ 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)); + + // 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 }(); @@ -234,6 +493,35 @@ abstract contract HubPoolTestBase is Test, Constants { vm.warp(block.timestamp + fixture.hubPool.liveness() + 1); } + /** + * @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 }(); + } + // ============ MockSpokePool Deployment ============ /**