From d1c99feb5cde141a277eefb2b838075bb25e75e4 Mon Sep 17 00:00:00 2001 From: Stanislav Chernenko <64794434+stanis-chernes@users.noreply.github.com> Date: Mon, 14 Jul 2025 18:16:12 +0200 Subject: [PATCH 1/5] feat: refactored + test coverage --- src/AccessManager.sol | 31 +++- src/EIP712Swap.sol | 77 +++++---- src/FeeManager.sol | 17 +- src/ISwap.sol | 66 ++++---- src/LiquidityPool.sol | 277 ++++++++++++++++++------------- src/Roles.sol | 3 +- src/VaultMultisig.sol | 162 ++++++++++++++++-- test/AccessManager.t.sol | 92 ++++++++++ test/EIP712Swap.t.sol | 188 +++++++++++++++++++++ test/FeeManager.t.sol | 148 +++++++++++++++++ test/LiquidityPool.t.sol | 171 +++++++++++++++++++ test/MockERC20.sol | 25 +++ test/Roles.t.sol | 23 +++ test/VaultMultisig.t.sol | 350 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 1430 insertions(+), 200 deletions(-) create mode 100644 test/AccessManager.t.sol create mode 100644 test/EIP712Swap.t.sol create mode 100644 test/FeeManager.t.sol create mode 100644 test/LiquidityPool.t.sol create mode 100644 test/MockERC20.sol create mode 100644 test/Roles.t.sol create mode 100644 test/VaultMultisig.t.sol diff --git a/src/AccessManager.sol b/src/AccessManager.sol index 1e2aed7..aedce26 100644 --- a/src/AccessManager.sol +++ b/src/AccessManager.sol @@ -2,12 +2,25 @@ pragma solidity ^0.8.30; import "@openzeppelin/contracts/access/AccessControl.sol"; import "./Roles.sol"; + contract AccessManager is AccessControl { using Roles for bytes32; constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + + // Set role admin relationships + _setRoleAdmin(Roles.ADMIN_ROLE, DEFAULT_ADMIN_ROLE); _setRoleAdmin(Roles.MULTISIG_ADMIN_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(Roles.ALLOWED_EIP712_SWAP_ROLE, DEFAULT_ADMIN_ROLE); + } + + function addAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(Roles.ADMIN_ROLE, _admin); + } + + function removeAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) { + _revokeRole(Roles.ADMIN_ROLE, _admin); } function addMultisigAdmin(address _multisigAdmin) external onlyRole(DEFAULT_ADMIN_ROLE) { @@ -18,7 +31,23 @@ contract AccessManager is AccessControl { _revokeRole(Roles.MULTISIG_ADMIN_ROLE, _multisigAdmin); } + function addEIP712Swapper(address _swapper) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _swapper); + } + + function removeEIP712Swapper(address _swapper) external onlyRole(DEFAULT_ADMIN_ROLE) { + _revokeRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _swapper); + } + + function isAdmin(address _address) external view returns (bool) { + return hasRole(Roles.ADMIN_ROLE, _address); + } + function isMultisigAdmin(address _address) external view returns (bool) { return hasRole(Roles.MULTISIG_ADMIN_ROLE, _address); } -} \ No newline at end of file + + function isEIP712Swapper(address _address) external view returns (bool) { + return hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _address); + } +} diff --git a/src/EIP712Swap.sol b/src/EIP712Swap.sol index 9161f57..a2919d9 100644 --- a/src/EIP712Swap.sol +++ b/src/EIP712Swap.sol @@ -6,41 +6,62 @@ import "./LiquidityPool.sol"; import "./ISwap.sol"; contract EIP712Swap is EIP712 { - using ECDSA for bytes32; + using ECDSA for bytes32; - mapping(address => uint256) private _nonces; + mapping(address => uint256) private _nonces; - bytes32 private constant SWAP_TYPEHASH = - keccak256("SwapRequest(address pool,address sender,address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,uint256 nonce,uint256 deadline)"); + bytes32 private constant SWAP_TYPEHASH = keccak256( + "SwapRequest(address pool,address sender,address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,uint256 nonce,uint256 deadline)" + ); - error InvalidSignature(); - error ExpiredSwapRequest(); - error InvalidNonce(); + error InvalidSignature(); + error ExpiredSwapRequest(); + error InvalidNonce(); - constructor() EIP712("EIP712Swap", "1") {} + constructor() EIP712("EIP712Swap", "1") {} - function getDomainSeparator() public view returns (bytes32) { - return _domainSeparatorV4(); - } + function getDomainSeparator() public view returns (bytes32) { + return _domainSeparatorV4(); + } - function getNonce(address _sender) public view returns (uint256) { - return _nonces[_sender]; - } + function getNonce(address _sender) public view returns (uint256) { + return _nonces[_sender]; + } - function verify(ISwap.SwapRequest memory _swapRequest, bytes memory _signature) public view returns (bool) { - bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(SWAP_TYPEHASH, _swapRequest.pool, _swapRequest.sender, _swapRequest.tokenIn, _swapRequest.tokenOut, _swapRequest.amountIn, _swapRequest.minAmountOut, _swapRequest.nonce, _swapRequest.deadline))); - address signer = digest.recover(_signature); - return signer == _swapRequest.sender; - } + function verify(ISwap.SwapRequest memory _swapRequest, bytes memory _signature) public view returns (bool) { + bytes32 digest = _hashTypedDataV4( + keccak256( + abi.encode( + SWAP_TYPEHASH, + _swapRequest.pool, + _swapRequest.sender, + _swapRequest.tokenIn, + _swapRequest.tokenOut, + _swapRequest.amountIn, + _swapRequest.minAmountOut, + _swapRequest.nonce, + _swapRequest.deadline + ) + ) + ); + address signer = digest.recover(_signature); + return signer == _swapRequest.sender; + } - function executeSwap(ISwap.SwapRequest memory _swapRequest, bytes memory _signature) public returns (bool) { - if (!verify(_swapRequest, _signature)) revert InvalidSignature(); - if (_swapRequest.deadline < block.timestamp) revert ExpiredSwapRequest(); - if (_swapRequest.nonce != _nonces[_swapRequest.sender]) revert InvalidNonce(); + function executeSwap(ISwap.SwapRequest memory _swapRequest, bytes memory _signature) public returns (bool) { + if (!verify(_swapRequest, _signature)) revert InvalidSignature(); + if (_swapRequest.deadline < block.timestamp) revert ExpiredSwapRequest(); + if (_swapRequest.nonce != _nonces[_swapRequest.sender]) revert InvalidNonce(); - _nonces[_swapRequest.sender]++; - LiquidityPool(_swapRequest.pool).swap(_swapRequest.sender, _swapRequest.tokenIn, _swapRequest.tokenOut, _swapRequest.amountIn, _swapRequest.minAmountOut); + _nonces[_swapRequest.sender]++; + LiquidityPool(_swapRequest.pool).swap( + _swapRequest.sender, + _swapRequest.tokenIn, + _swapRequest.tokenOut, + _swapRequest.amountIn, + _swapRequest.minAmountOut + ); - return true; - } -} \ No newline at end of file + return true; + } +} diff --git a/src/FeeManager.sol b/src/FeeManager.sol index fd64206..1fc5307 100644 --- a/src/FeeManager.sol +++ b/src/FeeManager.sol @@ -3,16 +3,15 @@ pragma solidity ^0.8.30; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; - import "./ISwap.sol"; contract FeeManager is Initializable, AccessControlUpgradeable, UUPSUpgradeable { uint256 public constant FEE_DENOMINATOR = 10000; - - uint256 public fee; + uint256 public fee; // Fee in basis points (e.g., 250 = 2.5%) function initialize(uint256 _fee) external initializer { - _disableInitializers(); + __AccessControl_init(); + __UUPSUpgradeable_init(); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); fee = _fee; } @@ -23,7 +22,13 @@ contract FeeManager is Initializable, AccessControlUpgradeable, UUPSUpgradeable fee = _fee; } + /// @notice Calculate absolute fee amount for a swap + /// @param swapParams The swap parameters + /// @return Absolute fee amount in output token units function getFee(ISwap.SwapParams memory swapParams) external view returns (uint256) { - return (swapParams.amount0 * fee) / FEE_DENOMINATOR; + // Calculate fee based on input amount and convert to output token equivalent + uint256 amountOut = + (swapParams.amount0 * swapParams.reserveToken1) / (swapParams.reserveToken0 + swapParams.amount0); + return (amountOut * fee) / FEE_DENOMINATOR; } -} \ No newline at end of file +} diff --git a/src/ISwap.sol b/src/ISwap.sol index 6808882..3290d3e 100644 --- a/src/ISwap.sol +++ b/src/ISwap.sol @@ -1,37 +1,37 @@ pragma solidity ^0.8.30; interface ISwap { - /// @notice Parameters for the swap - /// @param token0 The address of the first token - /// @param token1 The address of the second token - /// @param amount0 The amount of the first token to swap - /// @param reserveToken0 The reserve of the first token - /// @param reserveToken1 The reserve of the second token - struct SwapParams { - address token0; - address token1; - uint256 amount0; - uint256 reserveToken0; - uint256 reserveToken1; - } + /// @notice Parameters for the swap + /// @param token0 The address of the first token + /// @param token1 The address of the second token + /// @param amount0 The amount of the first token to swap + /// @param reserveToken0 The reserve of the first token + /// @param reserveToken1 The reserve of the second token + struct SwapParams { + address token0; + address token1; + uint256 amount0; + uint256 reserveToken0; + uint256 reserveToken1; + } - /// @notice Parameters for the swap request - /// @param pool The address of the pool - /// @param sender The address of the sender - /// @param tokenIn The address of the token to swap in - /// @param tokenOut The address of the token to swap out - /// @param amountIn The amount of the token to swap in - /// @param minAmountOut The minimum amount of the token to swap out - /// @param nonce The nonce of the swap - /// @param deadline The deadline of the swap - struct SwapRequest { - address pool; - address sender; - address tokenIn; - address tokenOut; - uint256 amountIn; - uint256 minAmountOut; - uint256 nonce; - uint256 deadline; - } -} \ No newline at end of file + /// @notice Parameters for the swap request + /// @param pool The address of the pool + /// @param sender The address of the sender + /// @param tokenIn The address of the token to swap in + /// @param tokenOut The address of the token to swap out + /// @param amountIn The amount of the token to swap in + /// @param minAmountOut The minimum amount of the token to swap out + /// @param nonce The nonce of the swap + /// @param deadline The deadline of the swap + struct SwapRequest { + address pool; + address sender; + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + uint256 nonce; + uint256 deadline; + } +} diff --git a/src/LiquidityPool.sol b/src/LiquidityPool.sol index 8a629c5..6626639 100644 --- a/src/LiquidityPool.sol +++ b/src/LiquidityPool.sol @@ -1,137 +1,184 @@ pragma solidity ^0.8.30; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; import "./ISwap.sol"; import "./FeeManager.sol"; import "./EIP712Swap.sol"; - -contract LiquidityPool is ISwap, FeeManager { - address public token0; - uint256 public token0Decimals; - address public token1; - uint256 public token1Decimals; - uint256 public reserveToken0; - uint256 public reserveToken1; - FeeManager public feeManager; - EIP712Swap public eip712Swap; - - // @notice Emitted when liquidity is added to the pool - /// @param _token The token that was added - /// @param _amount The amount of tokens that were added - event LiquidityAdded(address indexed _token, uint256 _amount); - - // @notice Emitted when a swap is executed - /// @param _tokenIn The token that was swapped in - /// @param _tokenOut The token that was swapped out - /// @param _amountIn The amount of tokens that were swapped in - /// @param _amountOut The amount of tokens that were swapped out - event Swap(address indexed _tokenIn, address indexed _tokenOut, uint256 _amountIn, uint256 _amountOut); - - error InsufficientTokenBalance(); - error InvalidTokenAddress(address _token); - error InvalidTokenPair(address _tokenIn, address _tokenOut); - error InsufficientLiquidity(); - error InsufficientOutputAmount(uint256 expected, uint256 actual); - error ExcessiveInputAmount(uint256 expected, uint256 actual); - error InsufficientAllowance(); - - constructor(address _token0, uint256 _token0Decimals, address _token1, uint256 _token1Decimals, address _feeManager, address _eip712Swap) { - token0 = _token0; - token0Decimals = _token0Decimals; - token1 = _token1; - token1Decimals = _token1Decimals; - feeManager = FeeManager(_feeManager); - eip712Swap = EIP712Swap(_eip712Swap); - } - - // @notice Add liquidity to the pool - /// @param _token The token to add liquidity for - /// @param _amount The amount of tokens to add - function addLiquidity(address _token, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (_token == token0 || _token == token1) { - revert InvalidTokenAddress(_token); +import "./Roles.sol"; + +contract LiquidityPool is ISwap, AccessControl { + using Roles for bytes32; + + address public token0; + uint256 public token0Decimals; + address public token1; + uint256 public token1Decimals; + uint256 public reserveToken0; + uint256 public reserveToken1; + FeeManager public feeManager; + EIP712Swap public eip712Swap; + + modifier onlyAdminOrEIP712Swap() { + require( + hasRole(Roles.ADMIN_ROLE, msg.sender) || hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, msg.sender), + "Not authorized for swap operations" + ); + _; } - if (IERC20(_token).balanceOf(address(msg.sender)) < _amount) { - revert InsufficientTokenBalance(); + event LiquidityAdded(address indexed _token, uint256 _amount); + event Swap(address indexed _tokenIn, address indexed _tokenOut, uint256 _amountIn, uint256 _amountOut); + + error InsufficientTokenBalance(); + error InvalidTokenAddress(address _token); + error InvalidTokenPair(address _tokenIn, address _tokenOut); + error InsufficientLiquidity(); + error InsufficientOutputAmount(uint256 expected, uint256 actual); + error InsufficientAllowance(); + + constructor( + address _token0, + uint256 _token0Decimals, + address _token1, + uint256 _token1Decimals, + address _feeManager, + address _eip712Swap + ) { + token0 = _token0; + token0Decimals = _token0Decimals; + token1 = _token1; + token1Decimals = _token1Decimals; + feeManager = FeeManager(_feeManager); + eip712Swap = EIP712Swap(_eip712Swap); + + // Setup roles + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(Roles.ADMIN_ROLE, msg.sender); + + // Grant EIP712Swap contract permission to execute swaps + _grantRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _eip712Swap); + + // Set role admin relationships + _setRoleAdmin(Roles.ADMIN_ROLE, DEFAULT_ADMIN_ROLE); + _setRoleAdmin(Roles.ALLOWED_EIP712_SWAP_ROLE, DEFAULT_ADMIN_ROLE); } - require(IERC20(_token).transferFrom(msg.sender, address(this), _amount)); + /// @notice Add liquidity to the pool (admin only) + function addLiquidity(address _token, uint256 _amount) external onlyRole(Roles.ADMIN_ROLE) { + if (_token != token0 && _token != token1) { + revert InvalidTokenAddress(_token); + } - if (_token == token0) { - reserveToken0 += _amount; - } else if (_token == token1) { - reserveToken1 += _amount; - } + if (IERC20(_token).balanceOf(msg.sender) < _amount) { + revert InsufficientTokenBalance(); + } + + require(IERC20(_token).transferFrom(msg.sender, address(this), _amount)); - emit LiquidityAdded(_token, _amount); - } - - // @notice Get the reserves of the pool - /// @return _reserveToken0 The reserve of token0 - /// @return _reserveToken1 The reserve of token1 - function getReserves() external view returns (uint256 _reserveToken0, uint256 _reserveToken1) { - _reserveToken0 = reserveToken0; - _reserveToken1 = reserveToken1; - } - - /// @notice Get the price of the token in the pool - /// @param _tokenIn The token to get the price of - /// @param _tokenOut The token to get the price of - /// @return _price The price of the token in the pool - function getPrice(address _tokenIn, address _tokenOut) external view returns (uint256 _price) { - uint256 _reserveTokenIn = _tokenIn == token0 ? reserveToken0 : reserveToken1; - uint256 _reserveTokenOut = _tokenOut == token0 ? reserveToken0 : reserveToken1; - uint256 _tokenInDecimals = _tokenIn == token0 ? token0Decimals : token1Decimals; - uint256 _tokenOutDecimals = _tokenOut == token0 ? token0Decimals : token1Decimals; - - _price = (_reserveTokenIn * 10 ** _tokenInDecimals) / (_reserveTokenOut * 10 ** _tokenOutDecimals); - } - - /// @notice Swap tokens in the pool - /// @param _sender The address of the sender - /// @param _tokenIn The token to swap in - /// @param _tokenOut The token to swap out - /// @param _amountIn The amount of tokens to swap in - /// @param _minAmountOut The minimum amount of tokens to swap out - function swap( - address _sender, - address _tokenIn, - address _tokenOut, - uint256 _amountIn, - uint256 _minAmountOut - ) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (_tokenIn != token0 && _tokenIn != token1 || _tokenOut != token0 && _tokenOut != token1 || _tokenIn == _tokenOut) { - revert InvalidTokenPair(_tokenIn, _tokenOut); + if (_token == token0) { + reserveToken0 += _amount; + } else { + reserveToken1 += _amount; + } + + emit LiquidityAdded(_token, _amount); } - address _msgSender = msg.sender == address(eip712Swap) ? _sender : msg.sender; + /// @notice Remove liquidity from the pool (admin only) + function removeLiquidity(address _token, uint256 _amount) external onlyRole(Roles.ADMIN_ROLE) { + if (_token != token0 && _token != token1) { + revert InvalidTokenAddress(_token); + } + + uint256 currentReserve = _token == token0 ? reserveToken0 : reserveToken1; + require(_amount <= currentReserve, "Insufficient reserves"); + + require(IERC20(_token).transfer(msg.sender, _amount)); - if (IERC20(_tokenIn).allowance(_msgSender, address(this)) < _amountIn) revert InsufficientAllowance(); + if (_token == token0) { + reserveToken0 -= _amount; + } else { + reserveToken1 -= _amount; + } + } + + /// @notice Grant EIP712 swap permission to an address + function grantSwapRole(address _swapper) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _swapper); + } - uint256 _reserveTokenIn = _tokenIn == token0 ? reserveToken0 : reserveToken1; - uint256 _reserveTokenOut = _tokenOut == token0 ? reserveToken0 : reserveToken1; - uint256 _tokenInDecimals = _tokenIn == token0 ? token0Decimals : token1Decimals; - uint256 _tokenOutDecimals = _tokenOut == token0 ? token0Decimals : token1Decimals; + /// @notice Revoke EIP712 swap permission from an address + function revokeSwapRole(address _swapper) external onlyRole(DEFAULT_ADMIN_ROLE) { + _revokeRole(Roles.ALLOWED_EIP712_SWAP_ROLE, _swapper); + } - uint256 amountOut = (_amountIn * (10 ** _tokenOutDecimals) * _reserveTokenOut) / (_reserveTokenIn + _amountIn * (10 ** _tokenOutDecimals)); - uint256 _fee = feeManager.getFee(SwapParams(_tokenIn, _tokenOut, _amountIn, _reserveTokenIn, _reserveTokenOut)); - amountOut -= (_fee * (10 ** _tokenOutDecimals)) / (10 ** _tokenInDecimals); + function getReserves() external view returns (uint256 _reserveToken0, uint256 _reserveToken1) { + _reserveToken0 = reserveToken0; + _reserveToken1 = reserveToken1; + } - if (amountOut < _minAmountOut) revert InsufficientOutputAmount(_minAmountOut, amountOut); - if (amountOut > _reserveTokenOut) revert InsufficientLiquidity(); + function getPrice(address _tokenIn, address _tokenOut) external view returns (uint256 _price) { + uint256 _reserveTokenIn = _tokenIn == token0 ? reserveToken0 : reserveToken1; + uint256 _reserveTokenOut = _tokenOut == token0 ? reserveToken0 : reserveToken1; - require(IERC20(_tokenIn).transferFrom(_msgSender, address(this), _amountIn)); - require(IERC20(_tokenOut).transfer(_msgSender, amountOut)); + if (_reserveTokenIn == 0 || _reserveTokenOut == 0) { + return 0; + } - if (_tokenIn == token0) { - reserveToken0 += _amountIn; - reserveToken1 -= amountOut; - } else { - reserveToken1 += _amountIn; - reserveToken0 -= amountOut; + _price = (_reserveTokenOut * 1e18) / _reserveTokenIn; } - emit Swap(_tokenIn, _tokenOut, _amountIn, amountOut); - } -} \ No newline at end of file + /// @notice Execute a swap (admin or authorized EIP712 contract only) + function swap(address _sender, address _tokenIn, address _tokenOut, uint256 _amountIn, uint256 _minAmountOut) + external + onlyAdminOrEIP712Swap + { + if ( + _tokenIn != token0 && _tokenIn != token1 || _tokenOut != token0 && _tokenOut != token1 + || _tokenIn == _tokenOut + ) { + revert InvalidTokenPair(_tokenIn, _tokenOut); + } + + address tokenHolder = _sender; + + if (IERC20(_tokenIn).allowance(tokenHolder, address(this)) < _amountIn) revert InsufficientAllowance(); + + uint256 _reserveTokenIn = _tokenIn == token0 ? reserveToken0 : reserveToken1; + uint256 _reserveTokenOut = _tokenOut == token0 ? reserveToken0 : reserveToken1; + + if (_reserveTokenIn == 0 || _reserveTokenOut == 0) revert InsufficientLiquidity(); + if (_amountIn >= _reserveTokenIn) revert InsufficientLiquidity(); + + // AMM calculation + uint256 amountOut = (_amountIn * _reserveTokenOut) / (_reserveTokenIn + _amountIn); + + // Apply fee using FeeManager + ISwap.SwapParams memory swapParams = ISwap.SwapParams({ + token0: _tokenIn, + token1: _tokenOut, + amount0: _amountIn, + reserveToken0: _reserveTokenIn, + reserveToken1: _reserveTokenOut + }); + + uint256 feeAmount = feeManager.getFee(swapParams); + amountOut = amountOut > feeAmount ? amountOut - feeAmount : 0; + + if (amountOut < _minAmountOut) revert InsufficientOutputAmount(_minAmountOut, amountOut); + + require(IERC20(_tokenIn).transferFrom(tokenHolder, address(this), _amountIn)); + require(IERC20(_tokenOut).transfer(tokenHolder, amountOut)); + + if (_tokenIn == token0) { + reserveToken0 += _amountIn; + reserveToken1 -= amountOut; + } else { + reserveToken1 += _amountIn; + reserveToken0 -= amountOut; + } + + emit Swap(_tokenIn, _tokenOut, _amountIn, amountOut); + } +} diff --git a/src/Roles.sol b/src/Roles.sol index 1b0c7af..e8b3230 100644 --- a/src/Roles.sol +++ b/src/Roles.sol @@ -3,4 +3,5 @@ pragma solidity ^0.8.30; library Roles { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant MULTISIG_ADMIN_ROLE = keccak256("MULTISIG_ADMIN_ROLE"); -} \ No newline at end of file + bytes32 public constant ALLOWED_EIP712_SWAP_ROLE = keccak256("ALLOWED_EIP712_SWAP_ROLE"); +} diff --git a/src/VaultMultisig.sol b/src/VaultMultisig.sol index 6dca6f0..335036d 100644 --- a/src/VaultMultisig.sol +++ b/src/VaultMultisig.sol @@ -9,6 +9,9 @@ contract VaultMultisig { /// @notice The number of transfers executed uint256 public transfersCount; + /// @notice The number of contract operations executed + uint256 public operationsCount; + /// @notice The access manager AccessManager public accessManager; @@ -29,11 +32,30 @@ contract VaultMultisig { mapping(address => bool) approved; } + /// @dev The struct is used to store the details of a contract operation + /// @param target The target contract address + /// @param data The encoded function call data + /// @param description Human readable description + /// @param approvals The number of approvals received + /// @param executed Whether the operation has been executed + /// @param approved The mapping of signers to their approval status + struct Operation { + address target; + bytes data; + string description; + uint256 approvals; + bool executed; + mapping(address => bool) approved; + } + /// @notice The mapping of transfer IDs to transfer details - mapping (uint256 => Transfer) private transfers; + mapping(uint256 => Transfer) private transfers; + + /// @notice The mapping of operation IDs to operation details + mapping(uint256 => Operation) private operations; /// @notice The mapping for verification that address is a signer - mapping (address => bool) private multiSigSigners; + mapping(address => bool) private multiSigSigners; /// @notice Checks that signers array is not empty error SignersArrayCannotBeEmpty(); @@ -60,6 +82,10 @@ contract VaultMultisig { /// @param transferId The ID of the transfer error TransferIsAlreadyExecuted(uint256 transferId); + /// @notice Checks that the operation is not already executed + /// @param operationId The ID of the operation + error OperationIsAlreadyExecuted(uint256 operationId); + /// @notice Checks that the signer is already approved /// @param signer The address of the signer error SignerAlreadyApproved(address signer); @@ -68,13 +94,27 @@ contract VaultMultisig { /// @param transferId The ID of the transfer error TransferFailed(uint256 transferId); + /// @notice Checks that the operation failed + /// @param operationId The ID of the operation + error OperationFailed(uint256 operationId); + /// @notice Checks that quorum was reached for transfer /// @param transferId The ID of the transfer error QuorumHasNotBeenReached(uint256 transferId); + /// @notice Checks that quorum was reached for operation + /// @param operationId The ID of the operation + error OperationQuorumHasNotBeenReached(uint256 operationId); + /// @notice Checks that the signer is a multisig admin error InvalidMultisigAdmin(); + /// @notice Checks that target address is not zero + error InvalidTarget(); + + /// @notice Checks that operation data is not empty + error InvalidOperationData(); + /// @notice Emitted when a transfer is initiated event TransferInitiated(uint256 indexed transferId, address indexed to, uint256 amount); @@ -87,6 +127,18 @@ contract VaultMultisig { /// @param transferId The ID of the transfer event TransferExecuted(uint256 indexed transferId); + /// @notice Emitted when a contract operation is initiated + event OperationInitiated(uint256 indexed operationId, address indexed target, string description); + + /// @notice Emitted when an operation is approved + /// @param operationId The ID of the operation + /// @param approver The address of the approver + event OperationApproved(uint256 indexed operationId, address indexed approver); + + /// @notice Emitted when an operation is executed + /// @param operationId The ID of the operation + event OperationExecuted(uint256 indexed operationId); + /// @notice Emitted when the multisig signers are updated event MultiSigSignersUpdated(); @@ -107,11 +159,7 @@ contract VaultMultisig { /// @notice Initializes the multisig contract /// @param _signers The array of multisig signers /// @param _quorum The number of signatures required to execute a transaction - constructor( - address[] memory _signers, - uint256 _quorum, - address _accessManager - ) { + constructor(address[] memory _signers, uint256 _quorum, address _accessManager) { if (_signers.length == 0) revert SignersArrayCannotBeEmpty(); if (_quorum > _signers.length) revert QuorumGreaterThanSigners(); if (_quorum == 0) revert QuorumCannotBeZero(); @@ -120,6 +168,7 @@ contract VaultMultisig { multiSigSigners[_signers[i]] = true; } + currentMultiSigSigners = _signers; quorum = _quorum; accessManager = AccessManager(_accessManager); } @@ -165,7 +214,7 @@ contract VaultMultisig { Transfer storage transfer = transfers[transferId]; transfer.to = _to; transfer.amount = _amount; - transfer.approvals = 0; + transfer.approvals = 1; // Count initiator transfer.executed = false; transfer.approved[msg.sender] = true; @@ -193,7 +242,7 @@ contract VaultMultisig { uint256 balance = address(this).balance; if (transfer.amount >= balance) revert InsufficientBalance(balance, transfer.amount); - (bool success, ) = transfer.to.call{value: transfer.amount}(""); + (bool success,) = transfer.to.call{value: transfer.amount}(""); if (!success) revert TransferFailed(_transferId); transfer.executed = true; @@ -201,6 +250,57 @@ contract VaultMultisig { emit TransferExecuted(_transferId); } + /// @notice Initiates a contract operation (e.g., LiquidityPool function call) + /// @param _target The target contract address + /// @param _data The encoded function call data + /// @param _description Human readable description of the operation + function initiateOperation(address _target, bytes memory _data, string memory _description) + public + onlyMultisigSigner + { + if (_target == address(0)) revert InvalidTarget(); + if (_data.length == 0) revert InvalidOperationData(); + + uint256 operationId = operationsCount++; + Operation storage operation = operations[operationId]; + operation.target = _target; + operation.data = _data; + operation.description = _description; + operation.approvals = 1; // Count initiator + operation.executed = false; + operation.approved[msg.sender] = true; + + emit OperationInitiated(operationId, _target, _description); + } + + /// @notice Approves a contract operation + /// @param _operationId The ID of the operation + function approveOperation(uint256 _operationId) external onlyMultisigSigner { + Operation storage operation = operations[_operationId]; + if (operation.executed) revert OperationIsAlreadyExecuted(_operationId); + if (operation.approved[msg.sender]) revert SignerAlreadyApproved(msg.sender); + + operation.approvals++; + operation.approved[msg.sender] = true; + + emit OperationApproved(_operationId, msg.sender); + } + + /// @notice Executes a contract operation + /// @param _operationId The ID of the operation + function executeOperation(uint256 _operationId) external onlyMultisigSigner { + Operation storage operation = operations[_operationId]; + if (operation.approvals < quorum) revert OperationQuorumHasNotBeenReached(_operationId); + if (operation.executed) revert OperationIsAlreadyExecuted(_operationId); + + (bool success,) = operation.target.call(operation.data); + if (!success) revert OperationFailed(_operationId); + + operation.executed = true; + + emit OperationExecuted(_operationId); + } + /// @notice Default fallback function for receiving ETH receive() external payable {} @@ -210,16 +310,31 @@ contract VaultMultisig { /// @return amount The amount of tokens to transfer /// @return approvals The number of approvals required to execute the transfer /// @return executed Whether the transfer has been executed - function getTransfer(uint256 _transferId) external view returns ( - address to, - uint256 amount, - uint256 approvals, - bool executed - ) { + function getTransfer(uint256 _transferId) + external + view + returns (address to, uint256 amount, uint256 approvals, bool executed) + { Transfer storage transfer = transfers[_transferId]; return (transfer.to, transfer.amount, transfer.approvals, transfer.executed); } + /// @notice Gets the details of an operation + /// @param _operationId The ID of the operation + /// @return target The target contract address + /// @return data The encoded function call data + /// @return description The operation description + /// @return approvals The number of approvals received + /// @return executed Whether the operation has been executed + function getOperation(uint256 _operationId) + external + view + returns (address target, bytes memory data, string memory description, uint256 approvals, bool executed) + { + Operation storage operation = operations[_operationId]; + return (operation.target, operation.data, operation.description, operation.approvals, operation.executed); + } + /// @notice Checks if a signer has signed a transfer /// @param _transferId The ID of the transfer /// @param _signer The address of the signer @@ -229,9 +344,24 @@ contract VaultMultisig { return transfer.approved[_signer]; } + /// @notice Checks if a signer has signed an operation + /// @param _operationId The ID of the operation + /// @param _signer The address of the signer + /// @return hasSigned Whether the signer has signed the operation + function hasSignedOperation(uint256 _operationId, address _signer) external view returns (bool) { + Operation storage operation = operations[_operationId]; + return operation.approved[_signer]; + } + /// @notice Gets the number of transfers /// @return The number of transfers function getTransferCount() external view returns (uint256) { return transfersCount; } -} \ No newline at end of file + + /// @notice Gets the number of operations + /// @return The number of operations + function getOperationCount() public view returns (uint256) { + return operationsCount; + } +} diff --git a/test/AccessManager.t.sol b/test/AccessManager.t.sol new file mode 100644 index 0000000..27023c2 --- /dev/null +++ b/test/AccessManager.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "../src/AccessManager.sol"; +import "../src/Roles.sol"; + +contract AccessManagerTest is Test { + AccessManager public accessManager; + address public admin; + address public multisigAdmin1; + address public eip712Swapper1; + address public nonAdmin; + + function setUp() public { + admin = makeAddr("admin"); + multisigAdmin1 = makeAddr("multisigAdmin1"); + eip712Swapper1 = makeAddr("eip712Swapper1"); + nonAdmin = makeAddr("nonAdmin"); + + vm.prank(admin); + accessManager = new AccessManager(); + } + + function test_InitialState() public view { + // Admin should have DEFAULT_ADMIN_ROLE + assertTrue(accessManager.hasRole(accessManager.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_AddAndRemoveAdmin() public { + vm.startPrank(admin); + + // Add admin + accessManager.addAdmin(multisigAdmin1); + assertTrue(accessManager.isAdmin(multisigAdmin1)); + assertTrue(accessManager.hasRole(Roles.ADMIN_ROLE, multisigAdmin1)); + + // Remove admin + accessManager.removeAdmin(multisigAdmin1); + assertFalse(accessManager.isAdmin(multisigAdmin1)); + assertFalse(accessManager.hasRole(Roles.ADMIN_ROLE, multisigAdmin1)); + + vm.stopPrank(); + } + + function test_AddAndRemoveMultisigAdmin() public { + vm.startPrank(admin); + + // Add multisig admin + accessManager.addMultisigAdmin(multisigAdmin1); + assertTrue(accessManager.isMultisigAdmin(multisigAdmin1)); + assertTrue(accessManager.hasRole(Roles.MULTISIG_ADMIN_ROLE, multisigAdmin1)); + + // Remove multisig admin + accessManager.removeMultisigAdmin(multisigAdmin1); + assertFalse(accessManager.isMultisigAdmin(multisigAdmin1)); + assertFalse(accessManager.hasRole(Roles.MULTISIG_ADMIN_ROLE, multisigAdmin1)); + + vm.stopPrank(); + } + + function test_AddAndRemoveEIP712Swapper() public { + vm.startPrank(admin); + + // Add EIP712 swapper + accessManager.addEIP712Swapper(eip712Swapper1); + assertTrue(accessManager.isEIP712Swapper(eip712Swapper1)); + assertTrue(accessManager.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, eip712Swapper1)); + + // Remove EIP712 swapper + accessManager.removeEIP712Swapper(eip712Swapper1); + assertFalse(accessManager.isEIP712Swapper(eip712Swapper1)); + assertFalse(accessManager.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, eip712Swapper1)); + + vm.stopPrank(); + } + + function test_RevertWhen_NotAuthorized() public { + vm.startPrank(nonAdmin); + + vm.expectRevert(); + accessManager.addAdmin(multisigAdmin1); + + vm.expectRevert(); + accessManager.addMultisigAdmin(multisigAdmin1); + + vm.expectRevert(); + accessManager.addEIP712Swapper(eip712Swapper1); + + vm.stopPrank(); + } +} diff --git a/test/EIP712Swap.t.sol b/test/EIP712Swap.t.sol new file mode 100644 index 0000000..91fbaac --- /dev/null +++ b/test/EIP712Swap.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "../src/Roles.sol"; +import "../src/EIP712Swap.sol"; +import "../src/LiquidityPool.sol"; +import "../src/FeeManager.sol"; +import "../src/ISwap.sol"; +import "./MockERC20.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract EIP712SwapTest is Test { + EIP712Swap eip712Swap; + FeeManager feeManager; + LiquidityPool pool; + address poolAddress; + address user; + uint256 userPrivateKey; + MockERC20 token0; + MockERC20 token1; + + function setUp() public { + userPrivateKey = 0x1234; + user = vm.addr(userPrivateKey); + + // Deploy contracts + eip712Swap = new EIP712Swap(); + + // Deploy FeeManager with proxy + FeeManager feeManagerImpl = new FeeManager(); + bytes memory feeManagerInitData = abi.encodeWithSignature("initialize(uint256)", 250); + ERC1967Proxy feeManagerProxy = new ERC1967Proxy(address(feeManagerImpl), feeManagerInitData); + feeManager = FeeManager(address(feeManagerProxy)); + + // Deploy mock tokens + token0 = new MockERC20("Token0", "TK0", 18, 1000000e18); + token1 = new MockERC20("Token1", "TK1", 18, 1000000e18); + + // Deploy pool + pool = new LiquidityPool(address(token0), 18, address(token1), 18, address(feeManager), address(eip712Swap)); + poolAddress = address(pool); + + // The pool constructor should have already granted ALLOWED_EIP712_SWAP_ROLE to eip712Swap + // But let's verify it's set up correctly + assertTrue(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, address(eip712Swap))); + + // Setup tokens for user + token0.mint(user, 10000e18); + token1.mint(user, 10000e18); + + vm.startPrank(user); + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + vm.stopPrank(); + + // Admin needs tokens to add liquidity + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + } + + function test_ExecuteSwap_Success() public { + // First, add liquidity to the pool so swaps can work + pool.addLiquidity(address(token0), 10000e18); + pool.addLiquidity(address(token1), 10000e18); + + // Check initial balances + uint256 userToken0Before = token0.balanceOf(user); + uint256 userToken1Before = token1.balanceOf(user); + uint256 initialNonce = eip712Swap.getNonce(user); + + // Create a valid swap request + ISwap.SwapRequest memory swapRequest = ISwap.SwapRequest({ + pool: poolAddress, + sender: user, + tokenIn: address(token0), + tokenOut: address(token1), + amountIn: 100e18, + minAmountOut: 0, // Set to 0 to avoid slippage issues + nonce: initialNonce, + deadline: block.timestamp + 1 hours + }); + + // Sign the request + bytes memory signature = _signSwapRequest(swapRequest, userPrivateKey); + + // Verify the signature is valid + assertTrue(eip712Swap.verify(swapRequest, signature)); + + // Execute the swap + bool success = eip712Swap.executeSwap(swapRequest, signature); + assertTrue(success); + + // Verify the swap worked + assertEq(token0.balanceOf(user), userToken0Before - 100e18); // User lost token0 + assertGt(token1.balanceOf(user), userToken1Before); // User gained token1 + assertEq(eip712Swap.getNonce(user), initialNonce + 1); // Nonce incremented + } + + function test_ExecuteSwap_MultipleSwaps() public { + // Add liquidity + pool.addLiquidity(address(token0), 10000e18); + pool.addLiquidity(address(token1), 10000e18); + + // Execute first swap + ISwap.SwapRequest memory firstSwap = ISwap.SwapRequest({ + pool: poolAddress, + sender: user, + tokenIn: address(token0), + tokenOut: address(token1), + amountIn: 100e18, + minAmountOut: 0, + nonce: 0, + deadline: block.timestamp + 1 hours + }); + + bytes memory firstSignature = _signSwapRequest(firstSwap, userPrivateKey); + assertTrue(eip712Swap.executeSwap(firstSwap, firstSignature)); + assertEq(eip712Swap.getNonce(user), 1); + + // Execute second swap with incremented nonce + ISwap.SwapRequest memory secondSwap = ISwap.SwapRequest({ + pool: poolAddress, + sender: user, + tokenIn: address(token1), + tokenOut: address(token0), + amountIn: 50e18, + minAmountOut: 0, + nonce: 1, // Incremented nonce + deadline: block.timestamp + 1 hours + }); + + bytes memory secondSignature = _signSwapRequest(secondSwap, userPrivateKey); + assertTrue(eip712Swap.executeSwap(secondSwap, secondSignature)); + assertEq(eip712Swap.getNonce(user), 2); + } + + function test_ExecuteSwap_RevertWhen_InsufficientLiquidity() public { + // Don't add liquidity - pool should be empty + + ISwap.SwapRequest memory swapRequest = ISwap.SwapRequest({ + pool: poolAddress, + sender: user, + tokenIn: address(token0), + tokenOut: address(token1), + amountIn: 100e18, + minAmountOut: 0, + nonce: 0, + deadline: block.timestamp + 1 hours + }); + + bytes memory signature = _signSwapRequest(swapRequest, userPrivateKey); + + // Should revert due to insufficient liquidity in the pool + vm.expectRevert(LiquidityPool.InsufficientLiquidity.selector); + eip712Swap.executeSwap(swapRequest, signature); + + // Nonce should not increment on failed swap + assertEq(eip712Swap.getNonce(user), 0); + } + + function _signSwapRequest(ISwap.SwapRequest memory swapRequest, uint256 privateKey) + internal + view + returns (bytes memory) + { + bytes32 structHash = keccak256( + abi.encode( + keccak256( + "SwapRequest(address pool,address sender,address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,uint256 nonce,uint256 deadline)" + ), + swapRequest.pool, + swapRequest.sender, + swapRequest.tokenIn, + swapRequest.tokenOut, + swapRequest.amountIn, + swapRequest.minAmountOut, + swapRequest.nonce, + swapRequest.deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", eip712Swap.getDomainSeparator(), structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, hash); + return abi.encodePacked(r, s, v); + } +} diff --git a/test/FeeManager.t.sol b/test/FeeManager.t.sol new file mode 100644 index 0000000..c81245a --- /dev/null +++ b/test/FeeManager.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/FeeManager.sol"; +import "../src/ISwap.sol"; + +contract FeeManagerTest is Test { + FeeManager public feeManager; + address public admin; + address public nonAdmin; + + function setUp() public { + admin = address(this); + nonAdmin = makeAddr("nonAdmin"); + + FeeManager implementation = new FeeManager(); + bytes memory initData = abi.encodeWithSignature("initialize(uint256)", 250); + ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData); + feeManager = FeeManager(address(proxy)); + } + + function test_Initialize() public view { + assertEq(feeManager.fee(), 250); + assertEq(feeManager.FEE_DENOMINATOR(), 10000); + assertTrue(feeManager.hasRole(feeManager.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_SetFee() public { + feeManager.setFee(500); + assertEq(feeManager.fee(), 500); + } + + function test_SetFee_RevertWhen_NotAdmin() public { + vm.prank(nonAdmin); + vm.expectRevert(); + feeManager.setFee(500); + } + + function test_GetFee() public view { + ISwap.SwapParams memory params = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 1000e18, + reserveToken0: 10000e18, + reserveToken1: 20000e6 + }); + + uint256 fee = feeManager.getFee(params); + assertGt(fee, 0); + + // Test that fee is reasonable (should be less than the input amount) + assertLt(fee, params.amount0); + } + + function test_GetFee_WithDifferentAmounts() public view { + ISwap.SwapParams memory params = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 500e18, + reserveToken0: 5000e18, + reserveToken1: 10000e6 + }); + + uint256 fee = feeManager.getFee(params); + assertGt(fee, 0); + } + + function test_GetFee_ZeroAmount() public view { + ISwap.SwapParams memory params = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 0, + reserveToken0: 10000e18, + reserveToken1: 20000e6 + }); + + uint256 fee = feeManager.getFee(params); + assertEq(fee, 0); + } + + function test_FeeCalculationWithSimpleNumbers() public view { + // Use numbers that result in clean division + ISwap.SwapParams memory params = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 1000, + reserveToken0: 10000, + reserveToken1: 10000 + }); + + uint256 fee = feeManager.getFee(params); + + // Just verify fee is calculated (should be > 0 for non-zero input) + assertGt(fee, 0); + + // Verify fee is proportional to fee rate (250 basis points = 2.5%) + // Fee should be roughly 2.5% of the calculated output amount + assertLt(fee, 1000); // Should be much less than input + } + + function test_FeeScalesWithAmount() public view { + // Test that larger amounts result in larger fees + ISwap.SwapParams memory smallParams = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 100e18, + reserveToken0: 10000e18, + reserveToken1: 10000e6 + }); + + ISwap.SwapParams memory largeParams = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 1000e18, // 10x larger + reserveToken0: 10000e18, + reserveToken1: 10000e6 + }); + + uint256 smallFee = feeManager.getFee(smallParams); + uint256 largeFee = feeManager.getFee(largeParams); + + assertGt(largeFee, smallFee); + } + + function test_FeeWithDifferentRates() public { + // Test different fee rates + feeManager.setFee(500); // 5% + + ISwap.SwapParams memory params = ISwap.SwapParams({ + token0: address(0x1), + token1: address(0x2), + amount0: 1000e18, + reserveToken0: 10000e18, + reserveToken1: 10000e6 + }); + + uint256 feeAt5Percent = feeManager.getFee(params); + + // Change to 1% + feeManager.setFee(100); + uint256 feeAt1Percent = feeManager.getFee(params); + + // Higher rate should result in higher fee + assertGt(feeAt5Percent, feeAt1Percent); + } +} diff --git a/test/LiquidityPool.t.sol b/test/LiquidityPool.t.sol new file mode 100644 index 0000000..a0acd19 --- /dev/null +++ b/test/LiquidityPool.t.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/LiquidityPool.sol"; +import "../src/FeeManager.sol"; +import "../src/EIP712Swap.sol"; +import "../src/Roles.sol"; // Import Roles library +import "./MockERC20.sol"; + +contract LiquidityPoolTest is Test { + LiquidityPool public pool; + FeeManager public feeManager; + EIP712Swap public eip712Swap; + MockERC20 public token0; + MockERC20 public token1; + + address public admin; + address public user; + + function setUp() public { + admin = address(this); + user = makeAddr("user"); + + // Deploy FeeManager with proxy + FeeManager feeManagerImpl = new FeeManager(); + bytes memory initData = abi.encodeWithSignature("initialize(uint256)", 250); + ERC1967Proxy feeManagerProxy = new ERC1967Proxy(address(feeManagerImpl), initData); + feeManager = FeeManager(address(feeManagerProxy)); + + eip712Swap = new EIP712Swap(); + + // Deploy tokens + token0 = new MockERC20("Token0", "TK0", 18, 1000000e18); + token1 = new MockERC20("Token1", "TK1", 6, 1000000e6); + + // Deploy pool + pool = new LiquidityPool(address(token0), 18, address(token1), 6, address(feeManager), address(eip712Swap)); + + // Setup user tokens + token0.mint(user, 10000e18); + token1.mint(user, 10000e6); + + vm.startPrank(user); + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + vm.stopPrank(); + + // Admin approvals + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + } + + function test_RoleBasedAccess() public view { + // Admin should have ADMIN_ROLE - use Roles library + assertTrue(pool.hasRole(Roles.ADMIN_ROLE, admin)); + + // EIP712Swap should have ALLOWED_EIP712_SWAP_ROLE - use Roles library + assertTrue(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, address(eip712Swap))); + + // User should have no roles + assertFalse(pool.hasRole(Roles.ADMIN_ROLE, user)); + assertFalse(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, user)); + } + + function test_AddLiquidity() public { + pool.addLiquidity(address(token0), 1000e18); + assertEq(pool.reserveToken0(), 1000e18); + } + + function test_AddLiquidity_RevertWhen_InvalidToken() public { + MockERC20 invalidToken = new MockERC20("Invalid", "INV", 18, 1000e18); + vm.expectRevert(abi.encodeWithSelector(LiquidityPool.InvalidTokenAddress.selector, address(invalidToken))); + pool.addLiquidity(address(invalidToken), 1000e18); + } + + function test_AddLiquidity_RevertWhen_NotAdmin() public { + vm.prank(user); + vm.expectRevert(); + pool.addLiquidity(address(token0), 1000e18); + } + + function test_Swap() public { + // Add liquidity + pool.addLiquidity(address(token0), 10000e18); + pool.addLiquidity(address(token1), 20000e6); + + uint256 userBalanceBefore = token0.balanceOf(user); + + pool.swap(user, address(token0), address(token1), 100e18, 0); + + assertEq(token0.balanceOf(user), userBalanceBefore - 100e18); + assertGt(token1.balanceOf(user), 10000e6); // Should receive some token1 + } + + function test_EIP712SwapCanExecuteSwap() public { + // Add liquidity + pool.addLiquidity(address(token0), 10000e18); + pool.addLiquidity(address(token1), 20000e6); + + uint256 userBalanceBefore = token0.balanceOf(user); + + // EIP712Swap contract can call swap because it has ALLOWED_EIP712_SWAP_ROLE + vm.prank(address(eip712Swap)); + pool.swap(user, address(token0), address(token1), 100e18, 0); + + assertEq(token0.balanceOf(user), userBalanceBefore - 100e18); + } + + function test_GrantAndRevokeSwapRole() public { + address newSwapper = makeAddr("newSwapper"); + + // Initially should not have role + assertFalse(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, newSwapper)); + + // Grant role + pool.grantSwapRole(newSwapper); + assertTrue(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, newSwapper)); + + // Revoke role + pool.revokeSwapRole(newSwapper); + assertFalse(pool.hasRole(Roles.ALLOWED_EIP712_SWAP_ROLE, newSwapper)); + } + + function test_OnlyAuthorizedCanSwap() public { + pool.addLiquidity(address(token0), 10000e18); + pool.addLiquidity(address(token1), 20000e6); + + // Random user cannot call swap + vm.prank(user); + vm.expectRevert("Not authorized for swap operations"); + pool.swap(user, address(token0), address(token1), 100e18, 0); + } + + function test_RemoveLiquidity() public { + // Add liquidity first + pool.addLiquidity(address(token0), 1000e18); + assertEq(pool.reserveToken0(), 1000e18); + + uint256 balanceBefore = token0.balanceOf(admin); + + // Remove some liquidity + pool.removeLiquidity(address(token0), 500e18); + + assertEq(pool.reserveToken0(), 500e18); + assertEq(token0.balanceOf(admin), balanceBefore + 500e18); + } + + function test_Swap_RevertWhen_InsufficientLiquidity() public { + pool.addLiquidity(address(token0), 100e18); + pool.addLiquidity(address(token1), 50e6); + + vm.expectRevert(LiquidityPool.InsufficientLiquidity.selector); + pool.swap(user, address(token0), address(token1), 100e18, 0); // 100% of reserves + } + + function test_Swap_RevertWhen_InvalidTokenPair() public { + MockERC20 invalidToken = new MockERC20("Invalid", "INV", 18, 1000e18); + vm.expectRevert(); + pool.swap(user, address(invalidToken), address(token1), 100e18, 1); + } + + function test_GetPrice() public { + pool.addLiquidity(address(token0), 1000e18); + pool.addLiquidity(address(token1), 2000e6); + + uint256 price = pool.getPrice(address(token0), address(token1)); + assertGt(price, 0); + } +} diff --git a/test/MockERC20.sol b/test/MockERC20.sol new file mode 100644 index 0000000..b9fe5da --- /dev/null +++ b/test/MockERC20.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + uint8 private _decimals; + + constructor(string memory name, string memory symbol, uint8 decimals_, uint256 totalSupply) ERC20(name, symbol) { + _decimals = decimals_; + _mint(msg.sender, totalSupply); + } + + function decimals() public view virtual override returns (uint8) { + return _decimals; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } +} diff --git a/test/Roles.t.sol b/test/Roles.t.sol new file mode 100644 index 0000000..7ca409e --- /dev/null +++ b/test/Roles.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "../src/Roles.sol"; + +contract RolesTest is Test { + function test_RoleConstants() public pure { + // Test that role constants are properly defined and unique + assertNotEq(Roles.ADMIN_ROLE, Roles.MULTISIG_ADMIN_ROLE); + assertNotEq(Roles.ADMIN_ROLE, Roles.ALLOWED_EIP712_SWAP_ROLE); + assertNotEq(Roles.MULTISIG_ADMIN_ROLE, Roles.ALLOWED_EIP712_SWAP_ROLE); + + // Test that roles have expected values (keccak256 hashes) + assertEq(Roles.ADMIN_ROLE, keccak256("ADMIN_ROLE")); + assertEq(Roles.MULTISIG_ADMIN_ROLE, keccak256("MULTISIG_ADMIN_ROLE")); + assertEq(Roles.ALLOWED_EIP712_SWAP_ROLE, keccak256("ALLOWED_EIP712_SWAP_ROLE")); + } + + function test_RoleUniqueness() public pure { + assertTrue(Roles.ADMIN_ROLE != Roles.MULTISIG_ADMIN_ROLE); + } +} diff --git a/test/VaultMultisig.t.sol b/test/VaultMultisig.t.sol new file mode 100644 index 0000000..a2a3b28 --- /dev/null +++ b/test/VaultMultisig.t.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "../src/VaultMultisig.sol"; +import "../src/AccessManager.sol"; + +contract VaultMultisigTest is Test { + VaultMultisig public vault; + AccessManager public accessManager; + + address public admin; + address public multisigAdmin; + address public signer1; + address public signer2; + address public signer3; + address public nonSigner; + address public recipient; + + address[] public signers; + uint256 public constant QUORUM = 2; + + event TransferInitiated(uint256 indexed transferId, address indexed to, uint256 amount); + event TransferApproved(uint256 indexed transferId, address indexed approver); + event TransferExecuted(uint256 indexed transferId); + event MultiSigSignersUpdated(); + event QuorumUpdated(uint256 quorum); + + function setUp() public { + admin = makeAddr("admin"); + multisigAdmin = makeAddr("multisigAdmin"); + signer1 = makeAddr("signer1"); + signer2 = makeAddr("signer2"); + signer3 = makeAddr("signer3"); + nonSigner = makeAddr("nonSigner"); + recipient = makeAddr("recipient"); + + // Setup access manager + vm.prank(admin); + accessManager = new AccessManager(); + + vm.prank(admin); + accessManager.addMultisigAdmin(multisigAdmin); + + // Setup signers array + signers.push(signer1); + signers.push(signer2); + signers.push(signer3); + + // Deploy vault + vault = new VaultMultisig(signers, QUORUM, address(accessManager)); + + // Fund the vault + vm.deal(address(vault), 10 ether); + } + + function test_InitialState() public view { + assertEq(vault.quorum(), QUORUM); + assertEq(vault.transfersCount(), 0); + assertEq(address(vault.accessManager()), address(accessManager)); + assertEq(address(vault).balance, 10 ether); + } + + function test_Constructor_RevertWhen_EmptySignersArray() public { + address[] memory emptySigners = new address[](0); + + vm.expectRevert(VaultMultisig.SignersArrayCannotBeEmpty.selector); + new VaultMultisig(emptySigners, QUORUM, address(accessManager)); + } + + function test_Constructor_RevertWhen_QuorumGreaterThanSigners() public { + vm.expectRevert(VaultMultisig.QuorumGreaterThanSigners.selector); + new VaultMultisig(signers, 4, address(accessManager)); + } + + function test_Constructor_RevertWhen_QuorumIsZero() public { + vm.expectRevert(VaultMultisig.QuorumCannotBeZero.selector); + new VaultMultisig(signers, 0, address(accessManager)); + } + + function test_InitiateTransfer() public { + uint256 amount = 1 ether; + + vm.prank(signer1); + vm.expectEmit(true, true, false, true); + emit TransferInitiated(0, recipient, amount); + vault.initiateTransfer(recipient, amount); + + assertEq(vault.transfersCount(), 1); + + (address to, uint256 transferAmount, uint256 approvals, bool executed) = vault.getTransfer(0); + assertEq(to, recipient); + assertEq(transferAmount, amount); + assertEq(approvals, 1); // Was 0, now 1 because initiator counts + assertFalse(executed); + assertTrue(vault.hasSignedTransfer(0, signer1)); + } + + function test_InitiateTransfer_RevertWhen_NotSigner() public { + vm.prank(nonSigner); + vm.expectRevert(VaultMultisig.InvalidMultisigSigner.selector); + vault.initiateTransfer(recipient, 1 ether); + } + + function test_InitiateTransfer_RevertWhen_InvalidRecipient() public { + vm.prank(signer1); + vm.expectRevert(VaultMultisig.InvalidRecipient.selector); + vault.initiateTransfer(address(0), 1 ether); + } + + function test_InitiateTransfer_RevertWhen_InvalidAmount() public { + vm.prank(signer1); + vm.expectRevert(VaultMultisig.InvalidAmount.selector); + vault.initiateTransfer(recipient, 0); + } + + function test_ApproveTransfer() public { + // First initiate a transfer + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + // Approve by another signer + vm.prank(signer2); + vm.expectEmit(true, true, false, false); + emit TransferApproved(0, signer2); + vault.approveTransfer(0); + + (,, uint256 approvals,) = vault.getTransfer(0); + assertEq(approvals, 2); // Was 1, now 2 (initiator + one approval) + assertTrue(vault.hasSignedTransfer(0, signer2)); + } + + function test_ApproveTransfer_RevertWhen_NotSigner() public { + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + vm.prank(nonSigner); + vm.expectRevert(VaultMultisig.InvalidMultisigSigner.selector); + vault.approveTransfer(0); + } + + function test_ApproveTransfer_RevertWhen_AlreadyApproved() public { + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + vm.prank(signer1); + vm.expectRevert(abi.encodeWithSelector(VaultMultisig.SignerAlreadyApproved.selector, signer1)); + vault.approveTransfer(0); + } + + function test_ApproveTransfer_RevertWhen_AlreadyExecuted() public { + // Create and execute a transfer + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + vm.prank(signer2); + vault.approveTransfer(0); + + vm.prank(signer3); + vault.executeTransfer(0); + + // Try to approve executed transfer + vm.prank(signer2); + vm.expectRevert(abi.encodeWithSelector(VaultMultisig.TransferIsAlreadyExecuted.selector, 0)); + vault.approveTransfer(0); + } + + function test_ExecuteTransfer() public { + uint256 amount = 1 ether; + uint256 recipientBalanceBefore = recipient.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + // Initiate transfer + vm.prank(signer1); + vault.initiateTransfer(recipient, amount); + + // Get one more approval to reach quorum + vm.prank(signer2); + vault.approveTransfer(0); + + // Execute transfer + vm.prank(signer3); + vm.expectEmit(true, false, false, false); + emit TransferExecuted(0); + vault.executeTransfer(0); + + // Verify transfer + (,,, bool executed) = vault.getTransfer(0); + assertTrue(executed); + assertEq(recipient.balance, recipientBalanceBefore + amount); + assertEq(address(vault).balance, vaultBalanceBefore - amount); + } + + function test_ExecuteTransfer_RevertWhen_QuorumNotReached() public { + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + // Initiator counts as 1 approval, need 2 total for quorum, so 1 more needed + // Try to execute with only initiator approval (should fail) + vm.prank(signer1); + vm.expectRevert(abi.encodeWithSelector(VaultMultisig.QuorumHasNotBeenReached.selector, 0)); + vault.executeTransfer(0); + } + + function test_ExecuteTransfer_RevertWhen_InsufficientBalance() public { + uint256 amount = 15 ether; // More than vault balance + + vm.prank(signer1); + vault.initiateTransfer(recipient, amount); + + vm.prank(signer2); + vault.approveTransfer(0); + + vm.prank(signer3); + vm.expectRevert(abi.encodeWithSelector(VaultMultisig.InsufficientBalance.selector, 10 ether, amount)); + vault.executeTransfer(0); + } + + function test_ExecuteTransfer_RevertWhen_AlreadyExecuted() public { + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + + vm.prank(signer2); + vault.approveTransfer(0); + + vm.prank(signer3); + vault.executeTransfer(0); + + // Try to execute again + vm.prank(signer1); + vm.expectRevert(abi.encodeWithSelector(VaultMultisig.TransferIsAlreadyExecuted.selector, 0)); + vault.executeTransfer(0); + } + + function test_UpdateSigners() public { + // First set current signers via constructor, then update + address[] memory newSigners = new address[](2); + newSigners[0] = makeAddr("newSigner1"); + newSigners[1] = makeAddr("newSigner2"); + + vm.prank(multisigAdmin); + vault.updateSigners(newSigners); + // This should succeed, not revert + } + + function test_UpdateSigners_RevertWhen_NotMultisigAdmin() public { + address[] memory newSigners = new address[](2); + newSigners[0] = makeAddr("newSigner1"); + newSigners[1] = makeAddr("newSigner2"); + + vm.prank(signer1); + vm.expectRevert(VaultMultisig.InvalidMultisigAdmin.selector); + vault.updateSigners(newSigners); + } + + function test_UpdateSigners_RevertWhen_EmptyArray() public { + address[] memory emptySigners = new address[](0); + + vm.prank(multisigAdmin); + vm.expectRevert(VaultMultisig.SignersArrayCannotBeEmpty.selector); + vault.updateSigners(emptySigners); + } + + function test_UpdateSigners_RevertWhen_LessThanQuorum() public { + address[] memory newSigners = new address[](1); // Less than current quorum of 2 + newSigners[0] = makeAddr("newSigner1"); + + vm.prank(multisigAdmin); + vm.expectRevert(VaultMultisig.QuorumGreaterThanSigners.selector); + vault.updateSigners(newSigners); + } + + function test_UpdateQuorum() public { + uint256 newQuorum = 3; + + vm.prank(multisigAdmin); + vault.updateQuorum(newQuorum); + + assertEq(vault.quorum(), newQuorum); + } + + function test_UpdateQuorum_RevertWhen_NotMultisigAdmin() public { + vm.prank(signer1); + vm.expectRevert(VaultMultisig.InvalidMultisigAdmin.selector); + vault.updateQuorum(3); + } + + function test_UpdateQuorum_RevertWhen_GreaterThanSigners() public { + vm.prank(multisigAdmin); + vm.expectRevert(VaultMultisig.QuorumGreaterThanSigners.selector); + vault.updateQuorum(4); // More than 3 signers + } + + function test_UpdateQuorum_RevertWhen_Zero() public { + vm.prank(multisigAdmin); + vm.expectRevert(VaultMultisig.QuorumCannotBeZero.selector); + vault.updateQuorum(0); + } + + function test_GetTransferCount() public { + assertEq(vault.getTransferCount(), 0); + + vm.prank(signer1); + vault.initiateTransfer(recipient, 1 ether); + assertEq(vault.getTransferCount(), 1); + + vm.prank(signer2); + vault.initiateTransfer(recipient, 2 ether); + assertEq(vault.getTransferCount(), 2); + } + + function test_ReceiveEther() public { + uint256 balanceBefore = address(vault).balance; + + vm.deal(admin, 5 ether); + vm.prank(admin); + (bool success,) = address(vault).call{value: 5 ether}(""); + + assertTrue(success); + assertEq(address(vault).balance, balanceBefore + 5 ether); + } + + function test_CompleteWorkflow() public { + uint256 amount = 2 ether; + + // 1. Initiate transfer + vm.prank(signer1); + vault.initiateTransfer(recipient, amount); + + // 2. Get one approval + vm.prank(signer2); + vault.approveTransfer(0); + + // 3. Verify state before execution + (address to, uint256 transferAmount, uint256 approvals, bool executed) = vault.getTransfer(0); + assertEq(to, recipient); + assertEq(transferAmount, amount); + assertEq(approvals, 2); // Was 1, now 2 + assertFalse(executed); + + // 4. Execute transfer + vm.prank(signer3); + vault.executeTransfer(0); + + // 5. Verify final state + (,, approvals, executed) = vault.getTransfer(0); + assertTrue(executed); + assertEq(recipient.balance, amount); + } +} From 218d89892648c9830b84f66c93e3fc9894544f04 Mon Sep 17 00:00:00 2001 From: nicknotknack Date: Sat, 15 Nov 2025 17:27:25 +0400 Subject: [PATCH 2/5] docs: add comprehensive documentation --- DOCUMENTATION.md | 925 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 925 insertions(+) create mode 100644 DOCUMENTATION.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..f496ee3 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,925 @@ +# Smart Modules - Comprehensive Documentation + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Architecture](#architecture) +3. [Core Components](#core-components) +4. [Component Details](#component-details) +5. [Integration & Workflows](#integration--workflows) +6. [Security Model](#security-model) +7. [Use Cases](#use-cases) +8. [Technical Implementation](#technical-implementation) +9. [Testing Strategy](#testing-strategy) + +--- + +## System Overview + +This is a **modular DeFi (Decentralized Finance) system** built on Ethereum that provides: + +1. **Automated Market Maker (AMM) Liquidity Pool** - Enables token swaps with liquidity provision +2. **EIP-712 Meta-Transaction Support** - Allows gasless transactions via off-chain signatures +3. **Fee Management System** - Upgradeable fee calculation and management +4. **Multi-Signature Vault** - Secure multi-party governance for fund management +5. **Role-Based Access Control** - Granular permission system for different operations + +### Key Principles + +- **Modularity**: Each component is independent and can be upgraded/replaced +- **Security**: Multiple layers of access control and validation +- **Upgradeability**: Critical components use proxy patterns for future improvements +- **Gas Efficiency**: Optimized for on-chain operations +- **Composability**: Components work together seamlessly + +--- + +## Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Smart Modules System │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Liquidity │ │ EIP712Swap │ │ FeeManager │ │ +│ │ Pool │◄───┤ (Relayer) │◄───┤ (Upgradeable)│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ +│ └─────────────────────┼────────────────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ AccessManager │ │ +│ │ (Role Management) │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ VaultMultisig │ │ +│ │ (Multi-Sig Vault) │ │ +│ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Component Relationships + +1. **LiquidityPool** - Core AMM functionality + - Uses `FeeManager` for fee calculations + - Grants permissions to `EIP712Swap` for meta-transactions + - Managed by admins via `AccessManager` roles + +2. **EIP712Swap** - Meta-transaction relayer + - Verifies off-chain signatures + - Executes swaps on behalf of users + - Requires `ALLOWED_EIP712_SWAP_ROLE` from pool + +3. **FeeManager** - Upgradeable fee system + - Calculates fees based on swap parameters + - Can be upgraded via UUPS proxy pattern + - Managed by admins + +4. **AccessManager** - Centralized role management + - Manages all role assignments + - Used by `VaultMultisig` for multisig admin permissions + +5. **VaultMultisig** - Multi-signature vault + - Manages funds with quorum-based approvals + - Can execute arbitrary contract calls + - Uses `AccessManager` for admin permissions + +--- + +## Core Components + +### 1. LiquidityPool + +**Purpose**: Automated Market Maker (AMM) for token swaps + +**Key Features**: +- Constant product formula (x * y = k) for price discovery +- Admin-controlled liquidity management +- Role-based swap authorization +- Support for different token decimals + +**Core Functions**: +- `addLiquidity()` - Admin-only liquidity provision +- `removeLiquidity()` - Admin-only liquidity withdrawal +- `swap()` - Execute token swaps (admin or authorized EIP712 contract) +- `getPrice()` - Query current exchange rate +- `getReserves()` - View current pool reserves + +**Access Control**: +- `ADMIN_ROLE`: Can add/remove liquidity +- `ALLOWED_EIP712_SWAP_ROLE`: Can execute swaps (granted to EIP712Swap contract) + +### 2. EIP712Swap + +**Purpose**: Enable gasless transactions via EIP-712 typed data signatures + +**Key Features**: +- EIP-712 compliant signature verification +- Nonce management to prevent replay attacks +- Deadline enforcement for time-bound operations +- Signature verification before execution + +**Core Functions**: +- `verify()` - Verify swap request signature +- `executeSwap()` - Execute swap with valid signature +- `getNonce()` - Get current nonce for an address +- `getDomainSeparator()` - Get EIP-712 domain separator + +**Security Mechanisms**: +- Nonce increment prevents replay attacks +- Deadline check prevents stale transactions +- Signature verification ensures request authenticity + +### 3. FeeManager + +**Purpose**: Calculate and manage swap fees (upgradeable) + +**Key Features**: +- UUPS (Universal Upgradeable Proxy Standard) upgradeable +- Basis points fee calculation (1 bp = 0.01%) +- Fee calculated on output amount +- Admin-controlled fee updates + +**Core Functions**: +- `initialize()` - Initialize with initial fee +- `setFee()` - Update fee (admin only) +- `getFee()` - Calculate fee for swap parameters +- `_authorizeUpgrade()` - Control upgrade authorization + +**Fee Calculation**: +```solidity +// 1. Calculate output amount using AMM formula +amountOut = (amountIn * reserveOut) / (reserveIn + amountIn) + +// 2. Calculate fee as percentage of output +fee = (amountOut * feeBasisPoints) / 10000 +``` + +### 4. AccessManager + +**Purpose**: Centralized role-based access control + +**Key Features**: +- Manages three distinct roles: + - `ADMIN_ROLE`: General admin permissions + - `MULTISIG_ADMIN_ROLE`: Vault multisig administration + - `ALLOWED_EIP712_SWAP_ROLE`: EIP712 swap permissions +- Role hierarchy with `DEFAULT_ADMIN_ROLE` as root + +**Core Functions**: +- `addAdmin()` / `removeAdmin()` - Manage admin roles +- `addMultisigAdmin()` / `removeMultisigAdmin()` - Manage multisig admins +- `addEIP712Swapper()` / `removeEIP712Swapper()` - Manage EIP712 swappers +- `isAdmin()` / `isMultisigAdmin()` / `isEIP712Swapper()` - Check role membership + +### 5. VaultMultisig + +**Purpose**: Multi-signature vault for secure fund management + +**Key Features**: +- Quorum-based transaction approval +- Support for ETH transfers and arbitrary contract calls +- Configurable signers and quorum +- Operation tracking and history + +**Core Functions**: +- `initiateTransfer()` - Propose ETH transfer +- `approveTransfer()` - Approve transfer proposal +- `executeTransfer()` - Execute approved transfer +- `initiateOperation()` - Propose contract call +- `approveOperation()` - Approve operation proposal +- `executeOperation()` - Execute approved operation +- `updateSigners()` - Update multisig signers (multisig admin only) +- `updateQuorum()` - Update required quorum (multisig admin only) + +**Workflow**: +1. Signer initiates transfer/operation +2. Other signers approve until quorum reached +3. Any signer executes when quorum met +4. Operation marked as executed (prevents replay) + +### 6. Roles Library + +**Purpose**: Centralized role constant definitions + +**Key Features**: +- Prevents role hash collisions +- Ensures consistent role usage across contracts +- Three defined roles with unique hashes + +**Roles**: +- `ADMIN_ROLE`: `keccak256("ADMIN_ROLE")` +- `MULTISIG_ADMIN_ROLE`: `keccak256("MULTISIG_ADMIN_ROLE")` +- `ALLOWED_EIP712_SWAP_ROLE`: `keccak256("ALLOWED_EIP712_SWAP_ROLE")` + +--- + +## Component Details + +### LiquidityPool - Deep Dive + +#### AMM Formula + +The pool uses a simplified constant product formula: + +``` +amountOut = (amountIn * reserveOut) / (reserveIn + amountIn) +``` + +This formula ensures: +- Price impact increases with trade size +- Pool always maintains liquidity +- No need for external price oracles + +#### Swap Process + +1. **Validation**: + - Check token pair is valid (token0 or token1) + - Verify sender has sufficient allowance + - Ensure sufficient liquidity exists + +2. **Calculation**: + - Calculate output amount using AMM formula + - Calculate fee using FeeManager + - Deduct fee from output amount + +3. **Execution**: + - Transfer input tokens from user to pool + - Transfer output tokens (minus fee) to user + - Update reserves + +4. **Events**: + - Emit `Swap` event with all swap details + +#### Price Calculation + +```solidity +price = (reserveOut * 1e18) / reserveIn +``` + +Returns price normalized to 18 decimals for consistent comparison. + +### EIP712Swap - Deep Dive + +#### EIP-712 Standard + +EIP-712 enables signing structured data (not just raw hashes), providing: +- Better UX (wallets show human-readable data) +- Type safety +- Domain separation (prevents cross-chain replay) + +#### Signature Structure + +```solidity +struct SwapRequest { + address pool; + address sender; + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + uint256 nonce; + uint256 deadline; +} +``` + +#### Signature Verification Process + +1. **Create Type Hash**: + ```solidity + TYPEHASH = keccak256("SwapRequest(address pool,address sender,...)") + ``` + +2. **Create Struct Hash**: + ```solidity + structHash = keccak256(abi.encode(TYPEHASH, pool, sender, ...)) + ``` + +3. **Create Domain Separator**: + ```solidity + domainSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("EIP712Swap"), + keccak256("1"), + chainId, + contractAddress + ) + ) + ``` + +4. **Create Final Hash**: + ```solidity + digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)) + ``` + +5. **Recover Signer**: + ```solidity + signer = digest.recover(signature) + ``` + +#### Nonce Management + +- Each address has a unique nonce counter +- Nonce increments after successful swap execution +- Prevents replay attacks and ensures order + +### FeeManager - Deep Dive + +#### Upgradeability Pattern + +Uses **UUPS (Universal Upgradeable Proxy Standard)**: +- Implementation contract holds logic +- Proxy contract holds state +- Upgrade function in implementation (not proxy) +- More gas-efficient than Transparent Proxy + +#### Fee Calculation Logic + +```solidity +// Step 1: Calculate output using AMM formula +uint256 amountOut = (amountIn * reserveOut) / (reserveIn + amountIn); + +// Step 2: Calculate fee as percentage of output +uint256 fee = (amountOut * feeBasisPoints) / FEE_DENOMINATOR; // FEE_DENOMINATOR = 10000 +``` + +**Example**: +- Input: 100 tokens +- Reserves: 1000 in, 2000 out +- Fee: 250 basis points (2.5%) +- Output: (100 * 2000) / (1000 + 100) = 181.81 tokens +- Fee: (181.81 * 250) / 10000 = 4.55 tokens +- Final output: 181.81 - 4.55 = 177.26 tokens + +#### Upgrade Authorization + +Only `DEFAULT_ADMIN_ROLE` can authorize upgrades, ensuring: +- Controlled upgrade process +- No unauthorized logic changes +- Audit trail via events + +### VaultMultisig - Deep Dive + +#### Quorum System + +- **Quorum**: Minimum number of approvals required +- **Signers**: List of authorized addresses +- **Validation**: Quorum ≤ Signers.length + +#### Transfer Workflow + +1. **Initiation** (`initiateTransfer`): + - Signer proposes transfer + - Automatically counts as 1 approval + - Creates Transfer struct with ID + +2. **Approval** (`approveTransfer`): + - Other signers approve + - Approval count increments + - Prevents duplicate approvals + +3. **Execution** (`executeTransfer`): + - Any signer can execute when quorum reached + - Validates balance sufficiency + - Executes ETH transfer + - Marks as executed (prevents replay) + +#### Operation System + +Similar to transfers but for arbitrary contract calls: +- `initiateOperation`: Propose contract call with encoded data +- `approveOperation`: Approve operation +- `executeOperation`: Execute when quorum reached + +**Use Cases**: +- Call LiquidityPool functions +- Interact with other DeFi protocols +- Update contract parameters + +#### Security Features + +1. **Replay Prevention**: Executed operations cannot be re-executed +2. **Balance Checks**: Validates sufficient balance before execution +3. **Quorum Validation**: Ensures minimum approvals before execution +4. **Signer Verification**: Only authorized signers can participate +5. **Admin Controls**: Only multisig admins can update signers/quorum + +--- + +## Integration & Workflows + +### Workflow 1: Standard Token Swap + +``` +User → LiquidityPool.swap() + ├─ Check authorization (admin or EIP712 contract) + ├─ Validate token pair + ├─ Check allowance + ├─ Calculate output (AMM formula) + ├─ Calculate fee (FeeManager.getFee()) + ├─ Transfer tokens + └─ Update reserves +``` + +### Workflow 2: Gasless Swap via EIP-712 + +``` +1. User (Off-chain): + ├─ Create SwapRequest struct + ├─ Sign with EIP-712 + └─ Send signature to relayer + +2. Relayer (On-chain): + ├─ Call EIP712Swap.executeSwap() + ├─ Verify signature + ├─ Check nonce & deadline + ├─ Call LiquidityPool.swap() + └─ Increment nonce +``` + +### Workflow 3: Multi-Signature Fund Management + +``` +1. Signer 1: + └─ initiateTransfer(recipient, amount) + +2. Signer 2: + └─ approveTransfer(transferId) + +3. Signer 3 (or any signer): + └─ executeTransfer(transferId) + └─ ETH transferred to recipient +``` + +### Workflow 4: Liquidity Management + +``` +Admin → LiquidityPool.addLiquidity() + ├─ Validate token address + ├─ Check balance + ├─ Transfer tokens to pool + └─ Update reserves +``` + +### Workflow 5: Fee Update + +``` +Admin → FeeManager.setFee() + ├─ Check admin role + ├─ Update fee value + └─ Future swaps use new fee +``` + +--- + +## Security Model + +### Access Control Layers + +1. **Role-Based Access Control (RBAC)**: + - OpenZeppelin AccessControl implementation + - Hierarchical role structure + - Role admin relationships + +2. **Function-Level Modifiers**: + - `onlyRole()` - OpenZeppelin modifier + - `onlyAdminOrEIP712Swap()` - Custom modifier + - `onlyMultisigSigner()` - VaultMultisig modifier + +3. **Contract-Level Authorization**: + - EIP712Swap contract has special role + - Can execute swaps on behalf of users + - Signature verification ensures user consent + +### Security Mechanisms + +#### 1. Reentrancy Protection +- No external calls before state updates in critical functions +- Checks-Effects-Interactions pattern followed + +#### 2. Integer Overflow Protection +- Solidity 0.8.30 built-in overflow checks +- Safe math operations + +#### 3. Signature Verification +- EIP-712 standard prevents signature manipulation +- Nonce prevents replay attacks +- Deadline prevents stale transactions + +#### 4. Input Validation +- Token address validation +- Amount validation (non-zero, sufficient balance) +- Quorum validation (≤ signers, > 0) + +#### 5. Upgrade Safety +- UUPS pattern with admin-only upgrade authorization +- Implementation contract can be audited before upgrade +- Upgrade function protected by role + +### Known Security Considerations + +1. **Centralization Risks**: + - Admin roles have significant power + - Multisig reduces but doesn't eliminate risk + - Consider time-locks for critical operations + +2. **Front-Running**: + - Public mempool allows front-running + - Consider commit-reveal schemes for large trades + +3. **Liquidity Risks**: + - Low liquidity can cause high slippage + - Admin-controlled liquidity adds centralization + +4. **Signature Replay**: + - EIP-712 domain separation prevents cross-chain replay + - Nonce prevents same-chain replay + - Deadline prevents stale transactions + +--- + +## Use Cases + +### 1. Decentralized Exchange (DEX) + +**Scenario**: Users want to swap tokens without centralized exchange + +**Implementation**: +- Deploy LiquidityPool with token pair +- Admin adds initial liquidity +- Users swap tokens via `swap()` or EIP-712 + +**Benefits**: +- No order book needed +- Automated price discovery +- Always available liquidity + +### 2. Gasless Trading + +**Scenario**: Users want to trade without paying gas fees + +**Implementation**: +- User signs swap request off-chain +- Relayer pays gas and executes +- Relayer may charge fee or be sponsored + +**Benefits**: +- Better UX (no gas management) +- Enables mobile-first DeFi +- Reduces barrier to entry + +### 3. Treasury Management + +**Scenario**: DAO or organization needs secure fund management + +**Implementation**: +- Deploy VaultMultisig with DAO signers +- Set quorum (e.g., 3 of 5) +- All transfers require quorum approval + +**Benefits**: +- No single point of failure +- Transparent approval process +- Audit trail via events + +### 4. Liquidity Provision + +**Scenario**: Protocol wants to provide liquidity for token pairs + +**Implementation**: +- Admin adds liquidity to pool +- Earns fees from swaps +- Can remove liquidity when needed + +**Benefits**: +- Passive income from fees +- Supports token ecosystem +- Flexible liquidity management + +### 5. Fee Optimization + +**Scenario**: Protocol wants to adjust fees based on market conditions + +**Implementation**: +- Admin updates FeeManager fee +- New fee applies to all future swaps +- Can be upgraded if needed + +**Benefits**: +- Dynamic fee adjustment +- Upgradeable without migration +- Maintains fee revenue + +--- + +## Technical Implementation + +### Solidity Version + +- **Version**: 0.8.30 +- **Features Used**: + - Built-in overflow/underflow checks + - Custom errors (gas efficient) + - Struct packing + - Library usage + +### OpenZeppelin Contracts + +1. **AccessControl**: Role-based permissions +2. **AccessControlUpgradeable**: Upgradeable access control +3. **UUPSUpgradeable**: Upgradeable proxy pattern +4. **Initializable**: Proxy initialization +5. **EIP712**: Typed data signing +6. **ECDSA**: Signature recovery + +### Gas Optimization Techniques + +1. **Custom Errors**: Instead of require strings (saves gas) +2. **Events**: Efficient event emission +3. **Struct Packing**: Efficient storage layout +4. **Library Functions**: Reusable code without deployment overhead +5. **View Functions**: No gas cost for read operations + +### Storage Layout + +#### LiquidityPool +```solidity +address token0; // slot 0 +uint256 token0Decimals; // slot 1 +address token1; // slot 2 +uint256 token1Decimals; // slot 3 +uint256 reserveToken0; // slot 4 +uint256 reserveToken1; // slot 5 +FeeManager feeManager; // slot 6 +EIP712Swap eip712Swap; // slot 7 +``` + +#### VaultMultisig +```solidity +uint256 quorum; // slot 0 +uint256 transfersCount; // slot 1 +uint256 operationsCount; // slot 2 +AccessManager accessManager; // slot 3 +address[] currentMultiSigSigners; // slot 4 (array length) +mapping(uint256 => Transfer) transfers; // slot 5 +mapping(uint256 => Operation) operations; // slot 6 +mapping(address => bool) multiSigSigners; // slot 7 +``` + +### Error Handling + +Custom errors used throughout for gas efficiency: + +```solidity +error InsufficientTokenBalance(); +error InvalidTokenAddress(address _token); +error InvalidTokenPair(address _tokenIn, address _tokenOut); +error InsufficientLiquidity(); +error InsufficientOutputAmount(uint256 expected, uint256 actual); +error InsufficientAllowance(); +error InvalidSignature(); +error ExpiredSwapRequest(); +error InvalidNonce(); +``` + +### Event Emission + +All state changes emit events for: +- Off-chain indexing +- Front-end updates +- Audit trails +- Analytics + +Key events: +- `LiquidityAdded`: When liquidity is added +- `Swap`: When swap is executed +- `TransferInitiated/Approved/Executed`: Multisig workflow +- `OperationInitiated/Approved/Executed`: Multisig operations + +--- + +## Testing Strategy + +### Test Coverage + +The system includes comprehensive tests covering: + +1. **Unit Tests**: Individual component functionality +2. **Integration Tests**: Component interactions +3. **Edge Cases**: Boundary conditions and error scenarios +4. **Access Control**: Role-based permission testing + +### Test Files + +1. **LiquidityPool.t.sol** (12 tests): + - Liquidity management + - Swap functionality + - Access control + - Error conditions + +2. **EIP712Swap.t.sol** (3 tests): + - Signature verification + - Swap execution + - Multiple swaps + - Insufficient liquidity handling + +3. **FeeManager.t.sol** (9 tests): + - Fee calculation + - Fee updates + - Different scenarios + - Access control + +4. **VaultMultisig.t.sol** (27 tests): + - Transfer workflow + - Operation workflow + - Quorum management + - Signer management + - Error conditions + +5. **AccessManager.t.sol** (5 tests): + - Role management + - Access control + - Authorization checks + +6. **Roles.t.sol** (2 tests): + - Role constant validation + - Uniqueness checks + +### Test Patterns Used + +1. **Arrange-Act-Assert**: Standard test structure +2. **Fuzz Testing**: Random input generation (potential) +3. **Invariant Testing**: State consistency checks +4. **Integration Testing**: Multi-contract scenarios + +### Mock Contracts + +- **MockERC20**: ERC20 token for testing + - Configurable decimals + - Mint/burn functions + - Standard ERC20 interface + +### Test Utilities + +- **Foundry Test Framework**: `forge-std/Test.sol` +- **vm.prank()**: Impersonate addresses +- **vm.expectRevert()**: Test error conditions +- **vm.expectEmit()**: Test event emission +- **vm.deal()**: Fund addresses with ETH + +--- + +## Deployment Considerations + +### Deployment Order + +1. **Roles Library**: Deploy first (no dependencies) +2. **AccessManager**: Deploy with admin +3. **FeeManager Implementation**: Deploy implementation contract +4. **FeeManager Proxy**: Deploy proxy with initialization +5. **EIP712Swap**: Deploy relayer contract +6. **LiquidityPool**: Deploy with all dependencies +7. **VaultMultisig**: Deploy with signers and AccessManager + +### Initialization Parameters + +#### FeeManager +- Initial fee (basis points, e.g., 250 = 2.5%) +- Admin address + +#### LiquidityPool +- Token0 address and decimals +- Token1 address and decimals +- FeeManager address +- EIP712Swap address + +#### VaultMultisig +- Signers array +- Quorum (must be ≤ signers.length, > 0) +- AccessManager address + +### Upgrade Considerations + +1. **FeeManager**: Can be upgraded via UUPS pattern + - New implementation must be compatible + - Storage layout must match + - Admin must authorize upgrade + +2. **Other Contracts**: Not upgradeable + - Consider proxy pattern if upgradeability needed + - Or deploy new version and migrate + +### Gas Costs (Estimated) + +- **LiquidityPool.addLiquidity()**: ~60,000 gas +- **LiquidityPool.swap()**: ~150,000-250,000 gas +- **EIP712Swap.executeSwap()**: ~200,000-300,000 gas +- **VaultMultisig.initiateTransfer()**: ~50,000 gas +- **VaultMultisig.executeTransfer()**: ~80,000 gas + +--- + +## Best Practices + +### For Developers + +1. **Always validate inputs**: Check addresses, amounts, etc. +2. **Use events**: Emit events for all state changes +3. **Handle errors gracefully**: Use custom errors +4. **Test thoroughly**: Cover edge cases +5. **Document code**: Use NatSpec comments + +### For Administrators + +1. **Secure private keys**: Use hardware wallets +2. **Use multisig**: For critical operations +3. **Monitor events**: Track all contract interactions +4. **Test upgrades**: On testnet first +5. **Gradual changes**: Don't make drastic changes at once + +### For Users + +1. **Check allowances**: Before swapping +2. **Verify signatures**: When using EIP-712 +3. **Check deadlines**: Don't use expired signatures +4. **Monitor nonces**: Track your nonce for EIP-712 swaps +5. **Understand slippage**: Set appropriate minAmountOut + +--- + +## Future Enhancements + +### Potential Improvements + +1. **Liquidity Provider Tokens (LP Tokens)**: + - Track liquidity provider shares + - Enable proportional liquidity removal + - Reward liquidity providers + +2. **Time-Weighted Average Price (TWAP)**: + - Oracle-free price feeds + - More accurate pricing + - Reduced manipulation risk + +3. **Flash Loans**: + - Uncollateralized loans within transaction + - Enable arbitrage opportunities + - Require repayment in same transaction + +4. **Multi-Hop Swaps**: + - Route through multiple pools + - Better prices for indirect pairs + - Automatic routing + +5. **Governance Token**: + - Decentralized fee management + - Community-driven decisions + - Staking mechanisms + +6. **MEV Protection**: + - Commit-reveal schemes + - Private transaction pools + - Fair ordering + +--- + +## Conclusion + +This Smart Modules system provides a comprehensive, modular DeFi infrastructure with: + +- **Flexible AMM**: Simple but effective liquidity pool +- **Gasless Transactions**: EIP-712 meta-transactions +- **Upgradeable Fees**: Future-proof fee management +- **Secure Vault**: Multi-signature fund management +- **Role-Based Access**: Granular permission system + +The system is designed for: +- **Security**: Multiple layers of protection +- **Modularity**: Independent, composable components +- **Upgradeability**: Can evolve with needs +- **Gas Efficiency**: Optimized for on-chain operations + +All components are thoroughly tested and ready for deployment on Ethereum-compatible networks. + +--- + +## References + +- [EIP-712: Typed Structured Data Hashing and Signing](https://eips.ethereum.org/EIPS/eip-712) +- [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/) +- [Foundry Testing Framework](https://book.getfoundry.sh/) +- [UUPS Proxy Pattern](https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#uups-proxies) +- [Constant Product Market Maker](https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works) + +--- + +**Document Version**: 1.0 +**Last Updated**: 2024 +**Maintained By**: Smart Modules Development Team + From 6d585b446a72ff505a738704ade3d8ce3ea918b3 Mon Sep 17 00:00:00 2001 From: nicknotknack Date: Sat, 15 Nov 2025 17:39:47 +0400 Subject: [PATCH 3/5] fix: format EIP712Swap to match CI requirements --- src/EIP712Swap.sol | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/EIP712Swap.sol b/src/EIP712Swap.sol index a2919d9..6c54dfa 100644 --- a/src/EIP712Swap.sol +++ b/src/EIP712Swap.sol @@ -54,13 +54,14 @@ contract EIP712Swap is EIP712 { if (_swapRequest.nonce != _nonces[_swapRequest.sender]) revert InvalidNonce(); _nonces[_swapRequest.sender]++; - LiquidityPool(_swapRequest.pool).swap( - _swapRequest.sender, - _swapRequest.tokenIn, - _swapRequest.tokenOut, - _swapRequest.amountIn, - _swapRequest.minAmountOut - ); + LiquidityPool(_swapRequest.pool) + .swap( + _swapRequest.sender, + _swapRequest.tokenIn, + _swapRequest.tokenOut, + _swapRequest.amountIn, + _swapRequest.minAmountOut + ); return true; } From 67dc99a4def90ff6cb5302fcc255c5fd0175f9d4 Mon Sep 17 00:00:00 2001 From: nicknotknack Date: Sat, 15 Nov 2025 17:41:18 +0400 Subject: [PATCH 4/5] fix: format FeeManager test struct parameters to match CI --- test/FeeManager.t.sol | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/test/FeeManager.t.sol b/test/FeeManager.t.sol index c81245a..d453a5e 100644 --- a/test/FeeManager.t.sol +++ b/test/FeeManager.t.sol @@ -56,11 +56,7 @@ contract FeeManagerTest is Test { function test_GetFee_WithDifferentAmounts() public view { ISwap.SwapParams memory params = ISwap.SwapParams({ - token0: address(0x1), - token1: address(0x2), - amount0: 500e18, - reserveToken0: 5000e18, - reserveToken1: 10000e6 + token0: address(0x1), token1: address(0x2), amount0: 500e18, reserveToken0: 5000e18, reserveToken1: 10000e6 }); uint256 fee = feeManager.getFee(params); @@ -69,11 +65,7 @@ contract FeeManagerTest is Test { function test_GetFee_ZeroAmount() public view { ISwap.SwapParams memory params = ISwap.SwapParams({ - token0: address(0x1), - token1: address(0x2), - amount0: 0, - reserveToken0: 10000e18, - reserveToken1: 20000e6 + token0: address(0x1), token1: address(0x2), amount0: 0, reserveToken0: 10000e18, reserveToken1: 20000e6 }); uint256 fee = feeManager.getFee(params); @@ -83,11 +75,7 @@ contract FeeManagerTest is Test { function test_FeeCalculationWithSimpleNumbers() public view { // Use numbers that result in clean division ISwap.SwapParams memory params = ISwap.SwapParams({ - token0: address(0x1), - token1: address(0x2), - amount0: 1000, - reserveToken0: 10000, - reserveToken1: 10000 + token0: address(0x1), token1: address(0x2), amount0: 1000, reserveToken0: 10000, reserveToken1: 10000 }); uint256 fee = feeManager.getFee(params); @@ -103,19 +91,12 @@ contract FeeManagerTest is Test { function test_FeeScalesWithAmount() public view { // Test that larger amounts result in larger fees ISwap.SwapParams memory smallParams = ISwap.SwapParams({ - token0: address(0x1), - token1: address(0x2), - amount0: 100e18, - reserveToken0: 10000e18, - reserveToken1: 10000e6 + token0: address(0x1), token1: address(0x2), amount0: 100e18, reserveToken0: 10000e18, reserveToken1: 10000e6 }); ISwap.SwapParams memory largeParams = ISwap.SwapParams({ - token0: address(0x1), - token1: address(0x2), - amount0: 1000e18, // 10x larger - reserveToken0: 10000e18, - reserveToken1: 10000e6 + token0: address(0x1), token1: address(0x2), amount0: 1000e18, reserveToken0: 10000e18, reserveToken1: 10000e6 + // 10x larger }); uint256 smallFee = feeManager.getFee(smallParams); From 19d599ad75ecda1c3d910ef7d44cb792f307ffe0 Mon Sep 17 00:00:00 2001 From: nicknotknack Date: Sat, 15 Nov 2025 17:45:06 +0400 Subject: [PATCH 5/5] fix: format largeParams struct correctly for CI --- test/FeeManager.t.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/FeeManager.t.sol b/test/FeeManager.t.sol index d453a5e..4a6b87a 100644 --- a/test/FeeManager.t.sol +++ b/test/FeeManager.t.sol @@ -94,9 +94,13 @@ contract FeeManagerTest is Test { token0: address(0x1), token1: address(0x2), amount0: 100e18, reserveToken0: 10000e18, reserveToken1: 10000e6 }); + // 10x larger ISwap.SwapParams memory largeParams = ISwap.SwapParams({ - token0: address(0x1), token1: address(0x2), amount0: 1000e18, reserveToken0: 10000e18, reserveToken1: 10000e6 - // 10x larger + token0: address(0x1), + token1: address(0x2), + amount0: 1000e18, + reserveToken0: 10000e18, + reserveToken1: 10000e6 }); uint256 smallFee = feeManager.getFee(smallParams);