From 8d78c2ce6f8e59f24cdbbbf015b088ff0f6ffb4b Mon Sep 17 00:00:00 2001 From: owen-eth Date: Mon, 12 Jan 2026 11:42:28 -0500 Subject: [PATCH 1/8] refactor: settlement contract + tests --- contracts/foundry.lock | 3 + contracts/foundry.toml | 9 + contracts/src/FastSettlementV2.sol | 420 ++++++++++++++++ .../src/interfaces/IFastSettlementV2.sol | 172 +++++++ contracts/test/FastSettlementV2.t.sol | 470 ++++++++++++++++++ .../test/FastSettlementV2_Integration.t.sol | 195 ++++++++ contracts/test/Permit2Build.t.sol | 8 + 7 files changed, 1277 insertions(+) create mode 100644 contracts/src/FastSettlementV2.sol create mode 100644 contracts/src/interfaces/IFastSettlementV2.sol create mode 100644 contracts/test/FastSettlementV2.t.sol create mode 100644 contracts/test/FastSettlementV2_Integration.t.sol create mode 100644 contracts/test/Permit2Build.t.sol diff --git a/contracts/foundry.lock b/contracts/foundry.lock index 977ce84..5904bf9 100644 --- a/contracts/foundry.lock +++ b/contracts/foundry.lock @@ -16,5 +16,8 @@ "name": "v5.5.0", "rev": "aa677e9d28ed78fc427ec47ba2baef2030c58e7c" } + }, + "lib/permit2": { + "rev": "cc56ad0f3439c502c246fc5cfcc3db92bb8b7219" } } \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 25b918f..a96f1c0 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -2,5 +2,14 @@ src = "src" out = "out" libs = ["lib"] +# solc = "0.8.27" +# evm_version = "cancun" +auto_detect_solc = true +via_ir = true +optimizer = true +optimizer_runs = 200 + +# Allow importing from different solidity versions +allow_paths = ["lib"] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/contracts/src/FastSettlementV2.sol b/contracts/src/FastSettlementV2.sol new file mode 100644 index 0000000..0ca239c --- /dev/null +++ b/contracts/src/FastSettlementV2.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {IFastSettlementV2, IExecutor} from "./interfaces/IFastSettlementV2.sol"; + +/// @notice Minimal interface for Permit2 SignatureTransfer +interface ISignatureTransfer { + struct TokenPermissions { + address token; + uint256 amount; + } + + struct PermitTransferFrom { + TokenPermissions permitted; + uint256 nonce; + uint256 deadline; + } + + struct SignatureTransferDetails { + address to; + uint256 requestedAmount; + } + + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external; +} + +/// @title FastSettlementV2 +/// @notice Production-grade intent settlement contract for the Fast Protocol +/// @dev Implements EIP-712 signed intents with Permit2 integration and Reactor pattern +/// @dev V2: Reactor-style execution with strict checks +/// @author Fast Protocol +contract FastSettlementV2 is IFastSettlementV2, Ownable2Step, ReentrancyGuard, Pausable { + using SafeERC20 for IERC20; + + // ============ Constants ============ + + /// @notice The Permit2 contract address (canonical deployment) + ISignatureTransfer public immutable PERMIT2; + + /// @notice Maximum nonce value to prevent lockout + uint256 public constant MAX_NONCE = type(uint256).max - 1000; + + /// @notice Basis points denominator (100% = 10000) + uint256 public constant BPS_DENOMINATOR = 10000; + + /// @notice EIP-712 typehash for Intent struct + /// @dev Uses uint256 for deadline to match Permit2 conventions + bytes32 public constant INTENT_TYPEHASH = + keccak256( + "Intent(address maker,address recipient,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint256 deadline,uint256 nonce,bytes32 refId)" + ); + + /// @notice Witness type string for Permit2 integration + string public constant WITNESS_TYPE_STRING = + "Intent witness)Intent(address maker,address recipient,address tokenIn,address tokenOut,uint256 amountIn,uint256 amountOut,uint256 deadline,uint256 nonce,bytes32 refId)TokenPermissions(address token,uint256 amount)"; + + /// @notice Nonce domain separator to avoid Permit2 collision + bytes32 public constant NONCE_DOMAIN = keccak256("FastSettlement.nonce.v2"); + + // ============ Immutables ============ + + /// @notice Chain ID cached at deployment for fork detection + uint256 private immutable _CACHED_CHAIN_ID; + + /// @notice Domain separator cached at deployment + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + + // ============ Storage ============ + + /// @notice Maps executor address to authorization status + mapping(address => bool) private _executors; + + /// @notice Whether the executor whitelist is currently active + bool public isExecutorWhitelistActive; + + /// @notice Maps maker => transformed nonce => used status + mapping(address => mapping(uint256 => bool)) private _usedNonces; + + /// @notice Maps maker => minimum valid nonce + mapping(address => uint256) private _minNonces; + + /// @notice Address that receives protocol's share of surplus + address private _surplusRecipient; + + // ============ Constructor ============ + + /// @notice Deploys the settlement contract + /// @param permit2_ The Permit2 contract address + /// @param owner_ The initial owner address + /// @param surplusRecipient_ The initial surplus recipient + constructor(address permit2_, address owner_, address surplusRecipient_) Ownable(owner_) { + // Note: owner_ validation is handled by OpenZeppelin's Ownable (throws OwnableInvalidOwner) + if (permit2_ == address(0)) revert InvalidPermit2Address(); + if (surplusRecipient_ == address(0)) revert InvalidSurplusRecipient(); + + PERMIT2 = ISignatureTransfer(permit2_); + _surplusRecipient = surplusRecipient_; + isExecutorWhitelistActive = true; + + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _computeDomainSeparator(); + } + + // ============ Modifiers ============ + + /// @notice Restricts function to authorized executors if whitelist is active + modifier checkExecutor() { + if (isExecutorWhitelistActive && !_executors[msg.sender]) { + revert UnauthorizedExecutor(); + } + _; + } + + // ============ External Functions ============ + + /// @inheritdoc IFastSettlementV2 + function settle( + Intent calldata intent, + bytes calldata signature, + bytes calldata executorData + ) external override checkExecutor nonReentrant whenNotPaused { + // 1. Validate intent (expiry, nonce, basics) + _validateIntentBasic(intent); + + bytes32 intentId = _hashIntent(intent); + uint256 transformedNonce = _transformNonce(intent.nonce); + + // 2. Mark nonce used (CEI) to prevent reentrancy/replay + _usedNonces[intent.maker][transformedNonce] = true; + + // 3. Pull tokens from user directly to the executor + // Note: The signature must sign 'msg.sender' (the executor) as the spender in Permit2 + _pullToExecutor(intent, signature); + + // 4. Execute Executor Callback + // No return value required. + // For ETH: Executor must send ETH to this contract. + // For ERC20: Executor must approve this contract AND have enough balance. + IExecutor(msg.sender).execute(intent, executorData); + + // 5. Verify Outcome and Distribute + uint256 protocolAmt; + + if (intent.tokenOut == address(0)) { + // ETH Output Flow + // Check if we have enough ETH to pay the user + // We expect the executor to have sent at least 'amountOut' + if (address(this).balance < intent.amountOut) { + revert InsufficientOutput(address(this).balance, intent.amountOut); + } + + // Calculations + // Surplus is whatever is left after paying the user + // Note: We snapshot balance once, but here we can just check what we have + uint256 available = address(this).balance; + protocolAmt = available - intent.amountOut; + + // Distribute + _sendETH(intent.recipient, intent.amountOut); + + if (protocolAmt > 0) { + _sendETH(_surplusRecipient, protocolAmt); + } + } else { + // ERC20 Output Flow + // Assume executor logic is correct. We just blindly transfer 'amountOut' from executor to recipient. + // If executor failed to approve or doesn't have balance, this reverts. + // surplus is expected to be transferred by the executor + + IERC20(intent.tokenOut).safeTransferFrom( + msg.sender, + intent.recipient, + intent.amountOut + ); + + // protocolAmt is 0 for ERC20 + } + + emit IntentSettled( + intentId, + intent.maker, + intent.tokenIn, + intent.tokenOut, + intent.amountIn, + intent.amountOut, + protocolAmt + ); + } + + /// @inheritdoc IFastSettlementV2 + function validate( + Intent calldata intent, + bytes calldata signature + ) external view override returns (bool isValid, string memory reason) { + if (block.timestamp > intent.deadline) return (false, "Expired"); + + uint256 transformedNonce = _transformNonce(intent.nonce); + if ( + _usedNonces[intent.maker][transformedNonce] || intent.nonce < _minNonces[intent.maker] + ) { + return (false, "Nonce Invalid"); + } + + if (signature.length == 0) return (false, "Missing Signature"); + + return (true, ""); + } + + /// @inheritdoc IFastSettlementV2 + function setExecutor(address executor, bool allowed) external override onlyOwner { + if (executor == address(0)) revert InvalidExecutorAddress(); + _executors[executor] = allowed; + emit ExecutorUpdated(executor, allowed); + } + + /// @inheritdoc IFastSettlementV2 + function setExecutorWhitelistActive(bool active) external override onlyOwner { + isExecutorWhitelistActive = active; + emit ExecutorWhitelistActiveUpdated(active); + } + + /// @inheritdoc IFastSettlementV2 + function setSurplusRecipient(address recipient) external override onlyOwner { + if (recipient == address(0)) revert InvalidSurplusRecipient(); + address old = _surplusRecipient; + _surplusRecipient = recipient; + emit SurplusRecipientUpdated(old, recipient); + } + + /// @inheritdoc IFastSettlementV2 + function invalidateNoncesUpTo(uint256 newMinNonce) external override { + uint256 currentMin = _minNonces[msg.sender]; + if (newMinNonce <= currentMin) revert InvalidNonceIncrement(); + if (newMinNonce > MAX_NONCE) revert NonceTooHigh(); + + _minNonces[msg.sender] = newMinNonce; + emit NonceInvalidated(msg.sender, newMinNonce); + } + + /// @inheritdoc IFastSettlementV2 + function pause() external override onlyOwner { + _pause(); + } + + /// @inheritdoc IFastSettlementV2 + function unpause() external override onlyOwner { + _unpause(); + } + + /// @inheritdoc IFastSettlementV2 + function rescueTokens(address token, address to, uint256 amount) external override onlyOwner { + if (to == address(0)) revert InvalidRecipient(); + IERC20(token).safeTransfer(to, amount); + emit TokensRescued(token, to, amount); + } + + // ============ View Functions ============ + + /// @inheritdoc IFastSettlementV2 + function getIntentId(Intent calldata intent) external view override returns (bytes32) { + return _hashIntent(intent); + } + + /// @inheritdoc IFastSettlementV2 + function isNonceUsed(address maker, uint256 nonce) external view override returns (bool) { + uint256 transformedNonce = _transformNonce(nonce); + return _usedNonces[maker][transformedNonce] || nonce < _minNonces[maker]; + } + + /// @inheritdoc IFastSettlementV2 + function getMinNonce(address maker) external view override returns (uint256) { + return _minNonces[maker]; + } + + /// @inheritdoc IFastSettlementV2 + function isExecutorAllowed(address executor) external view override returns (bool) { + return _executors[executor]; + } + + /// @inheritdoc IFastSettlementV2 + function getSurplusRecipient() external view override returns (address) { + return _surplusRecipient; + } + + /// @inheritdoc IFastSettlementV2 + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + // Recompute if chain ID changed (fork protection) + if (block.chainid != _CACHED_CHAIN_ID) { + return _computeDomainSeparator(); + } + return _CACHED_DOMAIN_SEPARATOR; + } + + // ============ Internal Functions ============ + + function _validateIntentBasic(Intent calldata intent) internal view { + if (block.timestamp > intent.deadline) revert TransactionExpired(); + if (intent.amountIn == 0) revert("Zero input"); + if (intent.amountOut == 0) revert("Zero output"); + // Permit2 does not support pulling ETH, so tokenIn must be a valid address + if (intent.tokenIn == address(0)) revert("ETH input not supported"); + + uint256 transformedNonce = _transformNonce(intent.nonce); + if ( + _usedNonces[intent.maker][transformedNonce] || intent.nonce < _minNonces[intent.maker] + ) { + revert NonceAlreadyUsed(); + } + } + + /// @notice Sends ETH to a recipient + function _sendETH(address to, uint256 amount) internal { + (bool success, ) = to.call{value: amount}(""); + if (!success) revert("ETH transfer failed"); + } + + function _pullToExecutor(Intent calldata intent, bytes calldata signature) internal { + bytes32 witness = _computeIntentWitness(intent); + + ISignatureTransfer.PermitTransferFrom memory permit = ISignatureTransfer + .PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({ + token: intent.tokenIn, + amount: intent.amountIn + }), + nonce: intent.nonce, + deadline: intent.deadline + }); + + // Transfer directly to msg.sender (the executor) + ISignatureTransfer.SignatureTransferDetails memory transferDetails = ISignatureTransfer + .SignatureTransferDetails({to: msg.sender, requestedAmount: intent.amountIn}); + + try + PERMIT2.permitWitnessTransferFrom( + permit, + transferDetails, + intent.maker, + witness, + WITNESS_TYPE_STRING, + signature + ) + { + // Success + } catch { + revert Permit2TransferFailed(); + } + } + + function _transformNonce(uint256 nonce) internal pure returns (uint256) { + return uint256(keccak256(abi.encode(NONCE_DOMAIN, nonce))); + } + + function _computeDomainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256("FastSettlement"), + keccak256("2"), + block.chainid, + address(this) + ) + ); + } + + function _hashIntent(Intent calldata intent) internal view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + INTENT_TYPEHASH, + intent.maker, + intent.recipient, + intent.tokenIn, + intent.tokenOut, + intent.amountIn, + intent.amountOut, + intent.deadline, + intent.nonce, + intent.refId + ) + ); + + return MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR(), structHash); + } + + function _computeIntentWitness(Intent calldata intent) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + INTENT_TYPEHASH, + intent.maker, + intent.recipient, + intent.tokenIn, + intent.tokenOut, + intent.amountIn, + intent.amountOut, + intent.deadline, + intent.nonce, + intent.refId + ) + ); + } + + receive() external payable {} +} diff --git a/contracts/src/interfaces/IFastSettlementV2.sol b/contracts/src/interfaces/IFastSettlementV2.sol new file mode 100644 index 0000000..8f1a13c --- /dev/null +++ b/contracts/src/interfaces/IFastSettlementV2.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @notice Interface for the executor callback +interface IExecutor { + /// @notice Callback function executed by the settlement contract + /// @param intent The intent being settled + /// @param inputData Arbitrary data passed by the executor + function execute(IFastSettlementV2.Intent calldata intent, bytes calldata inputData) external; +} + +/// @title IFastSettlementV2 +/// @notice Interface for the Fast Protocol Intent Settlement V2 +/// @dev Reactor-style settlement with direct Permit2 pulling +interface IFastSettlementV2 { + // ============ Structs ============ + + /// @notice Represents a user's signed swap intent + struct Intent { + address maker; + address recipient; + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 amountOut; // Was minOut + uint256 deadline; + uint256 nonce; + bytes32 refId; + } + + // ============ Events ============ + + /// @notice Emitted when an intent is successfully settled + /// @param intentId The computed hash of the intent + /// @param maker The address that signed the intent + /// @param tokenIn The token sold + /// @param tokenOut The token bought + /// @param amountIn The amount of tokenIn sold + /// @param amountOut The requested amountOut + /// @param protocolAmt The amount (if any) collected by protocol (Surplus/Spread) + event IntentSettled( + bytes32 indexed intentId, + address indexed maker, + address indexed tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut, + uint256 protocolAmt + ); + + /// @notice Emitted when an executor's authorization status changes + event ExecutorUpdated(address indexed executor, bool allowed); + + /// @notice Emitted when the whitelist mode changes + event ExecutorWhitelistActiveUpdated(bool active); + + /// @notice Emitted when the surplus recipient is updated + event SurplusRecipientUpdated(address indexed oldRecipient, address indexed newRecipient); + + /// @notice Emitted when a user invalidates their nonces up to a certain value + event NonceInvalidated(address indexed maker, uint256 newMinNonce); + + /// @notice Emitted when tokens are rescued by owner + event TokensRescued(address indexed token, address indexed to, uint256 amount); + + // ============ Errors ============ + + /// @notice Thrown when caller is not an authorized executor + error UnauthorizedExecutor(); + + /// @notice Thrown when settlement fails with details + error SettlementFailed(bytes32 intentId, string reason); + + /// @notice Thrown when the output amount is insufficient (for ETH checks mainly) + error InsufficientOutput(uint256 received, uint256 required); + + /// @notice Thrown when trying to set zero address as surplus recipient + error InvalidSurplusRecipient(); + + /// @notice Thrown when surplus basis points exceeds 100% + error InvalidSurplusBps(); + + /// @notice Thrown when recipient address is zero + error InvalidRecipient(); + + /// @notice Thrown when Permit2 address is zero + error InvalidPermit2Address(); + + /// @notice Thrown when owner address is zero + error InvalidOwnerAddress(); + + /// @notice Thrown when executor address is zero + error InvalidExecutorAddress(); + + /// @notice Thrown when nonce increment is invalid + error InvalidNonceIncrement(); + + /// @notice Thrown when nonce exceeds maximum allowed value + error NonceTooHigh(); + + /// @notice Thrown when transaction would expire soon + error TransactionExpired(); + + /// @notice Thrown when nonce is already used + error NonceAlreadyUsed(); + + /// @notice Thrown when Permit2 transfer fails + error Permit2TransferFailed(); + + // ============ External Functions ============ + + /// @notice Settles a single intent + /// @param intent The Intent struct to settle + /// @param signature EIP-712 signature from the maker + /// @param executorData Arbitrary data passed to the executor callback + function settle( + Intent calldata intent, + bytes calldata signature, + bytes calldata executorData + ) external; + + /// @notice Validates an intent without executing it + /// @param intent The intent to validate + /// @param signature The signature to check + /// @return isValid Whether the intent is valid + /// @return reason Reason for invalidity (empty if valid) + function validate( + Intent calldata intent, + bytes calldata signature + ) external view returns (bool isValid, string memory reason); + + /// @notice Adds or removes an executor from the allowlist + function setExecutor(address executor, bool allowed) external; + + /// @notice Sets whether the executor whitelist is active + function setExecutorWhitelistActive(bool active) external; + + /// @notice Sets the address that receives protocol's share of surplus output + function setSurplusRecipient(address recipient) external; + + /// @notice Invalidates all nonces below the specified value for the caller + function invalidateNoncesUpTo(uint256 newMinNonce) external; + + /// @notice Pauses the contract + function pause() external; + + /// @notice Unpauses the contract + function unpause() external; + + /// @notice Allows owner to rescue tokens accidentally sent to the contract + function rescueTokens(address token, address to, uint256 amount) external; + + // ============ View Functions ============ + + /// @notice Computes the intent ID (EIP-712 hash) + function getIntentId(Intent calldata intent) external view returns (bytes32); + + /// @notice Checks if a nonce has been used + function isNonceUsed(address maker, uint256 nonce) external view returns (bool); + + /// @notice Gets the minimum valid nonce for a maker + function getMinNonce(address maker) external view returns (uint256); + + /// @notice Checks if an executor is authorized + function isExecutorAllowed(address executor) external view returns (bool); + + /// @notice Gets the current surplus recipient + function getSurplusRecipient() external view returns (address); + + /// @notice Returns the EIP-712 domain separator + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/contracts/test/FastSettlementV2.t.sol b/contracts/test/FastSettlementV2.t.sol new file mode 100644 index 0000000..012281c --- /dev/null +++ b/contracts/test/FastSettlementV2.t.sol @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {FastSettlementV2} from "../src/FastSettlementV2.sol"; +import {IFastSettlementV2, IExecutor} from "../src/interfaces/IFastSettlementV2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; + +// ============ Mock Contracts ============ + +contract MockERC20 is ERC20 { + uint8 private _decimals; + + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _decimals = decimals_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function decimals() public view override returns (uint8) { + return _decimals; + } +} + +contract MockPermit2 { + struct TokenPermissions { + address token; + uint256 amount; + } + + struct PermitTransferFrom { + TokenPermissions permitted; + uint256 nonce; + uint256 deadline; + } + + struct SignatureTransferDetails { + address to; + uint256 requestedAmount; + } + + mapping(address => mapping(address => mapping(uint256 => bool))) public usedNonces; + + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner_, + bytes32, + string calldata, + bytes calldata + ) external { + // Simple mock - transfer to the requested recipient (executor) + require(!usedNonces[owner_][msg.sender][permit.nonce], "Nonce used"); + usedNonces[owner_][msg.sender][permit.nonce] = true; + + IERC20(permit.permitted.token).transferFrom( + owner_, + transferDetails.to, + transferDetails.requestedAmount + ); + } +} + +contract MockExecutor is IExecutor { + address public settlement; + bool public shouldFail; + bool public stealFunds; + uint256 public outputAmountOverride; + bool public skipApproval; + + constructor(address _settlement) { + settlement = _settlement; + } + + receive() external payable {} + + function setShouldFail(bool _fail) external { + shouldFail = _fail; + } + + function setStealFunds(bool _steal) external { + stealFunds = _steal; + } + + function setOutputAmountOverride(uint256 _amount) external { + outputAmountOverride = _amount; + } + + function setSkipApproval(bool _skip) external { + skipApproval = _skip; + } + + // Execute now returns void + function execute(IFastSettlementV2.Intent calldata intent, bytes calldata) external override { + require(msg.sender == settlement, "Only settlement"); + + if (shouldFail) { + revert("Executor failed"); + } + + if (stealFunds) { + return; + } + + // Mock swap: Mint output tokens to SELF (Executor) + // In real world, we would swap input for output + uint256 amountToProvide = outputAmountOverride > 0 + ? outputAmountOverride + : intent.amountOut; + + if (intent.tokenOut == address(0)) { + // ETH Output Flow + // Send ETH to settlement contract + // We assume MockExecutor has been dealt ETH + (bool success, ) = settlement.call{value: amountToProvide}(""); + require(success, "MockExecutor: ETH transfer failed"); + } else { + // ERC20 Output Flow + MockERC20(intent.tokenOut).mint(address(this), amountToProvide); + + // Approve settlement contract to pull output tokens + if (!skipApproval) { + IERC20(intent.tokenOut).approve(settlement, amountToProvide); + } + } + } +} + +// ============ Test Contract ============ + +contract FastSettlementV2Test is Test { + FastSettlementV2 public settlement; + MockPermit2 public permit2; + MockExecutor public executor; + MockERC20 public tokenIn; + MockERC20 public tokenOut; + + address public owner = makeAddr("owner"); + address public user = makeAddr("user"); + address public recipient = makeAddr("recipient"); + address public surplusRecipient = makeAddr("surplusRecipient"); + address public attacker = makeAddr("attacker"); + + function setUp() public { + // Deploy mocks + permit2 = new MockPermit2(); + tokenIn = new MockERC20("Token In", "TIN", 18); + tokenOut = new MockERC20("Token Out", "TOUT", 18); + + // Deploy settlement + vm.prank(owner); + settlement = new FastSettlementV2(address(permit2), owner, surplusRecipient); + + // Deploy executor + executor = new MockExecutor(address(settlement)); + + // Setup permissions + vm.startPrank(owner); + settlement.setExecutor(address(executor), true); + vm.stopPrank(); + + // Fund user + tokenIn.mint(user, 1000000e18); + vm.prank(user); + tokenIn.approve(address(permit2), type(uint256).max); + } + + // ============ Constructor Tests ============ + + function test_Constructor_RevertsOnZeroPermit2() public { + vm.expectRevert(IFastSettlementV2.InvalidPermit2Address.selector); + new FastSettlementV2(address(0), owner, surplusRecipient); + } + + function test_Constructor_RevertsOnZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new FastSettlementV2(address(permit2), address(0), surplusRecipient); + } + + // ============ Basic Settlement Tests ============ + + function test_Settle_Success() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + vm.prank(address(executor)); + settlement.settle(intent, "", ""); + + // User spent tokens + assertEq(tokenIn.balanceOf(user), 1000000e18 - 100e18); + + // Executor received tokens (via Permit2) + assertEq(tokenIn.balanceOf(address(executor)), 100e18); + + // Recipient received tokens + assertEq(tokenOut.balanceOf(recipient), 90e18); + } + + function test_Settle_ETH_Success() public { + IFastSettlementV2.Intent memory intent = _createIntent(user, 100e18, 90e18, address(0)); + + // Fund executor with ETH + vm.deal(address(executor), 100e18); + + uint256 recipientEthBefore = recipient.balance; + uint256 surplusEthBefore = surplusRecipient.balance; + + vm.prank(address(executor)); + settlement.settle(intent, "", ""); + + // Check ETH balances + assertEq(recipient.balance - recipientEthBefore, 90e18); + assertEq(surplusRecipient.balance - surplusEthBefore, 0); // No surplus + + // User still spent input tokens + assertEq(tokenIn.balanceOf(user), 1000000e18 - 100e18); + } + + function test_Settle_ETH_Surplus() public { + IFastSettlementV2.Intent memory intent = _createIntent(user, 100e18, 90e18, address(0)); + + // Fund executor with extra ETH + vm.deal(address(executor), 100e18); + + // Executor sends 100 ETH (10 surplus) + executor.setOutputAmountOverride(100e18); + + uint256 recipientEthBefore = recipient.balance; + uint256 surplusEthBefore = surplusRecipient.balance; + + vm.prank(address(executor)); + settlement.settle(intent, "", ""); + + assertEq(recipient.balance - recipientEthBefore, 90e18); + assertEq(surplusRecipient.balance - surplusEthBefore, 10e18); + } + + function test_Settle_RevertsForNonExecutor() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + vm.prank(attacker); + vm.expectRevert(IFastSettlementV2.UnauthorizedExecutor.selector); + settlement.settle(intent, "", ""); + } + + function test_Settle_RevertsWhenPaused() public { + vm.prank(owner); + settlement.pause(); + + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + vm.prank(address(executor)); + vm.expectRevert(abi.encodeWithSelector(Pausable.EnforcedPause.selector)); + settlement.settle(intent, "", ""); + } + + function test_Settle_RevertsWhenExecutorFails() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + executor.setShouldFail(true); + + vm.prank(address(executor)); + vm.expectRevert("Executor failed"); + settlement.settle(intent, "", ""); + } + + /* + DEPRECATED: We no longer check for insufficient output in ERC20 flow via a specific error if we blindly transfer. + Wait, if we blindly transfer 'amountOut', SafeERC20 will revert with "ERC20: transfer amount exceeds balance" or "insufficient allowance". + We keep this test but expect the standard SafeERC20 revert message (or generic revert) instead of InsufficientOutput. + HOWEVER, for ETH flow, we DO check InsufficientOutput. + */ + function test_Settle_ETH_RevertsWhenInsufficient() public { + IFastSettlementV2.Intent memory intent = _createIntent(user, 100e18, 90e18, address(0)); + + // Executor only sends 89 ETH (less than 90) + vm.deal(address(executor), 100e18); + executor.setOutputAmountOverride(89e18); + + vm.prank(address(executor)); + // We expect custom error InsufficientOutput(received, required) + vm.expectRevert( + abi.encodeWithSelector(IFastSettlementV2.InsufficientOutput.selector, 89e18, 90e18) + ); + settlement.settle(intent, "", ""); + } + + function test_Settle_RevertsWhenExecutorFailsApproval() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + // Executor forgets to approve + executor.setSkipApproval(true); + + vm.prank(address(executor)); + vm.expectRevert(); + settlement.settle(intent, "", ""); + } + + /* + Updated: ERC20 Surplus is NOT captured by contract anymore. + We verify recipient gets amountOut. Surplus Recipient gets nothing (0). + */ + function test_Settle_ERC20_IgnoresSurplus() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + // Executor has 100 tokens, intent asks for 90. + // Even if executor approves 100, contract only pulls 90. + executor.setOutputAmountOverride(100e18); + + vm.prank(address(executor)); + settlement.settle(intent, "", ""); + + assertEq(tokenOut.balanceOf(recipient), 90e18); + // Surplus recipient gets nothing from contract + assertEq(tokenOut.balanceOf(surplusRecipient), 0); + + // Executor keeps the rest (10) + assertEq(tokenOut.balanceOf(address(executor)), 10e18); + } + + // ============ Validate Tests ============ + + function test_Validate_Success() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + (bool valid, string memory reason) = settlement.validate(intent, "0x1234"); + assertTrue(valid); + assertEq(reason, ""); + } + + function test_Validate_Expired() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + intent.deadline = block.timestamp - 1; + + (bool valid, string memory reason) = settlement.validate(intent, "0x1234"); + assertFalse(valid); + assertEq(reason, "Expired"); + } + + function test_Validate_NonceUsed() public { + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + + vm.prank(address(executor)); + settlement.settle(intent, "", ""); + + (bool valid, string memory reason) = settlement.validate(intent, "0x1234"); + assertFalse(valid); + assertEq(reason, "Nonce Invalid"); + } + + // ============ Admin Function Tests ============ + + function test_Admin_SetExecutor() public { + address newExecutor = makeAddr("newExecutor"); + + vm.prank(owner); + settlement.setExecutor(newExecutor, true); + + assertTrue(settlement.isExecutorAllowed(newExecutor)); + + vm.prank(owner); + settlement.setExecutor(newExecutor, false); + + assertFalse(settlement.isExecutorAllowed(newExecutor)); + } + + function test_Admin_SetExecutorWhitelistActive() public { + assertTrue(settlement.isExecutorWhitelistActive()); + + vm.prank(owner); + settlement.setExecutorWhitelistActive(false); + + assertFalse(settlement.isExecutorWhitelistActive()); + + IFastSettlementV2.Intent memory intent = _createIntent( + user, + 100e18, + 90e18, + address(tokenOut) + ); + MockExecutor randomExecutor = new MockExecutor(address(settlement)); + + // This fails if randomExecutor doesn't implement callback + // We just check that the call reaches "checkExecutor" logic basically and passes + // But settlement logic requires valid callback. MockExecutor has it. + vm.deal(address(randomExecutor), 1 ether); // Gas? No logic needs eth + + vm.prank(address(randomExecutor)); + settlement.settle(intent, "", ""); + } + + function test_Admin_SetSurplusRecipient() public { + address newRecipient = makeAddr("newRecipient"); + + vm.prank(owner); + settlement.setSurplusRecipient(newRecipient); + + assertEq(settlement.getSurplusRecipient(), newRecipient); + } + + // ============ Helper Functions ============ + + function _createIntent( + address maker, + uint256 amountIn, + uint256 amountOut, + address _tokenOut + ) internal view returns (IFastSettlementV2.Intent memory) { + return + IFastSettlementV2.Intent({ + maker: maker, + recipient: recipient, + tokenIn: address(tokenIn), + tokenOut: _tokenOut, + amountIn: amountIn, + amountOut: amountOut, + deadline: block.timestamp + 1 hours, + nonce: 1, + refId: bytes32(0) + }); + } +} diff --git a/contracts/test/FastSettlementV2_Integration.t.sol b/contracts/test/FastSettlementV2_Integration.t.sol new file mode 100644 index 0000000..6e062b3 --- /dev/null +++ b/contracts/test/FastSettlementV2_Integration.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {FastSettlementV2} from "../src/FastSettlementV2.sol"; +import {IFastSettlementV2, IExecutor} from "../src/interfaces/IFastSettlementV2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +// ============ Mocks ============ +// Reuse standard mocks for Token and Executor, but use REAL Permit2 +contract MockERC20 is ERC20 { + uint8 private _decimals; + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _decimals = decimals_; + } + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + function decimals() public view override returns (uint8) { + return _decimals; + } +} + +contract MockExecutor is IExecutor { + address public settlement; + function execute(IFastSettlementV2.Intent calldata intent, bytes calldata) external override { + // Simple logic: Mint output token and approve settlement + // In integration test, we assume token is MockERC20 so we can mint + MockERC20(intent.tokenOut).mint(address(this), intent.amountOut); + IERC20(intent.tokenOut).approve(msg.sender, intent.amountOut); + } +} + +// ============ Integration Test ============ +contract FastSettlementV2IntegrationTest is Test { + // Real Contracts + FastSettlementV2 public settlement; + address public permit2; // Deployed via deployCode + MockExecutor public executor; + MockERC20 public tokenIn; + MockERC20 public tokenOut; + + // Actors + address public owner = makeAddr("owner"); + address public user = makeAddr("user"); // Maker + address public recipient = makeAddr("recipient"); + address public surplusRecipient = makeAddr("surplusRecipient"); + + // User Pvt Key for signing + uint256 public userKey; + + // Permit2 Constants (Copied from PermitHash.sol / Permit2.sol) + bytes32 public constant TOKEN_PERMISSIONS_TYPEHASH = + keccak256("TokenPermissions(address token,uint256 amount)"); + string public constant PERMIT_WITNESS_TRANSFER_TYPE_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + function setUp() public { + // Setup user with private key + (user, userKey) = makeAddrAndKey("user"); + + // 1. Deploy Real Permit2 + // We use deployCode to avoid importing incompatible 0.8.17 source directly + permit2 = deployCode("out/Permit2.sol/Permit2.json"); + + // 2. Deploy Tokens + tokenIn = new MockERC20("Token In", "TIN", 18); + tokenOut = new MockERC20("Token Out", "TOUT", 18); + + // 3. Deploy Settlement Contract linked to Real Permit2 + vm.prank(owner); + settlement = new FastSettlementV2(permit2, owner, surplusRecipient); + + // 4. Deploy Executor + executor = new MockExecutor(); // No settlement address needed in simple mock if we just trust caller? + // Wait, simple mock needs to know who to approve. + // Updated mock logic above implies it approves msg.sender (settlement) + + // 5. Whitelist Executor + vm.prank(owner); + settlement.setExecutor(address(executor), true); + + // 6. Fund User and Approve Permit2 + tokenIn.mint(user, 1000e18); + vm.prank(user); + IERC20(tokenIn).approve(permit2, type(uint256).max); + } + + function test_Integration_RealPermit2_Success() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + uint256 nonce = 0; + uint256 deadline = block.timestamp + 1 hours; + + // 1. Create Intent + IFastSettlementV2.Intent memory intent = IFastSettlementV2.Intent({ + maker: user, + recipient: recipient, + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + amountIn: amountIn, + amountOut: amountOut, + deadline: deadline, + nonce: nonce, // Permit2 tracks nonces globally per user? + // Note: Permit2 nonce is just a uint256. FastSettlement transforms it. + // But Permit2 also checks it? + // Wait, Permit2 signature transfer uses 'nonce' in the signed data. + // Does Permit2 enforce strictly increasing nonces? + // SignatureTransfer: `_useUnorderedNonce`. It uses bitmap. + // Nonce 0 is fine. + refId: bytes32(0) + }); + + // 2. Sign the Intent (Construct EIP-712 Signature for Permit2) + bytes memory signature = _getPermitSignature(intent, userKey); + + // 3. Execute Settle via Executor + vm.prank(address(executor)); + settlement.settle(intent, signature, ""); + + // 4. Verification + assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); // User pulled + assertEq(tokenIn.balanceOf(address(executor)), amountIn); // Executor received + assertEq(tokenOut.balanceOf(recipient), amountOut); // Recipient received + } + + // ============ Helper: Generate Real Permit2 Signature ============ + + function _getPermitSignature( + IFastSettlementV2.Intent memory intent, + uint256 pvtKey + ) internal view returns (bytes memory) { + // 1. Build Witness (The Intent Struct Hash) + bytes32 witness = keccak256( + abi.encode( + settlement.INTENT_TYPEHASH(), + intent.maker, + intent.recipient, + intent.tokenIn, + intent.tokenOut, + intent.amountIn, + intent.amountOut, + intent.deadline, + intent.nonce, + intent.refId + ) + ); + + // 2. Build TypeHash for PermitWitnessTransferFrom + // Permit2 constructs this dynamically: keccak256(abi.encodePacked(STUB, witnessTypeString)) + // Witness Type String from settlement contract + string memory witnessTypeString = settlement.WITNESS_TYPE_STRING(); + bytes32 typeHash = keccak256( + abi.encodePacked(PERMIT_WITNESS_TRANSFER_TYPE_STUB, witnessTypeString) + ); + + // 3. Build TokenPermissions Hash + bytes32 tokenPermissionsHash = keccak256( + abi.encode(TOKEN_PERMISSIONS_TYPEHASH, intent.tokenIn, intent.amountIn) + ); + + // 4. Build Full Struct Hash + // keccak256(abi.encode(typeHash, tokenPermissionsHash, spender, nonce, deadline, witness)) + // Spender is the Settlement Contract + bytes32 structHash = keccak256( + abi.encode( + typeHash, + tokenPermissionsHash, + address(settlement), // Spender + intent.nonce, + intent.deadline, + witness + ) + ); + + // 5. Build EIP-712 Digest + // Must use Permit2's Domain Separator! + // We can fetch it via staticcall presumably? Or just compute it. + // Permit2 is deployed at 'permit2' address. + // It implements DOMAIN_SEPARATOR() + (bool success, bytes memory data) = permit2.staticcall( + abi.encodeWithSignature("DOMAIN_SEPARATOR()") + ); + require(success, "Failed to get domain separator"); + bytes32 permit2DomainSeparator = abi.decode(data, (bytes32)); + + bytes32 digest = MessageHashUtils.toTypedDataHash(permit2DomainSeparator, structHash); + + // 6. Sign + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pvtKey, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/contracts/test/Permit2Build.t.sol b/contracts/test/Permit2Build.t.sol new file mode 100644 index 0000000..4b0cb5f --- /dev/null +++ b/contracts/test/Permit2Build.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import {Permit2} from "permit2/src/Permit2.sol"; + +contract Permit2Build { + Permit2 p; +} From ee32375f9c2850bb04746941618e3bafea4bf390 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Thu, 15 Jan 2026 19:40:44 -0500 Subject: [PATCH 2/8] feat: V3 contract for solver API flow --- contracts/src/FastSettlementV3.sol | 209 ++++++++++++ .../src/interfaces/IFastSettlementV3.sol | 80 +++++ contracts/src/interfaces/IPermit2.sol | 37 ++ contracts/test/FastSettlementV3.t.sol | 285 ++++++++++++++++ .../test/FastSettlementV3_Integration.t.sol | 318 ++++++++++++++++++ contracts/test/Permit2Build.t.sol | 8 - 6 files changed, 929 insertions(+), 8 deletions(-) create mode 100644 contracts/src/FastSettlementV3.sol create mode 100644 contracts/src/interfaces/IFastSettlementV3.sol create mode 100644 contracts/src/interfaces/IPermit2.sol create mode 100644 contracts/test/FastSettlementV3.t.sol create mode 100644 contracts/test/FastSettlementV3_Integration.t.sol delete mode 100644 contracts/test/Permit2Build.t.sol diff --git a/contracts/src/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol new file mode 100644 index 0000000..a4d1603 --- /dev/null +++ b/contracts/src/FastSettlementV3.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IPermit2} from "./interfaces/IPermit2.sol"; +import {IFastSettlementV3} from "./interfaces/IFastSettlementV3.sol"; + +/// @title FastSettlementV3 +/// @notice Implementation of IFastSettlementV3. +contract FastSettlementV3 is IFastSettlementV3, Ownable2Step, ReentrancyGuard { + using SafeERC20 for IERC20; + + // ============ Immutable & State Variables ============ + + IPermit2 public immutable PERMIT2; + address public executor; // Authorized caller of execute() + address public treasury; + + // ============ Type Hashes & Strings ============ + + bytes32 public constant INTENT_TYPEHASH = + keccak256( + bytes( + "Intent(address user,address inputToken,address outputToken,uint256 sellAmount,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)" + ) + ); + + string public constant WITNESS_TYPE_STRING = + "Intent witness)Intent(address user,address inputToken,address outputToken,uint256 sellAmount,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)TokenPermissions(address token,uint256 amount)"; + + // ============ Modifiers ============ + + modifier onlyExecutor() { + if (msg.sender != executor) revert UnauthorizedExecutor(); + _; + } + + // ============ Constructor ============ + + constructor(address _executor, address _permit2, address _treasury) Ownable(msg.sender) { + if (_permit2 == address(0)) revert InvalidPermit2(); + if (_treasury == address(0)) revert BadTreasury(); + executor = _executor; + PERMIT2 = IPermit2(_permit2); + treasury = _treasury; + } + + // ============ Receive ============ + + receive() external payable {} + + // ============ Main Execution ============ + + function execute( + Intent calldata intent, + bytes calldata signature, + SwapCall calldata swapData + ) external payable onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { + // 1. Validate Intent Constraints (Basic) + if (block.timestamp > intent.deadline) revert IntentExpired(); + // Nonce is checked by Permit2 + if (intent.recipient == address(0)) revert BadRecipient(); + if (intent.sellAmount == 0) revert BadSellAmount(); + if (intent.userAmtOut == 0) revert BadUserAmtOut(); + + // 2. Pull Funds via Permit2 Witness + // The Witness IS the Intent struct. + // The signature verifies: Permit(token, amount, nonce, deadline) + Witness(Intent) + + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ + token: intent.inputToken, + amount: intent.sellAmount + }), + nonce: intent.nonce, + deadline: intent.deadline + }); + + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2 + .SignatureTransferDetails({to: address(this), requestedAmount: intent.sellAmount}); + + // Hash the intent using our TypeHash + bytes32 witness = keccak256( + abi.encode( + INTENT_TYPEHASH, + intent.user, + intent.inputToken, + intent.outputToken, + intent.sellAmount, + intent.userAmtOut, + intent.recipient, + intent.deadline, + intent.nonce + ) + ); + + // Call Permit2. This validates signature, nonce, deadline, and transfers tokens. + PERMIT2.permitWitnessTransferFrom( + permit, + transferDetails, + intent.user, + witness, + WITNESS_TYPE_STRING, + signature + ); + + // 3. Approve Swap Target + IERC20(intent.inputToken).forceApprove(swapData.to, intent.sellAmount); + + // 4. Measure Balance Before + uint256 balBefore = _getBalance(intent.outputToken); + + // 5. Execute Swap Call + (bool success, bytes memory ret) = swapData.to.call{value: swapData.value}(swapData.data); + if (!success) { + // Bubble revert reason + assembly { + revert(add(ret, 32), mload(ret)) + } + } + + // 6. Measure Balance After + uint256 balAfter = _getBalance(intent.outputToken); + received = balAfter - balBefore; + + // 7. Enforce userAmtOut + if (received < intent.userAmtOut) revert InsufficientOut(received, intent.userAmtOut); + + // 8. Distribute Proceeds + if (intent.outputToken == address(0)) { + // Pay user + (bool paySuccess, ) = intent.recipient.call{value: intent.userAmtOut}(""); + require(paySuccess, "ETH transfer to recipient failed"); + + // Pay treasury surplus + surplus = received - intent.userAmtOut; + if (surplus > 0) { + (bool treasSuccess, ) = treasury.call{value: surplus}(""); + require(treasSuccess, "ETH transfer to treasury failed"); + } + } else { + // Pay user + IERC20(intent.outputToken).safeTransfer(intent.recipient, intent.userAmtOut); + + // Pay treasury surplus + surplus = received - intent.userAmtOut; + if (surplus > 0) { + IERC20(intent.outputToken).safeTransfer(treasury, surplus); + } + } + + // 9. Cleanup + // Reset approval + IERC20(intent.inputToken).forceApprove(swapData.to, 0); + + // Refund any leftover input tokens to user (if any) + uint256 inputRemains = IERC20(intent.inputToken).balanceOf(address(this)); + if (inputRemains > 0) { + IERC20(intent.inputToken).safeTransfer(intent.user, inputRemains); + } + + emit IntentExecuted( + intent.user, + intent.inputToken, + intent.outputToken, + intent.sellAmount, + intent.userAmtOut, + received, + surplus + ); + } + + // ============ Admin ============ + + function setExecutor(address _newExecutor) external onlyOwner { + address old = executor; + executor = _newExecutor; + emit ExecutorUpdated(old, _newExecutor); + } + + function setTreasury(address _newTreasury) external onlyOwner { + if (_newTreasury == address(0)) revert BadTreasury(); + address old = treasury; + treasury = _newTreasury; + emit TreasuryUpdated(old, _newTreasury); + } + + function rescueTokens(address token, uint256 amount) external onlyOwner { + if (token == address(0)) { + (bool s, ) = msg.sender.call{value: amount}(""); + require(s, "Rescue ETH failed"); + } else { + IERC20(token).safeTransfer(msg.sender, amount); + } + } + + // ============ Internal Helpers ============ + + function _getBalance(address token) internal view returns (uint256) { + if (token == address(0)) { + return address(this).balance; + } else { + return IERC20(token).balanceOf(address(this)); + } + } +} diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol new file mode 100644 index 0000000..ab1f896 --- /dev/null +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IFastSettlementV3 +/// @notice Interface for FastSettlementV3, defining structs, events, and errors. +interface IFastSettlementV3 { + // ============ Structs ============ + + struct Intent { + address user; + address inputToken; + address outputToken; // address(0) for ETH + uint256 sellAmount; + uint256 userAmtOut; + address recipient; + uint256 deadline; + uint256 nonce; + } + + struct SwapCall { + address to; + uint256 value; + bytes data; + } + + // ============ Events ============ + + event IntentExecuted( + address indexed user, + address indexed inputToken, + address indexed outputToken, + uint256 sellAmount, + uint256 userAmtOut, + uint256 received, + uint256 surplus + ); + + event ExecutorUpdated(address indexed oldExecutor, address indexed newExecutor); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + + // ============ Errors ============ + + error IntentExpired(); + error BadNonce(); + error BadTreasury(); + error BadRecipient(); + error BadSellAmount(); + error BadUserAmtOut(); + error BadCallTarget(); + error UnauthorizedExecutor(); + error InsufficientOut(uint256 received, uint256 userAmtOut); + error InvalidPermit2(); + + // ============ Functions ============ + + /// @notice Executes a user intent using Permit2 for input pulling and swap router for swapping. + /// @param intent The user's signed intent. + /// @param signature The Permit2 witness signature. + /// @param details The call data for the swap router. + /// @return received The amount of output tokens received. + /// @return surplus The surplus amount sent to treasury. + function execute( + Intent calldata intent, + bytes calldata signature, + SwapCall calldata details + ) external payable returns (uint256 received, uint256 surplus); + + /// @notice Sets the authorized executor who can call execute(). + /// @param _newExecutor The new executor address. + function setExecutor(address _newExecutor) external; + + /// @notice Sets the treasury address where surplus is sent. + /// @param _newTreasury The new treasury address. + function setTreasury(address _newTreasury) external; + + /// @notice Rescues tokens or ETH accidentally sent to the contract. + /// @param token The token address to rescue (address(0) for ETH). + /// @param amount The amount to rescue. + function rescueTokens(address token, uint256 amount) external; +} diff --git a/contracts/src/interfaces/IPermit2.sol b/contracts/src/interfaces/IPermit2.sol new file mode 100644 index 0000000..e3341ca --- /dev/null +++ b/contracts/src/interfaces/IPermit2.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +//Minimal Permit2 interface +interface IPermit2 { + struct TokenPermissions { + address token; + uint256 amount; + } + + struct PermitTransferFrom { + TokenPermissions permitted; + uint256 nonce; + uint256 deadline; + } + + struct SignatureTransferDetails { + address to; + uint256 requestedAmount; + } + + function permitTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes calldata signature + ) external; + + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external; +} diff --git a/contracts/test/FastSettlementV3.t.sol b/contracts/test/FastSettlementV3.t.sol new file mode 100644 index 0000000..af2239a --- /dev/null +++ b/contracts/test/FastSettlementV3.t.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {FastSettlementV3} from "../src/FastSettlementV3.sol"; +import {IFastSettlementV3} from "../src/interfaces/IFastSettlementV3.sol"; +import {IPermit2} from "../src/interfaces/IPermit2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +// ============ Mocks ============ + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockPermit2 is IPermit2 { + mapping(address => mapping(address => mapping(uint256 => bool))) public usedNonces; + + function permitTransferFrom( + PermitTransferFrom memory, + SignatureTransferDetails calldata, + address, + bytes calldata + ) external { + // Not used + } + + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 /*witness*/, + string calldata /*witnessTypeString*/, + bytes calldata /*signature*/ + ) external { + // Mock checks + if (usedNonces[owner][msg.sender][permit.nonce]) { + revert("InvalidNonce"); + } + usedNonces[owner][msg.sender][permit.nonce] = true; + + if (block.timestamp > permit.deadline) { + revert("Expired"); + } + + // Transfer tokens + IERC20(permit.permitted.token).transferFrom( + owner, + transferDetails.to, + transferDetails.requestedAmount + ); + } +} + +contract MockSwapRouter { + function swap( + address tokenIn, + uint256 amountIn, + address tokenOut, + uint256 amountOut, + bool isEthOut + ) external payable { + // Pull input tokens (FastSettlementV3 approves us) + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + + // Send output + if (isEthOut) { + // Assume we have ETH (deal in test) + (bool s, ) = msg.sender.call{value: amountOut}(""); + require(s, "MockSwapRouter: ETH transfer failed"); + } else { + MockERC20(tokenOut).mint(msg.sender, amountOut); + } + } +} + +// ============ Test Contract ============ + +contract FastSettlementV3Test is Test { + FastSettlementV3 public settlement; + MockPermit2 public permit2; + MockSwapRouter public swapRouter; + MockERC20 public tokenIn; + MockERC20 public tokenOut; + + address public executor = makeAddr("executor"); + address public user = makeAddr("user"); + address public recipient = makeAddr("recipient"); + address public treasury = makeAddr("treasury"); + + function setUp() public { + permit2 = new MockPermit2(); + swapRouter = new MockSwapRouter(); + tokenIn = new MockERC20("Token In", "TIN"); + tokenOut = new MockERC20("Token Out", "TOUT"); + + settlement = new FastSettlementV3(executor, address(permit2), treasury); + + tokenIn.mint(user, 1000e18); + vm.prank(user); + tokenIn.approve(address(permit2), type(uint256).max); + + vm.deal(address(swapRouter), 1000e18); // Give swap router ETH + } + + // Helpers + function _createIntent( + uint256 amountIn, + uint256 userAmtOut, + bool isEth + ) internal view returns (IFastSettlementV3.Intent memory) { + return + IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: isEth ? address(0) : address(tokenOut), + sellAmount: amountIn, + userAmtOut: userAmtOut, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + } + + function _createSwapCall( + uint256 amountIn, + uint256 amountOut, + bool isEth + ) internal view returns (IFastSettlementV3.SwapCall memory) { + return + IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + amountOut, + isEth + ) + }); + } + + // Tests + + function testExecute_ERC20toERC20_Success() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + // Exact match + uint256 actualOut = 90e18; + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); + + vm.prank(executor); + settlement.execute(intent, bytes("fakeSig"), call); + + assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); + assertEq(tokenOut.balanceOf(recipient), actualOut); + assertEq(tokenOut.balanceOf(treasury), 0); + } + + function testExecute_Surplus() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + uint256 actualOut = 100e18; // 10 surplus + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); + + vm.prank(executor); + settlement.execute(intent, bytes("fakeSig"), call); + + assertEq(tokenOut.balanceOf(recipient), 90e18); + assertEq(tokenOut.balanceOf(treasury), 10e18); // Surplus captured by contract treasury + } + + function testExecute_InsufficientOut() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + uint256 actualOut = 89e18; // Too low + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); + + vm.prank(executor); + vm.expectRevert( + abi.encodeWithSignature("InsufficientOut(uint256,uint256)", actualOut, userAmtOut) + ); + settlement.execute(intent, bytes("fakeSig"), call); + } + + function testExecute_ETH_Success() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + uint256 actualOut = 95e18; // 5 surplus + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, true); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, true); + + uint256 recipientEthBefore = recipient.balance; + uint256 treasuryEthBefore = treasury.balance; + + vm.prank(executor); + settlement.execute(intent, bytes("fakeSig"), call); + + assertEq(recipient.balance - recipientEthBefore, 90e18); + assertEq(treasury.balance - treasuryEthBefore, 5e18); + } + + function testExecute_ReplayProtection() public { + // MockPermit2 handles nonces. + uint256 amountIn = 100e18; + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, 90e18, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, 90e18, false); + + vm.startPrank(executor); + settlement.execute(intent, bytes("fakeSig"), call); + + // Replay + vm.expectRevert("InvalidNonce"); // MockPermit2 error + settlement.execute(intent, bytes("fakeSig"), call); + vm.stopPrank(); + } + + function testExecute_UnauthorizedExecutor() public { + IFastSettlementV3.Intent memory intent = _createIntent(100e18, 90e18, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 90e18, false); + + vm.prank(makeAddr("randomCaller")); + vm.expectRevert(IFastSettlementV3.UnauthorizedExecutor.selector); + settlement.execute(intent, bytes("fakeSig"), call); + } + + function testSetExecutor() public { + address newExecutor = makeAddr("newExecutor"); + settlement.setExecutor(newExecutor); + assertEq(settlement.executor(), newExecutor); + } + + function testSetTreasury() public { + address newTreasury = makeAddr("newTreasury"); + settlement.setTreasury(newTreasury); + assertEq(settlement.treasury(), newTreasury); + } + + function testRescueTokens() public { + // Send accidental tokens + tokenIn.mint(address(settlement), 50e18); + + settlement.rescueTokens(address(tokenIn), 50e18); + assertEq(tokenIn.balanceOf(address(this)), 50e18); + } + + function testExecute_RevertIfTokensBypassContract() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + + // Construct call that sends 0 to contract (simulating funds went elsewhere) + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + 0, // AmountOut to contract is 0 + false + ) + }); + + vm.prank(executor); + vm.expectRevert(abi.encodeWithSignature("InsufficientOut(uint256,uint256)", 0, userAmtOut)); + settlement.execute(intent, bytes("fakeSig"), call); + } +} diff --git a/contracts/test/FastSettlementV3_Integration.t.sol b/contracts/test/FastSettlementV3_Integration.t.sol new file mode 100644 index 0000000..aae6aa9 --- /dev/null +++ b/contracts/test/FastSettlementV3_Integration.t.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {FastSettlementV3} from "../src/FastSettlementV3.sol"; +import {IFastSettlementV3} from "../src/interfaces/IFastSettlementV3.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +// ============ Mocks ============ +contract MockERC20 is ERC20 { + uint8 private _decimals; + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _decimals = decimals_; + } + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + function decimals() public view override returns (uint8) { + return _decimals; + } +} + +contract MockSwapRouter { + function swap( + address tokenIn, + uint256 amountIn, + address tokenOut, + uint256 amountOut, + bool isEthOut + ) external payable { + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + if (isEthOut) { + (bool s, ) = msg.sender.call{value: amountOut}(""); + require(s, "ETH transfer failed"); + } else { + MockERC20(tokenOut).mint(msg.sender, amountOut); + } + } +} + +// ============ Integration Test ============ +contract FastSettlementV3IntegrationTest is Test { + // Real Contracts + FastSettlementV3 public settlement; + address public permit2; // Deployed via deployCode + MockSwapRouter public swapRouter; + MockERC20 public tokenIn; + MockERC20 public tokenOut; + + // Actors + address public owner = makeAddr("owner"); + address public executor; + uint256 public executorKey; + address public user; + uint256 public userKey; + address public recipient = makeAddr("recipient"); + address public treasury = makeAddr("treasury"); + + // Permit2 Constants (Copied from PermitHash.sol / Permit2.sol) + bytes32 public constant TOKEN_PERMISSIONS_TYPEHASH = + keccak256("TokenPermissions(address token,uint256 amount)"); + string public constant PERMIT_WITNESS_TRANSFER_TYPE_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + function setUp() public { + // Setup user and executor with private keys + (user, userKey) = makeAddrAndKey("user"); + (executor, executorKey) = makeAddrAndKey("executor"); + + // 1. Deploy Real Permit2 + permit2 = deployCode("out/Permit2.sol/Permit2.json"); + + // 2. Deploy Tokens + tokenIn = new MockERC20("Token In", "TIN", 18); + tokenOut = new MockERC20("Token Out", "TOUT", 18); + + // 3. Deploy Swap Router + swapRouter = new MockSwapRouter(); + + // 4. Deploy Settlement Contract linked to Real Permit2 + vm.prank(owner); + settlement = new FastSettlementV3(executor, permit2, treasury); + + // 5. Fund User and Approve Permit2 + tokenIn.mint(user, 1000e18); + vm.prank(user); + IERC20(tokenIn).approve(permit2, type(uint256).max); + + // 6. Fund swap router with ETH for ETH output tests + vm.deal(address(swapRouter), 1000e18); + } + + function test_Integration_RealPermit2_ERC20toERC20() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + uint256 nonce = 0; + uint256 deadline = block.timestamp + 1 hours; + + // 1. Create Intent + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + sellAmount: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: deadline, + nonce: nonce + }); + + // 2. Create Swap Call + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + amountOut, + false + ) + }); + + // 3. Sign the Intent (Construct EIP-712 Signature for Permit2) + bytes memory signature = _getPermitSignature(intent, userKey); + + // 4. Execute via Executor + vm.prank(executor); + settlement.execute(intent, signature, call); + + // 5. Verification + assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); // User pulled + assertEq(tokenIn.balanceOf(address(swapRouter)), amountIn); // SwapRouter received + assertEq(tokenOut.balanceOf(recipient), amountOut); // Recipient received + } + + function test_Integration_RealPermit2_ERC20toETH() public { + uint256 amountIn = 100e18; + uint256 amountOut = 1e18; // 1 ETH + uint256 nonce = 0; + uint256 deadline = block.timestamp + 1 hours; + + // 1. Create Intent + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(0), // ETH output + sellAmount: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: deadline, + nonce: nonce + }); + + // 2. Create Swap Call + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), // tokenOut ignored when isEthOut=true + amountOut, + true // isEthOut + ) + }); + + // 3. Sign + bytes memory signature = _getPermitSignature(intent, userKey); + + // 4. Execute + uint256 recipientEthBefore = recipient.balance; + vm.prank(executor); + settlement.execute(intent, signature, call); + + // 5. Verification + assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); + assertEq(recipient.balance - recipientEthBefore, amountOut); + } + + function test_Integration_RealPermit2_InvalidSignature() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + uint256 nonce = 0; + uint256 deadline = block.timestamp + 1 hours; + + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + sellAmount: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: deadline, + nonce: nonce + }); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + amountOut, + false + ) + }); + + // Sign with wrong key + bytes memory badSignature = _getPermitSignature(intent, executorKey); + + // Should revert (Permit2 signature validation) + vm.prank(executor); + vm.expectRevert(); // Permit2 will revert with InvalidSigner or similar + settlement.execute(intent, badSignature, call); + } + + function test_Integration_UnauthorizedExecutor() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + sellAmount: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + amountOut, + false + ) + }); + + bytes memory signature = _getPermitSignature(intent, userKey); + + // Non-executor tries to call + address randomCaller = makeAddr("randomCaller"); + vm.prank(randomCaller); + vm.expectRevert(IFastSettlementV3.UnauthorizedExecutor.selector); + settlement.execute(intent, signature, call); + } + + // ============ Helper: Generate Real Permit2 Signature ============ + + function _getPermitSignature( + IFastSettlementV3.Intent memory intent, + uint256 pvtKey + ) internal view returns (bytes memory) { + // 1. Build Witness (The Intent Struct Hash) + bytes32 witness = keccak256( + abi.encode( + settlement.INTENT_TYPEHASH(), + intent.user, + intent.inputToken, + intent.outputToken, + intent.sellAmount, + intent.userAmtOut, + intent.recipient, + intent.deadline, + intent.nonce + ) + ); + + // 2. Build TypeHash for PermitWitnessTransferFrom + string memory witnessTypeString = settlement.WITNESS_TYPE_STRING(); + bytes32 typeHash = keccak256( + abi.encodePacked(PERMIT_WITNESS_TRANSFER_TYPE_STUB, witnessTypeString) + ); + + // 3. Build TokenPermissions Hash + bytes32 tokenPermissionsHash = keccak256( + abi.encode(TOKEN_PERMISSIONS_TYPEHASH, intent.inputToken, intent.sellAmount) + ); + + // 4. Build Full Struct Hash + bytes32 structHash = keccak256( + abi.encode( + typeHash, + tokenPermissionsHash, + address(settlement), // Spender + intent.nonce, + intent.deadline, + witness + ) + ); + + // 5. Build EIP-712 Digest using Permit2's Domain Separator + (bool success, bytes memory data) = permit2.staticcall( + abi.encodeWithSignature("DOMAIN_SEPARATOR()") + ); + require(success, "Failed to get domain separator"); + bytes32 permit2DomainSeparator = abi.decode(data, (bytes32)); + + bytes32 digest = MessageHashUtils.toTypedDataHash(permit2DomainSeparator, structHash); + + // 6. Sign + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pvtKey, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/contracts/test/Permit2Build.t.sol b/contracts/test/Permit2Build.t.sol deleted file mode 100644 index 4b0cb5f..0000000 --- a/contracts/test/Permit2Build.t.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.17; - -import {Permit2} from "permit2/src/Permit2.sol"; - -contract Permit2Build { - Permit2 p; -} From e99531a1628e43bd9ad78950e5bea4999abbbe21 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 16 Jan 2026 14:14:05 -0500 Subject: [PATCH 3/8] upgrade support, monitor init balance, various minor updates --- contracts/src/FastSettlementV3.sol | 184 +++++++++--------- contracts/src/FastSettlementV3Storage.sol | 10 + .../src/interfaces/IFastSettlementV3.sol | 6 +- contracts/test/FastSettlementV3.t.sol | 11 +- .../test/FastSettlementV3_Integration.t.sol | 106 +++++++++- 5 files changed, 216 insertions(+), 101 deletions(-) create mode 100644 contracts/src/FastSettlementV3Storage.sol diff --git a/contracts/src/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol index a4d1603..f59c668 100644 --- a/contracts/src/FastSettlementV3.sol +++ b/contracts/src/FastSettlementV3.sol @@ -1,53 +1,76 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + Ownable2StepUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {IPermit2} from "./interfaces/IPermit2.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {IFastSettlementV3} from "./interfaces/IFastSettlementV3.sol"; +import {IPermit2} from "./interfaces/IPermit2.sol"; +import {FastSettlementV3Storage} from "./FastSettlementV3Storage.sol"; /// @title FastSettlementV3 -/// @notice Implementation of IFastSettlementV3. -contract FastSettlementV3 is IFastSettlementV3, Ownable2Step, ReentrancyGuard { +/// @notice V3 implementation using UUPS Upgradeable pattern. +contract FastSettlementV3 is + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + ReentrancyGuard, + IFastSettlementV3, + FastSettlementV3Storage +{ using SafeERC20 for IERC20; + using Address for address payable; - // ============ Immutable & State Variables ============ - - IPermit2 public immutable PERMIT2; - address public executor; // Authorized caller of execute() - address public treasury; - - // ============ Type Hashes & Strings ============ + // ============ Constants & Immutables ============ bytes32 public constant INTENT_TYPEHASH = keccak256( bytes( - "Intent(address user,address inputToken,address outputToken,uint256 sellAmount,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)" + "Intent(address user,address inputToken,address outputToken,uint256 inputAmt,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)" ) ); string public constant WITNESS_TYPE_STRING = - "Intent witness)Intent(address user,address inputToken,address outputToken,uint256 sellAmount,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)TokenPermissions(address token,uint256 amount)"; + "Intent witness)Intent(address user,address inputToken,address outputToken,uint256 inputAmt,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)TokenPermissions(address token,uint256 amount)"; - // ============ Modifiers ============ - - modifier onlyExecutor() { - if (msg.sender != executor) revert UnauthorizedExecutor(); - _; - } + // Permit2 address is constant, so immutable is fine for upgradeable contracts + // (set in constructor of implementation). + IPermit2 public immutable PERMIT2; - // ============ Constructor ============ + // ============ Constructor & Initializer ============ - constructor(address _executor, address _permit2, address _treasury) Ownable(msg.sender) { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _permit2) { if (_permit2 == address(0)) revert InvalidPermit2(); + PERMIT2 = IPermit2(_permit2); + _disableInitializers(); + } + + function initialize(address _executor, address _treasury) public initializer { if (_treasury == address(0)) revert BadTreasury(); + if (_executor == address(0)) revert BadExecutor(); + __Ownable_init(msg.sender); executor = _executor; - PERMIT2 = IPermit2(_permit2); treasury = _treasury; } + // ============ UUPS Authorization ============ + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + // ============ Modifiers ============ + + modifier onlyExecutor() { + if (msg.sender != executor) revert UnauthorizedExecutor(); + _; + } + // ============ Receive ============ receive() external payable {} @@ -59,45 +82,30 @@ contract FastSettlementV3 is IFastSettlementV3, Ownable2Step, ReentrancyGuard { bytes calldata signature, SwapCall calldata swapData ) external payable onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { - // 1. Validate Intent Constraints (Basic) + // Validate constraints if (block.timestamp > intent.deadline) revert IntentExpired(); - // Nonce is checked by Permit2 if (intent.recipient == address(0)) revert BadRecipient(); - if (intent.sellAmount == 0) revert BadSellAmount(); + if (intent.inputAmt == 0) revert BadInputAmt(); if (intent.userAmtOut == 0) revert BadUserAmtOut(); - // 2. Pull Funds via Permit2 Witness - // The Witness IS the Intent struct. - // The signature verifies: Permit(token, amount, nonce, deadline) + Witness(Intent) + // Track start balance + uint256 startInputBal = _getBalance(intent.inputToken); + // Pull funds IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ permitted: IPermit2.TokenPermissions({ token: intent.inputToken, - amount: intent.sellAmount + amount: intent.inputAmt }), nonce: intent.nonce, deadline: intent.deadline }); IPermit2.SignatureTransferDetails memory transferDetails = IPermit2 - .SignatureTransferDetails({to: address(this), requestedAmount: intent.sellAmount}); - - // Hash the intent using our TypeHash - bytes32 witness = keccak256( - abi.encode( - INTENT_TYPEHASH, - intent.user, - intent.inputToken, - intent.outputToken, - intent.sellAmount, - intent.userAmtOut, - intent.recipient, - intent.deadline, - intent.nonce - ) - ); + .SignatureTransferDetails({to: address(this), requestedAmount: intent.inputAmt}); + + bytes32 witness = keccak256(abi.encode(INTENT_TYPEHASH, intent)); - // Call Permit2. This validates signature, nonce, deadline, and transfers tokens. PERMIT2.permitWitnessTransferFrom( permit, transferDetails, @@ -107,66 +115,69 @@ contract FastSettlementV3 is IFastSettlementV3, Ownable2Step, ReentrancyGuard { signature ); - // 3. Approve Swap Target - IERC20(intent.inputToken).forceApprove(swapData.to, intent.sellAmount); + // Approve router + if (intent.inputToken != address(0)) { + // Approve sellAmount + IERC20(intent.inputToken).forceApprove(swapData.to, intent.inputAmt); + } - // 4. Measure Balance Before - uint256 balBefore = _getBalance(intent.outputToken); + // Execute swap + // Pass ETH if input is native + uint256 valueToSend = (intent.inputToken == address(0)) ? intent.inputAmt : 0; - // 5. Execute Swap Call - (bool success, bytes memory ret) = swapData.to.call{value: swapData.value}(swapData.data); - if (!success) { - // Bubble revert reason - assembly { - revert(add(ret, 32), mload(ret)) - } - } + uint256 outputBalBefore = _getBalance(intent.outputToken); - // 6. Measure Balance After - uint256 balAfter = _getBalance(intent.outputToken); - received = balAfter - balBefore; + (bool success, ) = swapData.to.call{value: valueToSend}(swapData.data); + if (!success) revert BadCallTarget(); - // 7. Enforce userAmtOut + uint256 outputBalAfter = _getBalance(intent.outputToken); + received = outputBalAfter - outputBalBefore; + + // Verify output + // Solvers must account for fees if (received < intent.userAmtOut) revert InsufficientOut(received, intent.userAmtOut); - // 8. Distribute Proceeds + // Distribute proceeds + surplus = received - intent.userAmtOut; + + // Pay user if (intent.outputToken == address(0)) { - // Pay user - (bool paySuccess, ) = intent.recipient.call{value: intent.userAmtOut}(""); - require(paySuccess, "ETH transfer to recipient failed"); - - // Pay treasury surplus - surplus = received - intent.userAmtOut; - if (surplus > 0) { - (bool treasSuccess, ) = treasury.call{value: surplus}(""); - require(treasSuccess, "ETH transfer to treasury failed"); - } + payable(intent.recipient).sendValue(intent.userAmtOut); } else { - // Pay user IERC20(intent.outputToken).safeTransfer(intent.recipient, intent.userAmtOut); + } - // Pay treasury surplus - surplus = received - intent.userAmtOut; - if (surplus > 0) { + // Pay treasury + if (surplus > 0) { + if (intent.outputToken == address(0)) { + payable(treasury).sendValue(surplus); + } else { IERC20(intent.outputToken).safeTransfer(treasury, surplus); } } - // 9. Cleanup // Reset approval - IERC20(intent.inputToken).forceApprove(swapData.to, 0); + if (intent.inputToken != address(0)) { + IERC20(intent.inputToken).forceApprove(swapData.to, 0); + } - // Refund any leftover input tokens to user (if any) - uint256 inputRemains = IERC20(intent.inputToken).balanceOf(address(this)); - if (inputRemains > 0) { - IERC20(intent.inputToken).safeTransfer(intent.user, inputRemains); + // Refund unused input + uint256 finalInputBal = _getBalance(intent.inputToken); + if (finalInputBal > startInputBal) { + uint256 unused = finalInputBal - startInputBal; + // Refund + if (intent.inputToken == address(0)) { + payable(intent.user).sendValue(unused); + } else { + IERC20(intent.inputToken).safeTransfer(intent.user, unused); + } } emit IntentExecuted( intent.user, intent.inputToken, intent.outputToken, - intent.sellAmount, + intent.inputAmt, intent.userAmtOut, received, surplus @@ -190,8 +201,7 @@ contract FastSettlementV3 is IFastSettlementV3, Ownable2Step, ReentrancyGuard { function rescueTokens(address token, uint256 amount) external onlyOwner { if (token == address(0)) { - (bool s, ) = msg.sender.call{value: amount}(""); - require(s, "Rescue ETH failed"); + payable(msg.sender).sendValue(amount); } else { IERC20(token).safeTransfer(msg.sender, amount); } diff --git a/contracts/src/FastSettlementV3Storage.sol b/contracts/src/FastSettlementV3Storage.sol new file mode 100644 index 0000000..7a6fff7 --- /dev/null +++ b/contracts/src/FastSettlementV3Storage.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract FastSettlementV3Storage { + address public executor; + address public treasury; + + // Gap for upgrade safety + uint256[50] private __gap; +} diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol index ab1f896..216b92a 100644 --- a/contracts/src/interfaces/IFastSettlementV3.sol +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -10,7 +10,7 @@ interface IFastSettlementV3 { address user; address inputToken; address outputToken; // address(0) for ETH - uint256 sellAmount; + uint256 inputAmt; uint256 userAmtOut; address recipient; uint256 deadline; @@ -29,7 +29,7 @@ interface IFastSettlementV3 { address indexed user, address indexed inputToken, address indexed outputToken, - uint256 sellAmount, + uint256 inputAmt, uint256 userAmtOut, uint256 received, uint256 surplus @@ -44,7 +44,7 @@ interface IFastSettlementV3 { error BadNonce(); error BadTreasury(); error BadRecipient(); - error BadSellAmount(); + error BadInputAmt(); error BadUserAmtOut(); error BadCallTarget(); error UnauthorizedExecutor(); diff --git a/contracts/test/FastSettlementV3.t.sol b/contracts/test/FastSettlementV3.t.sol index af2239a..11617ca 100644 --- a/contracts/test/FastSettlementV3.t.sol +++ b/contracts/test/FastSettlementV3.t.sol @@ -9,6 +9,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + // ============ Mocks ============ contract MockERC20 is ERC20 { @@ -99,7 +101,12 @@ contract FastSettlementV3Test is Test { tokenIn = new MockERC20("Token In", "TIN"); tokenOut = new MockERC20("Token Out", "TOUT"); - settlement = new FastSettlementV3(executor, address(permit2), treasury); + FastSettlementV3 impl = new FastSettlementV3(address(permit2)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(FastSettlementV3.initialize, (executor, treasury)) + ); + settlement = FastSettlementV3(payable(address(proxy))); tokenIn.mint(user, 1000e18); vm.prank(user); @@ -119,7 +126,7 @@ contract FastSettlementV3Test is Test { user: user, inputToken: address(tokenIn), outputToken: isEth ? address(0) : address(tokenOut), - sellAmount: amountIn, + inputAmt: amountIn, userAmtOut: userAmtOut, recipient: recipient, deadline: block.timestamp + 1 hours, diff --git a/contracts/test/FastSettlementV3_Integration.t.sol b/contracts/test/FastSettlementV3_Integration.t.sol index aae6aa9..af38088 100644 --- a/contracts/test/FastSettlementV3_Integration.t.sol +++ b/contracts/test/FastSettlementV3_Integration.t.sol @@ -8,6 +8,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + // ============ Mocks ============ contract MockERC20 is ERC20 { uint8 private _decimals; @@ -79,9 +81,15 @@ contract FastSettlementV3IntegrationTest is Test { // 3. Deploy Swap Router swapRouter = new MockSwapRouter(); - // 4. Deploy Settlement Contract linked to Real Permit2 - vm.prank(owner); - settlement = new FastSettlementV3(executor, permit2, treasury); + // 4. Deploy Settlement Contract linked to Real Permit2 (via Proxy) + vm.startPrank(owner); + FastSettlementV3 impl = new FastSettlementV3(permit2); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(FastSettlementV3.initialize, (executor, treasury)) + ); + settlement = FastSettlementV3(payable(address(proxy))); + vm.stopPrank(); // 5. Fund User and Approve Permit2 tokenIn.mint(user, 1000e18); @@ -103,7 +111,7 @@ contract FastSettlementV3IntegrationTest is Test { user: user, inputToken: address(tokenIn), outputToken: address(tokenOut), - sellAmount: amountIn, + inputAmt: amountIn, userAmtOut: amountOut, recipient: recipient, deadline: deadline, @@ -148,7 +156,7 @@ contract FastSettlementV3IntegrationTest is Test { user: user, inputToken: address(tokenIn), outputToken: address(0), // ETH output - sellAmount: amountIn, + inputAmt: amountIn, userAmtOut: amountOut, recipient: recipient, deadline: deadline, @@ -192,7 +200,7 @@ contract FastSettlementV3IntegrationTest is Test { user: user, inputToken: address(tokenIn), outputToken: address(tokenOut), - sellAmount: amountIn, + inputAmt: amountIn, userAmtOut: amountOut, recipient: recipient, deadline: deadline, @@ -221,6 +229,86 @@ contract FastSettlementV3IntegrationTest is Test { settlement.execute(intent, badSignature, call); } + function test_Integration_RealPermit2_TamperedIntent_Amount() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + + // Sign ORIGINAL intent + bytes memory signature = _getPermitSignature(intent, userKey); + + // Tamper with input amount + intent.inputAmt = 50e18; + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + intent.inputAmt, + address(tokenOut), + amountOut, + false + ) + }); + + vm.prank(executor); + // Should revert because signature doesn't match new amount + vm.expectRevert(); + settlement.execute(intent, signature, call); + } + + function test_Integration_RealPermit2_TamperedIntent_Recipient() public { + uint256 amountIn = 100e18; + uint256 amountOut = 90e18; + + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: amountIn, + userAmtOut: amountOut, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + + // Sign ORIGINAL intent + bytes memory signature = _getPermitSignature(intent, userKey); + + // Tamper with recipient + intent.recipient = makeAddr("attacker"); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + amountOut, + false + ) + }); + + vm.prank(executor); + // Should revert because signature doesn't match new recipient + vm.expectRevert(); + settlement.execute(intent, signature, call); + } + function test_Integration_UnauthorizedExecutor() public { uint256 amountIn = 100e18; uint256 amountOut = 90e18; @@ -229,7 +317,7 @@ contract FastSettlementV3IntegrationTest is Test { user: user, inputToken: address(tokenIn), outputToken: address(tokenOut), - sellAmount: amountIn, + inputAmt: amountIn, userAmtOut: amountOut, recipient: recipient, deadline: block.timestamp + 1 hours, @@ -271,7 +359,7 @@ contract FastSettlementV3IntegrationTest is Test { intent.user, intent.inputToken, intent.outputToken, - intent.sellAmount, + intent.inputAmt, intent.userAmtOut, intent.recipient, intent.deadline, @@ -287,7 +375,7 @@ contract FastSettlementV3IntegrationTest is Test { // 3. Build TokenPermissions Hash bytes32 tokenPermissionsHash = keccak256( - abi.encode(TOKEN_PERMISSIONS_TYPEHASH, intent.inputToken, intent.sellAmount) + abi.encode(TOKEN_PERMISSIONS_TYPEHASH, intent.inputToken, intent.inputAmt) ); // 4. Build Full Struct Hash From d0799fb6f727632959e747b683eaa3aa11101b34 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 16 Jan 2026 15:11:41 -0500 Subject: [PATCH 4/8] added missing error --- contracts/src/interfaces/IFastSettlementV3.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol index 216b92a..fe8733a 100644 --- a/contracts/src/interfaces/IFastSettlementV3.sol +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -43,6 +43,7 @@ interface IFastSettlementV3 { error IntentExpired(); error BadNonce(); error BadTreasury(); + error BadExecutor(); error BadRecipient(); error BadInputAmt(); error BadUserAmtOut(); From 1a1863835fb4ce65c12578c8932b2a14a134faf7 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Fri, 16 Jan 2026 16:05:37 -0500 Subject: [PATCH 5/8] added permit2 for testing --- .gitmodules | 3 +++ contracts/lib/permit2 | 1 + contracts/remappings.txt | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 160000 contracts/lib/permit2 diff --git a/.gitmodules b/.gitmodules index d2791da..adaa123 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "contracts/lib/openzeppelin-contracts-upgradeable"] path = contracts/lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "contracts/lib/permit2"] + path = contracts/lib/permit2 + url = https://github.com/uniswap/permit2 diff --git a/contracts/lib/permit2 b/contracts/lib/permit2 new file mode 160000 index 0000000..cc56ad0 --- /dev/null +++ b/contracts/lib/permit2 @@ -0,0 +1 @@ +Subproject commit cc56ad0f3439c502c246fc5cfcc3db92bb8b7219 diff --git a/contracts/remappings.txt b/contracts/remappings.txt index ed14cd9..b2bc956 100644 --- a/contracts/remappings.txt +++ b/contracts/remappings.txt @@ -4,4 +4,6 @@ erc4626-tests/=lib/openzeppelin-contracts-upgradeable/lib/erc4626-tests/ forge-std/=lib/forge-std/src/ halmos-cheatcodes/=lib/openzeppelin-contracts-upgradeable/lib/halmos-cheatcodes/src/ openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/ -openzeppelin-contracts/=lib/openzeppelin-contracts/ \ No newline at end of file +openzeppelin-contracts/=lib/openzeppelin-contracts/ +permit2/=lib/permit2/ +solmate/=lib/permit2/lib/solmate/ \ No newline at end of file From c0e0e269a085170fb56bc6da09549af2364f7703 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Mon, 19 Jan 2026 16:27:55 -0500 Subject: [PATCH 6/8] fix ETH input logic, comments --- contracts/src/FastSettlementV3.sol | 28 +++++-------------- .../src/interfaces/IFastSettlementV3.sol | 1 + 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/contracts/src/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol index f59c668..f9359a7 100644 --- a/contracts/src/FastSettlementV3.sol +++ b/contracts/src/FastSettlementV3.sol @@ -39,8 +39,6 @@ contract FastSettlementV3 is string public constant WITNESS_TYPE_STRING = "Intent witness)Intent(address user,address inputToken,address outputToken,uint256 inputAmt,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)TokenPermissions(address token,uint256 amount)"; - // Permit2 address is constant, so immutable is fine for upgradeable contracts - // (set in constructor of implementation). IPermit2 public immutable PERMIT2; // ============ Constructor & Initializer ============ @@ -84,6 +82,7 @@ contract FastSettlementV3 is ) external payable onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { // Validate constraints if (block.timestamp > intent.deadline) revert IntentExpired(); + if (intent.inputToken == address(0)) revert BadInputToken(); if (intent.recipient == address(0)) revert BadRecipient(); if (intent.inputAmt == 0) revert BadInputAmt(); if (intent.userAmtOut == 0) revert BadUserAmtOut(); @@ -115,26 +114,19 @@ contract FastSettlementV3 is signature ); - // Approve router - if (intent.inputToken != address(0)) { - // Approve sellAmount - IERC20(intent.inputToken).forceApprove(swapData.to, intent.inputAmt); - } + // Approve swap contract + IERC20(intent.inputToken).forceApprove(swapData.to, intent.inputAmt); // Execute swap - // Pass ETH if input is native - uint256 valueToSend = (intent.inputToken == address(0)) ? intent.inputAmt : 0; - uint256 outputBalBefore = _getBalance(intent.outputToken); - (bool success, ) = swapData.to.call{value: valueToSend}(swapData.data); + (bool success, ) = swapData.to.call(swapData.data); if (!success) revert BadCallTarget(); uint256 outputBalAfter = _getBalance(intent.outputToken); received = outputBalAfter - outputBalBefore; - // Verify output - // Solvers must account for fees + // Verify output, quote must account for fees if (received < intent.userAmtOut) revert InsufficientOut(received, intent.userAmtOut); // Distribute proceeds @@ -157,20 +149,14 @@ contract FastSettlementV3 is } // Reset approval - if (intent.inputToken != address(0)) { - IERC20(intent.inputToken).forceApprove(swapData.to, 0); - } + IERC20(intent.inputToken).forceApprove(swapData.to, 0); // Refund unused input uint256 finalInputBal = _getBalance(intent.inputToken); if (finalInputBal > startInputBal) { uint256 unused = finalInputBal - startInputBal; // Refund - if (intent.inputToken == address(0)) { - payable(intent.user).sendValue(unused); - } else { - IERC20(intent.inputToken).safeTransfer(intent.user, unused); - } + IERC20(intent.inputToken).safeTransfer(intent.user, unused); } emit IntentExecuted( diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol index fe8733a..e131740 100644 --- a/contracts/src/interfaces/IFastSettlementV3.sol +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -45,6 +45,7 @@ interface IFastSettlementV3 { error BadTreasury(); error BadExecutor(); error BadRecipient(); + error BadInputToken(); error BadInputAmt(); error BadUserAmtOut(); error BadCallTarget(); From 765e5c0a18ecdd0e81a13a48e160108c3dde8ecb Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 21 Jan 2026 12:56:21 -0500 Subject: [PATCH 7/8] optimizations, remove payable from execute --- contracts/src/FastSettlementV3.sol | 25 +++++++++++-------- .../src/interfaces/IFastSettlementV3.sol | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/contracts/src/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol index f9359a7..7fc439c 100644 --- a/contracts/src/FastSettlementV3.sol +++ b/contracts/src/FastSettlementV3.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { Ownable2StepUpgradeable } from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; @@ -20,7 +20,7 @@ contract FastSettlementV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable, - ReentrancyGuard, + ReentrancyGuardTransient, IFastSettlementV3, FastSettlementV3Storage { @@ -79,7 +79,7 @@ contract FastSettlementV3 is Intent calldata intent, bytes calldata signature, SwapCall calldata swapData - ) external payable onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { + ) external onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { // Validate constraints if (block.timestamp > intent.deadline) revert IntentExpired(); if (intent.inputToken == address(0)) revert BadInputToken(); @@ -114,16 +114,19 @@ contract FastSettlementV3 is signature ); + // Cache output token + address outputToken = intent.outputToken; + // Approve swap contract IERC20(intent.inputToken).forceApprove(swapData.to, intent.inputAmt); // Execute swap - uint256 outputBalBefore = _getBalance(intent.outputToken); + uint256 outputBalBefore = _getBalance(outputToken); (bool success, ) = swapData.to.call(swapData.data); if (!success) revert BadCallTarget(); - uint256 outputBalAfter = _getBalance(intent.outputToken); + uint256 outputBalAfter = _getBalance(outputToken); received = outputBalAfter - outputBalBefore; // Verify output, quote must account for fees @@ -133,18 +136,18 @@ contract FastSettlementV3 is surplus = received - intent.userAmtOut; // Pay user - if (intent.outputToken == address(0)) { + if (outputToken == address(0)) { payable(intent.recipient).sendValue(intent.userAmtOut); } else { - IERC20(intent.outputToken).safeTransfer(intent.recipient, intent.userAmtOut); + IERC20(outputToken).safeTransfer(intent.recipient, intent.userAmtOut); } // Pay treasury if (surplus > 0) { - if (intent.outputToken == address(0)) { + if (outputToken == address(0)) { payable(treasury).sendValue(surplus); } else { - IERC20(intent.outputToken).safeTransfer(treasury, surplus); + IERC20(outputToken).safeTransfer(treasury, surplus); } } @@ -162,7 +165,7 @@ contract FastSettlementV3 is emit IntentExecuted( intent.user, intent.inputToken, - intent.outputToken, + outputToken, intent.inputAmt, intent.userAmtOut, received, diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol index e131740..59120bf 100644 --- a/contracts/src/interfaces/IFastSettlementV3.sol +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -65,7 +65,7 @@ interface IFastSettlementV3 { Intent calldata intent, bytes calldata signature, SwapCall calldata details - ) external payable returns (uint256 received, uint256 surplus); + ) external returns (uint256 received, uint256 surplus); /// @notice Sets the authorized executor who can call execute(). /// @param _newExecutor The new executor address. From 56f422ac34a156037254f80ed464393807402825 Mon Sep 17 00:00:00 2001 From: owen-eth Date: Wed, 21 Jan 2026 20:49:23 -0500 Subject: [PATCH 8/8] support ETH as input token, update and add tests --- contracts/src/FastSettlementV3.sol | 158 +++-- contracts/src/FastSettlementV3Storage.sol | 5 +- .../src/interfaces/IFastSettlementV3.sol | 36 +- contracts/src/interfaces/IWETH.sol | 10 + contracts/test/FastSettlementV3.t.sol | 551 ++++++++++++++++-- .../test/FastSettlementV3_Integration.t.sol | 39 +- 6 files changed, 690 insertions(+), 109 deletions(-) create mode 100644 contracts/src/interfaces/IWETH.sol diff --git a/contracts/src/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol index 7fc439c..c968498 100644 --- a/contracts/src/FastSettlementV3.sol +++ b/contracts/src/FastSettlementV3.sol @@ -12,10 +12,11 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {IFastSettlementV3} from "./interfaces/IFastSettlementV3.sol"; import {IPermit2} from "./interfaces/IPermit2.sol"; +import {IWETH} from "./interfaces/IWETH.sol"; import {FastSettlementV3Storage} from "./FastSettlementV3Storage.sol"; /// @title FastSettlementV3 -/// @notice V3 implementation using UUPS Upgradeable pattern. +/// @notice V3 implementation using UUPS Upgradeable pattern with dual entry points. contract FastSettlementV3 is Initializable, UUPSUpgradeable, @@ -40,86 +41,95 @@ contract FastSettlementV3 is "Intent witness)Intent(address user,address inputToken,address outputToken,uint256 inputAmt,uint256 userAmtOut,address recipient,uint256 deadline,uint256 nonce)TokenPermissions(address token,uint256 amount)"; IPermit2 public immutable PERMIT2; + IWETH public immutable WETH; // ============ Constructor & Initializer ============ /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _permit2) { + constructor(address _permit2, address _weth) { if (_permit2 == address(0)) revert InvalidPermit2(); + if (_weth == address(0)) revert InvalidWETH(); PERMIT2 = IPermit2(_permit2); + WETH = IWETH(_weth); _disableInitializers(); } - function initialize(address _executor, address _treasury) public initializer { + function initialize( + address _executor, + address _treasury, + address[] calldata _initialSwapTargets + ) public initializer { if (_treasury == address(0)) revert BadTreasury(); if (_executor == address(0)) revert BadExecutor(); __Ownable_init(msg.sender); executor = _executor; treasury = _treasury; - } - // ============ UUPS Authorization ============ + // Whitelist initial swap targets + for (uint256 i = 0; i < _initialSwapTargets.length; i++) { + allowedSwapTargets[_initialSwapTargets[i]] = true; + } + } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} - // ============ Modifiers ============ - modifier onlyExecutor() { if (msg.sender != executor) revert UnauthorizedExecutor(); _; } - // ============ Receive ============ - receive() external payable {} - // ============ Main Execution ============ + // ============ External Execution Entry Points ============ - function execute( + /// @inheritdoc IFastSettlementV3 + function executeWithPermit( Intent calldata intent, bytes calldata signature, SwapCall calldata swapData ) external onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { - // Validate constraints - if (block.timestamp > intent.deadline) revert IntentExpired(); + // Validate intent + _validateIntent(intent); + if (!allowedSwapTargets[swapData.to]) revert UnauthorizedSwapTarget(); + // For Permit2 path, inputToken must be ERC20 if (intent.inputToken == address(0)) revert BadInputToken(); - if (intent.recipient == address(0)) revert BadRecipient(); - if (intent.inputAmt == 0) revert BadInputAmt(); - if (intent.userAmtOut == 0) revert BadUserAmtOut(); - - // Track start balance - uint256 startInputBal = _getBalance(intent.inputToken); - - // Pull funds - IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ - permitted: IPermit2.TokenPermissions({ - token: intent.inputToken, - amount: intent.inputAmt - }), - nonce: intent.nonce, - deadline: intent.deadline - }); + // Pull funds via Permit2 + _pullWithPermit2(intent, signature); - IPermit2.SignatureTransferDetails memory transferDetails = IPermit2 - .SignatureTransferDetails({to: address(this), requestedAmount: intent.inputAmt}); + // Execute swap using the ERC20 input token + return _execute(intent, swapData, intent.inputToken); + } - bytes32 witness = keccak256(abi.encode(INTENT_TYPEHASH, intent)); + /// @inheritdoc IFastSettlementV3 + function executeWithETH( + Intent calldata intent, + SwapCall calldata swapData + ) external payable nonReentrant returns (uint256 received, uint256 surplus) { + // Input token must be ETH + if (intent.inputToken != address(0)) revert ExpectedETHInput(); + if (intent.user != msg.sender) revert UnauthorizedCaller(); + if (msg.value != intent.inputAmt) revert InvalidETHAmount(); + if (!allowedSwapTargets[swapData.to]) revert UnauthorizedSwapTarget(); + _validateIntent(intent); + // Wrap ETH + WETH.deposit{value: msg.value}(); + // Execute swap using WETH + return _execute(intent, swapData, address(WETH)); + } - PERMIT2.permitWitnessTransferFrom( - permit, - transferDetails, - intent.user, - witness, - WITNESS_TYPE_STRING, - signature - ); + // ============ Internal Logic ============ + function _execute( + Intent calldata intent, + SwapCall calldata swapData, + address actualInputToken + ) internal returns (uint256 received, uint256 surplus) { // Cache output token address outputToken = intent.outputToken; - + // Track input balance (for refund logic) + uint256 startInputBal = _getBalance(actualInputToken); // Approve swap contract - IERC20(intent.inputToken).forceApprove(swapData.to, intent.inputAmt); - + IERC20(actualInputToken).forceApprove(swapData.to, intent.inputAmt); // Execute swap uint256 outputBalBefore = _getBalance(outputToken); @@ -129,10 +139,8 @@ contract FastSettlementV3 is uint256 outputBalAfter = _getBalance(outputToken); received = outputBalAfter - outputBalBefore; - // Verify output, quote must account for fees + // Verify output + calculate surplus if (received < intent.userAmtOut) revert InsufficientOut(received, intent.userAmtOut); - - // Distribute proceeds surplus = received - intent.userAmtOut; // Pay user @@ -141,8 +149,7 @@ contract FastSettlementV3 is } else { IERC20(outputToken).safeTransfer(intent.recipient, intent.userAmtOut); } - - // Pay treasury + // Send surplus to treasury if (surplus > 0) { if (outputToken == address(0)) { payable(treasury).sendValue(surplus); @@ -150,16 +157,13 @@ contract FastSettlementV3 is IERC20(outputToken).safeTransfer(treasury, surplus); } } - // Reset approval - IERC20(intent.inputToken).forceApprove(swapData.to, 0); - + IERC20(actualInputToken).forceApprove(swapData.to, 0); // Refund unused input - uint256 finalInputBal = _getBalance(intent.inputToken); + uint256 finalInputBal = _getBalance(actualInputToken); if (finalInputBal > startInputBal) { uint256 unused = finalInputBal - startInputBal; - // Refund - IERC20(intent.inputToken).safeTransfer(intent.user, unused); + IERC20(actualInputToken).safeTransfer(intent.user, unused); } emit IntentExecuted( @@ -173,8 +177,52 @@ contract FastSettlementV3 is ); } + function _validateIntent(Intent calldata intent) internal view { + if (block.timestamp > intent.deadline) revert IntentExpired(); + if (intent.recipient == address(0)) revert BadRecipient(); + if (intent.inputAmt == 0) revert BadInputAmt(); + if (intent.userAmtOut == 0) revert BadUserAmtOut(); + } + + function _pullWithPermit2(Intent calldata intent, bytes calldata signature) internal { + IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ + token: intent.inputToken, + amount: intent.inputAmt + }), + nonce: intent.nonce, + deadline: intent.deadline + }); + + IPermit2.SignatureTransferDetails memory transferDetails = IPermit2 + .SignatureTransferDetails({to: address(this), requestedAmount: intent.inputAmt}); + + bytes32 witness = keccak256(abi.encode(INTENT_TYPEHASH, intent)); + + PERMIT2.permitWitnessTransferFrom( + permit, + transferDetails, + intent.user, + witness, + WITNESS_TYPE_STRING, + signature + ); + } + // ============ Admin ============ + /// @inheritdoc IFastSettlementV3 + function setSwapTargets( + address[] calldata targets, + bool[] calldata allowed + ) external onlyOwner { + if (targets.length != allowed.length) revert ArrayLengthMismatch(); + for (uint256 i = 0; i < targets.length; i++) { + allowedSwapTargets[targets[i]] = allowed[i]; + } + emit SwapTargetsUpdated(targets, allowed); + } + function setExecutor(address _newExecutor) external onlyOwner { address old = executor; executor = _newExecutor; @@ -196,8 +244,6 @@ contract FastSettlementV3 is } } - // ============ Internal Helpers ============ - function _getBalance(address token) internal view returns (uint256) { if (token == address(0)) { return address(this).balance; diff --git a/contracts/src/FastSettlementV3Storage.sol b/contracts/src/FastSettlementV3Storage.sol index 7a6fff7..72bfe72 100644 --- a/contracts/src/FastSettlementV3Storage.sol +++ b/contracts/src/FastSettlementV3Storage.sol @@ -4,7 +4,8 @@ pragma solidity ^0.8.20; contract FastSettlementV3Storage { address public executor; address public treasury; + mapping(address => bool) public allowedSwapTargets; - // Gap for upgrade safety - uint256[50] private __gap; + // Gap for upgrade safety (reduced by 1 for new mapping) + uint256[49] private __gap; } diff --git a/contracts/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol index 59120bf..a0444c4 100644 --- a/contracts/src/interfaces/IFastSettlementV3.sol +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -8,8 +8,8 @@ interface IFastSettlementV3 { struct Intent { address user; - address inputToken; - address outputToken; // address(0) for ETH + address inputToken; // address(0) for native ETH input + address outputToken; // address(0) for native ETH output uint256 inputAmt; uint256 userAmtOut; address recipient; @@ -37,6 +37,7 @@ interface IFastSettlementV3 { event ExecutorUpdated(address indexed oldExecutor, address indexed newExecutor); event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + event SwapTargetsUpdated(address[] targets, bool[] allowed); // ============ Errors ============ @@ -52,22 +53,43 @@ interface IFastSettlementV3 { error UnauthorizedExecutor(); error InsufficientOut(uint256 received, uint256 userAmtOut); error InvalidPermit2(); + error InvalidWETH(); + error UnauthorizedCaller(); + error ExpectedETHInput(); + error InvalidETHAmount(); + error UnauthorizedSwapTarget(); + error ArrayLengthMismatch(); // ============ Functions ============ - /// @notice Executes a user intent using Permit2 for input pulling and swap router for swapping. + /// @notice Executes a user intent using Permit2 for input pulling. Callable only by executor. /// @param intent The user's signed intent. /// @param signature The Permit2 witness signature. - /// @param details The call data for the swap router. + /// @param swapData The call data for the swap router. /// @return received The amount of output tokens received. /// @return surplus The surplus amount sent to treasury. - function execute( + function executeWithPermit( Intent calldata intent, bytes calldata signature, - SwapCall calldata details + SwapCall calldata swapData ) external returns (uint256 received, uint256 surplus); - /// @notice Sets the authorized executor who can call execute(). + /// @notice Executes a swap using native ETH as input. Callable by anyone (user must be msg.sender). + /// @param intent The user's intent (inputToken must be address(0)). + /// @param swapData The call data for the swap router. + /// @return received The amount of output tokens received. + /// @return surplus The surplus amount sent to treasury. + function executeWithETH( + Intent calldata intent, + SwapCall calldata swapData + ) external payable returns (uint256 received, uint256 surplus); + + /// @notice Sets allowed swap targets in batch. Only owner. + /// @param targets Array of swap target addresses. + /// @param allowed Array of boolean values (true = allowed, false = disallowed). + function setSwapTargets(address[] calldata targets, bool[] calldata allowed) external; + + /// @notice Sets the authorized executor who can call executeWithPermit(). /// @param _newExecutor The new executor address. function setExecutor(address _newExecutor) external; diff --git a/contracts/src/interfaces/IWETH.sol b/contracts/src/interfaces/IWETH.sol new file mode 100644 index 0000000..d60ac23 --- /dev/null +++ b/contracts/src/interfaces/IWETH.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IWETH { + function deposit() external payable; + function withdraw(uint256 wad) external; + function balanceOf(address owner) external view returns (uint256); + function transfer(address to, uint256 value) external returns (bool); + function approve(address spender, uint256 value) external returns (bool); +} diff --git a/contracts/test/FastSettlementV3.t.sol b/contracts/test/FastSettlementV3.t.sol index 11617ca..348f8e5 100644 --- a/contracts/test/FastSettlementV3.t.sol +++ b/contracts/test/FastSettlementV3.t.sol @@ -5,6 +5,7 @@ import {Test, console2} from "forge-std/Test.sol"; import {FastSettlementV3} from "../src/FastSettlementV3.sol"; import {IFastSettlementV3} from "../src/interfaces/IFastSettlementV3.sol"; import {IPermit2} from "../src/interfaces/IPermit2.sol"; +import {IWETH} from "../src/interfaces/IWETH.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; @@ -20,6 +21,24 @@ contract MockERC20 is ERC20 { } } +contract MockWETH is MockERC20 { + constructor() MockERC20("Wrapped Ether", "WETH") {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 wad) external { + _burn(msg.sender, wad); + (bool success, ) = msg.sender.call{value: wad}(""); + require(success, "MockWETH: ETH transfer failed"); + } + + receive() external payable { + _mint(msg.sender, msg.value); + } +} + contract MockPermit2 is IPermit2 { mapping(address => mapping(address => mapping(uint256 => bool))) public usedNonces; @@ -60,6 +79,12 @@ contract MockPermit2 is IPermit2 { } contract MockSwapRouter { + address public weth; + + constructor(address _weth) { + weth = _weth; + } + function swap( address tokenIn, uint256 amountIn, @@ -79,6 +104,15 @@ contract MockSwapRouter { MockERC20(tokenOut).mint(msg.sender, amountOut); } } + + // For WETH input swaps + function swapWETH(uint256 amountIn, address tokenOut, uint256 amountOut) external { + // Pull WETH from caller + IERC20(weth).transferFrom(msg.sender, address(this), amountIn); + + // Send output + MockERC20(tokenOut).mint(msg.sender, amountOut); + } } // ============ Test Contract ============ @@ -87,6 +121,7 @@ contract FastSettlementV3Test is Test { FastSettlementV3 public settlement; MockPermit2 public permit2; MockSwapRouter public swapRouter; + MockWETH public weth; MockERC20 public tokenIn; MockERC20 public tokenOut; @@ -97,14 +132,19 @@ contract FastSettlementV3Test is Test { function setUp() public { permit2 = new MockPermit2(); - swapRouter = new MockSwapRouter(); + weth = new MockWETH(); + swapRouter = new MockSwapRouter(address(weth)); tokenIn = new MockERC20("Token In", "TIN"); tokenOut = new MockERC20("Token Out", "TOUT"); - FastSettlementV3 impl = new FastSettlementV3(address(permit2)); + // Initial swap targets to whitelist + address[] memory initialTargets = new address[](1); + initialTargets[0] = address(swapRouter); + + FastSettlementV3 impl = new FastSettlementV3(address(permit2), address(weth)); ERC1967Proxy proxy = new ERC1967Proxy( address(impl), - abi.encodeCall(FastSettlementV3.initialize, (executor, treasury)) + abi.encodeCall(FastSettlementV3.initialize, (executor, treasury, initialTargets)) ); settlement = FastSettlementV3(payable(address(proxy))); @@ -113,19 +153,37 @@ contract FastSettlementV3Test is Test { tokenIn.approve(address(permit2), type(uint256).max); vm.deal(address(swapRouter), 1000e18); // Give swap router ETH + vm.deal(user, 1000e18); // Give user ETH for executeWithETH tests } // Helpers function _createIntent( uint256 amountIn, uint256 userAmtOut, - bool isEth + bool isEthOutput ) internal view returns (IFastSettlementV3.Intent memory) { return IFastSettlementV3.Intent({ user: user, inputToken: address(tokenIn), - outputToken: isEth ? address(0) : address(tokenOut), + outputToken: isEthOutput ? address(0) : address(tokenOut), + inputAmt: amountIn, + userAmtOut: userAmtOut, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + } + + function _createETHInputIntent( + uint256 amountIn, + uint256 userAmtOut + ) internal view returns (IFastSettlementV3.Intent memory) { + return + IFastSettlementV3.Intent({ + user: user, + inputToken: address(0), // ETH input + outputToken: address(tokenOut), inputAmt: amountIn, userAmtOut: userAmtOut, recipient: recipient, @@ -137,7 +195,7 @@ contract FastSettlementV3Test is Test { function _createSwapCall( uint256 amountIn, uint256 amountOut, - bool isEth + bool isEthOutput ) internal view returns (IFastSettlementV3.SwapCall memory) { return IFastSettlementV3.SwapCall({ @@ -149,31 +207,47 @@ contract FastSettlementV3Test is Test { amountIn, address(tokenOut), amountOut, - isEth + isEthOutput ) }); } - // Tests + function _createWETHSwapCall( + uint256 amountIn, + uint256 amountOut + ) internal view returns (IFastSettlementV3.SwapCall memory) { + return + IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swapWETH.selector, + amountIn, + address(tokenOut), + amountOut + ) + }); + } - function testExecute_ERC20toERC20_Success() public { + // ============ executeWithPermit Tests ============ + + function testExecuteWithPermit_ERC20toERC20_Success() public { uint256 amountIn = 100e18; uint256 userAmtOut = 90e18; - // Exact match uint256 actualOut = 90e18; IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); vm.prank(executor); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); assertEq(tokenOut.balanceOf(recipient), actualOut); assertEq(tokenOut.balanceOf(treasury), 0); } - function testExecute_Surplus() public { + function testExecuteWithPermit_Surplus() public { uint256 amountIn = 100e18; uint256 userAmtOut = 90e18; uint256 actualOut = 100e18; // 10 surplus @@ -182,13 +256,13 @@ contract FastSettlementV3Test is Test { IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); vm.prank(executor); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); assertEq(tokenOut.balanceOf(recipient), 90e18); - assertEq(tokenOut.balanceOf(treasury), 10e18); // Surplus captured by contract treasury + assertEq(tokenOut.balanceOf(treasury), 10e18); } - function testExecute_InsufficientOut() public { + function testExecuteWithPermit_InsufficientOut() public { uint256 amountIn = 100e18; uint256 userAmtOut = 90e18; uint256 actualOut = 89e18; // Too low @@ -200,10 +274,10 @@ contract FastSettlementV3Test is Test { vm.expectRevert( abi.encodeWithSignature("InsufficientOut(uint256,uint256)", actualOut, userAmtOut) ); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); } - function testExecute_ETH_Success() public { + function testExecuteWithPermit_ETHOutput_Success() public { uint256 amountIn = 100e18; uint256 userAmtOut = 90e18; uint256 actualOut = 95e18; // 5 surplus @@ -215,36 +289,167 @@ contract FastSettlementV3Test is Test { uint256 treasuryEthBefore = treasury.balance; vm.prank(executor); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); assertEq(recipient.balance - recipientEthBefore, 90e18); assertEq(treasury.balance - treasuryEthBefore, 5e18); } - function testExecute_ReplayProtection() public { - // MockPermit2 handles nonces. + function testExecuteWithPermit_ReplayProtection() public { uint256 amountIn = 100e18; IFastSettlementV3.Intent memory intent = _createIntent(amountIn, 90e18, false); IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, 90e18, false); vm.startPrank(executor); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); // Replay vm.expectRevert("InvalidNonce"); // MockPermit2 error - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); vm.stopPrank(); } - function testExecute_UnauthorizedExecutor() public { + function testExecuteWithPermit_UnauthorizedExecutor() public { IFastSettlementV3.Intent memory intent = _createIntent(100e18, 90e18, false); IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 90e18, false); vm.prank(makeAddr("randomCaller")); vm.expectRevert(IFastSettlementV3.UnauthorizedExecutor.selector); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_RevertIfTokensBypassContract() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: address(swapRouter), + value: 0, + data: abi.encodeWithSelector( + MockSwapRouter.swap.selector, + address(tokenIn), + amountIn, + address(tokenOut), + 0, // AmountOut to contract is 0 + false + ) + }); + + vm.prank(executor); + vm.expectRevert(abi.encodeWithSignature("InsufficientOut(uint256,uint256)", 0, userAmtOut)); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + // ============ executeWithETH Tests ============ + + function testExecuteWithETH_Success() public { + uint256 amountIn = 1e18; + uint256 userAmtOut = 900e18; + uint256 actualOut = 950e18; // 50 surplus + + IFastSettlementV3.Intent memory intent = _createETHInputIntent(amountIn, userAmtOut); + IFastSettlementV3.SwapCall memory call = _createWETHSwapCall(amountIn, actualOut); + + uint256 recipientBalBefore = tokenOut.balanceOf(recipient); + uint256 treasuryBalBefore = tokenOut.balanceOf(treasury); + + vm.prank(user); + settlement.executeWithETH{value: amountIn}(intent, call); + + assertEq(tokenOut.balanceOf(recipient) - recipientBalBefore, userAmtOut); + assertEq(tokenOut.balanceOf(treasury) - treasuryBalBefore, 50e18); + } + + function testExecuteWithETH_UnauthorizedCaller() public { + IFastSettlementV3.Intent memory intent = _createETHInputIntent(1e18, 900e18); + IFastSettlementV3.SwapCall memory call = _createWETHSwapCall(1e18, 900e18); + + // Someone else tries to execute user's intent + address attacker = makeAddr("attacker"); + vm.deal(attacker, 10e18); + + vm.prank(attacker); + vm.expectRevert(IFastSettlementV3.UnauthorizedCaller.selector); + settlement.executeWithETH{value: 1e18}(intent, call); + } + + function testExecuteWithETH_WrongInputToken() public { + // Intent with ERC20 input, not ETH + IFastSettlementV3.Intent memory intent = _createIntent(1e18, 900e18, false); + IFastSettlementV3.SwapCall memory call = _createWETHSwapCall(1e18, 900e18); + + vm.prank(user); + vm.expectRevert(IFastSettlementV3.ExpectedETHInput.selector); + settlement.executeWithETH{value: 1e18}(intent, call); + } + + function testExecuteWithETH_WrongETHAmount() public { + IFastSettlementV3.Intent memory intent = _createETHInputIntent(1e18, 900e18); + IFastSettlementV3.SwapCall memory call = _createWETHSwapCall(1e18, 900e18); + + vm.prank(user); + vm.expectRevert(IFastSettlementV3.InvalidETHAmount.selector); + settlement.executeWithETH{value: 0.5e18}(intent, call); // Wrong amount + } + + // ============ Swap Target Whitelist Tests ============ + + function testUnauthorizedSwapTarget() public { + IFastSettlementV3.Intent memory intent = _createIntent(100e18, 90e18, false); + + // Create call to non-whitelisted address + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: makeAddr("maliciousRouter"), + value: 0, + data: bytes("") + }); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.UnauthorizedSwapTarget.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); } + function testSetSwapTargets() public { + address newTarget = makeAddr("newRouter"); + address anotherTarget = makeAddr("anotherRouter"); + + address[] memory targets = new address[](2); + targets[0] = newTarget; + targets[1] = anotherTarget; + + bool[] memory allowed = new bool[](2); + allowed[0] = true; + allowed[1] = true; + + settlement.setSwapTargets(targets, allowed); + + assertTrue(settlement.allowedSwapTargets(newTarget)); + assertTrue(settlement.allowedSwapTargets(anotherTarget)); + + // Disable one + allowed[0] = false; + settlement.setSwapTargets(targets, allowed); + + assertFalse(settlement.allowedSwapTargets(newTarget)); + assertTrue(settlement.allowedSwapTargets(anotherTarget)); + } + + function testSetSwapTargets_ArrayLengthMismatch() public { + address[] memory targets = new address[](2); + targets[0] = makeAddr("a"); + targets[1] = makeAddr("b"); + + bool[] memory allowed = new bool[](1); + allowed[0] = true; + + vm.expectRevert(IFastSettlementV3.ArrayLengthMismatch.selector); + settlement.setSwapTargets(targets, allowed); + } + + // ============ Admin Tests ============ + function testSetExecutor() public { address newExecutor = makeAddr("newExecutor"); settlement.setExecutor(newExecutor); @@ -265,28 +470,302 @@ contract FastSettlementV3Test is Test { assertEq(tokenIn.balanceOf(address(this)), 50e18); } - function testExecute_RevertIfTokensBypassContract() public { + function testRescueTokens_ETH() public { + // Send accidental ETH + vm.deal(address(settlement), 1e18); + + uint256 ownerBalBefore = address(this).balance; + settlement.rescueTokens(address(0), 1e18); + assertEq(address(this).balance - ownerBalBefore, 1e18); + } + + // ============ Intent Validation Edge Cases ============ + + function testExecuteWithPermit_ExpiredDeadline() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: 100e18, + userAmtOut: 90e18, + recipient: recipient, + deadline: block.timestamp - 1, // Already expired + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 90e18, false); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.IntentExpired.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_ZeroRecipient() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: 100e18, + userAmtOut: 90e18, + recipient: address(0), // Bad recipient + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 90e18, false); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.BadRecipient.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_ZeroInputAmt() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: 0, // Zero input + userAmtOut: 90e18, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createSwapCall(0, 90e18, false); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.BadInputAmt.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_ZeroUserAmtOut() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + outputToken: address(tokenOut), + inputAmt: 100e18, + userAmtOut: 0, // Zero output + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 0, false); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.BadUserAmtOut.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_BadInputToken() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(0), // ETH not allowed in Permit2 path + outputToken: address(tokenOut), + inputAmt: 100e18, + userAmtOut: 90e18, + recipient: recipient, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createSwapCall(100e18, 90e18, false); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.BadInputToken.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + // ============ Swap Failure Tests ============ + + function testExecuteWithPermit_BadCallTarget() public { + IFastSettlementV3.Intent memory intent = _createIntent(100e18, 90e18, false); + + // Create call with bad data that will cause the swap to fail + address failingRouter = address(new FailingSwapRouter()); + + // Whitelist the failing router + address[] memory targets = new address[](1); + targets[0] = failingRouter; + bool[] memory allowed = new bool[](1); + allowed[0] = true; + settlement.setSwapTargets(targets, allowed); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: failingRouter, + value: 0, + data: abi.encodeWithSelector(FailingSwapRouter.failingSwap.selector) + }); + + vm.prank(executor); + vm.expectRevert(IFastSettlementV3.BadCallTarget.selector); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + } + + // ============ Input Refund Tests ============ + + function testExecuteWithPermit_RefundsExtraTokensReturnedBySwap() public { uint256 amountIn = 100e18; - uint256 userAmtOut = 90e18; + uint256 amountOut = 90e18; + uint256 extraReturned = 20e18; // Swap returns extra tokens to the contract - IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, amountOut, false); + + // Create a router that returns extra tokens to the contract + address bonusRouter = address(new BonusReturnRouter(address(tokenIn), address(tokenOut))); + + // Whitelist it + address[] memory targets = new address[](1); + targets[0] = bonusRouter; + bool[] memory allowed = new bool[](1); + allowed[0] = true; + settlement.setSwapTargets(targets, allowed); - // Construct call that sends 0 to contract (simulating funds went elsewhere) IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ - to: address(swapRouter), + to: bonusRouter, value: 0, data: abi.encodeWithSelector( - MockSwapRouter.swap.selector, - address(tokenIn), + BonusReturnRouter.swapWithBonus.selector, amountIn, - address(tokenOut), - 0, // AmountOut to contract is 0 - false + amountOut, + extraReturned ) }); + uint256 userBalBefore = tokenIn.balanceOf(user); + vm.prank(executor); - vm.expectRevert(abi.encodeWithSignature("InsufficientOut(uint256,uint256)", 0, userAmtOut)); - settlement.execute(intent, bytes("fakeSig"), call); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + + // User should get the extra tokens as a refund + uint256 userBalAfter = tokenIn.balanceOf(user); + // User started with 1000, lost 100 to permit2, got 20 back as refund = 920 + assertEq( + userBalAfter, + userBalBefore - amountIn + extraReturned, + "User should receive refund of extra tokens" + ); + } + + // ============ Treasury ETH Surplus Test ============ + + function testExecuteWithPermit_ETHSurplusToTreasury() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 1e18; // User gets 1 ETH + uint256 actualOut = 1.5e18; // Swap produces 1.5 ETH (0.5 surplus) + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, true); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, true); + + uint256 treasuryEthBefore = treasury.balance; + + vm.prank(executor); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + + assertEq(treasury.balance - treasuryEthBefore, 0.5e18, "Treasury should get ETH surplus"); + } + + // ============ Admin Edge Cases ============ + + function testSetTreasury_ZeroAddress() public { + vm.expectRevert(IFastSettlementV3.BadTreasury.selector); + settlement.setTreasury(address(0)); + } + + function testSetExecutor_ToZeroAddress() public { + // This is allowed - disables executor + settlement.setExecutor(address(0)); + assertEq(settlement.executor(), address(0)); + } + + // ============ executeWithETH Edge Cases ============ + + function testExecuteWithETH_ExpiredDeadline() public { + IFastSettlementV3.Intent memory intent = IFastSettlementV3.Intent({ + user: user, + inputToken: address(0), + outputToken: address(tokenOut), + inputAmt: 1e18, + userAmtOut: 900e18, + recipient: recipient, + deadline: block.timestamp - 1, // Expired + nonce: 0 + }); + IFastSettlementV3.SwapCall memory call = _createWETHSwapCall(1e18, 900e18); + + vm.prank(user); + vm.expectRevert(IFastSettlementV3.IntentExpired.selector); + settlement.executeWithETH{value: 1e18}(intent, call); + } + + function testExecuteWithETH_UnauthorizedSwapTarget() public { + IFastSettlementV3.Intent memory intent = _createETHInputIntent(1e18, 900e18); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: makeAddr("unwhitelistedRouter"), + value: 0, + data: bytes("") + }); + + vm.prank(user); + vm.expectRevert(IFastSettlementV3.UnauthorizedSwapTarget.selector); + settlement.executeWithETH{value: 1e18}(intent, call); + } + + // ============ Receive ETH ============ + + function testReceiveETH() public { + vm.deal(address(this), 1e18); + (bool success, ) = address(settlement).call{value: 1e18}(""); + assertTrue(success, "Should accept ETH"); + assertEq(address(settlement).balance, 1e18); + } + + receive() external payable {} +} + +// ============ Additional Mock Contracts ============ + +contract FailingSwapRouter { + function failingSwap() external pure { + revert("Swap failed"); + } +} + +contract PartialConsumptionRouter { + address public tokenIn; + address public tokenOut; + + constructor(address _tokenIn, address _tokenOut) { + tokenIn = _tokenIn; + tokenOut = _tokenOut; + } + + function partialSwap(uint256 pullAmount, uint256 consumeAmount, uint256 outputAmount) external { + // Pull full amount from settlement + IERC20(tokenIn).transferFrom(msg.sender, address(this), pullAmount); + // Return unused portion to settlement (simulating a DEX that doesn't use all input) + uint256 unused = pullAmount - consumeAmount; + if (unused > 0) { + IERC20(tokenIn).transfer(msg.sender, unused); + } + // Mint output + MockERC20(tokenOut).mint(msg.sender, outputAmount); + } +} + +contract BonusReturnRouter { + address public tokenIn; + address public tokenOut; + + constructor(address _tokenIn, address _tokenOut) { + tokenIn = _tokenIn; + tokenOut = _tokenOut; + } + + function swapWithBonus(uint256 pullAmount, uint256 outputAmount, uint256 bonusReturn) external { + // Pull full amount from settlement + IERC20(tokenIn).transferFrom(msg.sender, address(this), pullAmount); + // Return the pulled amount back (DEX only needs to hold it temporarily) + IERC20(tokenIn).transfer(msg.sender, pullAmount); + // Swap also gives bonus tokens back (e.g., rebate, arbitrage profit, etc.) + MockERC20(tokenIn).mint(msg.sender, bonusReturn); + // Mint output + MockERC20(tokenOut).mint(msg.sender, outputAmount); } } diff --git a/contracts/test/FastSettlementV3_Integration.t.sol b/contracts/test/FastSettlementV3_Integration.t.sol index af38088..6e59c25 100644 --- a/contracts/test/FastSettlementV3_Integration.t.sol +++ b/contracts/test/FastSettlementV3_Integration.t.sol @@ -24,6 +24,24 @@ contract MockERC20 is ERC20 { } } +contract MockWETH is MockERC20 { + constructor() MockERC20("Wrapped Ether", "WETH", 18) {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 wad) external { + _burn(msg.sender, wad); + (bool success, ) = msg.sender.call{value: wad}(""); + require(success, "MockWETH: ETH transfer failed"); + } + + receive() external payable { + _mint(msg.sender, msg.value); + } +} + contract MockSwapRouter { function swap( address tokenIn, @@ -48,6 +66,7 @@ contract FastSettlementV3IntegrationTest is Test { FastSettlementV3 public settlement; address public permit2; // Deployed via deployCode MockSwapRouter public swapRouter; + MockWETH public weth; MockERC20 public tokenIn; MockERC20 public tokenOut; @@ -75,6 +94,7 @@ contract FastSettlementV3IntegrationTest is Test { permit2 = deployCode("out/Permit2.sol/Permit2.json"); // 2. Deploy Tokens + weth = new MockWETH(); tokenIn = new MockERC20("Token In", "TIN", 18); tokenOut = new MockERC20("Token Out", "TOUT", 18); @@ -82,11 +102,14 @@ contract FastSettlementV3IntegrationTest is Test { swapRouter = new MockSwapRouter(); // 4. Deploy Settlement Contract linked to Real Permit2 (via Proxy) + address[] memory initialTargets = new address[](1); + initialTargets[0] = address(swapRouter); + vm.startPrank(owner); - FastSettlementV3 impl = new FastSettlementV3(permit2); + FastSettlementV3 impl = new FastSettlementV3(permit2, address(weth)); ERC1967Proxy proxy = new ERC1967Proxy( address(impl), - abi.encodeCall(FastSettlementV3.initialize, (executor, treasury)) + abi.encodeCall(FastSettlementV3.initialize, (executor, treasury, initialTargets)) ); settlement = FastSettlementV3(payable(address(proxy))); vm.stopPrank(); @@ -137,7 +160,7 @@ contract FastSettlementV3IntegrationTest is Test { // 4. Execute via Executor vm.prank(executor); - settlement.execute(intent, signature, call); + settlement.executeWithPermit(intent, signature, call); // 5. Verification assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); // User pulled @@ -183,7 +206,7 @@ contract FastSettlementV3IntegrationTest is Test { // 4. Execute uint256 recipientEthBefore = recipient.balance; vm.prank(executor); - settlement.execute(intent, signature, call); + settlement.executeWithPermit(intent, signature, call); // 5. Verification assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); @@ -226,7 +249,7 @@ contract FastSettlementV3IntegrationTest is Test { // Should revert (Permit2 signature validation) vm.prank(executor); vm.expectRevert(); // Permit2 will revert with InvalidSigner or similar - settlement.execute(intent, badSignature, call); + settlement.executeWithPermit(intent, badSignature, call); } function test_Integration_RealPermit2_TamperedIntent_Amount() public { @@ -266,7 +289,7 @@ contract FastSettlementV3IntegrationTest is Test { vm.prank(executor); // Should revert because signature doesn't match new amount vm.expectRevert(); - settlement.execute(intent, signature, call); + settlement.executeWithPermit(intent, signature, call); } function test_Integration_RealPermit2_TamperedIntent_Recipient() public { @@ -306,7 +329,7 @@ contract FastSettlementV3IntegrationTest is Test { vm.prank(executor); // Should revert because signature doesn't match new recipient vm.expectRevert(); - settlement.execute(intent, signature, call); + settlement.executeWithPermit(intent, signature, call); } function test_Integration_UnauthorizedExecutor() public { @@ -343,7 +366,7 @@ contract FastSettlementV3IntegrationTest is Test { address randomCaller = makeAddr("randomCaller"); vm.prank(randomCaller); vm.expectRevert(IFastSettlementV3.UnauthorizedExecutor.selector); - settlement.execute(intent, signature, call); + settlement.executeWithPermit(intent, signature, call); } // ============ Helper: Generate Real Permit2 Signature ============