diff --git a/examples/CrossChainSwapper.sol b/examples/CrossChainSwapper.sol new file mode 100644 index 0000000..e490ce1 --- /dev/null +++ b/examples/CrossChainSwapper.sol @@ -0,0 +1,168 @@ +// 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/src/Intent.sol b/src/Intent.sol index bd49d01..1ea4d7e 100644 --- a/src/Intent.sol +++ b/src/Intent.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; +pragma solidity 0.8.26; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -11,6 +11,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./interfaces/IGateway.sol"; import "./interfaces/IRouter.sol"; import "./interfaces/IIntent.sol"; +import "./interfaces/IntentTarget.sol"; import "./utils/PayloadUtils.sol"; /** @@ -67,9 +68,26 @@ contract Intent is uint256 salt ); + // Event emitted when a new intent with call is created + event IntentInitiatedWithCall( + bytes32 indexed intentId, + address indexed asset, + uint256 amount, + uint256 targetChain, + bytes receiver, + uint256 tip, + uint256 salt, + bytes data + ); + // Event emitted when an intent is fulfilled event IntentFulfilled(bytes32 indexed intentId, address indexed asset, uint256 amount, address indexed receiver); + // Event emitted when an intent with call is fulfilled + event IntentFulfilledWithCall( + bytes32 indexed intentId, address indexed asset, uint256 amount, address indexed receiver, bytes data + ); + // Event emitted when an intent is settled event IntentSettled( bytes32 indexed intentId, @@ -82,6 +100,19 @@ contract Intent is uint256 paidTip ); + // Event emitted when an intent with call is settled + event IntentSettledWithCall( + bytes32 indexed intentId, + address indexed asset, + uint256 amount, + address indexed receiver, + bool fulfilled, + address fulfiller, + uint256 actualAmount, + uint256 paidTip, + bytes data + ); + // Event emitted when the gateway is updated event GatewayUpdated(address indexed oldGateway, address indexed newGateway); @@ -163,6 +194,27 @@ contract Intent is * @param asset The ERC20 token address * @param amount Amount to transfer * @param receiver Receiver address + * @param isCall Whether this is a callable intent + * @param data Custom data for contract calls + * @return The computed fulfillment index + */ + function getFulfillmentIndex( + bytes32 intentId, + address asset, + uint256 amount, + address receiver, + bool isCall, + bytes calldata data + ) public pure returns (bytes32) { + return PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); + } + + /** + * @dev Calculates the fulfillment index for the given parameters (backward compatibility) + * @param intentId The ID of the intent + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address * @return The computed fulfillment index */ function getFulfillmentIndex(bytes32 intentId, address asset, uint256 amount, address receiver) @@ -182,6 +234,7 @@ contract Intent is * @param tip Tip for the fulfiller * @param salt Salt for intent ID generation * @return intentId The generated intent ID + * @notice This function is maintained for backward compatibility - use initiateTransfer instead */ function initiate( address asset, @@ -191,6 +244,92 @@ contract Intent is uint256 tip, uint256 salt ) external whenNotPaused returns (bytes32) { + return _initiate(asset, amount, targetChain, receiver, tip, salt, false, "", 0); + } + + /** + * @dev Initiates a new intent for cross-chain transfer + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @return intentId The generated intent ID + */ + function initiateTransfer( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt + ) external whenNotPaused returns (bytes32) { + return _initiate(asset, amount, targetChain, receiver, tip, salt, false, "", 0); + } + + /** + * @dev Initiates a new intent for cross-chain transfer with contract call + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format (must implement IntentTarget) + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @param data Custom data to be passed to the receiver contract + * @param gasLimit Optional gas limit for the target chain transaction + * @return intentId The generated intent ID + */ + function initiateCall( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt, + bytes calldata data, + uint256 gasLimit + ) external whenNotPaused returns (bytes32) { + return _initiate(asset, amount, targetChain, receiver, tip, salt, true, data, gasLimit); + } + + /** + * @dev Initiates a new intent for cross-chain transfer with contract call (backward compatibility) + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format (must implement IntentTarget) + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @param data Custom data to be passed to the receiver contract + * @return intentId The generated intent ID + */ + function initiateCall( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt, + bytes calldata data + ) external whenNotPaused returns (bytes32) { + return _initiate(asset, amount, targetChain, receiver, tip, salt, true, data, 0); + } + + /** + * @dev Internal function to initiate an intent + */ + function _initiate( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt, + bool isCall, + bytes memory data, + uint256 gasLimit + ) internal returns (bytes32) { // Cannot initiate a transfer to the current chain require(targetChain != block.chainid, "Target chain cannot be the current chain"); @@ -207,7 +346,8 @@ contract Intent is ++intentCounter; // Create payload for crosschain transaction - bytes memory payload = PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiver); + bytes memory payload = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiver, isCall, data, gasLimit); if (isZetaChain) { // ZetaChain as source - direct call to router without going through gateway @@ -217,8 +357,12 @@ contract Intent is _initiateFromConnectedChain(asset, totalAmount, payload); } - // Emit event - emit IntentInitiated(intentId, asset, amount, targetChain, receiver, tip, salt); + // Emit appropriate event + if (isCall) { + emit IntentInitiatedWithCall(intentId, asset, amount, targetChain, receiver, tip, salt, data); + } else { + emit IntentInitiated(intentId, asset, amount, targetChain, receiver, tip, salt); + } return intentId; } @@ -279,14 +423,49 @@ contract Intent is * @param asset The ERC20 token address * @param amount Amount to transfer * @param receiver Receiver address + * @notice This function is maintained for backward compatibility - use fulfillTransfer instead + */ + function fulfill(bytes32 intentId, address asset, uint256 amount, address receiver) external whenNotPaused { + _fulfill(intentId, asset, amount, receiver, false, ""); + } + + /** + * @dev Fulfills an intent by transferring tokens to the receiver + * @param intentId The ID of the intent to fulfill + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address + */ + function fulfillTransfer(bytes32 intentId, address asset, uint256 amount, address receiver) + external + whenNotPaused + { + _fulfill(intentId, asset, amount, receiver, false, ""); + } + + /** + * @dev Fulfills an intent by transferring tokens to the receiver and calling onFulfill + * @param intentId The ID of the intent to fulfill + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address that implements IntentTarget + * @param data Custom data to be passed to the receiver contract */ - function fulfill(bytes32 intentId, address asset, uint256 amount, address receiver) + function fulfillCall(bytes32 intentId, address asset, uint256 amount, address receiver, bytes calldata data) external whenNotPaused - nonReentrant + { + _fulfill(intentId, asset, amount, receiver, true, data); + } + + /** + * @dev Internal function for intent fulfillment + */ + function _fulfill(bytes32 intentId, address asset, uint256 amount, address receiver, bool isCall, bytes memory data) + internal { // Compute the fulfillment index - bytes32 fulfillmentIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver); + bytes32 fulfillmentIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); // Check if intent is already fulfilled with these parameters require(fulfillments[fulfillmentIndex] == address(0), "Intent already fulfilled with these parameters"); @@ -294,14 +473,23 @@ contract Intent is // Check if intent has already been settled require(!settlements[fulfillmentIndex].settled, "Intent already settled"); - // Register the fulfillment - fulfillments[fulfillmentIndex] = msg.sender; - // Transfer tokens from the sender to the receiver IERC20(asset).safeTransferFrom(msg.sender, receiver, amount); - // Emit event - emit IntentFulfilled(intentId, asset, amount, receiver); + // If this is a call intent, call onFulfill - this will revert if it fails + if (isCall) { + IntentTarget(receiver).onFulfill(intentId, asset, amount, data); + } + + // Register the fulfillment + fulfillments[fulfillmentIndex] = msg.sender; + + // Emit appropriate event + if (isCall) { + emit IntentFulfilledWithCall(intentId, asset, amount, receiver, data); + } else { + emit IntentFulfilled(intentId, asset, amount, receiver); + } } /** @@ -312,6 +500,8 @@ contract Intent is * @param receiver Receiver address * @param tip Tip for the fulfiller * @param actualAmount Actual amount to transfer after fees + * @param isCall Whether this is a callable intent + * @param data Custom data for contract calls * @return fulfilled Whether the intent was fulfilled */ function _settle( @@ -320,10 +510,12 @@ contract Intent is uint256 amount, address receiver, uint256 tip, - uint256 actualAmount + uint256 actualAmount, + bool isCall, + bytes memory data ) internal returns (bool) { // Compute the fulfillment index using the original amount - bytes32 fulfillmentIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver); + bytes32 fulfillmentIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); // Check if intent has already been settled require(!settlements[fulfillmentIndex].settled, "Intent already settled"); @@ -342,21 +534,58 @@ contract Intent is uint256 paidTip = 0; // If there's a fulfiller, transfer the actual amount + tip to them - // Otherwise, transfer actual amount + tip to the receiver + // Otherwise, transfer actual amount + tip to the receiver and call onFulfill if it's a call intent if (fulfilled) { settlement.paidTip = tip; paidTip = tip; + IERC20(asset).safeTransfer(fulfiller, actualAmount + tip); + + // 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); + } } else { + // Transfer tokens to the receiver IERC20(asset).safeTransfer(receiver, actualAmount + tip); + + // If this is a call intent, call onFulfill and onSettle + if (isCall) { + // First call onFulfill + IntentTarget(receiver).onFulfill(intentId, asset, actualAmount, data); + + // Then call onSettle with isFulfilled = false + IntentTarget(receiver).onSettle(intentId, asset, amount, data, fulfillmentIndex, false); + } } - // Emit the IntentSettled event - emit IntentSettled(intentId, asset, amount, receiver, fulfilled, fulfiller, actualAmount, paidTip); + // Emit the appropriate event + if (isCall) { + emit IntentSettledWithCall( + intentId, asset, amount, receiver, fulfilled, fulfiller, actualAmount, paidTip, data + ); + } else { + emit IntentSettled(intentId, asset, amount, receiver, fulfilled, fulfiller, actualAmount, paidTip); + } return fulfilled; } + /** + * @dev Internal compatibility function that calls the new _settle with default isCall and data + */ + function _settle( + bytes32 intentId, + address asset, + uint256 amount, + address receiver, + uint256 tip, + uint256 actualAmount + ) internal returns (bool) { + return _settle(intentId, asset, amount, receiver, tip, actualAmount, false, ""); + } + /** * @dev Handles incoming cross-chain messages * @param context Message context containing sender information @@ -367,7 +596,6 @@ contract Intent is external payable onlyGatewayOrRouter - nonReentrant returns (bytes memory) { // Verify sender is the router @@ -381,7 +609,16 @@ contract Intent is IERC20(payload.asset).safeTransferFrom(msg.sender, address(this), totalTransfer); // Settle the intent - _settle(payload.intentId, payload.asset, payload.amount, payload.receiver, payload.tip, payload.actualAmount); + _settle( + payload.intentId, + payload.asset, + payload.amount, + payload.receiver, + payload.tip, + payload.actualAmount, + payload.isCall, + payload.data + ); return ""; } diff --git a/src/Router.sol b/src/Router.sol index 7180fc4..767a523 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -58,7 +58,7 @@ contract Router is bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // Default gas limit for withdraw operations - uint256 private constant DEFAULT_WITHDRAW_GAS_LIMIT = 300000; + uint256 private constant DEFAULT_WITHDRAW_GAS_LIMIT = 400000; // Current gas limit for withdraw operations (can be modified by admin) uint256 public withdrawGasLimit; @@ -237,7 +237,7 @@ contract Router is address zrc20, uint256 amountWithTip, bytes calldata payload - ) external override onlyGatewayOrIntent whenNotPaused nonReentrant { + ) external override onlyGatewayOrIntent whenNotPaused { // Decode intent payload PayloadUtils.IntentPayload memory intentPayload = PayloadUtils.decodeIntentPayload(payload); @@ -266,9 +266,12 @@ contract Router is settlementInfo.targetAsset, receiverAddress, settlementInfo.tipAfterSwap, - settlementInfo.actualAmount // actual amount to transfer after all costs + settlementInfo.actualAmount, // actual amount to transfer after all costs + intentPayload.isCall, // pass isCall from intent payload + intentPayload.data // pass data from intent payload ); + // Check if target chain is the current chain (ZetaChain) if (isZetaChainDestination) { // Process settlement directly on ZetaChain _processChainsSettlementOnZetaChain( @@ -334,8 +337,12 @@ contract Router is intentInfo.intentPayload.amount, intentInfo.amountWithTip, sourceDecimals, targetDecimals ); - // Get the appropriate gas limit for the target chain - settlementInfo.gasLimit = _getChainGasLimit(intentInfo.intentPayload.targetChain); + // Get the appropriate gas limit for the target chain - use custom gas limit if provided, otherwise fallback to default + if (intentInfo.intentPayload.gasLimit > 0) { + settlementInfo.gasLimit = intentInfo.intentPayload.gasLimit; + } else { + settlementInfo.gasLimit = _getChainGasLimit(intentInfo.intentPayload.targetChain); + } // Initialize gas fee variables settlementInfo.gasZRC20 = address(0); diff --git a/src/interfaces/ICallableIntent.sol b/src/interfaces/ICallableIntent.sol new file mode 100644 index 0000000..73438f1 --- /dev/null +++ b/src/interfaces/ICallableIntent.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/** + * @title IntentTarget + * @dev Interface for contracts that want to support intent calls + */ +interface IntentTarget { + /** + * @dev Called during intent fulfillment to execute custom logic + * @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; + + /** + * @dev Called during intent settlement to execute custom logic + * @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; +} diff --git a/src/interfaces/IIntent.sol b/src/interfaces/IIntent.sol index 3d02865..d9349da 100644 --- a/src/interfaces/IIntent.sol +++ b/src/interfaces/IIntent.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; +pragma solidity 0.8.26; /** * @title IIntent @@ -12,7 +12,7 @@ interface IIntent { } /** - * @dev Initiates a new intent for cross-chain transfer + * @dev Initiates a new intent for cross-chain transfer (backward compatibility) * @param asset The ERC20 token address * @param amount Amount to receive on target chain * @param targetChain Target chain ID @@ -30,6 +30,98 @@ interface IIntent { uint256 salt ) external returns (bytes32); + /** + * @dev Initiates a new intent for cross-chain transfer + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @return intentId The generated intent ID + */ + function initiateTransfer( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt + ) external returns (bytes32); + + /** + * @dev Initiates a new intent for cross-chain transfer with contract call + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format (must implement ICallableIntent) + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @param data Custom data to be passed to the receiver contract + * @return intentId The generated intent ID + */ + function initiateCall( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt, + bytes calldata data + ) external returns (bytes32); + + /** + * @dev Initiates a new intent for cross-chain transfer with contract call with custom gas limit + * @param asset The ERC20 token address + * @param amount Amount to receive on target chain + * @param targetChain Target chain ID + * @param receiver Receiver address in bytes format (must implement ICallableIntent) + * @param tip Tip for the fulfiller + * @param salt Salt for intent ID generation + * @param data Custom data to be passed to the receiver contract + * @param gasLimit Custom gas limit for the target chain transaction + * @return intentId The generated intent ID + */ + function initiateCall( + address asset, + uint256 amount, + uint256 targetChain, + bytes calldata receiver, + uint256 tip, + uint256 salt, + bytes calldata data, + uint256 gasLimit + ) external returns (bytes32); + + /** + * @dev Fulfills an intent by transferring tokens to the receiver (backward compatibility) + * @param intentId The ID of the intent to fulfill + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address + */ + function fulfill(bytes32 intentId, address asset, uint256 amount, address receiver) external; + + /** + * @dev Fulfills an intent by transferring tokens to the receiver + * @param intentId The ID of the intent to fulfill + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address + */ + function fulfillTransfer(bytes32 intentId, address asset, uint256 amount, address receiver) external; + + /** + * @dev Fulfills an intent by transferring tokens to the receiver and calling onFulfill + * @param intentId The ID of the intent to fulfill + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address that implements IntentTarget + * @param data Custom data to be passed to the receiver contract + */ + function fulfillCall(bytes32 intentId, address asset, uint256 amount, address receiver, bytes calldata data) + external; + /** * @dev Handles incoming cross-chain messages * @param context Message context containing sender information diff --git a/src/interfaces/IntentTarget.sol b/src/interfaces/IntentTarget.sol new file mode 100644 index 0000000..f9bb883 --- /dev/null +++ b/src/interfaces/IntentTarget.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/** + * @title IntentTarget + * @dev Interface for contracts that want to support intent calls + */ +interface IntentTarget { + /** + * @dev Called during intent fulfillment to execute custom logic + * @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; + + /** + * @dev Called during intent settlement to execute custom logic + * @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 + */ + function onSettle( + bytes32 intentId, + address asset, + uint256 amount, + bytes calldata data, + bytes32 fulfillmentIndex, + bool isFulfilled + ) external; +} diff --git a/src/utils/PayloadUtils.sol b/src/utils/PayloadUtils.sol index 643d323..288b6e0 100644 --- a/src/utils/PayloadUtils.sol +++ b/src/utils/PayloadUtils.sol @@ -12,11 +12,45 @@ library PayloadUtils { uint256 tip; uint256 targetChain; bytes receiver; + bool isCall; + bytes data; + uint256 gasLimit; } /** * @dev Encodes intent data into a payload for cross-chain transaction */ + function encodeIntentPayload( + bytes32 intentId, + uint256 amount, + uint256 tip, + uint256 targetChain, + bytes memory receiver, + bool isCall, + bytes memory data, + uint256 gasLimit + ) internal pure returns (bytes memory) { + return abi.encode(intentId, amount, tip, targetChain, receiver, isCall, data, gasLimit); + } + + /** + * @dev Encodes intent data into a payload for cross-chain transaction (without gasLimit, sets it to 0) + */ + function encodeIntentPayload( + bytes32 intentId, + uint256 amount, + uint256 tip, + uint256 targetChain, + bytes memory receiver, + bool isCall, + bytes memory data + ) internal pure returns (bytes memory) { + return encodeIntentPayload(intentId, amount, tip, targetChain, receiver, isCall, data, 0); + } + + /** + * @dev Encodes standard intent data into a payload (backward compatibility) + */ function encodeIntentPayload( bytes32 intentId, uint256 amount, @@ -24,18 +58,34 @@ library PayloadUtils { uint256 targetChain, bytes memory receiver ) internal pure returns (bytes memory) { - return abi.encode(intentId, amount, tip, targetChain, receiver); + return encodeIntentPayload(intentId, amount, tip, targetChain, receiver, false, "", 0); } /** * @dev Decodes payload back into intent data */ function decodeIntentPayload(bytes memory payload) internal pure returns (IntentPayload memory) { - (bytes32 intentId, uint256 amount, uint256 tip, uint256 targetChain, bytes memory receiver) = - abi.decode(payload, (bytes32, uint256, uint256, uint256, bytes)); + ( + bytes32 intentId, + uint256 amount, + uint256 tip, + uint256 targetChain, + bytes memory receiver, + bool isCall, + bytes memory data, + uint256 gasLimit + ) = abi.decode(payload, (bytes32, uint256, uint256, uint256, bytes, bool, bytes, uint256)); - return - IntentPayload({intentId: intentId, amount: amount, tip: tip, targetChain: targetChain, receiver: receiver}); + return IntentPayload({ + intentId: intentId, + amount: amount, + tip: tip, + targetChain: targetChain, + receiver: receiver, + isCall: isCall, + data: data, + gasLimit: gasLimit + }); } /** @@ -57,11 +107,31 @@ library PayloadUtils { // The actual amount to be transferred after deducting any fees, slippage, or gas costs // This may be lower than the original 'amount' if the tip wasn't sufficient to cover all costs uint256 actualAmount; + // Whether this is a callable intent + bool isCall; + // Custom data to be used in contract calls + bytes data; } /** * @dev Encodes settlement data into a payload */ + function encodeSettlementPayload( + bytes32 intentId, + uint256 amount, + address asset, + address receiver, + uint256 tip, + uint256 actualAmount, + bool isCall, + bytes memory data + ) internal pure returns (bytes memory) { + return abi.encode(intentId, amount, asset, receiver, tip, actualAmount, isCall, data); + } + + /** + * @dev Encodes standard settlement data into a payload (backward compatibility) + */ function encodeSettlementPayload( bytes32 intentId, uint256 amount, @@ -70,15 +140,23 @@ library PayloadUtils { uint256 tip, uint256 actualAmount ) internal pure returns (bytes memory) { - return abi.encode(intentId, amount, asset, receiver, tip, actualAmount); + return encodeSettlementPayload(intentId, amount, asset, receiver, tip, actualAmount, false, ""); } /** * @dev Decodes settlement payload back into data */ function decodeSettlementPayload(bytes memory payload) internal pure returns (SettlementPayload memory) { - (bytes32 intentId, uint256 amount, address asset, address receiver, uint256 tip, uint256 actualAmount) = - abi.decode(payload, (bytes32, uint256, address, address, uint256, uint256)); + ( + bytes32 intentId, + uint256 amount, + address asset, + address receiver, + uint256 tip, + uint256 actualAmount, + bool isCall, + bytes memory data + ) = abi.decode(payload, (bytes32, uint256, address, address, uint256, uint256, bool, bytes)); return SettlementPayload({ intentId: intentId, @@ -86,7 +164,9 @@ library PayloadUtils { asset: asset, receiver: receiver, tip: tip, - actualAmount: actualAmount + actualAmount: actualAmount, + isCall: isCall, + data: data }); } @@ -96,6 +176,27 @@ library PayloadUtils { * @param asset The ERC20 token address * @param amount Amount to transfer * @param receiver Receiver address + * @param isCall Whether this is a callable intent + * @param data Custom data for contract calls + * @return The computed fulfillment index + */ + function computeFulfillmentIndex( + bytes32 intentId, + address asset, + uint256 amount, + address receiver, + bool isCall, + bytes memory data + ) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(intentId, asset, amount, receiver, isCall, data)); + } + + /** + * @dev Computes a unique index for a fulfillment (backward compatibility) + * @param intentId The ID of the intent + * @param asset The ERC20 token address + * @param amount Amount to transfer + * @param receiver Receiver address * @return The computed fulfillment index */ function computeFulfillmentIndex(bytes32 intentId, address asset, uint256 amount, address receiver) @@ -103,7 +204,7 @@ library PayloadUtils { pure returns (bytes32) { - return keccak256(abi.encodePacked(intentId, asset, amount, receiver)); + return computeFulfillmentIndex(intentId, asset, amount, receiver, false, ""); } /** diff --git a/test/Intent.t.sol b/test/Intent.t.sol index 50a3b60..de8f136 100644 --- a/test/Intent.t.sol +++ b/test/Intent.t.sol @@ -8,6 +8,7 @@ import {Intent} from "../src/Intent.sol"; import {MockGateway} from "./mocks/MockGateway.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; import {MockRouter} from "./mocks/MockRouter.sol"; +import {MockIntentTarget} from "./mocks/MockIntentTarget.sol"; import {PayloadUtils} from "../src/utils/PayloadUtils.sol"; import {IGateway} from "../src/interfaces/IGateway.sol"; import {IRouter} from "../src/interfaces/IRouter.sol"; @@ -38,9 +39,26 @@ contract IntentTest is Test { uint256 salt ); + // Define the event for intent with call + event IntentInitiatedWithCall( + bytes32 indexed intentId, + address indexed asset, + uint256 amount, + uint256 targetChain, + bytes receiver, + uint256 tip, + uint256 salt, + bytes data + ); + // Define the event for intent fulfillment event IntentFulfilled(bytes32 indexed intentId, address indexed asset, uint256 amount, address indexed receiver); + // Define the event for intent fulfillment with call + event IntentFulfilledWithCall( + bytes32 indexed intentId, address indexed asset, uint256 amount, address indexed receiver, bytes data + ); + // Define the event for intent settlement event IntentSettled( bytes32 indexed intentId, @@ -53,6 +71,19 @@ contract IntentTest is Test { uint256 paidTip ); + // Define the event for intent settlement with call + event IntentSettledWithCall( + bytes32 indexed intentId, + address indexed asset, + uint256 amount, + address indexed receiver, + bool fulfilled, + address fulfiller, + uint256 actualAmount, + uint256 paidTip, + bytes data + ); + // Define events for gateway and router updates event GatewayUpdated(address indexed oldGateway, address indexed newGateway); event RouterUpdated(address indexed oldRouter, address indexed newRouter); @@ -185,12 +216,14 @@ contract IntentTest is Test { address asset = address(token); uint256 amount = 50 ether; address receiver = user2; + bool isCall = false; + bytes memory data = ""; // Expected result calculated using PayloadUtils directly - bytes32 expectedIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver); + bytes32 expectedIndex = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); // Call the function and verify result - bytes32 actualIndex = intent.getFulfillmentIndex(intentId, asset, amount, receiver); + bytes32 actualIndex = intent.getFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); // Verify the computed index matches what we expect assertEq(actualIndex, expectedIndex, "Fulfillment index computation does not match expected value"); @@ -198,7 +231,7 @@ contract IntentTest is Test { // Verify it matches with the internal computation too assertEq( actualIndex, - keccak256(abi.encodePacked(intentId, asset, amount, receiver)), + keccak256(abi.encodePacked(intentId, asset, amount, receiver, isCall, data)), "Index doesn't match raw computation" ); } @@ -251,7 +284,7 @@ contract IntentTest is Test { assertEq(payload.amount, amount); assertEq(payload.tip, tip); assertEq(payload.targetChain, targetChain); - assertEq(keccak256(payload.receiver), keccak256(receiver)); + assertTrue(keccak256(payload.receiver) == keccak256(receiver), "Receiver should match"); } function test_Initiate_SameChainReverts() public { @@ -337,7 +370,7 @@ contract IntentTest is Test { assertEq(payload.amount, amount); assertEq(payload.tip, tip); assertEq(payload.targetChain, targetChain); - assertEq(keccak256(payload.receiver), keccak256(receiver)); + assertTrue(keccak256(payload.receiver) == keccak256(receiver), "Receiver should match"); } function test_InitiateInsufficientBalance() public { @@ -359,6 +392,100 @@ contract IntentTest is Test { intent.initiate(address(token), amount, targetChain, receiver, tip, salt); } + function test_InitiateTransfer() public { + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + uint256 currentChainId = block.chainid; + + // Mint tokens for initiateTransfer + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Expect the IntentInitiated event + vm.expectEmit(true, true, false, false); + emit IntentInitiated( + intent.computeIntentId(0, salt, currentChainId), // First intent ID with chainId + address(token), + amount, + targetChain, + receiver, + tip, + salt + ); + + // Call initiateTransfer + vm.prank(user1); + bytes32 intentId = intent.initiateTransfer(address(token), amount, targetChain, receiver, tip, salt); + + // Verify intent ID + assertEq(intentId, intent.computeIntentId(0, salt, currentChainId)); + + // Verify gateway received the correct amount + assertEq(token.balanceOf(address(gateway)), amount + tip); + + // Verify gateway call data + (address callReceiver, uint256 callAmount, address callAsset, bytes memory callPayload,) = gateway.lastCall(); + assertEq(callReceiver, router); + assertEq(callAmount, amount + tip); + assertEq(callAsset, address(token)); + + // Verify payload + PayloadUtils.IntentPayload memory payload = PayloadUtils.decodeIntentPayload(callPayload); + assertEq(payload.intentId, intentId); + assertEq(payload.amount, amount); + assertEq(payload.tip, tip); + assertEq(payload.targetChain, targetChain); + assertEq(keccak256(payload.receiver), keccak256(receiver)); + } + + function test_InitiateTransfer_ComparingWithInitiate() public { + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + + // Prepare for first intent (initiate) + uint256 initialCounter = intent.intentCounter(); + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Call initiate + vm.prank(user1); + bytes32 initiateId = intent.initiate(address(token), amount, targetChain, receiver, tip, salt); + + // Prepare for second intent (initiateTransfer) + uint256 secondCounter = intent.intentCounter(); + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Call initiateTransfer + vm.prank(user1); + bytes32 transferId = intent.initiateTransfer(address(token), amount, targetChain, receiver, tip, salt); + + // Verify that both functions increment the counter the same way + assertEq(secondCounter - initialCounter, 1, "initiate should increment counter by 1"); + assertEq(intent.intentCounter() - secondCounter, 1, "initiateTransfer should increment counter by 1"); + + // Verify intent IDs follow the same pattern + bytes32 expectedInitiateId = intent.computeIntentId(initialCounter, salt, block.chainid); + bytes32 expectedTransferId = intent.computeIntentId(secondCounter, salt, block.chainid); + + assertEq(initiateId, expectedInitiateId, "initiate ID calculation should match"); + assertEq(transferId, expectedTransferId, "initiateTransfer ID calculation should match"); + + // Verify gateway received the correct amount from both calls + assertEq(token.balanceOf(address(gateway)), (amount + tip) * 2); + } + function test_Fulfill() public { // First create an intent uint256 amount = 100 ether; @@ -396,6 +523,89 @@ contract IntentTest is Test { assertEq(token.balanceOf(user2), amount); } + function test_FulfillTransfer() public { + // First create an intent + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + vm.prank(user1); + bytes32 intentId = intent.initiate(address(token), amount, targetChain, receiver, tip, salt); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Expect the IntentFulfilled event + vm.expectEmit(true, true, false, true); + emit IntentFulfilled(intentId, address(token), amount, user2); + + // Call fulfillTransfer + vm.prank(user1); + intent.fulfillTransfer(intentId, address(token), amount, user2); + + // Verify fulfillment was registered + bytes32 fulfillmentIndex = PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, user2); + assertEq(intent.fulfillments(fulfillmentIndex), user1); + + // Verify tokens were transferred from user1 to user2 + assertEq(token.balanceOf(user2), amount); + } + + function test_FulfillTransfer_ComparingWithFulfill() public { + // Create two intents for testing + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt1 = 123; + uint256 salt2 = 456; + + // Mint tokens for initiating both intents + token.mint(user1, (amount + tip) * 2); + vm.prank(user1); + token.approve(address(intent), (amount + tip) * 2); + + // Initiate first intent + vm.prank(user1); + bytes32 intentId1 = intent.initiate(address(token), amount, targetChain, receiver, tip, salt1); + + // Initiate second intent + vm.prank(user1); + bytes32 intentId2 = intent.initiate(address(token), amount, targetChain, receiver, tip, salt2); + + // Mint tokens for fulfillment + token.mint(user1, amount * 2); + vm.prank(user1); + token.approve(address(intent), amount * 2); + + // Fulfill first intent with fulfill + vm.prank(user1); + intent.fulfill(intentId1, address(token), amount, user2); + + // Fulfill second intent with fulfillTransfer + vm.prank(user1); + intent.fulfillTransfer(intentId2, address(token), amount, user2); + + // Verify both fulfillments were registered + bytes32 fulfillIndex1 = PayloadUtils.computeFulfillmentIndex(intentId1, address(token), amount, user2); + bytes32 fulfillIndex2 = PayloadUtils.computeFulfillmentIndex(intentId2, address(token), amount, user2); + + assertEq(intent.fulfillments(fulfillIndex1), user1, "fulfill should register user1 as fulfiller"); + assertEq(intent.fulfillments(fulfillIndex2), user1, "fulfillTransfer should register user1 as fulfiller"); + + // Verify user2 received tokens from both fulfillments + assertEq(token.balanceOf(user2), amount * 2, "User2 should receive tokens from both fulfillments"); + } + function test_FulfillAlreadyFulfilled() public { // First create and fulfill an intent uint256 amount = 100 ether; @@ -1154,8 +1364,627 @@ contract IntentTest is Test { intent.updateRouter(makeAddr("anotherRouter")); } - // TODO: Add more tests for: - // - complete - // - onCall - // - onRevert + function test_InitiateCall() public { + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256,address)", 42, user1); + uint256 currentChainId = block.chainid; + + // Mint tokens for initiateCall + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Expect the IntentInitiatedWithCall event + vm.expectEmit(true, true, false, false); + emit IntentInitiatedWithCall( + intent.computeIntentId(0, salt, currentChainId), // First intent ID with chainId + address(token), + amount, + targetChain, + receiver, + tip, + salt, + data + ); + + // Call initiateCall + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiver, tip, salt, data); + + // Verify intent ID + assertEq(intentId, intent.computeIntentId(0, salt, currentChainId)); + + // Verify gateway received the correct amount + assertEq(token.balanceOf(address(gateway)), amount + tip); + + // Verify gateway call data + (address callReceiver, uint256 callAmount, address callAsset, bytes memory callPayload,) = gateway.lastCall(); + assertEq(callReceiver, router); + assertEq(callAmount, amount + tip); + assertEq(callAsset, address(token)); + + // Verify payload + PayloadUtils.IntentPayload memory payload = PayloadUtils.decodeIntentPayload(callPayload); + assertEq(payload.intentId, intentId); + assertEq(payload.amount, amount); + assertEq(payload.tip, tip); + assertEq(payload.targetChain, targetChain); + assertTrue(keccak256(payload.receiver) == keccak256(receiver), "Receiver should match"); + assertEq(payload.isCall, true); + assertTrue(keccak256(payload.data) == keccak256(data), "Data should match"); + } + + function test_InitiateCall_SameChainReverts() public { + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = block.chainid; // Same as current chain + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Mint tokens for initiateCall + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Call initiateCall and expect revert + vm.prank(user1); + vm.expectRevert("Target chain cannot be the current chain"); + intent.initiateCall(address(token), amount, targetChain, receiver, tip, salt, data); + } + + function test_InitiateCall_FromZetaChain() public { + // Deploy a new intent contract that has isZetaChain=true + Intent zetaIntent = _deployZetaChainIntent(); + + // Deploy mock router implementation + MockRouter mockRouter = new MockRouter(); + + // Update the intent contract to use mock router + zetaIntent.updateRouter(address(mockRouter)); + + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256,address)", 42, user1); + uint256 currentChainId = block.chainid; + + // Mint tokens for initiateCall + token.mint(user1, amount + tip); + + // Record user1's balance before + uint256 user1BalanceBefore = token.balanceOf(user1); + + vm.prank(user1); + token.approve(address(zetaIntent), amount + tip); + + // Expect the IntentInitiatedWithCall event + vm.expectEmit(true, true, false, false); + emit IntentInitiatedWithCall( + zetaIntent.computeIntentId(0, salt, currentChainId), // First intent ID with chainId + address(token), + amount, + targetChain, + receiver, + tip, + salt, + data + ); + + // Call initiateCall + vm.prank(user1); + bytes32 intentId = zetaIntent.initiateCall(address(token), amount, targetChain, receiver, tip, salt, data); + + // Verify intent ID + assertEq(intentId, zetaIntent.computeIntentId(0, salt, currentChainId)); + + // Verify tokens were transferred from user1 + assertEq(token.balanceOf(user1), user1BalanceBefore - (amount + tip)); + + // Verify router received the call + assertEq(mockRouter.lastZRC20(), address(token)); + assertEq(mockRouter.lastAmount(), amount + tip); + + // Verify context + assertEq(mockRouter.lastContextChainID(), currentChainId); + assertEq(mockRouter.lastContextSenderEVM(), address(zetaIntent)); + + // Verify payload + bytes memory routerPayload = mockRouter.lastPayload(); + PayloadUtils.IntentPayload memory payload = PayloadUtils.decodeIntentPayload(routerPayload); + assertEq(payload.intentId, intentId); + assertEq(payload.amount, amount); + assertEq(payload.tip, tip); + assertEq(payload.targetChain, targetChain); + assertTrue(keccak256(payload.receiver) == keccak256(receiver), "Receiver should match"); + assertEq(payload.isCall, true); + assertTrue(keccak256(payload.data) == keccak256(data), "Data should match"); + } + + function test_InitiateCall_ComparingWithInitiate() public { + // Test parameters + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiver = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Get initial counter + uint256 initialCounter = intent.intentCounter(); + + // Prepare for first intent (initiateTransfer) + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Call initiateTransfer + vm.prank(user1); + bytes32 transferId = intent.initiateTransfer(address(token), amount, targetChain, receiver, tip, salt); + + // Verify counter incremented + assertEq(intent.intentCounter(), initialCounter + 1); + + // Prepare for second intent (initiateCall) + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Call initiateCall + vm.prank(user1); + bytes32 callId = intent.initiateCall(address(token), amount, targetChain, receiver, tip, salt, data); + + // Verify counter incremented again + assertEq(intent.intentCounter(), initialCounter + 2); + + // Verify gateway received the correct amount from both calls + assertEq(token.balanceOf(address(gateway)), (amount + tip) * 2); + } + + function test_FulfillCall() public { + // First create an intent + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Expect the IntentFulfilledWithCall event + vm.expectEmit(true, true, false, true); + emit IntentFulfilledWithCall(intentId, address(token), amount, address(mockTarget), data); + + // Call fulfillCall + vm.prank(user1); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Verify fulfillment was registered + bytes32 fulfillmentIndex = + PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, address(mockTarget), true, data); + assertEq(intent.fulfillments(fulfillmentIndex), user1); + + // Verify tokens were transferred from user1 to mockTarget + assertEq(token.balanceOf(address(mockTarget)), amount); + + // Verify the onFulfill method was called with correct parameters + assertTrue(mockTarget.onFulfillCalled(), "onFulfill should have been called"); + assertEq(mockTarget.lastIntentId(), intentId, "Intent ID should match"); + assertEq(mockTarget.lastAsset(), address(token), "Asset should match"); + assertEq(mockTarget.lastAmount(), amount, "Amount should match"); + assertTrue(keccak256(mockTarget.lastData()) == keccak256(data), "Data should match"); + } + + function test_FulfillCall_TargetReverts() public { + // First create an intent with call + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Set the target to revert + mockTarget.setShouldRevert(true); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Call fulfillCall - expect it to revert with the target's revert message + vm.prank(user1); + vm.expectRevert("MockIntentTarget: intentional revert in onFulfill"); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Verify fulfillment was NOT registered since the transaction reverted + bytes32 fulfillmentIndex = + PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, address(mockTarget), true, data); + assertEq(intent.fulfillments(fulfillmentIndex), address(0), "Fulfillment should not be registered after revert"); + + // Verify tokens were NOT transferred to the target + assertEq(token.balanceOf(address(mockTarget)), 0, "Target should not receive tokens after revert"); + } + + function test_FulfillCall_NonContractReceiver() public { + // First create an intent + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // We'll use MockIntentTarget for the fulfillment instead of an EOA + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Call fulfillCall with the mock target (a contract) + vm.prank(user1); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Verify fulfillment was registered + bytes32 fulfillmentIndex = + PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, address(mockTarget), true, data); + assertEq(intent.fulfillments(fulfillmentIndex), user1); + + // Verify tokens were transferred to the mock target + assertEq(token.balanceOf(address(mockTarget)), amount); + } + + function test_FulfillCall_TokenAccessDuringOnFulfill() public { + // First create an intent + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // We'll use MockIntentTarget for the fulfillment + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Enable balance checking + mockTarget.setShouldCheckBalances(true); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Call fulfillCall + vm.prank(user1); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Verify that the token balance was correct during onFulfill call + assertEq(mockTarget.balanceDuringOnFulfill(), amount, "Token balance should be available during onFulfill"); + } + + function test_SettleCall() public { + // First create an intent with call + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Fulfill the intent with fulfillCall + vm.prank(user1); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Prepare settlement payload (with isCall=true and data) + bytes memory settlementPayload = PayloadUtils.encodeSettlementPayload( + intentId, + amount, + address(token), + address(mockTarget), + tip, + amount, // actualAmount same as amount in the test case + true, // isCall + data // data for the call + ); + + // Transfer tokens to gateway for settlement + token.mint(address(gateway), amount + tip); + vm.prank(address(gateway)); + token.approve(address(intent), amount + tip); + + // Reset the mock target to clear any state from the fulfill call + mockTarget.reset(); + + // Call onCall through gateway to settle the intent + vm.prank(address(gateway)); + intent.onCall(IIntent.MessageContext({sender: router}), settlementPayload); + + // Verify settlement record + bytes32 fulfillmentIndex = + PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, address(mockTarget), true, data); + (bool settled, bool fulfilled, uint256 paidTip, address fulfiller) = intent.settlements(fulfillmentIndex); + assertTrue(settled, "Settlement should be marked as settled"); + assertTrue(fulfilled, "Settlement should be marked as fulfilled"); + assertEq(paidTip, tip, "Paid tip should match the input tip"); + assertEq(fulfiller, user1, "Fulfiller should be user1"); + + // Verify tokens were transferred to fulfiller (amount + tip) + assertEq(token.balanceOf(user1), amount + tip, "User1 should receive amount + tip"); + + // Verify onSettle was called on the target + assertTrue(mockTarget.onSettleCalled(), "onSettle should have been called"); + assertEq(mockTarget.lastIntentId(), intentId, "Intent ID should match"); + assertEq(mockTarget.lastAsset(), address(token), "Asset should match"); + assertEq(mockTarget.lastAmount(), amount, "Amount should match"); + assertEq(keccak256(mockTarget.lastData()), keccak256(data), "Data should match"); + assertEq(mockTarget.lastFulfillmentIndex(), fulfillmentIndex, "Fulfillment index should match"); + + // Verify isFulfilled parameter was true + assertTrue(mockTarget.lastIsFulfilled(), "isFulfilled should be true"); + } + + function test_SettleCall_TargetReverts() public { + // First create an intent with call + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Mint tokens to the fulfiller (user1) and approve them for the intent contract + token.mint(user1, amount); + vm.prank(user1); + token.approve(address(intent), amount); + + // Fulfill the intent with fulfillCall + vm.prank(user1); + intent.fulfillCall(intentId, address(token), amount, address(mockTarget), data); + + // Now store user's balance after fulfillment for later comparison + uint256 userBalanceBeforeSettlement = token.balanceOf(user1); + + // Prepare settlement payload (with isCall=true and data) + bytes memory settlementPayload = PayloadUtils.encodeSettlementPayload( + intentId, + amount, + address(token), + address(mockTarget), + tip, + amount, // actualAmount same as amount in the test case + true, // isCall + data // data for the call + ); + + // Transfer tokens to gateway for settlement + token.mint(address(gateway), amount + tip); + vm.prank(address(gateway)); + token.approve(address(intent), amount + tip); + + // Reset the mock target and set it to revert + mockTarget.reset(); + mockTarget.setShouldRevert(true); + + // Get the gateway's token balance before the call + uint256 gatewayBalanceBefore = token.balanceOf(address(gateway)); + + // Call onCall through gateway to settle the intent - expect it to revert + vm.prank(address(gateway)); + vm.expectRevert("MockIntentTarget: intentional revert in onSettle"); + intent.onCall(IIntent.MessageContext({sender: router}), settlementPayload); + + // The entire transaction should revert, so balances should remain unchanged + assertEq(token.balanceOf(user1), userBalanceBeforeSettlement, "User1 balance should be unchanged"); + assertEq(token.balanceOf(address(gateway)), gatewayBalanceBefore, "Gateway balance should be unchanged"); + } + + function test_SettleCall_WithoutFulfillment() public { + // First create an intent with call + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Prepare settlement payload (with isCall=true and data) - Not fulfilling first + bytes memory settlementPayload = PayloadUtils.encodeSettlementPayload( + intentId, + amount, + address(token), + address(mockTarget), + tip, + amount, // actualAmount same as amount in the test case + true, // isCall + data // data for the call + ); + + // Transfer tokens to gateway for settlement + token.mint(address(gateway), amount + tip); + vm.prank(address(gateway)); + token.approve(address(intent), amount + tip); + + // Call onCall through gateway to settle the intent + vm.prank(address(gateway)); + intent.onCall(IIntent.MessageContext({sender: router}), settlementPayload); + + // Verify settlement record + bytes32 fulfillmentIndex = + PayloadUtils.computeFulfillmentIndex(intentId, address(token), amount, address(mockTarget), true, data); + (bool settled, bool fulfilled, uint256 paidTip, address fulfiller) = intent.settlements(fulfillmentIndex); + assertTrue(settled, "Settlement should be marked as settled"); + assertFalse(fulfilled, "Settlement should be marked as not fulfilled"); + assertEq(paidTip, 0, "Paid tip should be 0"); + assertEq(fulfiller, address(0), "Fulfiller should be address(0)"); + + // Verify tokens were transferred to target (amount + tip) + assertEq(token.balanceOf(address(mockTarget)), amount + tip, "Target should receive amount + tip"); + + // Verify onFulfill was called on the target + assertTrue(mockTarget.onFulfillCalled(), "onFulfill should have been called"); + + // Verify onSettle was also called + assertTrue(mockTarget.onSettleCalled(), "onSettle should have been called"); + + // Verify the common parameters + assertEq(mockTarget.lastIntentId(), intentId, "Intent ID should match"); + assertEq(mockTarget.lastAsset(), address(token), "Asset should match"); + assertEq(mockTarget.lastAmount(), amount, "Amount should match"); + assertEq(keccak256(mockTarget.lastData()), keccak256(data), "Data should match"); + + // Verify fulfillmentIndex was passed to onSettle + assertEq(mockTarget.lastFulfillmentIndex(), fulfillmentIndex, "Fulfillment index should match"); + + // Verify isFulfilled parameter was false + assertFalse(mockTarget.lastIsFulfilled(), "isFulfilled should be false"); + } + + function test_SettleCall_TokenAccessDuringOnFulfill() public { + // First create an intent with call + uint256 amount = 100 ether; + uint256 tip = 10 ether; + uint256 targetChain = 1; + bytes memory receiverBytes = abi.encodePacked(user2); + uint256 salt = 123; + bytes memory data = abi.encodeWithSignature("someFunction(uint256)", 42); + + // Deploy a mock target contract that implements IntentTarget + MockIntentTarget mockTarget = new MockIntentTarget(); + + // Enable balance checking + mockTarget.setShouldCheckBalances(true); + + // Mint tokens for initiate + token.mint(user1, amount + tip); + vm.prank(user1); + token.approve(address(intent), amount + tip); + + // Initiate an intent with call + vm.prank(user1); + bytes32 intentId = intent.initiateCall(address(token), amount, targetChain, receiverBytes, tip, salt, data); + + // Prepare settlement payload (with isCall=true and data) - Not fulfilling first + bytes memory settlementPayload = PayloadUtils.encodeSettlementPayload( + intentId, + amount, + address(token), + address(mockTarget), + tip, + amount, // actualAmount same as amount in the test case + true, // isCall + data // data for the call + ); + + // Transfer tokens to gateway for settlement + token.mint(address(gateway), amount + tip); + vm.prank(address(gateway)); + token.approve(address(intent), amount + tip); + + // Call onCall through gateway to settle the intent + vm.prank(address(gateway)); + intent.onCall(IIntent.MessageContext({sender: router}), settlementPayload); + + // Verify that the token balance was correct during onFulfill call + assertEq( + mockTarget.balanceDuringOnFulfill(), amount + tip, "Token balance should be available during onFulfill" + ); + } } diff --git a/test/PayloadUtils.t.sol b/test/PayloadUtils.t.sol index 0078b94..1ca2d32 100644 --- a/test/PayloadUtils.t.sol +++ b/test/PayloadUtils.t.sol @@ -15,9 +15,13 @@ contract PayloadUtilsTest is Test { uint256 targetChain = 42; address receiver = makeAddr("receiver"); bytes memory receiverBytes = abi.encodePacked(receiver); + bool isCall = false; + bytes memory data = ""; + uint256 gasLimit = 300000; // Encode intent payload - bytes memory encoded = PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes); + bytes memory encoded = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes, isCall, data, gasLimit); // Decode intent payload PayloadUtils.IntentPayload memory decoded = PayloadUtils.decodeIntentPayload(encoded); @@ -28,6 +32,9 @@ contract PayloadUtilsTest is Test { assertEq(decoded.tip, tip, "Tip mismatch"); assertEq(decoded.targetChain, targetChain, "Target chain mismatch"); assertEq(keccak256(decoded.receiver), keccak256(receiverBytes), "Receiver bytes mismatch"); + assertEq(decoded.isCall, isCall, "isCall mismatch"); + assertEq(keccak256(decoded.data), keccak256(data), "Data mismatch"); + assertEq(decoded.gasLimit, gasLimit, "Gas limit mismatch"); } function test_EncodeDecodeIntentPayload_ZeroValues() public pure { @@ -37,9 +44,13 @@ contract PayloadUtilsTest is Test { uint256 tip = 0; uint256 targetChain = 0; bytes memory receiverBytes = new bytes(20); // all zeros address + bool isCall = false; + bytes memory data = ""; + uint256 gasLimit = 0; // Encode intent payload - bytes memory encoded = PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes); + bytes memory encoded = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes, isCall, data, gasLimit); // Decode intent payload PayloadUtils.IntentPayload memory decoded = PayloadUtils.decodeIntentPayload(encoded); @@ -50,6 +61,9 @@ contract PayloadUtilsTest is Test { assertEq(decoded.tip, tip, "Tip mismatch"); assertEq(decoded.targetChain, targetChain, "Target chain mismatch"); assertEq(keccak256(decoded.receiver), keccak256(receiverBytes), "Receiver bytes mismatch"); + assertEq(decoded.isCall, isCall, "isCall mismatch"); + assertEq(keccak256(decoded.data), keccak256(data), "Data mismatch"); + assertEq(decoded.gasLimit, gasLimit, "Gas limit mismatch"); } function test_EncodeDecodeIntentPayload_LargeValues() public { @@ -60,9 +74,13 @@ contract PayloadUtilsTest is Test { uint256 targetChain = type(uint256).max - 2; address receiver = makeAddr("receiver"); bytes memory receiverBytes = abi.encodePacked(receiver); + bool isCall = true; + bytes memory data = abi.encodePacked("some-long-data-for-the-call", uint256(123456789)); + uint256 gasLimit = type(uint256).max - 3; // Encode intent payload - bytes memory encoded = PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes); + bytes memory encoded = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes, isCall, data, gasLimit); // Decode intent payload PayloadUtils.IntentPayload memory decoded = PayloadUtils.decodeIntentPayload(encoded); @@ -73,6 +91,39 @@ contract PayloadUtilsTest is Test { assertEq(decoded.tip, tip, "Tip mismatch"); assertEq(decoded.targetChain, targetChain, "Target chain mismatch"); assertEq(keccak256(decoded.receiver), keccak256(receiverBytes), "Receiver bytes mismatch"); + assertEq(decoded.isCall, isCall, "isCall mismatch"); + assertEq(keccak256(decoded.data), keccak256(data), "Data mismatch"); + assertEq(decoded.gasLimit, gasLimit, "Gas limit mismatch"); + } + + function test_EncodeDecodeIntentPayload_CustomGasLimit() public { + // Create test data with custom gas limit + bytes32 intentId = keccak256("test-intent"); + uint256 amount = 1000 ether; + uint256 tip = 50 ether; + uint256 targetChain = 42; + address receiver = makeAddr("receiver"); + bytes memory receiverBytes = abi.encodePacked(receiver); + bool isCall = true; + bytes memory data = "0x123456"; + uint256 gasLimit = 500000; // Custom gas limit + + // Encode intent payload + bytes memory encoded = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChain, receiverBytes, isCall, data, gasLimit); + + // Decode intent payload + PayloadUtils.IntentPayload memory decoded = PayloadUtils.decodeIntentPayload(encoded); + + // Assert all fields match, especially the gas limit + assertEq(decoded.intentId, intentId, "Intent ID mismatch"); + assertEq(decoded.amount, amount, "Amount mismatch"); + assertEq(decoded.tip, tip, "Tip mismatch"); + assertEq(decoded.targetChain, targetChain, "Target chain mismatch"); + assertEq(keccak256(decoded.receiver), keccak256(receiverBytes), "Receiver bytes mismatch"); + assertEq(decoded.isCall, isCall, "isCall mismatch"); + assertEq(keccak256(decoded.data), keccak256(data), "Data mismatch"); + assertEq(decoded.gasLimit, gasLimit, "Gas limit mismatch"); } function test_EncodeDecodeSettlementPayload() public { @@ -199,10 +250,12 @@ contract PayloadUtilsTest is Test { address asset = makeAddr("asset"); uint256 amount = 1000 ether; address receiver = makeAddr("receiver"); + bool isCall = false; + bytes memory data = ""; - bytes32 index = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver); + bytes32 index = PayloadUtils.computeFulfillmentIndex(intentId, asset, amount, receiver, isCall, data); - bytes32 expected = keccak256(abi.encodePacked(intentId, asset, amount, receiver)); + bytes32 expected = keccak256(abi.encodePacked(intentId, asset, amount, receiver, isCall, data)); assertEq(index, expected, "Fulfillment index computation failed"); } @@ -214,23 +267,36 @@ contract PayloadUtilsTest is Test { address asset = makeAddr("asset"); uint256 amount = 1000 ether; address receiver = makeAddr("receiver"); + bool isCall = false; + bytes memory data = ""; - bytes32 index1 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, receiver); + bytes32 index1 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, receiver, isCall, data); - bytes32 index2 = PayloadUtils.computeFulfillmentIndex(intentId2, asset, amount, receiver); + bytes32 index2 = PayloadUtils.computeFulfillmentIndex(intentId2, asset, amount, receiver, isCall, data); assertFalse(index1 == index2, "Indices should be different for different intent IDs"); - bytes32 index3 = PayloadUtils.computeFulfillmentIndex(intentId1, makeAddr("different-asset"), amount, receiver); + bytes32 index3 = + PayloadUtils.computeFulfillmentIndex(intentId1, makeAddr("different-asset"), amount, receiver, isCall, data); assertFalse(index1 == index3, "Indices should be different for different assets"); - bytes32 index4 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount + 1, receiver); + bytes32 index4 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount + 1, receiver, isCall, data); assertFalse(index1 == index4, "Indices should be different for different amounts"); - bytes32 index5 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, makeAddr("different-receiver")); + bytes32 index5 = + PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, makeAddr("different-receiver"), isCall, data); assertFalse(index1 == index5, "Indices should be different for different receivers"); + + // Also test with different isCall and data values + bytes32 index6 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, receiver, true, data); + + assertFalse(index1 == index6, "Indices should be different for different isCall values"); + + bytes32 index7 = PayloadUtils.computeFulfillmentIndex(intentId1, asset, amount, receiver, isCall, "some data"); + + assertFalse(index1 == index7, "Indices should be different for different data values"); } } diff --git a/test/Router.t.sol b/test/Router.t.sol index 2bbe8b9..44bd8a6 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -391,7 +391,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Set modest slippage (5%) swapModule.setSlippage(500); @@ -461,7 +461,10 @@ contract RouterTest is Test { intentAmount, // Use the smaller amount in the payload tip, targetChainId, - receiver + receiver, + false, + "", + 0 ); // Set modest slippage (10%) - with enough input to create the desired scenario @@ -540,7 +543,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Set high slippage (8%) so we can observe amount reduction swapModule.setSlippage(800); // 8% slippage = 8 ether on 100 ether @@ -630,7 +633,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Mock decimals for input token (6 decimals like USDC) vm.mockCall(address(inputToken), abi.encodeWithSelector(IZRC20.decimals.selector), abi.encode(uint8(6))); @@ -897,7 +900,7 @@ contract RouterTest is Test { uint256 tip = 10 ether; bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Mock setup for withdrawGasFeeWithGasLimit uint256 gasFee = 1 ether; @@ -990,7 +993,8 @@ contract RouterTest is Test { uint256 tip = 10 ether; bytes memory receiver = abi.encodePacked(user2); - bytes memory intentPayloadBytes = PayloadUtils.encodeIntentPayload(intentId, amount, tip, zetaChainId, receiver); + bytes memory intentPayloadBytes = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, zetaChainId, receiver, false, "", 0); // Set modest slippage (5%) swapModule.setSlippage(500); @@ -1184,7 +1188,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Set modest slippage (5%) swapModule.setSlippage(500); @@ -1243,7 +1247,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Mock setup for IZRC20 withdrawGasFeeWithGasLimit uint256 gasFee = 1 ether; @@ -1338,7 +1342,7 @@ contract RouterTest is Test { bytes memory receiver = abi.encodePacked(user2); bytes memory intentPayloadBytes = - PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver); + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", 0); // Set modest slippage (5%) swapModule.setSlippage(500); @@ -1373,4 +1377,63 @@ contract RouterTest is Test { vm.expectRevert("Swap returned invalid amount"); router.onCall(context, address(inputToken), amount + tip, intentPayloadBytes); } + + function test_OnCall_UsesCustomGasLimitFromPayload() public { + // Setup intent contract + uint256 sourceChainId = 1; + uint256 targetChainId = 42161; // Arbitrum chain ID + address sourceIntentContract = makeAddr("sourceIntentContract"); + address targetIntentContract = makeAddr("targetIntentContract"); + router.setIntentContract(sourceChainId, sourceIntentContract); + router.setIntentContract(targetChainId, targetIntentContract); + + // Setup token associations + string memory tokenName = "USDC"; + router.addToken(tokenName); + address inputAsset = makeAddr("input_asset"); + address targetAsset = makeAddr("target_asset"); + router.addTokenAssociation(tokenName, sourceChainId, inputAsset, address(inputToken)); + router.addTokenAssociation(tokenName, targetChainId, targetAsset, address(targetZRC20)); + + // Set a custom gas limit for the target chain + uint256 chainSpecificGasLimit = 500000; + router.setChainWithdrawGasLimit(targetChainId, chainSpecificGasLimit); + + // Setup intent payload with custom gas limit that's different from chain config + bytes32 intentId = keccak256("test-intent-custom-gas"); + uint256 amount = 100 ether; + uint256 tip = 10 ether; + bytes memory receiver = abi.encodePacked(user2); + uint256 customGasLimit = 700000; // Custom gas limit different from chain config + + bytes memory intentPayloadBytes = + PayloadUtils.encodeIntentPayload(intentId, amount, tip, targetChainId, receiver, false, "", customGasLimit); + + // Mock withdrawGasFeeWithGasLimit to verify it's called with the custom gas limit from payload + uint256 gasFee = 1 ether; + vm.mockCall( + address(targetZRC20), + abi.encodeWithSelector(IZRC20.withdrawGasFeeWithGasLimit.selector, customGasLimit), + abi.encode(address(gasZRC20), gasFee) + ); + + // Mint tokens to make the test work + inputToken.mint(address(router), amount + tip); + targetZRC20.mint(address(swapModule), amount + tip); + gasZRC20.mint(address(swapModule), gasFee); + + // Setup context + IGateway.ZetaChainMessageContext memory context = IGateway.ZetaChainMessageContext({ + chainID: sourceChainId, + sender: abi.encodePacked(sourceIntentContract), + senderEVM: sourceIntentContract + }); + + // Call onCall + vm.prank(address(gateway)); + router.onCall(context, address(inputToken), amount + tip, intentPayloadBytes); + + // The test passes if the mock call with the custom gas limit is used + // If the chain-specific gas limit was used instead, the mock would not match and the test would fail + } } diff --git a/test/mocks/MockIntent.sol b/test/mocks/MockIntent.sol index 9dc635b..9dfcfcf 100644 --- a/test/mocks/MockIntent.sol +++ b/test/mocks/MockIntent.sol @@ -17,6 +17,36 @@ contract MockIntent is IIntent { return bytes32(0); } + function initiateTransfer(address, uint256, uint256, bytes calldata, uint256, uint256) external returns (bytes32) { + return bytes32(0); + } + + function initiateCall(address, uint256, uint256, bytes calldata, uint256, uint256, bytes calldata) + external + returns (bytes32) + { + return bytes32(0); + } + + function initiateCall(address, uint256, uint256, bytes calldata, uint256, uint256, bytes calldata, uint256) + external + returns (bytes32) + { + return bytes32(0); + } + + function fulfill(bytes32, address, uint256, address) external { + // Empty implementation + } + + function fulfillTransfer(bytes32, address, uint256, address) external { + // Empty implementation + } + + function fulfillCall(bytes32, address, uint256, address, bytes calldata) external { + // Empty implementation + } + /** * @dev Mock implementation of onCall function to record the parameters * @param context The message context containing sender information diff --git a/test/mocks/MockIntentTarget.sol b/test/mocks/MockIntentTarget.sol new file mode 100644 index 0000000..dc06fe6 --- /dev/null +++ b/test/mocks/MockIntentTarget.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "../../src/interfaces/IntentTarget.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title MockIntentTarget + * @dev Mock implementation of IntentTarget for testing + */ +contract MockIntentTarget is IntentTarget { + // Variables to track calls + bool public onFulfillCalled; + bool public onSettleCalled; + bytes32 public lastIntentId; + address public lastAsset; + uint256 public lastAmount; + bytes public lastData; + bytes32 public lastFulfillmentIndex; + bool public lastIsFulfilled; + + // Variables to track token balance during function calls + uint256 public balanceDuringOnFulfill; + + // Variables that can be set to control behavior + bool public shouldRevert; + bool public shouldCheckBalances; + + function setShouldRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } + + function setShouldCheckBalances(bool _shouldCheckBalances) external { + shouldCheckBalances = _shouldCheckBalances; + } + + /** + * @dev Implementation of onFulfill to record parameters and optionally revert + */ + function onFulfill(bytes32 intentId, address asset, uint256 amount, bytes calldata data) external override { + if (shouldRevert) { + revert("MockIntentTarget: intentional revert in onFulfill"); + } + + onFulfillCalled = true; + lastIntentId = intentId; + lastAsset = asset; + lastAmount = amount; + lastData = data; + + // Check the current token balance if requested + if (shouldCheckBalances) { + balanceDuringOnFulfill = IERC20(asset).balanceOf(address(this)); + } + } + + /** + * @dev Implementation of onSettle to record parameters and optionally revert + */ + function onSettle( + bytes32 intentId, + address asset, + uint256 amount, + bytes calldata data, + bytes32 fulfillmentIndex, + bool isFulfilled + ) external override { + if (shouldRevert) { + revert("MockIntentTarget: intentional revert in onSettle"); + } + + onSettleCalled = true; + lastIntentId = intentId; + lastAsset = asset; + lastAmount = amount; + lastData = data; + lastFulfillmentIndex = fulfillmentIndex; + lastIsFulfilled = isFulfilled; + } + + /** + * @dev Reset all tracking variables + */ + function reset() external { + onFulfillCalled = false; + onSettleCalled = false; + lastIntentId = bytes32(0); + lastAsset = address(0); + lastAmount = 0; + lastData = ""; + lastFulfillmentIndex = bytes32(0); + lastIsFulfilled = false; + shouldRevert = false; + balanceDuringOnFulfill = 0; + shouldCheckBalances = false; + } +}