diff --git a/.gitignore b/.gitignore index e9bee0f..dbecd59 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ out/ /broadcast/*/31337/ /broadcast/**/dry-run/ -# Docs -docs/ - # Dotenv file .env diff --git a/docs/aerodrome.md b/docs/aerodrome.md new file mode 100644 index 0000000..799a3c1 --- /dev/null +++ b/docs/aerodrome.md @@ -0,0 +1,102 @@ +# Aerodrome Cross-Chain Swap Integration: Creating Intents + +This document explains how to create intents for cross-chain swaps on Aerodrome DEX using the AerodromeInitiator contract. + +## Creating an Aerodrome Swap Intent + +You can use Foundry's `cast` command to create Aerodrome swap intents from the command line. + +### Step 1: Approve Tokens to the Initiator + +First, approve the AerodromeInitiator contract to spend your tokens: + +```shell +cast send [TOKEN_ADDRESS] "approve(address,uint256)" \ + [AERODROME_INITIATOR_ADDRESS] \ + [AMOUNT+TIP] \ + --rpc-url [SOURCE_RPC_URL] \ + --private-key [YOUR_PRIVATE_KEY] +``` + +### Step 2: Create the Swap Intent + +Call the `initiateAerodromeSwap` function on the AerodromeInitiator contract: + +```shell +# Define the parameters for the swap +SOURCE_TOKEN=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 # Source chain USDC +TARGET_TOKEN=0x4200000000000000000000000000000000000006 # Base WETH +AMOUNT=1000000 # 1 USDC (6 decimals) +TIP=100000 # 0.1 USDC +SALT=123 # Any unique number +GAS_LIMIT=300000 +MIN_AMOUNT_OUT=0 # Be careful with this in production! +DEADLINE=$(($(date +%s) + 3600)) # 1 hour from now +RECEIVER=0xYourAddressHere # Your address on Base + +# Execute the swap intent creation +cast send $AERODROME_INITIATOR_ADDRESS \ + "initiateAerodromeSwap(address,uint256,uint256,uint256,uint256,address[],bool[],uint256,uint256,address)" \ + $SOURCE_TOKEN \ + $AMOUNT \ + $TIP \ + $SALT \ + $GAS_LIMIT \ + "[\"$SOURCE_TOKEN\",\"$TARGET_TOKEN\"]" \ + "[false]" \ + $MIN_AMOUNT_OUT \ + $DEADLINE \ + $RECEIVER \ + --rpc-url $SOURCE_RPC_URL \ + --private-key $PRIVATE_KEY +``` + +## Complete Example (Ready to Run) + +Here's a concrete example with all parameters directly in the command: + +```shell +# This command initiates a swap of 1 USDC to WETH on Base +cast send 0x123456789AbCdEf0123456789aBcDeF01234567 \ + "initiateAerodromeSwap(address,uint256,uint256,uint256,uint256,address[],bool[],uint256,uint256,address)" \ + 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 \ + 1000000 \ + 100000 \ + 12345 \ + 300000 \ + "[\"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\"0x4200000000000000000000000000000000000006\"]" \ + "[false]" \ + 0 \ + 1718438400 \ + 0xaB1234C567dEf890123456789aBcDef012345678 \ + --rpc-url https://zetachain-evm.blockpi.network/v1/rpc/public \ + --private-key 0xYOUR_PRIVATE_KEY_HERE +``` + +Replace: +- `0x123456789AbCdEf0123456789aBcDeF01234567` with your AerodromeInitiator contract address +- `0xaB1234C567dEf890123456789aBcDef012345678` with your receiving address on Base +- `0xYOUR_PRIVATE_KEY_HERE` with your private key +- `1718438400` with a future timestamp (this example is set to June 15, 2024) + +## Parameter Breakdown + +- **SOURCE_TOKEN**: The token you're sending (e.g., USDC on ZetaChain) +- **AMOUNT**: Amount of tokens to swap (in the token's smallest unit) +- **TIP**: Incentive for fulfillers to execute your intent +- **SALT**: A unique number to generate a distinct intent ID +- **GAS_LIMIT**: Gas limit for execution on Base +- **PATH**: Array of token addresses for the swap path (must start with SOURCE_TOKEN) +- **STABLE_FLAGS**: Array of booleans indicating if each pool is stable (false) or volatile (true) +- **MIN_AMOUNT_OUT**: Minimum amount to receive after the swap +- **DEADLINE**: Unix timestamp after which the swap will revert +- **RECEIVER**: Address on Base to receive the swapped tokens + +## Troubleshooting + +### Common Issues + +1. **Path and Flags Length Mismatch**: Ensure that `stableFlags.length == path.length - 1` +2. **Token Mismatch**: The first token in the path must match the `asset` parameter +3. **Insufficient Approval**: Ensure you've approved the initiator to spend at least `amount + tip` tokens + diff --git a/examples/CrossChainSwapper.sol b/examples/CrossChainSwapper.sol deleted file mode 100644 index e490ce1..0000000 --- a/examples/CrossChainSwapper.sol +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.26; - -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; -import "../src/interfaces/IntentTarget.sol"; - -// Uniswap V2 Router interface (partial) -interface IUniswapV2Router { - function swapExactTokensForTokens( - uint256 amountIn, - uint256 amountOutMin, - address[] calldata path, - address to, - uint256 deadline - ) external returns (uint256[] memory amounts); -} - -/** - * @title CrossChainSwapper - * @dev Example implementation of IntentTarget that performs token swaps - */ -contract CrossChainSwapper is IntentTarget, Ownable { - // Uniswap V2 Router address - address public uniswapRouter; - - // Reward configuration - uint256 public rewardPercentage = 5; // 5% reward to fulfillers - - /** - * @dev Constructor - * @param _uniswapRouter The Uniswap V2 Router address - */ - constructor(address _uniswapRouter) Ownable(msg.sender) { - uniswapRouter = _uniswapRouter; - } - - /** - * @dev Update the Uniswap router address - * @param _uniswapRouter The new router address - */ - function setUniswapRouter(address _uniswapRouter) external onlyOwner { - uniswapRouter = _uniswapRouter; - } - - /** - * @dev Set reward percentage for fulfillers - * @param _percentage New percentage (0-100) - */ - function setRewardPercentage(uint256 _percentage) external onlyOwner { - require(_percentage <= 100, "Percentage must be between 0-100"); - rewardPercentage = _percentage; - } - - /** - * @dev Called by the protocol during intent fulfillment - * @param intentId The ID of the intent - * @param asset The ERC20 token address - * @param amount Amount transferred - * @param data Custom data for execution - */ - function onFulfill( - bytes32 intentId, - address asset, - uint256 amount, - bytes calldata data - ) external override { - // Decode the swap parameters from the data field - ( - address[] memory path, - uint256 minAmountOut, - uint256 deadline, - address receiver - ) = decodeSwapParams(data); - - // Ensure the first token in the path matches the received asset - require(path[0] == asset, "Asset mismatch"); - - // Approve router to spend the tokens - IERC20(asset).approve(uniswapRouter, amount); - - // Execute the swap on Uniswap - IUniswapV2Router(uniswapRouter).swapExactTokensForTokens( - amount, - minAmountOut, - path, - receiver, - deadline - ); - } - - /** - * @dev Called by the protocol during intent settlement - * @param intentId The ID of the intent - * @param asset The ERC20 token address - * @param amount Amount transferred - * @param data Custom data for execution - * @param fulfillmentIndex The fulfillment index for this intent - */ - function onSettle( - bytes32 intentId, - address asset, - uint256 amount, - bytes calldata data, - bytes32 fulfillmentIndex - ) external override { - // This function is called when an intent is settled - // We can implement custom logic here, such as rewarding the fulfiller - - // We could send a small reward to the fulfiller from this contract's balance - // This might be tokens previously sent to this contract for this purpose - - // Example: get receiver address from the data - (, , , address receiver) = decodeSwapParams(data); - - // Example: transfer a small reward from this contract to the fulfiller - // This assumes this contract holds some tokens for rewards - // In a real implementation, you might have a more sophisticated reward system - - // Get fulfiller address from the Intent contract (passed as msg.sender) - address intentContract = msg.sender; - - // Note: In a real implementation, you would have a way to get the fulfiller address - // For this example, we're just showing the concept - // Normally, you could call a view function on the Intent contract to get the fulfiller - } - - /** - * @dev Helper function to decode swap parameters from the bytes data - * @param data The encoded swap parameters - * @return path Array of token addresses for the swap path - * @return minAmountOut Minimum output amount - * @return deadline Transaction deadline - * @return receiver Address that will receive the swapped tokens - */ - function decodeSwapParams( - bytes memory data - ) - internal - pure - returns ( - address[] memory path, - uint256 minAmountOut, - uint256 deadline, - address receiver - ) - { - // Decode the packed data - return abi.decode(data, (address[], uint256, uint256, address)); - } - - /** - * @dev Helper function to encode swap parameters - * @param path Array of token addresses for the swap path - * @param minAmountOut Minimum output amount - * @param deadline Transaction deadline - * @param receiver Address that will receive the swapped tokens - * @return The encoded parameters as bytes - */ - function encodeSwapParams( - address[] memory path, - uint256 minAmountOut, - uint256 deadline, - address receiver - ) public pure returns (bytes memory) { - return abi.encode(path, minAmountOut, deadline, receiver); - } -} diff --git a/script/targetModules/base/AerodromeInitiator.s.sol b/script/targetModules/base/AerodromeInitiator.s.sol new file mode 100644 index 0000000..35ef500 --- /dev/null +++ b/script/targetModules/base/AerodromeInitiator.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {AerodromeInitiator} from "../../../src/targetModules/base/AerodromeInitiator.sol"; + +/** + * @title AerodromeInitiatorScript + * @dev Deployment script for the Aerodrome initiator contract on source chain + */ +contract AerodromeInitiatorScript is Script { + function setUp() public {} + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + // Get addresses from environment variables + address intentContract = vm.envAddress("INTENT_CONTRACT"); + address targetModule = vm.envAddress("AERODROME_MODULE_ADDRESS"); + uint256 targetChainId = vm.envUint("BASE_CHAIN_ID"); + + // Deploy AerodromeInitiator on the source chain + AerodromeInitiator aerodromeInitiator = new AerodromeInitiator(intentContract, targetModule, targetChainId); + + console2.log("AerodromeInitiator deployed to:", address(aerodromeInitiator)); + console2.log("Intent Contract:", intentContract); + console2.log("Target Module:", targetModule); + console2.log("Target Chain ID:", targetChainId); + + vm.stopBroadcast(); + } +} diff --git a/script/targetModules/base/AerodromeModule.s.sol b/script/targetModules/base/AerodromeModule.s.sol new file mode 100644 index 0000000..9b7cb01 --- /dev/null +++ b/script/targetModules/base/AerodromeModule.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {AerodromeModule} from "../../../src/targetModules/base/AerodromeModule.sol"; + +/** + * @title AerodromeModuleScript + * @dev Deployment script for the Aerodrome module contract on Base chain + */ +contract AerodromeModuleScript is Script { + function setUp() public {} + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + // Get addresses from environment variables + address aerodromeRouter = vm.envAddress("AERODROME_ROUTER"); + address poolFactory = vm.envAddress("AERODROME_POOL_FACTORY"); + address intentContract = vm.envAddress("INTENT_CONTRACT"); + + // Deploy AerodromeModule on the target chain (Base) + AerodromeModule aerodromeModule = new AerodromeModule(aerodromeRouter, poolFactory, intentContract); + + console2.log("AerodromeModule deployed to:", address(aerodromeModule)); + console2.log("Aerodrome Router:", aerodromeRouter); + console2.log("Pool Factory:", poolFactory); + console2.log("Intent Contract:", intentContract); + + // Optional: Set reward percentage if different from default (5%) + // uint256 customRewardPercentage = 10; + // aerodromeModule.setRewardPercentage(customRewardPercentage); + // console2.log("Reward percentage set to:", customRewardPercentage); + + vm.stopBroadcast(); + } +} diff --git a/script/targetModules/base/TryDirectSwap.s.sol b/script/targetModules/base/TryDirectSwap.s.sol new file mode 100644 index 0000000..8e88eec --- /dev/null +++ b/script/targetModules/base/TryDirectSwap.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IAerodromeRouter { + struct Route { + address from; + address to; + bool stable; + address factory; + } + + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); +} + +contract TryDirectSwapScript is Script { + // Aerodrome router on Base + address constant AERODROME_ROUTER = 0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43; + + // Token addresses + address constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC + address constant WETH = 0x4200000000000000000000000000000000000006; // WETH + + // Aerodrome pool factory + address constant POOL_FACTORY = 0x420DD381b31aEf6683db6B902084cB0FFECe40Da; + + // Parameters for the swap + uint256 constant AMOUNT_IN = 100000; // 0.1 USDC (6 decimals) + uint256 constant MIN_AMOUNT_OUT = 0; + uint256 constant DEADLINE = type(uint256).max; // Max deadline + address constant RECEIVER = 0xD8ba46B6fc4b29d645eE44403060e91F38fbF9C1; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console.log("Attempting swap with account:", deployer); + console.log("USDC balance before:", IERC20(USDC).balanceOf(deployer)); + console.log("WETH balance before:", IERC20(WETH).balanceOf(deployer)); + + // Create route + IAerodromeRouter.Route[] memory routes = new IAerodromeRouter.Route[](1); + routes[0] = IAerodromeRouter.Route({from: USDC, to: WETH, stable: false, factory: POOL_FACTORY}); + + vm.startBroadcast(deployerPrivateKey); + + // Approve router to spend tokens + IERC20(USDC).approve(AERODROME_ROUTER, AMOUNT_IN); + + // Execute swap + IAerodromeRouter(AERODROME_ROUTER).swapExactTokensForTokens( + AMOUNT_IN, MIN_AMOUNT_OUT, routes, RECEIVER, DEADLINE + ); + + vm.stopBroadcast(); + + console.log("USDC balance after:", IERC20(USDC).balanceOf(deployer)); + console.log("WETH balance after:", IERC20(WETH).balanceOf(RECEIVER)); + } +} diff --git a/script/targetModules/base/TryIntentSwap.s.sol b/script/targetModules/base/TryIntentSwap.s.sol new file mode 100644 index 0000000..cfa3044 --- /dev/null +++ b/script/targetModules/base/TryIntentSwap.s.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {AerodromeInitiator} from "../../../src/targetModules/base/AerodromeInitiator.sol"; +import {IIntent} from "../../../src/interfaces/IIntent.sol"; + +contract TryIntentSwapScript is Script { + // Contract addresses + address constant AERODROME_INITIATOR = 0xbAFeFC473e886557A7Bc8a283EdF4Cf47a3E17f9; + + // Token addresses + address constant USDC_BASE = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address constant USDC_ARB = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + address constant WETH = 0x4200000000000000000000000000000000000006; + + // Parameters for the swap + uint256 constant AMOUNT_IN = 100000; + uint256 constant TIP = 200000; + uint256 constant SALT = 123456; + uint256 constant GAS_LIMIT = 600000; + uint256 constant MIN_AMOUNT_OUT = 0; + uint256 constant DEADLINE = type(uint256).max; + address constant RECEIVER = 0xD8ba46B6fc4b29d645eE44403060e91F38fbF9C1; + + // Swap path and stable flags + address[] public path; + bool[] public stableFlags; + + // AerodromeInitiator address if not deploying new one + address public existingInitiator; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console.log("Attempting to create intent with account:", deployer); + console.log("USDC balance before:", IERC20(USDC_ARB).balanceOf(deployer)); + + // Initialize swap path + path = new address[](2); + path[0] = USDC_BASE; + path[1] = WETH; + + // Initialize stable flags + stableFlags = new bool[](1); + stableFlags[0] = false; // USDC/WETH pool is not stable + + vm.startBroadcast(deployerPrivateKey); + + // Get AerodromeInitiator instance + AerodromeInitiator aerodromeInitiator = AerodromeInitiator(AERODROME_INITIATOR); + + // Approve tokens for AerodromeInitiator + IERC20(USDC_ARB).approve(address(aerodromeInitiator), AMOUNT_IN + TIP); + console.log("Approved USDC for AerodromeInitiator"); + + // Initiate swap + bytes32 intentId = aerodromeInitiator.initiateAerodromeSwap( + USDC_ARB, AMOUNT_IN, TIP, SALT, GAS_LIMIT, path, stableFlags, MIN_AMOUNT_OUT, DEADLINE, RECEIVER + ); + + console.log("Intent created with ID:", uint256(intentId)); + + vm.stopBroadcast(); + + console.log("USDC balance after:", IERC20(USDC_ARB).balanceOf(deployer)); + } +} diff --git a/src/Intent.sol b/src/Intent.sol index 1ea4d7e..3cde71e 100644 --- a/src/Intent.sol +++ b/src/Intent.sol @@ -544,7 +544,7 @@ contract Intent is // If this is a call intent, call onSettle on the receiver if (isCall) { // Call onSettle with isFulfilled = true - IntentTarget(receiver).onSettle(intentId, asset, amount, data, fulfillmentIndex, true); + IntentTarget(receiver).onSettle(intentId, asset, amount, data, fulfillmentIndex, true, tip); } } else { // Transfer tokens to the receiver @@ -556,7 +556,7 @@ contract Intent is IntentTarget(receiver).onFulfill(intentId, asset, actualAmount, data); // Then call onSettle with isFulfilled = false - IntentTarget(receiver).onSettle(intentId, asset, amount, data, fulfillmentIndex, false); + IntentTarget(receiver).onSettle(intentId, asset, amount, data, fulfillmentIndex, false, tip); } } diff --git a/src/Router.sol b/src/Router.sol index 767a523..946d72f 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -355,13 +355,13 @@ contract Router is IZRC20(settlementInfo.targetZRC20).withdrawGasFeeWithGasLimit(settlementInfo.gasLimit); } + settlementInfo.actualAmount = settlementInfo.wantedAmount; + // Check if source and target ZRC20 are the same if (intentInfo.zrc20 == settlementInfo.targetZRC20) { // No swap needed, use original amounts settlementInfo.amountWithTipOut = intentInfo.amountWithTip; settlementInfo.tipAfterSwap = wantedTip; - // Actual amount is exactly the wanted amount - settlementInfo.actualAmount = settlementInfo.wantedAmount; } else { // Approve swap module to spend tokens IERC20(intentInfo.zrc20).approve(swapModule, intentInfo.amountWithTip); diff --git a/src/interfaces/IntentTarget.sol b/src/interfaces/IntentTarget.sol index f9bb883..0e41b20 100644 --- a/src/interfaces/IntentTarget.sol +++ b/src/interfaces/IntentTarget.sol @@ -23,6 +23,7 @@ interface IntentTarget { * @param data Custom data for execution * @param fulfillmentIndex The fulfillment index for this intent * @param isFulfilled Whether the intent was fulfilled before settlement + * @param tipAmount Tip amount for this intent, can be used to redistribute if not fulfilled */ function onSettle( bytes32 intentId, @@ -30,6 +31,7 @@ interface IntentTarget { uint256 amount, bytes calldata data, bytes32 fulfillmentIndex, - bool isFulfilled + bool isFulfilled, + uint256 tipAmount ) external; } diff --git a/src/targetModules/base/AerodromeInitiator.sol b/src/targetModules/base/AerodromeInitiator.sol new file mode 100644 index 0000000..19fefa4 --- /dev/null +++ b/src/targetModules/base/AerodromeInitiator.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "../../interfaces/IIntent.sol"; +import "./AerodromeSwapLib.sol"; + +/** + * @title AerodromeInitiator + * @dev Contract to create intents on the source chain that will execute swaps on Aerodrome DEX on Base + */ +contract AerodromeInitiator is Ownable { + using SafeERC20 for IERC20; + + // Intent contract address + address public intent; + + // Target AerodromeModule address on Base chain + address public targetModule; + + // Base chain ID + uint256 public targetChainId; + + // Events + event IntentCreated( + bytes32 indexed intentId, + address indexed asset, + uint256 amount, + address receiver, + uint256 tip, + address[] path, + uint256 minAmountOut + ); + + /** + * @dev Constructor + * @param _intent The Intent contract address + * @param _targetModule The AerodromeModule address on the target chain + * @param _targetChainId The target chain ID (Base chain ID) + */ + constructor(address _intent, address _targetModule, uint256 _targetChainId) Ownable(msg.sender) { + require(_intent != address(0), "Invalid intent contract address"); + require(_targetModule != address(0), "Invalid target module address"); + require(_targetChainId != 0, "Invalid target chain ID"); + + intent = _intent; + targetModule = _targetModule; + targetChainId = _targetChainId; + } + + /** + * @dev Update the Intent contract address + * @param _intent The new Intent contract address + */ + function setIntent(address _intent) external onlyOwner { + require(_intent != address(0), "Invalid intent contract address"); + intent = _intent; + } + + /** + * @dev Update the target AerodromeModule address + * @param _targetModule The new target module address + */ + function setTargetModule(address _targetModule) external onlyOwner { + require(_targetModule != address(0), "Invalid target module address"); + targetModule = _targetModule; + } + + /** + * @dev Update the target chain ID + * @param _targetChainId The new target chain ID + */ + function setTargetChainId(uint256 _targetChainId) external onlyOwner { + require(_targetChainId != 0, "Invalid target chain ID"); + targetChainId = _targetChainId; + } + + /** + * @dev Initiates a cross-chain swap on Aerodrome + * @param asset The source token address + * @param amount Amount to swap + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @param gasLimit Gas limit for the target chain transaction + * @param path Array of token addresses for the swap path + * @param stableFlags Array of booleans indicating if pools are stable or volatile + * @param minAmountOut Minimum output amount + * @param deadline Transaction deadline + * @param receiver Address that will receive the swapped tokens + * @return intentId The generated intent ID + */ + function initiateAerodromeSwap( + address asset, + uint256 amount, + uint256 tip, + uint256 salt, + uint256 gasLimit, + address[] calldata path, + bool[] calldata stableFlags, + uint256 minAmountOut, + uint256 deadline, + address receiver + ) external returns (bytes32) { + // Input validations + require(asset != address(0), "Asset cannot be zero address"); + require(amount > 0, "Amount must be greater than zero"); + require(gasLimit > 0, "Gas limit must be greater than zero"); + require(path.length >= 2, "Invalid path"); + require(path.length - 1 == stableFlags.length, "Path and flags length mismatch"); + require(deadline > block.timestamp, "Deadline must be in the future"); + require(receiver != address(0), "Receiver cannot be zero address"); + require(minAmountOut > 0, "Minimum output amount must be greater than zero"); + + // Encode the swap parameters + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, receiver); + + // Transfer tokens from sender to this contract + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount + tip); + + // Approve the Intent contract to spend the tokens + IERC20(asset).approve(intent, amount + tip); + + // Initiate the call through the Intent contract + bytes32 intentId = IIntent(intent).initiateCall( + asset, amount, targetChainId, abi.encodePacked(targetModule), tip, salt, data, gasLimit + ); + + // Emit event + emit IntentCreated(intentId, asset, amount, receiver, tip, path, minAmountOut); + + return intentId; + } +} diff --git a/src/targetModules/base/AerodromeModule.sol b/src/targetModules/base/AerodromeModule.sol new file mode 100644 index 0000000..61a6c08 --- /dev/null +++ b/src/targetModules/base/AerodromeModule.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "../../interfaces/IntentTarget.sol"; +import "./AerodromeSwapLib.sol"; + +/** + * @title Aerodrome Router interface + * @dev Interface for the Aerodrome Router contract (inspired by Uniswap V2) + */ +interface IAerodromeRouter { + struct Route { + address from; + address to; + bool stable; + address factory; + } + + function factory() external pure returns (address); + + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); + + function getAmountsOut(uint256 amountIn, Route[] memory routes) external view returns (uint256[] memory amounts); +} + +/** + * @title AerodromeModule + * @dev Implementation of IntentTarget that performs token swaps on Aerodrome DEX on Base + */ +contract AerodromeModule is IntentTarget, Ownable { + using SafeERC20 for IERC20; + + // Aerodrome Router address + address public aerodromeRouter; + + // Pool Factory address + address public poolFactory; + + // Intent contract address + address public intentContract; + + // Reward configuration + uint256 public rewardPercentage = 5; // 5% reward to fulfillers + + /** + * @dev Constructor + * @param _aerodromeRouter The Aerodrome Router address + * @param _poolFactory The Pool Factory address + * @param _intentContract The Intent contract address + */ + constructor(address _aerodromeRouter, address _poolFactory, address _intentContract) Ownable(msg.sender) { + require(_aerodromeRouter != address(0), "Invalid router address"); + require(_poolFactory != address(0), "Invalid factory address"); + require(_intentContract != address(0), "Invalid intent contract address"); + aerodromeRouter = _aerodromeRouter; + poolFactory = _poolFactory; + intentContract = _intentContract; + } + + /** + * @dev Modifier to restrict function calls to the Intent contract only + */ + modifier onlyIntent() { + require(msg.sender == intentContract, "Caller is not the Intent contract"); + _; + } + + /** + * @dev Update the Aerodrome router address + * @param _aerodromeRouter The new router address + */ + function setAerodromeRouter(address _aerodromeRouter) external onlyOwner { + require(_aerodromeRouter != address(0), "Invalid router address"); + aerodromeRouter = _aerodromeRouter; + } + + /** + * @dev Update the pool factory address + * @param _poolFactory The new factory address + */ + function setPoolFactory(address _poolFactory) external onlyOwner { + require(_poolFactory != address(0), "Invalid factory address"); + poolFactory = _poolFactory; + } + + /** + * @dev Update the Intent contract address + * @param _intentContract The new Intent contract address + */ + function setIntentContract(address _intentContract) external onlyOwner { + require(_intentContract != address(0), "Invalid intent contract address"); + intentContract = _intentContract; + } + + /** + * @dev Set reward percentage for fulfillers + * @param _percentage New percentage (0-100) + */ + function setRewardPercentage(uint256 _percentage) external onlyOwner { + require(_percentage <= 100, "Percentage must be between 0-100"); + rewardPercentage = _percentage; + } + + /** + * @dev Called by the protocol during intent fulfillment + * @param asset The ERC20 token address + * @param amount Amount transferred + * @param data Custom data for execution + */ + function onFulfill(bytes32, address asset, uint256 amount, bytes calldata data) external override onlyIntent { + // Decode the swap parameters from the data field + (address[] memory path, bool[] memory stableFlags, uint256 minAmountOut, uint256 deadline, address receiver) = + AerodromeSwapLib.decodeSwapParams(data); + + // Ensure the first token in the path matches the received asset + require(path[0] == asset, "Asset mismatch"); + + // Verify that the tokens have been received + uint256 balance = IERC20(asset).balanceOf(address(this)); + require(balance >= amount, "Insufficient token balance received"); + + // Create the Aerodrome routes + IAerodromeRouter.Route[] memory routes = new IAerodromeRouter.Route[](path.length - 1); + for (uint256 i = 0; i < path.length - 1; i++) { + routes[i] = + IAerodromeRouter.Route({from: path[i], to: path[i + 1], stable: stableFlags[i], factory: poolFactory}); + } + + // Approve router to spend the tokens + IERC20(asset).approve(aerodromeRouter, amount); + + // Execute the swap on Aerodrome + uint256[] memory amounts = + IAerodromeRouter(aerodromeRouter).swapExactTokensForTokens(amount, minAmountOut, routes, receiver, deadline); + + // Verify that the output amount meets the minimum expected + require(amounts.length > 0 && amounts[amounts.length - 1] >= minAmountOut, "Insufficient output amount"); + } + + /** + * @dev Called by the protocol during intent settlement + * @param intentId The ID of the intent + * @param asset The ERC20 token address + * @param amount Amount transferred + * @param data Custom data for execution + * @param fulfillmentIndex The fulfillment index for this intent + * @param isFulfilled Whether the intent was fulfilled before settlement + * @param tipAmount Tip amount for this intent, can be used to redistribute if not fulfilled + */ + function onSettle( + bytes32 intentId, + address asset, + uint256 amount, + bytes calldata data, + bytes32 fulfillmentIndex, + bool isFulfilled, + uint256 tipAmount + ) external override onlyIntent { + // If the intent was not fulfilled, we give back the tip to the swap receiver here + if (!isFulfilled) { + (address[] memory path, bool[] memory stableFlags, uint256 minAmountOut, uint256 deadline, address receiver) + = AerodromeSwapLib.decodeSwapParams(data); + + IERC20(asset).safeTransfer(receiver, tipAmount); + } + } + + /** + * @dev Get the expected output amount for a swap + * @param amountIn The input amount + * @param path Array of token addresses for the swap path + * @param stableFlags Array of booleans indicating if pools are stable or volatile + * @return The expected output amount + */ + function getExpectedOutput(uint256 amountIn, address[] memory path, bool[] memory stableFlags) + external + view + returns (uint256) + { + require(path.length >= 2, "Invalid path"); + require(path.length - 1 == stableFlags.length, "Path and flags length mismatch"); + + // Create the Aerodrome routes + IAerodromeRouter.Route[] memory routes = new IAerodromeRouter.Route[](path.length - 1); + for (uint256 i = 0; i < path.length - 1; i++) { + routes[i] = + IAerodromeRouter.Route({from: path[i], to: path[i + 1], stable: stableFlags[i], factory: poolFactory}); + } + + // Get amounts out + uint256[] memory amounts = IAerodromeRouter(aerodromeRouter).getAmountsOut(amountIn, routes); + + // Return the final output amount + return amounts[amounts.length - 1]; + } +} diff --git a/src/targetModules/base/AerodromeSwapLib.sol b/src/targetModules/base/AerodromeSwapLib.sol new file mode 100644 index 0000000..b1a34b9 --- /dev/null +++ b/src/targetModules/base/AerodromeSwapLib.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/** + * @title AerodromeSwapLib + * @dev Library for encoding and decoding Aerodrome swap parameters + */ +library AerodromeSwapLib { + /** + * @dev Helper function to encode swap parameters + * @param path Array of token addresses for the swap path + * @param stableFlags Array of booleans indicating if pools are stable or volatile + * @param minAmountOut Minimum output amount + * @param deadline Transaction deadline + * @param receiver Address that will receive the swapped tokens + * @return The encoded parameters as bytes + */ + function encodeSwapParams( + address[] memory path, + bool[] memory stableFlags, + uint256 minAmountOut, + uint256 deadline, + address receiver + ) public pure returns (bytes memory) { + return abi.encode(path, stableFlags, minAmountOut, deadline, receiver); + } + + /** + * @dev Helper function to decode swap parameters from the bytes data + * @param data The encoded swap parameters + * @return path Array of token addresses for the swap path + * @return stableFlags Array of booleans indicating if pools are stable or volatile + * @return minAmountOut Minimum output amount + * @return deadline Transaction deadline + * @return receiver Address that will receive the swapped tokens + */ + function decodeSwapParams(bytes memory data) + public + pure + returns ( + address[] memory path, + bool[] memory stableFlags, + uint256 minAmountOut, + uint256 deadline, + address receiver + ) + { + // Decode the packed data + return abi.decode(data, (address[], bool[], uint256, uint256, address)); + } +} diff --git a/test/mocks/MockIntent.sol b/test/mocks/MockIntent.sol index 9dfcfcf..a034487 100644 --- a/test/mocks/MockIntent.sol +++ b/test/mocks/MockIntent.sol @@ -13,26 +13,32 @@ contract MockIntent is IIntent { address private _lastCaller; bytes private _lastMessage; - function initiate(address, uint256, uint256, bytes calldata, uint256, uint256) external returns (bytes32) { - return bytes32(0); + function initiate(address, uint256, uint256, bytes calldata, uint256, uint256) external virtual returns (bytes32) { + return bytes32(uint256(1)); } - function initiateTransfer(address, uint256, uint256, bytes calldata, uint256, uint256) external returns (bytes32) { - return bytes32(0); + function initiateTransfer(address, uint256, uint256, bytes calldata, uint256, uint256) + external + virtual + returns (bytes32) + { + return bytes32(uint256(1)); } function initiateCall(address, uint256, uint256, bytes calldata, uint256, uint256, bytes calldata) external + virtual returns (bytes32) { - return bytes32(0); + return bytes32(uint256(1)); } function initiateCall(address, uint256, uint256, bytes calldata, uint256, uint256, bytes calldata, uint256) external + virtual returns (bytes32) { - return bytes32(0); + return bytes32(uint256(1)); } function fulfill(bytes32, address, uint256, address) external { diff --git a/test/mocks/MockIntentTarget.sol b/test/mocks/MockIntentTarget.sol index dc06fe6..5466d8c 100644 --- a/test/mocks/MockIntentTarget.sol +++ b/test/mocks/MockIntentTarget.sol @@ -18,6 +18,7 @@ contract MockIntentTarget is IntentTarget { bytes public lastData; bytes32 public lastFulfillmentIndex; bool public lastIsFulfilled; + uint256 public lastTipAmount; // Variables to track token balance during function calls uint256 public balanceDuringOnFulfill; @@ -63,7 +64,8 @@ contract MockIntentTarget is IntentTarget { uint256 amount, bytes calldata data, bytes32 fulfillmentIndex, - bool isFulfilled + bool isFulfilled, + uint256 tipAmount ) external override { if (shouldRevert) { revert("MockIntentTarget: intentional revert in onSettle"); @@ -76,6 +78,7 @@ contract MockIntentTarget is IntentTarget { lastData = data; lastFulfillmentIndex = fulfillmentIndex; lastIsFulfilled = isFulfilled; + lastTipAmount = tipAmount; } /** @@ -90,6 +93,7 @@ contract MockIntentTarget is IntentTarget { lastData = ""; lastFulfillmentIndex = bytes32(0); lastIsFulfilled = false; + lastTipAmount = 0; shouldRevert = false; balanceDuringOnFulfill = 0; shouldCheckBalances = false; diff --git a/test/targetModules/base/Aerodrome.t.sol b/test/targetModules/base/Aerodrome.t.sol new file mode 100644 index 0000000..763bdc2 --- /dev/null +++ b/test/targetModules/base/Aerodrome.t.sol @@ -0,0 +1,645 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {AerodromeModule} from "../../../src/targetModules/base/AerodromeModule.sol"; +import {AerodromeInitiator} from "../../../src/targetModules/base/AerodromeInitiator.sol"; +import {AerodromeSwapLib} from "../../../src/targetModules/base/AerodromeSwapLib.sol"; +import {MockERC20} from "../../mocks/MockERC20.sol"; +import {MockIntent} from "../../mocks/MockIntent.sol"; + +// Mock Aerodrome Router Interface for testing +interface IAerodromeRouter { + struct Route { + address from; + address to; + bool stable; + address factory; + } + + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); + + function getAmountsOut(uint256 amountIn, Route[] memory routes) external view returns (uint256[] memory amounts); +} + +// Mock Aerodrome Router +contract MockAerodromeRouter { + struct Route { + address from; + address to; + bool stable; + address factory; + } + + // Keep track of the last swap + uint256 public lastAmountIn; + uint256 public lastAmountOutMin; + address public lastReceiver; + address public lastFactoryUsed; + + // Mock a 1:1 swap with 2% fee for testing purposes + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + Route[] calldata routes, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts) { + // Save parameters for assertions + lastAmountIn = amountIn; + lastAmountOutMin = amountOutMin; + lastReceiver = to; + + if (routes.length > 0) { + lastFactoryUsed = routes[0].factory; + + // Transfer tokens + IERC20(routes[0].from).transferFrom(msg.sender, address(this), amountIn); + + // Calculate output (98% to simulate fee) + uint256 amountOut = amountIn * 98 / 100; + require(amountOut >= amountOutMin, "Insufficient output amount"); + + // Generate last token in path + address lastToken = routes[routes.length - 1].to; + MockERC20(lastToken).mint(to, amountOut); + + // Return amounts + amounts = new uint256[](2); + amounts[0] = amountIn; + amounts[1] = amountOut; + return amounts; + } + + revert("Invalid routes"); + } + + function getAmountsOut(uint256 amountIn, Route[] memory routes) external pure returns (uint256[] memory amounts) { + amounts = new uint256[](routes.length + 1); + amounts[0] = amountIn; + + // Simulate a 2% fee on each hop + uint256 currentAmount = amountIn; + for (uint256 i = 0; i < routes.length; i++) { + currentAmount = currentAmount * 98 / 100; + amounts[i + 1] = currentAmount; + } + + return amounts; + } +} + +// Mock Intent contract with token transfer functionality +contract MockIntentWithTransfer is MockIntent { + function initiateCall( + address asset, + uint256 amount, + uint256, + bytes calldata, + uint256 tip, + uint256, + bytes calldata, + uint256 + ) external override returns (bytes32) { + // Transfer tokens from sender to this contract + IERC20(asset).transferFrom(msg.sender, address(this), amount + tip); + return bytes32(uint256(1)); + } +} + +contract AerodromeTest is Test { + // Contracts to test + AerodromeModule public aerodromeModule; + AerodromeInitiator public aerodromeInitiator; + + // Mocks + MockAerodromeRouter public mockRouter; + MockIntentWithTransfer public mockIntent; + MockERC20 public tokenA; + MockERC20 public tokenB; + MockERC20 public tokenC; + + // Test addresses + address public owner; + address public user; + address public poolFactory; + + // Constants + uint256 public constant AMOUNT = 1000 ether; + uint256 public constant MIN_AMOUNT_OUT = 950 ether; + uint256 public constant TIP = 10 ether; + uint256 public constant TARGET_CHAIN_ID = 8453; // Base chain ID + + // Set up the test environment + function setUp() public { + // Setup addresses + owner = address(this); + user = makeAddr("user"); + poolFactory = makeAddr("poolFactory"); + + // Deploy mock contracts + mockRouter = new MockAerodromeRouter(); + mockIntent = new MockIntentWithTransfer(); + + // Deploy tokens + tokenA = new MockERC20("Token A", "TKNA"); + tokenB = new MockERC20("Token B", "TKNB"); + tokenC = new MockERC20("Token C", "TKNC"); + + // Mint tokens to user + tokenA.mint(user, AMOUNT * 10); + tokenB.mint(address(mockRouter), AMOUNT * 10); + tokenC.mint(address(mockRouter), AMOUNT * 10); + + // Deploy module + aerodromeModule = new AerodromeModule(address(mockRouter), poolFactory, address(mockIntent)); + + // Deploy initiator + aerodromeInitiator = new AerodromeInitiator(address(mockIntent), address(aerodromeModule), TARGET_CHAIN_ID); + + // Approve tokens + vm.startPrank(user); + tokenA.approve(address(aerodromeInitiator), type(uint256).max); + vm.stopPrank(); + } + + // Test initialization parameters + function test_Initialization() public { + assertEq(aerodromeModule.aerodromeRouter(), address(mockRouter), "Router address mismatch"); + assertEq(aerodromeModule.poolFactory(), poolFactory, "Pool factory address mismatch"); + assertEq(aerodromeModule.intentContract(), address(mockIntent), "Intent contract address mismatch"); + assertEq(aerodromeModule.rewardPercentage(), 5, "Default reward percentage mismatch"); + + assertEq(aerodromeInitiator.intent(), address(mockIntent), "Intent address mismatch"); + assertEq(aerodromeInitiator.targetModule(), address(aerodromeModule), "Target module address mismatch"); + assertEq(aerodromeInitiator.targetChainId(), TARGET_CHAIN_ID, "Target chain ID mismatch"); + } + + // Test setting addresses in the module contract + function test_SetAddresses() public { + address newRouter = makeAddr("newRouter"); + address newFactory = makeAddr("newFactory"); + address newIntent = makeAddr("newIntent"); + + aerodromeModule.setAerodromeRouter(newRouter); + aerodromeModule.setPoolFactory(newFactory); + aerodromeModule.setIntentContract(newIntent); + + assertEq(aerodromeModule.aerodromeRouter(), newRouter, "New router address not set"); + assertEq(aerodromeModule.poolFactory(), newFactory, "New pool factory address not set"); + assertEq(aerodromeModule.intentContract(), newIntent, "New intent contract address not set"); + } + + // Test setting addresses in the initiator contract + function test_SetInitiatorAddresses() public { + address newIntent = makeAddr("newIntent"); + address newModule = makeAddr("newModule"); + uint256 newChainId = 1; + + aerodromeInitiator.setIntent(newIntent); + aerodromeInitiator.setTargetModule(newModule); + aerodromeInitiator.setTargetChainId(newChainId); + + assertEq(aerodromeInitiator.intent(), newIntent, "New intent address not set"); + assertEq(aerodromeInitiator.targetModule(), newModule, "New target module address not set"); + assertEq(aerodromeInitiator.targetChainId(), newChainId, "New target chain ID not set"); + } + + // Test setting reward percentage + function test_SetRewardPercentage() public { + uint256 newPercentage = 10; + aerodromeModule.setRewardPercentage(newPercentage); + assertEq(aerodromeModule.rewardPercentage(), newPercentage, "New reward percentage not set"); + } + + // Test setting invalid reward percentage (>100) + function test_SetInvalidRewardPercentage() public { + uint256 invalidPercentage = 101; + vm.expectRevert("Percentage must be between 0-100"); + aerodromeModule.setRewardPercentage(invalidPercentage); + } + + // Test AerodromeSwapLib encoding and decoding + function test_SwapParamsEncoding() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + address receiver = user; + + // Encode the data + bytes memory encoded = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, receiver); + + // Decode the data + ( + address[] memory decodedPath, + bool[] memory decodedFlags, + uint256 decodedMinOut, + uint256 decodedDeadline, + address decodedReceiver + ) = AerodromeSwapLib.decodeSwapParams(encoded); + + // Verify decoding + assertEq(decodedPath.length, path.length, "Path length mismatch"); + assertEq(decodedPath[0], path[0], "Path[0] mismatch"); + assertEq(decodedPath[1], path[1], "Path[1] mismatch"); + + assertEq(decodedFlags.length, stableFlags.length, "Flags length mismatch"); + assertEq(decodedFlags[0], stableFlags[0], "Flags[0] mismatch"); + + assertEq(decodedMinOut, minAmountOut, "Min amount out mismatch"); + assertEq(decodedDeadline, deadline, "Deadline mismatch"); + assertEq(decodedReceiver, receiver, "Receiver mismatch"); + } + + // Test initiating a swap + function test_InitiateAerodromeSwap() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Call the initiator + vm.prank(user); + bytes32 intentId = aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, TIP, salt, gasLimit, path, stableFlags, minAmountOut, deadline, user + ); + + // Verify the intent was created + assertFalse(intentId == bytes32(0), "Intent ID should not be zero"); + + // Verify token transfer + assertEq(tokenA.balanceOf(user), AMOUNT * 10 - AMOUNT - TIP, "Tokens not transferred from user"); + assertEq(tokenA.balanceOf(address(mockIntent)), AMOUNT + TIP, "Tokens not transferred to intent contract"); + } + + // Test onFulfill + function test_OnFulfill() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + + // Encode the swap data + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, user); + + // Mint tokens to the module + tokenA.mint(address(aerodromeModule), AMOUNT); + + // Mock the intent call + vm.prank(address(mockIntent)); + aerodromeModule.onFulfill(bytes32(0), address(tokenA), AMOUNT, data); + + // Verify the swap + assertEq(mockRouter.lastAmountIn(), AMOUNT, "Amount in mismatch"); + assertEq(mockRouter.lastAmountOutMin(), minAmountOut, "Amount out min mismatch"); + assertEq(mockRouter.lastReceiver(), user, "Receiver mismatch"); + assertEq(mockRouter.lastFactoryUsed(), poolFactory, "Factory mismatch"); + + // Expected output with 2% fee + uint256 expectedOutput = AMOUNT * 98 / 100; + assertEq(tokenB.balanceOf(user), expectedOutput, "User did not receive swapped tokens"); + } + + // Test onFulfill with invalid caller (not the intent contract) + function test_OnFulfillInvalidCaller() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + + // Encode the swap data + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, user); + + // Try to call onFulfill from unauthorized address + vm.prank(user); + vm.expectRevert("Caller is not the Intent contract"); + aerodromeModule.onFulfill(bytes32(0), address(tokenA), AMOUNT, data); + } + + // Test getExpectedOutput + function test_GetExpectedOutput() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + // Get expected output + uint256 output = aerodromeModule.getExpectedOutput(AMOUNT, path, stableFlags); + + // Expected output with 2% fee + uint256 expectedOutput = AMOUNT * 98 / 100; + assertEq(output, expectedOutput, "Expected output mismatch"); + } + + // Test onSettle when intent is not fulfilled + function test_OnSettle_NotFulfilled() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 tipAmount = 5 ether; + + // Encode the swap data + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, user); + + // Mint tokenA to the module to be sent as tip + tokenA.mint(address(aerodromeModule), tipAmount); + + // Record user's balance before onSettle + uint256 userBalanceBefore = tokenA.balanceOf(user); + + // Call onSettle with isFulfilled = false from the intent contract + vm.prank(address(mockIntent)); + aerodromeModule.onSettle( + bytes32(0), // intentId + address(tokenA), // asset + AMOUNT, // amount + data, // data containing receiver + bytes32(uint256(1)), // fulfillmentIndex + false, // isFulfilled = false -> should send tip to receiver + tipAmount // tip amount + ); + + // Check that the tip was sent to the user (receiver) + uint256 userBalanceAfter = tokenA.balanceOf(user); + assertEq(userBalanceAfter, userBalanceBefore + tipAmount, "Tip was not sent to receiver"); + } + + // Test onSettle when intent is fulfilled + function test_OnSettle_Fulfilled() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 tipAmount = 5 ether; + + // Encode the swap data + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, user); + + // Mint tokenA to the module + tokenA.mint(address(aerodromeModule), tipAmount); + + // Record user's balance before onSettle + uint256 userBalanceBefore = tokenA.balanceOf(user); + + // Call onSettle with isFulfilled = true from the intent contract + vm.prank(address(mockIntent)); + aerodromeModule.onSettle( + bytes32(0), // intentId + address(tokenA), // asset + AMOUNT, // amount + data, // data containing receiver + bytes32(uint256(1)), // fulfillmentIndex + true, // isFulfilled = true -> should NOT send tip to receiver + tipAmount // tip amount + ); + + // Check that the tip was NOT sent to the user (since intent was fulfilled) + uint256 userBalanceAfter = tokenA.balanceOf(user); + assertEq(userBalanceAfter, userBalanceBefore, "Tip should not be sent when intent is fulfilled"); + } + + // Test onSettle with invalid caller + function test_OnSettle_InvalidCaller() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + + // Encode the swap data + bytes memory data = AerodromeSwapLib.encodeSwapParams(path, stableFlags, minAmountOut, deadline, user); + + // Try to call onSettle from unauthorized address + vm.prank(user); + vm.expectRevert("Caller is not the Intent contract"); + aerodromeModule.onSettle( + bytes32(0), // intentId + address(tokenA), // asset + AMOUNT, // amount + data, // data + bytes32(uint256(1)), // fulfillmentIndex + false, // isFulfilled + TIP // tipAmount + ); + } + + // Test initiating a swap with zero address for asset + function test_InitiateSwap_ZeroAsset() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(0); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Expect revert + vm.prank(user); + vm.expectRevert("Asset cannot be zero address"); + aerodromeInitiator.initiateAerodromeSwap( + address(0), AMOUNT, TIP, salt, gasLimit, path, stableFlags, minAmountOut, deadline, user + ); + } + + // Test initiating a swap with zero amount + function test_InitiateSwap_ZeroAmount() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Expect revert + vm.prank(user); + vm.expectRevert("Amount must be greater than zero"); + aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), 0, TIP, salt, gasLimit, path, stableFlags, minAmountOut, deadline, user + ); + } + + // Test initiating a swap with zero gas limit + function test_InitiateSwap_ZeroGasLimit() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + + // Expect revert + vm.prank(user); + vm.expectRevert("Gas limit must be greater than zero"); + aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, TIP, salt, 0, path, stableFlags, minAmountOut, deadline, user + ); + } + + // Test initiating a swap with expired deadline + function test_InitiateSwap_ExpiredDeadline() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp - 1; // Expired deadline + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Expect revert + vm.prank(user); + vm.expectRevert("Deadline must be in the future"); + aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, TIP, salt, gasLimit, path, stableFlags, minAmountOut, deadline, user + ); + } + + // Test initiating a swap with zero address for receiver + function test_InitiateSwap_ZeroReceiver() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Expect revert + vm.prank(user); + vm.expectRevert("Receiver cannot be zero address"); + aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, TIP, salt, gasLimit, path, stableFlags, minAmountOut, deadline, address(0) + ); + } + + // Test initiating a swap with zero minAmountOut + function test_InitiateSwap_ZeroMinAmountOut() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Expect revert + vm.prank(user); + vm.expectRevert("Minimum output amount must be greater than zero"); + aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, TIP, salt, gasLimit, path, stableFlags, 0, deadline, user + ); + } + + // Test initiating a swap with zero tip (should succeed) + function test_InitiateSwap_ZeroTip() public { + // Create test data + address[] memory path = new address[](2); + path[0] = address(tokenA); + path[1] = address(tokenB); + + bool[] memory stableFlags = new bool[](1); + stableFlags[0] = false; + + uint256 minAmountOut = MIN_AMOUNT_OUT; + uint256 deadline = block.timestamp + 1 hours; + uint256 salt = 123; + uint256 gasLimit = 300000; + + // Call the initiator with zero tip + vm.prank(user); + bytes32 intentId = aerodromeInitiator.initiateAerodromeSwap( + address(tokenA), AMOUNT, 0, salt, gasLimit, path, stableFlags, minAmountOut, deadline, user + ); + + // Verify the intent was created + assertFalse(intentId == bytes32(0), "Intent ID should not be zero"); + + // Verify token transfer (only AMOUNT, no tip) + assertEq(tokenA.balanceOf(user), AMOUNT * 10 - AMOUNT, "Tokens not transferred from user"); + assertEq(tokenA.balanceOf(address(mockIntent)), AMOUNT, "Tokens not transferred to intent contract"); + } +}