diff --git a/forked-env-example/.env.example b/forked-env-example/.env.example new file mode 100644 index 000000000..33d7e7840 --- /dev/null +++ b/forked-env-example/.env.example @@ -0,0 +1,5 @@ +# GMX Fork Testing Environment Variables + +# Arbitrum RPC URL (required) +# You can use a public RPC or a provider like Alchemy/Infura for better performance +ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc diff --git a/forked-env-example/.gitignore b/forked-env-example/.gitignore new file mode 100644 index 000000000..190c0a15c --- /dev/null +++ b/forked-env-example/.gitignore @@ -0,0 +1,32 @@ +# Compiler files +cache/ +out/ + +# Dependencies +lib/ +.gitmodules + +# Environment variables +.env + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Dotenv file +.env +.env.test + +# Node.js (if using Hardhat/TypeScript tooling) +node_modules/ + +# macOS +.DS_Store + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/forked-env-example/QUICKSTART.md b/forked-env-example/QUICKSTART.md new file mode 100644 index 000000000..187d23781 --- /dev/null +++ b/forked-env-example/QUICKSTART.md @@ -0,0 +1,60 @@ +# GMX Forked env example - Quick Start + +Standalone fork tests for GMX Synthetics V2 on Arbitrum. Describes the steps to open/close positions using real mainnet contracts. + +**Self-contained**: Copy this directory anywhere and follow the setup below. + +## Setup (2 minutes) + +```bash +cd forked-env-example + +# Initialize git (required for forge install if setting up as a stand alone repo) +git init + +# Install dependencies (just forge-std) +forge install foundry-rs/forge-std --no-commit + +# Set Arbitrum RPC URL +cp .env.example .env && source .env +``` + +## Run Tests + +```bash +forge test --fork-url $ARBITRUM_RPC_URL -vv +forge test --fork-url $ARBITRUM_RPC_URL --match-test testOpenLongPosition -vv +forge test --fork-url $ARBITRUM_RPC_URL --match-test testCloseLongPosition -vv +``` + +## What This Does + +Tests demonstrate the GMX order flow: + +1. **Create order** - User sends collateral + execution fee to GMX +2. **Execute order** - Keeper executes with oracle prices (mocked in these tests) +3. **Verify position** - Check position was created/closed correctly + +Example: `testOpenLongPosition` opens a 2.5x leveraged long ETH position with 0.001 ETH collateral (~$3.89 → ~$9.7 position). + +## How It Works + +**Fork testing**: Tests run against real GMX contracts on Arbitrum mainnet at block 392496384 (matches a real transaction). + +**Oracle mocking**: GMX uses Chainlink Data Streams (off-chain signed prices). Oracle provider bytecode is replaced with a mock using **`vm.etch`** so orders can be executed without real signatures. + +**Key files**: +- `contracts/constants/GmxArbitrumAddresses.sol` - Production contract addresses (all arbitrum deployments [here](https://github.com/gmx-io/gmx-synthetics/blob/main/docs/arbitrum-deployments.md)) +- `contracts/mocks/MockOracleProvider.sol` - Oracle price mocking. Critical step to bypass the Chainlink Data Stream signature verification on a forked env +- `contracts/interfaces/IGmxV2.sol` - Minimal GMX interfaces. Miminal code copied from the GMX contracts/interfaces. +- `contracts/utils/GmxForkHelpers.sol` - Reusable helpers for order creation, state queries +- `test/GmxOrderFlow.t.sol` - Main test contract + +## What You'll Learn + +- How to create GMX orders (MarketIncrease, MarketDecrease) +- Two-step execution model (user creates → keeper executes) +- Handling oracle prices and execution fees +- Querying positions and verifying state changes + +**Oracle provider address** mocked `0xE1d5a068c5b75E0c7Ea1A9Fe8EA056f9356C6fFD` (Chainlink Data Stream provider verified from mainnet txs). diff --git a/forked-env-example/contracts/constants/GmxArbitrumAddresses.sol b/forked-env-example/contracts/constants/GmxArbitrumAddresses.sol new file mode 100644 index 000000000..8f90cca85 --- /dev/null +++ b/forked-env-example/contracts/constants/GmxArbitrumAddresses.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * GMX Synthetics V2 contract addresses on Arbitrum mainnet + * @dev All addresses from official GMX deployments + * @dev Source: https://github.com/gmx-io/gmx-synthetics/blob/main/docs/arbitrum-deployments.md + * @dev Last Updated: Aug 13, 2025 + */ +library GmxArbitrumAddresses { + // ============ Core Protocol Contracts ============ + + /// Central key-value store for all protocol data + address internal constant DATA_STORE = 0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8; + + /// Role-based access control + address internal constant ROLE_STORE = 0x3c3d99FD298f679DBC2CEcd132b4eC4d0F5e6e72; + + /// Reader contract for querying protocol state + address internal constant READER = 0x65A6CC451BAfF7e7B4FDAb4157763aB4b6b44D0E; + + /// Router contract for plugin transfers + address internal constant ROUTER = 0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6; + + /// Main entry point for creating deposits, withdrawals, and orders + address internal constant EXCHANGE_ROUTER = 0x87d66368cD08a7Ca42252f5ab44B2fb6d1Fb8d15; + + /// Handles order execution logic + address internal constant ORDER_HANDLER = 0x04315E233C1c6FfA61080B76E29d5e8a1f7B4A35; + + /// Oracle contract for price feeds + address internal constant ORACLE = 0x7F01614cA5198Ec979B1aAd1DAF0DE7e0a215BDF; + + /// Oracle price store + address internal constant ORACLE_STORE = 0xA8AF9B86fC47deAde1bc66B12673706615E2B011; + + /// Chainlink Data Streams oracle provider + /// @dev Used for WETH and USDC price feeds on mainnet + address internal constant CHAINLINK_DATA_STREAM_PROVIDER = 0xE1d5a068c5b75E0c7Ea1A9Fe8EA056f9356C6fFD; + + /// Order vault - holds collateral for pending orders + address internal constant ORDER_VAULT = 0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5; + + /// Deposit handler + address internal constant DEPOSIT_HANDLER = 0x563E8cDB5Ba929039c2Bb693B78CE12dC0AAfaDa; + + /// Withdrawal handler + address internal constant WITHDRAWAL_HANDLER = 0x1EC018d2b6ACCA20a0bEDb86450b7E27D1D8355B; + + /// Event emitter + address internal constant EVENT_EMITTER = 0xC8ee91A54287DB53897056e12D9819156D3822Fb; + + /// Deposit vault + address internal constant DEPOSIT_VAULT = 0xF89e77e8Dc11691C9e8757e84aaFbCD8A67d7A55; + + /// Withdrawal vault + address internal constant WITHDRAWAL_VAULT = 0x0628D46b5D145f183AdB6Ef1f2c97eD1C4701C55; + + // ============ Markets ============ + + /// ETH/USD perp market + /// @dev Long collateral: WETH, Short collateral: USDC, Index: WETH + address internal constant ETH_USD_MARKET = 0x70d95587d40A2caf56bd97485aB3Eec10Bee6336; + + /// BTC/USD perp market + /// @dev Long collateral: WBTC, Short collateral: USDC, Index: WBTC + address internal constant BTC_USD_MARKET = 0x47c031236e19d024b42f8AE6780E44A573170703; + + // ============ Tokens ============ + + /// Wrapped ETH on Arbitrum + address internal constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; + + /// Native USDC on Arbitrum (not bridged) + address internal constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + + /// Wrapped BTC on Arbitrum + address internal constant WBTC = 0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f; + + /// USDC.e (bridged USDC from Ethereum) + address internal constant USDC_E = 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8; +} diff --git a/forked-env-example/contracts/interfaces/IGmxV2.sol b/forked-env-example/contracts/interfaces/IGmxV2.sol new file mode 100644 index 000000000..584f4553b --- /dev/null +++ b/forked-env-example/contracts/interfaces/IGmxV2.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * Minimal interface definitions copied from GMX Synthetics V2 + * @dev This file contains only the essential interfaces needed for order flow testing + */ + +// ============================================================================ +// Exchange Router - Main entry point for users +// ============================================================================ + +interface IExchangeRouter { + struct CreateOrderParams { + CreateOrderParamsAddresses addresses; + CreateOrderParamsNumbers numbers; + OrderType orderType; + DecreasePositionSwapType decreasePositionSwapType; + bool isLong; + bool shouldUnwrapNativeToken; + bool autoCancel; + bytes32 referralCode; + bytes32[] dataList; + } + + struct CreateOrderParamsAddresses { + address receiver; + address cancellationReceiver; + address callbackContract; + address uiFeeReceiver; + address market; + address initialCollateralToken; + address[] swapPath; + } + + struct CreateOrderParamsNumbers { + uint256 sizeDeltaUsd; // Position size change in USD (scaled by 1e30) + uint256 initialCollateralDeltaAmount; // Collateral amount in token decimals + uint256 triggerPrice; // Trigger price for limit/stop orders (scaled by 1e30) + uint256 acceptablePrice; // Max price for longs, min price for shorts (scaled by 1e12) + uint256 executionFee; // Fee for keepers in native token + uint256 callbackGasLimit; // Gas limit for callback contract + uint256 minOutputAmount; // Min output for decrease orders/swaps + uint256 validFromTime; // Order valid from timestamp + } + + enum OrderType { + MarketSwap, // 0: Swap at market price + LimitSwap, // 1: Swap when price reaches trigger + MarketIncrease, // 2: Open/increase position at market price + LimitIncrease, // 3: Open/increase position at limit price + MarketDecrease, // 4: Close/decrease position at market price + LimitDecrease, // 5: Close/decrease position at limit price + StopLossDecrease, // 6: Stop loss order + Liquidation, // 7: Liquidation order (keeper only) + StopIncrease // 8: Stop order to increase position + } + + enum DecreasePositionSwapType { + NoSwap, // 0: No swap + SwapPnlTokenToCollateralToken, // 1: Swap PnL to collateral + SwapCollateralTokenToPnlToken // 2: Swap collateral to PnL token + } + + /// Create a new order + /// @param params Order parameters + /// @return orderKey Unique identifier for the order + function createOrder(CreateOrderParams calldata params) external payable returns (bytes32); + + /// Send wrapped native tokens to a receiver + function sendWnt(address receiver, uint256 amount) external payable; +} + +// ============================================================================ +// Order Handler - Executed by keepers +// ============================================================================ + +interface IOrderHandler { + /// Execute an order (keeper only) + /// @param key Order key + /// @param oracleParams Oracle price data + function executeOrder(bytes32 key, OracleUtils.SetPricesParams calldata oracleParams) external; +} + +// ============================================================================ +// Oracle - Price feed management +// ============================================================================ + +library OracleUtils { + struct SetPricesParams { + address[] tokens; // Token addresses + address[] providers; // Price providers + bytes[] data; // Signed price data + } + + struct ValidatedPrice { + address token; + uint256 min; + uint256 max; + uint256 timestamp; + address provider; + } +} + +library Price { + struct Props { + uint256 min; + uint256 max; + } +} + +interface IOracle { + function setPrices(OracleUtils.SetPricesParams memory params) external; + function setPrimaryPrice(address token, Price.Props memory price) external; + function setTimestamps(uint256 minTimestamp, uint256 maxTimestamp) external; + function getPrimaryPrice(address token) external view returns (Price.Props memory); +} + +interface IOracleStore { + function getSigners() external view returns (address[] memory); +} + +// ============================================================================ +// Reader - Query protocol state +// ============================================================================ + +interface IReader { + function getOrder(address dataStore, bytes32 key) external view returns (Order.Props memory); + function getPosition(address dataStore, bytes32 key) external view returns (Position.Props memory); + function getMarket(address dataStore, address marketAddress) external view returns (Market.Props memory); +} + +// ============================================================================ +// Data Structures +// ============================================================================ + +library Order { + struct Props { + Addresses addresses; + Numbers numbers; + Flags flags; + bytes32[] _dataList; + } + + struct Addresses { + address account; + address receiver; + address cancellationReceiver; + address callbackContract; + address uiFeeReceiver; + address market; + address initialCollateralToken; + address[] swapPath; + } + + struct Numbers { + IExchangeRouter.OrderType orderType; + IExchangeRouter.DecreasePositionSwapType decreasePositionSwapType; + uint256 sizeDeltaUsd; + uint256 initialCollateralDeltaAmount; + uint256 triggerPrice; + uint256 acceptablePrice; + uint256 executionFee; + uint256 callbackGasLimit; + uint256 minOutputAmount; + uint256 updatedAtTime; + uint256 validFromTime; + uint256 srcChainId; + } + + struct Flags { + bool isLong; + bool shouldUnwrapNativeToken; + bool isFrozen; + bool autoCancel; + } +} + +library Position { + struct Props { + Addresses addresses; + Numbers numbers; + Flags flags; + } + + struct Addresses { + address account; + address market; + address collateralToken; + } + + struct Numbers { + uint256 sizeInUsd; // Position size in USD (1e30) + uint256 sizeInTokens; // Position size in index tokens + uint256 collateralAmount; // Collateral amount + uint256 borrowingFactor; // Borrowing factor at open + uint256 fundingFeeAmountPerSize; // Funding fee per size + uint256 longTokenClaimableFundingAmountPerSize; + uint256 shortTokenClaimableFundingAmountPerSize; + uint256 increasedAtBlock; // Block number position was increased + uint256 decreasedAtBlock; // Block number position was decreased + uint256 increasedAtTime; // Timestamp position was increased + uint256 decreasedAtTime; // Timestamp position was decreased + } + + struct Flags { + bool isLong; + } +} + +library Market { + struct Props { + address marketToken; + address indexToken; + address longToken; + address shortToken; + } +} + +// ============================================================================ +// DataStore - Key-value storage +// ============================================================================ + +interface IDataStore { + function getUint(bytes32 key) external view returns (uint256); + function setUint(bytes32 key, uint256 value) external; + function getAddress(bytes32 key) external view returns (address); + function getBool(bytes32 key) external view returns (bool); + function getBytes32(bytes32 key) external view returns (bytes32); + function getBytes32Count(bytes32 setKey) external view returns (uint256); + function getBytes32ValuesAt(bytes32 setKey, uint256 start, uint256 end) external view returns (bytes32[] memory); +} + +// ============================================================================ +// RoleStore - Access control +// ============================================================================ + +interface IRoleStore { + function hasRole(address account, bytes32 roleKey) external view returns (bool); + function getRoleMembers(bytes32 roleKey, uint256 start, uint256 end) external view returns (address[] memory); + function getRoleMemberCount(bytes32 roleKey) external view returns (uint256); +} + +// ============================================================================ +// Common Keys +// ============================================================================ + +library Keys { + bytes32 internal constant ORDER_LIST = keccak256(abi.encode("ORDER_LIST")); + bytes32 internal constant POSITION_LIST = keccak256(abi.encode("POSITION_LIST")); + bytes32 internal constant ORDER_KEEPER = keccak256(abi.encode("ORDER_KEEPER")); + bytes32 internal constant ACCOUNT_ORDER_LIST = keccak256(abi.encode("ACCOUNT_ORDER_LIST")); + bytes32 internal constant ACCOUNT_POSITION_LIST = keccak256(abi.encode("ACCOUNT_POSITION_LIST")); + + /// @dev Uses double-hash + function accountOrderListKey(address account) internal pure returns (bytes32) { + return keccak256(abi.encode(ACCOUNT_ORDER_LIST, account)); + } + /// @dev Uses double-hash + function accountPositionListKey(address account) internal pure returns (bytes32) { + return keccak256(abi.encode(ACCOUNT_POSITION_LIST, account)); + } +} diff --git a/forked-env-example/contracts/mock/MockOracleProvider.sol b/forked-env-example/contracts/mock/MockOracleProvider.sol new file mode 100644 index 000000000..c4f2d190b --- /dev/null +++ b/forked-env-example/contracts/mock/MockOracleProvider.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import "../interfaces/IGmxV2.sol"; + +/** + * @title MockOracleProvider + * Mock oracle provider for testing that returns preset prices without validation + * @dev This bypasses the Chainlink Data Stream signature verification for fork testing + * @dev Implements IOracleProvider interface as expected by GMX + */ + +contract MockOracleProvider /* is IOracleProvider */ { + mapping(address => Price.Props) public tokenPrices; + + /// Set price for a token + function setPrice(address token, uint256 minPrice, uint256 maxPrice) external { + tokenPrices[token].min = minPrice; + tokenPrices[token].max = maxPrice; + } + + /// Get prices for a token (called by Oracle during validation) + /// @dev Returns the preset prices without any validation + /// @dev Implements IOracleProvider.getOraclePrice - returns OracleUtils.ValidatedPrice struct + function getOraclePrice( + address token, + bytes memory /* data */ + ) external returns (OracleUtils.ValidatedPrice memory validatedPrice) { + Price.Props memory price = tokenPrices[token]; + + validatedPrice.token = token; + validatedPrice.min = price.min; + validatedPrice.max = price.max; + validatedPrice.timestamp = block.timestamp; + validatedPrice.provider = address(this); + + return validatedPrice; + } + + /// Should adjust timestamp - required by IOracleProvider + /// @dev Returns false for this mock (no timestamp adjustment needed) + function shouldAdjustTimestamp() external pure returns (bool) { + return false; + } + + /// Is Chainlink on-chain provider - required by IOracleProvider + /// @dev Returns false for this mock (not a Chainlink provider) + function isChainlinkOnChainProvider() external pure returns (bool) { + return false; + } +} diff --git a/forked-env-example/contracts/utils/GmxForkHelpers.sol b/forked-env-example/contracts/utils/GmxForkHelpers.sol new file mode 100644 index 000000000..a215b7e52 --- /dev/null +++ b/forked-env-example/contracts/utils/GmxForkHelpers.sol @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import "../interfaces/IGmxV2.sol"; +import "../constants/GmxArbitrumAddresses.sol"; +import "../mock/MockOracleProvider.sol"; + +/** + * Helper utilities for GMX fork testing + * @dev Provides common functions for order creation, oracle mocking, and state queries + */ +abstract contract GmxForkHelpers is Test { + using GmxArbitrumAddresses for *; + + // GMX contracts (loaded in inheriting contract setUp) + IExchangeRouter internal exchangeRouter; + IOrderHandler internal orderHandler; + IOracle internal oracle; + IReader internal reader; + IDataStore internal dataStore; + IRoleStore internal roleStore; + IOracleStore internal oracleStore; + + // ============================================================================ + // Token Funding Helpers + // ============================================================================ + + /// Fund an address with native ETH + function dealETH(address recipient, uint256 amount) internal { + vm.deal(recipient, amount); + } + + /// Fund an address with ERC20 tokens using Foundry's deal cheatcode + /// @dev This works for most standard ERC20 tokens + function dealTokens(address token, address recipient, uint256 amount) internal { + deal(token, recipient, amount); + } + + // ============================================================================ + // Keeper Management + // ============================================================================ + + /// Get first active ORDER_KEEPER from RoleStore + /// @return keeper address of an active keeper + function getActiveKeeper() internal view returns (address keeper) { + uint256 keeperCount = roleStore.getRoleMemberCount(Keys.ORDER_KEEPER); + require(keeperCount > 0, "No ORDER_KEEPERs found"); + + address[] memory keepers = roleStore.getRoleMembers(Keys.ORDER_KEEPER, 0, 1); + keeper = keepers[0]; + + console.log("Active keeper found:", keeper); + } + + // ============================================================================ + // Execution Fee + // ============================================================================ + + /// Get execution fee for order creation + /// @dev Hardcoded for simplicity in this example. In production, this should be calculated + /// based on gas limits from DataStore, multiplier factors, oracle price counts and current gas price + function getExecutionFee() internal pure returns (uint256) { + return 0.0002 ether; + } + + // ============================================================================ + // Order Parameter Builders + // ============================================================================ + + /// Create parameters for a MarketIncrease order (open/increase long/short position) + /// @param market Market address + /// @param collateralToken Token to use as collateral + /// @param collateralAmount Amount of collateral in token decimals (execution fee will be added if token is WETH) + /// @param sizeDeltaUsd Position size in USD (scaled by 1e30) + /// @param isLong true for long, false for short + function createIncreaseOrderParams( + address market, + address collateralToken, + uint256 collateralAmount, + uint256 sizeDeltaUsd, + bool isLong + ) internal view returns (IExchangeRouter.CreateOrderParams memory params) { + address[] memory emptySwapPath = new address[](0); + uint256 executionFee = getExecutionFee(); + + // When collateral token is WETH, execution fee is deducted from transferred amount + // So we need to add it to the collateral amount + uint256 initialCollateralDeltaAmount = collateralAmount; + if (collateralToken == GmxArbitrumAddresses.WETH) { + initialCollateralDeltaAmount = collateralAmount + executionFee; + } + + params.addresses = IExchangeRouter.CreateOrderParamsAddresses({ + receiver: address(this), + cancellationReceiver: address(this), + callbackContract: address(0), + uiFeeReceiver: address(0), + market: market, + initialCollateralToken: collateralToken, + swapPath: emptySwapPath + }); + + params.numbers = IExchangeRouter.CreateOrderParamsNumbers({ + sizeDeltaUsd: sizeDeltaUsd, + initialCollateralDeltaAmount: initialCollateralDeltaAmount, + triggerPrice: 0, // 0 for market orders + acceptablePrice: isLong ? type(uint256).max : 1, // Match fuzzing: max for long, 1 for short + executionFee: executionFee, + callbackGasLimit: 200000, // Match fuzzing: 200k gas for callbacks + minOutputAmount: 1, // Match fuzzing: minimal output requirement + validFromTime: 0 + }); + + params.orderType = IExchangeRouter.OrderType.MarketIncrease; + params.decreasePositionSwapType = IExchangeRouter.DecreasePositionSwapType.NoSwap; + params.isLong = isLong; + params.shouldUnwrapNativeToken = false; + params.autoCancel = true; // Match fuzzing: enable auto-cancel + params.referralCode = bytes32(0); + params.dataList = new bytes32[](0); + } + + /// Create parameters for a MarketDecrease order (close/decrease position) + /// @param market Market address + /// @param collateralToken Collateral token of the position + /// @param sizeDeltaUsd Position size to decrease in USD (scaled by 1e30) + /// @param isLong true for long, false for short + function createDecreaseOrderParams( + address market, + address collateralToken, + uint256 sizeDeltaUsd, + bool isLong + ) internal view returns (IExchangeRouter.CreateOrderParams memory params) { + address[] memory emptySwapPath = new address[](0); + + params.addresses = IExchangeRouter.CreateOrderParamsAddresses({ + receiver: address(this), + cancellationReceiver: address(this), + callbackContract: address(0), + uiFeeReceiver: address(0), + market: market, + initialCollateralToken: collateralToken, + swapPath: emptySwapPath + }); + + params.numbers = IExchangeRouter.CreateOrderParamsNumbers({ + sizeDeltaUsd: sizeDeltaUsd, + initialCollateralDeltaAmount: 0, // For decrease, we're closing, not adding collateral + triggerPrice: 0, + acceptablePrice: isLong ? 0 : type(uint256).max, // No slippage limit + executionFee: getExecutionFee(), + callbackGasLimit: 0, + minOutputAmount: 0, + validFromTime: 0 + }); + + params.orderType = IExchangeRouter.OrderType.MarketDecrease; + params.decreasePositionSwapType = IExchangeRouter.DecreasePositionSwapType.NoSwap; + params.isLong = isLong; + params.shouldUnwrapNativeToken = false; + params.autoCancel = false; + params.referralCode = bytes32(0); + params.dataList = new bytes32[](0); + } + + // ============================================================================ + // Event Parsing + // ============================================================================ + + /// Extract order key from OrderCreated event logs + /// @param logs Transaction logs + /// @return orderKey The created order's key + function getOrderKeyFromLogs(Vm.Log[] memory logs) internal pure returns (bytes32 orderKey) { + // OrderCreated event signature: OrderCreated(bytes32 key, ...) + bytes32 orderCreatedTopic = keccak256("OrderCreated(bytes32,Order.Props)"); + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == orderCreatedTopic) { + orderKey = logs[i].topics[1]; // First indexed parameter is the key + return orderKey; + } + } + + revert("OrderCreated event not found in logs"); + } + + // ============================================================================ + // Oracle Price Mocking + // ============================================================================ + + /// Mock GMX oracle provider calls for testing + /// @dev Uses vm.etch to replace the provider contract with our mock + /// @param wethPrice WETH price in USD (e.g., 5000 for $5000/ETH) + /// @param usdcPrice USDC price in USD (e.g., 1 for $1/USDC) + /// @return mockProviderAddress The address of the actual provider being mocked + function setupMockOracleProvider(uint256 wethPrice, uint256 usdcPrice) internal returns (address mockProviderAddress) { + // GMX price format: price * 10^30 / 10^tokenDecimals + // For WETH (18 decimals): $5000 = 5000 * 10^30 / 10^18 = 5000 * 10^12 + // For USDC (6 decimals): $1 = 1 * 10^30 / 10^6 = 1 * 10^24 + uint256 wethPriceFormatted = wethPrice * 1e12; + uint256 usdcPriceFormatted = usdcPrice * 1e24; + + // Use the actual Data Streams provider address from mainnet + // This is what production uses (verified from etherscan transaction) + address providerAddress = GmxArbitrumAddresses.CHAINLINK_DATA_STREAM_PROVIDER; + + // Deploy a MockOracleProvider implementation + MockOracleProvider mockImpl = new MockOracleProvider(); + + // Replace the bytecode at the production provider address with our mock + vm.etch(providerAddress, address(mockImpl).code); + + // Configure prices in the mock (now at the production address) + MockOracleProvider(providerAddress).setPrice( + GmxArbitrumAddresses.WETH, + wethPriceFormatted, + wethPriceFormatted + ); + + MockOracleProvider(providerAddress).setPrice( + GmxArbitrumAddresses.USDC, + usdcPriceFormatted, + usdcPriceFormatted + ); + + console.log("Replaced oracle provider bytecode at:", providerAddress); + console.log("WETH price set to:", wethPriceFormatted); + console.log("USDC price set to:", usdcPriceFormatted); + + return providerAddress; + } + + // ============================================================================ + // Order Queries + // ============================================================================ + + /// Get total order count from global order list + /// @return count Number of orders in the global list + function getOrderCount() internal view returns (uint256) { + return dataStore.getBytes32Count(Keys.ORDER_LIST); + } + + /// Get order count for a specific account + /// @param account Account address + /// @return count Number of orders for the account + function getAccountOrderCount(address account) internal view returns (uint256) { + bytes32 accountOrderListKey = Keys.accountOrderListKey(account); + return dataStore.getBytes32Count(accountOrderListKey); + } + + // ============================================================================ + // Position Queries + // ============================================================================ + + /// Get position for an account + /// @param account Position owner + /// @param market Market address + /// @param collateralToken Collateral token + /// @param isLong Long or short position + function getPosition( + address account, + address market, + address collateralToken, + bool isLong + ) internal view returns (Position.Props memory) { + bytes32 positionKey = getPositionKey(account, market, collateralToken, isLong); + return reader.getPosition(address(dataStore), positionKey); + } + + /// Get total position count from global position list + /// @return count Number of positions in the global list + function getPositionCount() internal view returns (uint256) { + return dataStore.getBytes32Count(Keys.POSITION_LIST); + } + + /// Get position keys from global position list + /// @param start Starting index + /// @param end Ending index + /// @return Position keys in the specified range + function getPositionKeys(uint256 start, uint256 end) internal view returns (bytes32[] memory) { + return dataStore.getBytes32ValuesAt(Keys.POSITION_LIST, start, end); + } + + /// Get position count for a specific account + /// @param account Account address + /// @return count Number of positions for the account + function getAccountPositionCount(address account) internal view returns (uint256) { + bytes32 accountPositionListKey = Keys.accountPositionListKey(account); + return dataStore.getBytes32Count(accountPositionListKey); + } + + /// Get position keys for a specific account + /// @param account Account address + /// @param start Starting index + /// @param end Ending index + /// @return Position keys for the account in the specified range + function getAccountPositionKeys(address account, uint256 start, uint256 end) internal view returns (bytes32[] memory) { + bytes32 accountPositionListKey = Keys.accountPositionListKey(account); + return dataStore.getBytes32ValuesAt(accountPositionListKey, start, end); + } + + /// Get pending impact amount key for a position + /// @param positionKey The position key + /// @return The pending impact amount key + function getPendingImpactAmountKey(bytes32 positionKey) internal pure returns (bytes32) { + // PENDING_IMPACT_AMOUNT = keccak256(abi.encode("PENDING_IMPACT_AMOUNT")) + bytes32 PENDING_IMPACT_AMOUNT = keccak256(abi.encode("PENDING_IMPACT_AMOUNT")); + return keccak256(abi.encode(positionKey, PENDING_IMPACT_AMOUNT)); + } + + /// Compute position key from parameters (following Hardhat test pattern) + /// @dev Position key = keccak256(abi.encodePacked(account, market, collateralToken, isLong)) + /// @param account Position owner + /// @param market Market address + /// @param collateralToken Collateral token address + /// @param isLong True for long, false for short + /// @return Position key (keccak256 hash of parameters) + function getPositionKey( + address account, + address market, + address collateralToken, + bool isLong + ) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(account, market, collateralToken, isLong)); + } +} diff --git a/forked-env-example/foundry.toml b/forked-env-example/foundry.toml new file mode 100644 index 000000000..961d862ce --- /dev/null +++ b/forked-env-example/foundry.toml @@ -0,0 +1,31 @@ +[profile.default] +src = "contracts" +out = "out" +libs = ["lib"] +solc_version = "0.8.20" +evm_version = "paris" # Arbitrum uses Paris EVM (not Cancun yet) +optimizer = true +optimizer_runs = 200 +via_ir = false + +[profile.default.fuzz] +runs = 256 + +[profile.default.invariant] +runs = 256 +depth = 15 +fail_on_revert = true + +# RPC endpoints for forking +[rpc_endpoints] +arbitrum = "${ARBITRUM_RPC_URL}" + +# Etherscan API keys for contract verification (optional) +[etherscan] +arbitrum = { key = "${ARBISCAN_API_KEY}", url = "https://api.arbiscan.io/api" } + +# Formatter settings +[fmt] +line_length = 120 +tab_width = 4 +bracket_spacing = true diff --git a/forked-env-example/test/GmxOrderFlow.t.sol b/forked-env-example/test/GmxOrderFlow.t.sol new file mode 100644 index 000000000..a7325701d --- /dev/null +++ b/forked-env-example/test/GmxOrderFlow.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import "../contracts/interfaces/IGmxV2.sol"; +import "../contracts/constants/GmxArbitrumAddresses.sol"; +import "../contracts/utils/GmxForkHelpers.sol"; + +/** + * Fork tests demonstrating GMX Synthetics V2 order flow on Arbitrum + * @dev Tests the complete flow of opening and closing positions using GMX + * + * Prerequisites: + * 1. Set ARBITRUM_RPC_URL in .env file + * 2. Run: forge test --fork-url $ARBITRUM_RPC_URL --fork-block-number 392496384 -vv + * + * Key Learnings: + * - GMX uses Chainlink Data Streams for oracle prices (off-chain signed data) + * - To test on a fork, the oracle provider bytecode is replaced with a mock using vm.etch + * - The production provider address is 0xE1d5a068c5b75E0c7Ea1A9Fe8EA056f9356C6fFD (verified from mainnet txs) + * - Order execution emits PositionIncrease and OrderExecuted events via EventLog1 generic emitter + */ +contract GmxOrderFlowTest is Test, GmxForkHelpers { + // Test user + address user; + + // Tokens + IERC20 weth; + IERC20 usdc; + + // Test parameters + uint256 constant INITIAL_ETH_BALANCE = 100 ether; + // Reference Mainnet tx 0x68a77542fd9ba2bcd342099158dd17c0918cee70726ecd2e2446b0f16c46da50 + // fork at block 392496384 - matches mainnet order execution for accurate comparison + // oracle price: 0xdd631636455a0 = $3,892.32 + uint256 constant FORK_BLOCK_NUMBER = 392496384; + + // GMX price format: price * 1e30 / (10^tokenDecimals) + // For ETH ($3892, 18 decimals): 3892 * 1e30 / 1e18 = 3892 * 1e12 + uint256 constant ETH_PRICE_USD = 3892; // Match mainnet order execution price ($3,892.32 rounded) + uint256 constant USDC_PRICE_USD = 1; // $1 per USDC + + uint256 constant ETH_COLLATERAL = 0.001 ether; // 0.001 ETH collateral + + // Active keeper address (queried in setUp) + address keeper; + + function setUp() public { + string memory rpcUrl = vm.envString("ARBITRUM_RPC_URL"); + vm.createSelectFork(rpcUrl, FORK_BLOCK_NUMBER); + + console.log("=== Fork Setup ==="); + console.log("Chain ID:", block.chainid); + console.log("Block number:", block.number); + console.log("=================="); + + // Load GMX contracts using deployed addresses + exchangeRouter = IExchangeRouter(GmxArbitrumAddresses.EXCHANGE_ROUTER); + orderHandler = IOrderHandler(GmxArbitrumAddresses.ORDER_HANDLER); + oracle = IOracle(GmxArbitrumAddresses.ORACLE); + reader = IReader(GmxArbitrumAddresses.READER); + dataStore = IDataStore(GmxArbitrumAddresses.DATA_STORE); + roleStore = IRoleStore(GmxArbitrumAddresses.ROLE_STORE); + oracleStore = IOracleStore(GmxArbitrumAddresses.ORACLE_STORE); + + // Load token contracts + weth = IERC20(GmxArbitrumAddresses.WETH); + usdc = IERC20(GmxArbitrumAddresses.USDC); + + user = makeAddr("user"); + console.log("User address:", user); + + // Fund user with ETH + dealETH(user, INITIAL_ETH_BALANCE); + console.log("User funded with:", INITIAL_ETH_BALANCE / 1e18, "ETH"); + + // Get active keeper for order execution + keeper = getActiveKeeper(); + + console.log("==================\n"); + } + + // ============================================================================ + // Test 1: Open Long Position + // ============================================================================ + + /// forge test --fork-url $ARBITRUM_RPC_URL --fork-block-number 392496384 --match-test testOpenLongPosition -vv + + /// Test opening a long ETH position + /// @dev This test demonstrates the complete flow of creating and executing a MarketIncrease order + function testOpenLongPosition() public { + console.log("\n=== TEST: Open Long ETH Position ===\n"); + + // Test parameters - MATCH MAINNET ORDER EXACTLY + // Mainnet reference: 0x68a77542fd9ba2bcd342099158dd17c0918cee70726ecd2e2446b0f16c46da50 + // Mainnet: User sent 0.001 ETH as native ETH, Router wrapped to WETH collateral, 2.5x leverage → ~$9.7 position at $3,892/ETH + console.log("Opening position with:"); + console.log("- Collateral: %s wei (~$%s at $%s/ETH)", ETH_COLLATERAL, ETH_COLLATERAL * ETH_PRICE_USD / 1e18, ETH_PRICE_USD); + console.log("- Position Size: $%s", (ETH_COLLATERAL * ETH_PRICE_USD * 2.5e30) / 1e18 / 1e30); + console.log("- Leverage: 2.5x"); + console.log("- Direction: LONG"); + + // Record initial balances + uint256 initialEthBalance = user.balance; + uint256 initialKeeperEthBalance = keeper.balance; + + console.log("Initial balances:"); + console.log("User ETH --> %s (wei), (~%s ETH)", initialEthBalance, initialEthBalance / 1e18); + console.log("Keeper ETH --> %s (wei), (~%s ETH)", initialKeeperEthBalance, initialKeeperEthBalance / 1e18); + console.log(""); + + // === Step 1: Record initial state === + uint256 orderCount = getOrderCount(); + uint256 userOrderCount = getAccountOrderCount(user); + uint256 userPositionCount = getAccountPositionCount(user); + uint256 positionCount = getPositionCount(); + + bytes32 actualPositionKey; + + // === Step 2: Create order === + { + bytes32 orderKey = _createOrder(ETH_COLLATERAL, true); + console.log("Order created. Order key:", vm.toString(orderKey), "\n"); + + // Verify order was created + assertEq(getOrderCount(), orderCount + 1, "Order count +1"); + assertEq(getAccountOrderCount(user), userOrderCount + 1, "User order count +1"); + assertEq(getAccountPositionCount(user), userPositionCount, "Position count unchanged"); + + // === Step 3: Execute order === + actualPositionKey = _executeOrder(orderKey); + console.log("Position created. Position key:", vm.toString(actualPositionKey), "\n"); + } + + // === Step 4: Verify final state === + assertEq(getOrderCount(), orderCount, "Order count back to initial (order consumed)"); + assertEq(getAccountOrderCount(user), userOrderCount, "User order count back to initial"); + assertEq(getAccountPositionCount(user), userPositionCount + 1, "User position count +1"); + assertEq(getPositionCount(), positionCount + 1, "Global position count +1"); + + console.log("Final balances:"); + uint256 finalEthBalance = user.balance; + uint256 finalKeeperEthBalance = keeper.balance; + console.log("User ETH --> %s (wei), (~%s ETH), diff: -%s (wei)", finalEthBalance, finalEthBalance / 1e18, initialEthBalance - finalEthBalance); + console.log("Keeper ETH --> %s (wei), (~%s ETH), diff: +%s (wei)", finalKeeperEthBalance, finalKeeperEthBalance / 1e18, finalKeeperEthBalance - initialKeeperEthBalance); + } + + // ============================================================================ + // Test 2: Close Long Position + // ============================================================================ + + /// forge test --fork-url $ARBITRUM_RPC_URL --fork-block-number 392496384 --match-test testCloseLongPosition -vv + + /// Test closing a long ETH position + /// @dev This test first opens a position, then closes it completely + function testCloseLongPosition() public { + console.log("\n=== TEST: Close Long ETH Position ===\n"); + + // === Step 1: Open Position === + bytes32 orderKey = _createOrder(ETH_COLLATERAL, true); + bytes32 positionKey = _executeOrder(orderKey); + console.log("Position created. Position key:", vm.toString(positionKey), "\n"); + + // Record state after opening + uint256 initialWethBalance = weth.balanceOf(user); + + assertEq(getAccountPositionCount(user), 1, "Should have 1 position after opening"); + + // === Step 2: Close Position === + uint256 positionSizeUsd = (ETH_COLLATERAL * ETH_PRICE_USD * 2.5e30) / 1e18; + bytes32 closeOrderKey = _createDecreaseOrder(positionSizeUsd); + _executeOrder(closeOrderKey); + console.log("Position closed. Decrease order key:", vm.toString(closeOrderKey), "\n"); + + // === Step 3: Verify Results === + assertEq(getAccountPositionCount(user), 0, "Position count should be 0 after closing"); + + uint256 finalWethBalance = weth.balanceOf(user); + uint256 wethReceived = finalWethBalance - initialWethBalance; + + console.log("After closing position:"); + console.log("- Initial WETH balance:", initialWethBalance); + console.log("- Final WETH balance:", finalWethBalance); + console.log("- WETH received:", wethReceived); + + assertGt(wethReceived, 0, "Should receive WETH back (collateral returned)"); + } + + // ============================================================================ + // Internal Helpers + // ============================================================================ + + /// Create an increase order (long or short) + /// @param collateralAmount Amount of collateral in ETH + /// @param isLong true for long, false for short + /// @return orderKey The order key + function _createOrder(uint256 collateralAmount, bool isLong) internal returns (bytes32 orderKey) { + uint256 executionFee = getExecutionFee(); + uint256 leverage = 2.5e30; + uint256 positionSizeUsd = (collateralAmount * ETH_PRICE_USD * leverage) / 1e18; + + IExchangeRouter.CreateOrderParams memory orderParams = createIncreaseOrderParams({ + market: GmxArbitrumAddresses.ETH_USD_MARKET, + collateralToken: address(weth), + collateralAmount: collateralAmount, + sizeDeltaUsd: positionSizeUsd, + isLong: isLong + }); + + orderParams.numbers.executionFee = executionFee; + orderParams.numbers.initialCollateralDeltaAmount = collateralAmount + executionFee; + orderParams.addresses.receiver = user; + orderParams.addresses.cancellationReceiver = user; + orderParams.numbers.acceptablePrice = type(uint256).max; + orderParams.numbers.callbackGasLimit = 200000; + orderParams.numbers.minOutputAmount = 1; + orderParams.autoCancel = true; + + vm.startPrank(user); + uint256 totalEthNeeded = orderParams.numbers.initialCollateralDeltaAmount; + exchangeRouter.sendWnt{value: totalEthNeeded}(GmxArbitrumAddresses.ORDER_VAULT, totalEthNeeded); + orderKey = exchangeRouter.createOrder{value: 0}(orderParams); + vm.stopPrank(); + } + + /// Execute an order as keeper + /// @param orderKey The order key to execute + /// @return positionKey The resulting position key + function _executeOrder(bytes32 orderKey) internal returns (bytes32 positionKey) { + setupMockOracleProvider(ETH_PRICE_USD, USDC_PRICE_USD); + + vm.startPrank(keeper); + OracleUtils.SetPricesParams memory oracleParams; + oracleParams.tokens = new address[](2); + oracleParams.tokens[0] = address(weth); + oracleParams.tokens[1] = address(usdc); + oracleParams.providers = new address[](2); + oracleParams.providers[0] = GmxArbitrumAddresses.CHAINLINK_DATA_STREAM_PROVIDER; + oracleParams.providers[1] = GmxArbitrumAddresses.CHAINLINK_DATA_STREAM_PROVIDER; + oracleParams.data = new bytes[](2); + orderHandler.executeOrder(orderKey, oracleParams); + vm.stopPrank(); + + return getPositionKey(user, GmxArbitrumAddresses.ETH_USD_MARKET, address(weth), true); + } + + /// Create a decrease order to close a position + /// @param positionSizeUsd The size in USD (30 decimals) to decrease + /// @return orderKey The order key + function _createDecreaseOrder(uint256 positionSizeUsd) internal returns (bytes32 orderKey) { + IExchangeRouter.CreateOrderParams memory orderParams = createDecreaseOrderParams({ + market: GmxArbitrumAddresses.ETH_USD_MARKET, + collateralToken: address(weth), + sizeDeltaUsd: positionSizeUsd, + isLong: true + }); + + orderParams.numbers.acceptablePrice = 0; // 0 = market price + orderParams.addresses.receiver = user; // Collateral returned to user + uint256 executionFee = getExecutionFee(); + orderParams.numbers.executionFee = executionFee; + + vm.startPrank(user); + exchangeRouter.sendWnt{value: executionFee}(GmxArbitrumAddresses.ORDER_VAULT, executionFee); + orderKey = exchangeRouter.createOrder{value: 0}(orderParams); + vm.stopPrank(); + } +}