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/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/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 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/FastSettlementV3.sol b/contracts/src/FastSettlementV3.sol new file mode 100644 index 0000000..c968498 --- /dev/null +++ b/contracts/src/FastSettlementV3.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MIT +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 {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"; +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 with dual entry points. +contract FastSettlementV3 is + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + ReentrancyGuardTransient, + IFastSettlementV3, + FastSettlementV3Storage +{ + using SafeERC20 for IERC20; + using Address for address payable; + + // ============ Constants & Immutables ============ + + bytes32 public constant INTENT_TYPEHASH = + keccak256( + bytes( + "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 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, 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, + 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; + + // Whitelist initial swap targets + for (uint256 i = 0; i < _initialSwapTargets.length; i++) { + allowedSwapTargets[_initialSwapTargets[i]] = true; + } + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + modifier onlyExecutor() { + if (msg.sender != executor) revert UnauthorizedExecutor(); + _; + } + + receive() external payable {} + + // ============ External Execution Entry Points ============ + + /// @inheritdoc IFastSettlementV3 + function executeWithPermit( + Intent calldata intent, + bytes calldata signature, + SwapCall calldata swapData + ) external onlyExecutor nonReentrant returns (uint256 received, uint256 surplus) { + // Validate intent + _validateIntent(intent); + if (!allowedSwapTargets[swapData.to]) revert UnauthorizedSwapTarget(); + // For Permit2 path, inputToken must be ERC20 + if (intent.inputToken == address(0)) revert BadInputToken(); + // Pull funds via Permit2 + _pullWithPermit2(intent, signature); + + // Execute swap using the ERC20 input token + return _execute(intent, swapData, intent.inputToken); + } + + /// @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)); + } + + // ============ 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(actualInputToken).forceApprove(swapData.to, intent.inputAmt); + // Execute swap + uint256 outputBalBefore = _getBalance(outputToken); + + (bool success, ) = swapData.to.call(swapData.data); + if (!success) revert BadCallTarget(); + + uint256 outputBalAfter = _getBalance(outputToken); + received = outputBalAfter - outputBalBefore; + + // Verify output + calculate surplus + if (received < intent.userAmtOut) revert InsufficientOut(received, intent.userAmtOut); + surplus = received - intent.userAmtOut; + + // Pay user + if (outputToken == address(0)) { + payable(intent.recipient).sendValue(intent.userAmtOut); + } else { + IERC20(outputToken).safeTransfer(intent.recipient, intent.userAmtOut); + } + // Send surplus to treasury + if (surplus > 0) { + if (outputToken == address(0)) { + payable(treasury).sendValue(surplus); + } else { + IERC20(outputToken).safeTransfer(treasury, surplus); + } + } + // Reset approval + IERC20(actualInputToken).forceApprove(swapData.to, 0); + // Refund unused input + uint256 finalInputBal = _getBalance(actualInputToken); + if (finalInputBal > startInputBal) { + uint256 unused = finalInputBal - startInputBal; + IERC20(actualInputToken).safeTransfer(intent.user, unused); + } + + emit IntentExecuted( + intent.user, + intent.inputToken, + outputToken, + intent.inputAmt, + intent.userAmtOut, + received, + surplus + ); + } + + 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; + 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)) { + payable(msg.sender).sendValue(amount); + } else { + IERC20(token).safeTransfer(msg.sender, amount); + } + } + + 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/FastSettlementV3Storage.sol b/contracts/src/FastSettlementV3Storage.sol new file mode 100644 index 0000000..72bfe72 --- /dev/null +++ b/contracts/src/FastSettlementV3Storage.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract FastSettlementV3Storage { + address public executor; + address public treasury; + mapping(address => bool) public allowedSwapTargets; + + // Gap for upgrade safety (reduced by 1 for new mapping) + uint256[49] private __gap; +} 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/src/interfaces/IFastSettlementV3.sol b/contracts/src/interfaces/IFastSettlementV3.sol new file mode 100644 index 0000000..a0444c4 --- /dev/null +++ b/contracts/src/interfaces/IFastSettlementV3.sol @@ -0,0 +1,104 @@ +// 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(0) for native ETH input + address outputToken; // address(0) for native ETH output + uint256 inputAmt; + 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 inputAmt, + uint256 userAmtOut, + uint256 received, + uint256 surplus + ); + + event ExecutorUpdated(address indexed oldExecutor, address indexed newExecutor); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + event SwapTargetsUpdated(address[] targets, bool[] allowed); + + // ============ Errors ============ + + error IntentExpired(); + error BadNonce(); + error BadTreasury(); + error BadExecutor(); + error BadRecipient(); + error BadInputToken(); + error BadInputAmt(); + error BadUserAmtOut(); + error BadCallTarget(); + 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. Callable only by executor. + /// @param intent The user's signed intent. + /// @param signature The Permit2 witness signature. + /// @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 executeWithPermit( + Intent calldata intent, + bytes calldata signature, + SwapCall calldata swapData + ) external returns (uint256 received, uint256 surplus); + + /// @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; + + /// @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/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/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/FastSettlementV3.t.sol b/contracts/test/FastSettlementV3.t.sol new file mode 100644 index 0000000..348f8e5 --- /dev/null +++ b/contracts/test/FastSettlementV3.t.sol @@ -0,0 +1,771 @@ +// 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 {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"; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.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 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; + + 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 { + address public weth; + + constructor(address _weth) { + weth = _weth; + } + + 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); + } + } + + // 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 ============ + +contract FastSettlementV3Test is Test { + FastSettlementV3 public settlement; + MockPermit2 public permit2; + MockSwapRouter public swapRouter; + MockWETH public weth; + 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(); + weth = new MockWETH(); + swapRouter = new MockSwapRouter(address(weth)); + tokenIn = new MockERC20("Token In", "TIN"); + tokenOut = new MockERC20("Token Out", "TOUT"); + + // 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, initialTargets)) + ); + settlement = FastSettlementV3(payable(address(proxy))); + + tokenIn.mint(user, 1000e18); + vm.prank(user); + 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 isEthOutput + ) internal view returns (IFastSettlementV3.Intent memory) { + return + IFastSettlementV3.Intent({ + user: user, + inputToken: address(tokenIn), + 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, + deadline: block.timestamp + 1 hours, + nonce: 0 + }); + } + + function _createSwapCall( + uint256 amountIn, + uint256 amountOut, + bool isEthOutput + ) 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, + isEthOutput + ) + }); + } + + 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 + ) + }); + } + + // ============ executeWithPermit Tests ============ + + function testExecuteWithPermit_ERC20toERC20_Success() public { + uint256 amountIn = 100e18; + uint256 userAmtOut = 90e18; + uint256 actualOut = 90e18; + + IFastSettlementV3.Intent memory intent = _createIntent(amountIn, userAmtOut, false); + IFastSettlementV3.SwapCall memory call = _createSwapCall(amountIn, actualOut, false); + + vm.prank(executor); + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + + assertEq(tokenIn.balanceOf(user), 1000e18 - amountIn); + assertEq(tokenOut.balanceOf(recipient), actualOut); + assertEq(tokenOut.balanceOf(treasury), 0); + } + + function testExecuteWithPermit_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.executeWithPermit(intent, bytes("fakeSig"), call); + + assertEq(tokenOut.balanceOf(recipient), 90e18); + assertEq(tokenOut.balanceOf(treasury), 10e18); + } + + function testExecuteWithPermit_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.executeWithPermit(intent, bytes("fakeSig"), call); + } + + function testExecuteWithPermit_ETHOutput_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.executeWithPermit(intent, bytes("fakeSig"), call); + + assertEq(recipient.balance - recipientEthBefore, 90e18); + assertEq(treasury.balance - treasuryEthBefore, 5e18); + } + + 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.executeWithPermit(intent, bytes("fakeSig"), call); + + // Replay + vm.expectRevert("InvalidNonce"); // MockPermit2 error + settlement.executeWithPermit(intent, bytes("fakeSig"), call); + vm.stopPrank(); + } + + 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.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); + 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 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 amountOut = 90e18; + uint256 extraReturned = 20e18; // Swap returns extra tokens to the contract + + 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); + + IFastSettlementV3.SwapCall memory call = IFastSettlementV3.SwapCall({ + to: bonusRouter, + value: 0, + data: abi.encodeWithSelector( + BonusReturnRouter.swapWithBonus.selector, + amountIn, + amountOut, + extraReturned + ) + }); + + uint256 userBalBefore = tokenIn.balanceOf(user); + + vm.prank(executor); + 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 new file mode 100644 index 0000000..6e59c25 --- /dev/null +++ b/contracts/test/FastSettlementV3_Integration.t.sol @@ -0,0 +1,429 @@ +// 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"; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.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 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, + 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; + MockWETH public weth; + 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 + weth = new MockWETH(); + 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 (via Proxy) + address[] memory initialTargets = new address[](1); + initialTargets[0] = address(swapRouter); + + vm.startPrank(owner); + FastSettlementV3 impl = new FastSettlementV3(permit2, address(weth)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(FastSettlementV3.initialize, (executor, treasury, initialTargets)) + ); + settlement = FastSettlementV3(payable(address(proxy))); + vm.stopPrank(); + + // 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), + inputAmt: 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.executeWithPermit(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 + inputAmt: 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.executeWithPermit(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), + inputAmt: 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.executeWithPermit(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.executeWithPermit(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.executeWithPermit(intent, signature, 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), + inputAmt: 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.executeWithPermit(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.inputAmt, + 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.inputAmt) + ); + + // 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); + } +}