diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..34a4a52 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore index a49555a..9c549b5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,15 @@ states/ # Optional .env -.env.local \ No newline at end of file +.env.local + +# Foundry +.env.foundry +out/ +cache/ + +# VSCode +.vscode/ + +# Node +node_modules/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9296efd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/README.md b/README.md index 251accb..9265b45 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,66 @@ -# Smart Modules +## Foundry -**Smart Modules** is a curated collection of Solidity components built for developers who want to master real-world smart contract engineering. +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** -This repository is part of an [Solidity Bootcamp](https://bootcamp.solidity.university) program by [Solidity University](https://solidity.university), covering core topics like: +Foundry consists of: -- ✅ Multisig wallets -- ✅ Gasless transactions -- ✅ EIP-712 signatures -- ✅ TBA +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +## Documentation -> 🚧 **This repository is under active development.** Expect new modules, upgrades, and educational content in upcoming commits. +https://book.getfoundry.sh/ ---- +## Usage -## 🚀 Getting Started +### Build -```bash -git clone https://github.com/solidity-university/smart-modules.git -cd smart-modules -forge install -forge build +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..2d9cb54 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,7 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.30" + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..77041d2 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..e4f7021 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit e4f70216d759d8e6a64144a9e1f7bbeed78e7079 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..60b305a --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 60b305a8f3ff0c7688f02ac470417b6bbf1c4d27 diff --git a/src/AccessManager.sol b/src/AccessManager.sol new file mode 100644 index 0000000..8b6e6dd --- /dev/null +++ b/src/AccessManager.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +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); + _setRoleAdmin(Roles.MULTISIG_ADMIN_ROLE, DEFAULT_ADMIN_ROLE); + } + + function addMultisigAdmin(address _multisigAdmin) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(Roles.MULTISIG_ADMIN_ROLE, _multisigAdmin); + } + + function removeMultisigAdmin(address _multisigAdmin) external onlyRole(DEFAULT_ADMIN_ROLE) { + _revokeRole(Roles.MULTISIG_ADMIN_ROLE, _multisigAdmin); + } + + function isMultisigAdmin(address _address) external view returns (bool) { + return hasRole(Roles.MULTISIG_ADMIN_ROLE, _address); + } +} diff --git a/src/EIP712Swap.sol b/src/EIP712Swap.sol new file mode 100644 index 0000000..86094bb --- /dev/null +++ b/src/EIP712Swap.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "./LiquidityPool.sol"; +import "./ISwap.sol"; + +contract EIP712Swap is EIP712 { + using ECDSA for bytes32; + + 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)" + ); + + error InvalidSignature(); + error ExpiredSwapRequest(); + error InvalidNonce(); + + constructor() EIP712("EIP712Swap", "1") {} + + function getDomainSeparator() public view returns (bytes32) { + return _domainSeparatorV4(); + } + + 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 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 + ); + + return true; + } +} diff --git a/src/FeeManager.sol b/src/FeeManager.sol new file mode 100644 index 0000000..ad23eed --- /dev/null +++ b/src/FeeManager.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +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; + + function initialize(uint256 _fee) external initializer { + _disableInitializers(); + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + fee = _fee; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} + + function setFee(uint256 _fee) external onlyRole(DEFAULT_ADMIN_ROLE) { + fee = _fee; + } + + function getFee(ISwap.SwapParams memory swapParams) external view returns (uint256) { + return (swapParams.amount0 * fee) / FEE_DENOMINATOR; + } +} diff --git a/src/ISwap.sol b/src/ISwap.sol new file mode 100644 index 0000000..c42c389 --- /dev/null +++ b/src/ISwap.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +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 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 new file mode 100644 index 0000000..dc06017 --- /dev/null +++ b/src/LiquidityPool.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "@openzeppelin/contracts/token/ERC20/IERC20.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); + } + + if (IERC20(_token).balanceOf(address(msg.sender)) < _amount) { + revert InsufficientTokenBalance(); + } + + require(IERC20(_token).transferFrom(msg.sender, address(this), _amount)); + + if (_token == token0) { + reserveToken0 += _amount; + } else if (_token == token1) { + reserveToken1 += _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); + } + + address _msgSender = msg.sender == address(eip712Swap) ? _sender : msg.sender; + + if (IERC20(_tokenIn).allowance(_msgSender, address(this)) < _amountIn) revert InsufficientAllowance(); + + uint256 _reserveTokenIn = _tokenIn == token0 ? reserveToken0 : reserveToken1; + uint256 _reserveTokenOut = _tokenOut == token0 ? reserveToken0 : reserveToken1; + uint256 _tokenInDecimals = _tokenIn == token0 ? token0Decimals : token1Decimals; + uint256 _tokenOutDecimals = _tokenOut == token0 ? token0Decimals : token1Decimals; + + 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); + + if (amountOut < _minAmountOut) revert InsufficientOutputAmount(_minAmountOut, amountOut); + if (amountOut > _reserveTokenOut) revert InsufficientLiquidity(); + + require(IERC20(_tokenIn).transferFrom(_msgSender, address(this), _amountIn)); + require(IERC20(_tokenOut).transfer(_msgSender, 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 new file mode 100644 index 0000000..6f15733 --- /dev/null +++ b/src/Roles.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +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"); +} diff --git a/src/VaultMultisig.sol b/src/VaultMultisig.sol new file mode 100644 index 0000000..766cb81 --- /dev/null +++ b/src/VaultMultisig.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "./AccessManager.sol"; + +contract VaultMultisig { + /// @notice The number of signatures required to execute a transaction + uint256 public quorum; + + /// @notice The number of transfers executed + uint256 public transfersCount; + + /// @notice The access manager + AccessManager public accessManager; + + /// @notice The current multisig signers + address[] public currentMultiSigSigners; + + /// @dev The struct is used to store the details of a transfer + /// @param to The address of the recipient + /// @param amount The amount of tokens to transfer + /// @param approvals The number of approvals required to execute the transfer + /// @param executed Whether the transfer has been executed + /// @param approved The mapping of signers to their approval status + struct Transfer { + address to; + uint256 amount; + uint256 approvals; + bool executed; + mapping(address => bool) approved; + } + + /// @notice The mapping of transfer IDs to transfer details + mapping(uint256 => Transfer) private transfers; + + /// @notice The mapping for verification that address is a signer + mapping(address => bool) private multiSigSigners; + + /// @notice Checks that signers array is not empty + error SignersArrayCannotBeEmpty(); + + /// @notice Checks that quorum is not greather than the number of signers + error QuorumGreaterThanSigners(); + + /// @notice Checks that quorum is greater than zero + error QuorumCannotBeZero(); + + /// @notice Checks that the recipient is not the zero address + error InvalidRecipient(); + + /// @notice Checks that amount is greater than zero + error InvalidAmount(); + + /// @notice Checks that the signer is a multisig signer + error InvalidMultisigSigner(); + + /// @notice Checks that the balance is sufficient for the transfer + error InsufficientBalance(uint256 balance, uint256 desiredAmount); + + /// @notice Checks that the transfer is not already executed + /// @param transferId The ID of the transfer + error TransferIsAlreadyExecuted(uint256 transferId); + + /// @notice Checks that the signer is already approved + /// @param signer The address of the signer + error SignerAlreadyApproved(address signer); + + /// @notice Checks that the transfer failed + /// @param transferId The ID of the transfer + error TransferFailed(uint256 transferId); + + /// @notice Checks that quorum was reached for transfer + /// @param transferId The ID of the transfer + error QuorumHasNotBeenReached(uint256 transferId); + + /// @notice Checks that the signer is a multisig admin + error InvalidMultisigAdmin(); + + /// @notice Emitted when a transfer is initiated + event TransferInitiated(uint256 indexed transferId, address indexed to, uint256 amount); + + /// @notice Emitted when a transfer is approved + /// @param transferId The ID of the transfer + /// @param approver The address of the approver + event TransferApproved(uint256 indexed transferId, address indexed approver); + + /// @notice Emitted when a transfer is executed + /// @param transferId The ID of the transfer + event TransferExecuted(uint256 indexed transferId); + + /// @notice Emitted when the multisig signers are updated + event MultiSigSignersUpdated(); + + /// @notice Emitted when the quorum is updated + /// @param quorum The new quorum + event QuorumUpdated(uint256 quorum); + + modifier onlyMultisigSigner() { + if (!multiSigSigners[msg.sender]) revert InvalidMultisigSigner(); + _; + } + + modifier onlyMultisigAdmin() { + if (!accessManager.isMultisigAdmin(msg.sender)) revert InvalidMultisigAdmin(); + _; + } + + /// @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) { + if (_signers.length == 0) revert SignersArrayCannotBeEmpty(); + if (_quorum > _signers.length) revert QuorumGreaterThanSigners(); + if (_quorum == 0) revert QuorumCannotBeZero(); + + for (uint256 i = 0; i < _signers.length; i++) { + multiSigSigners[_signers[i]] = true; + } + + quorum = _quorum; + accessManager = AccessManager(_accessManager); + } + + /// @notice Updates the multisig signers + /// @param _signers The array of multisig signers + function updateSigners(address[] memory _signers) external onlyMultisigAdmin { + if (_signers.length == 0) revert SignersArrayCannotBeEmpty(); + if (_signers.length < quorum) revert QuorumGreaterThanSigners(); + + for (uint256 i = 0; i < currentMultiSigSigners.length; i++) { + multiSigSigners[currentMultiSigSigners[i]] = false; + } + + for (uint256 i = 0; i < _signers.length; i++) { + multiSigSigners[_signers[i]] = true; + } + + currentMultiSigSigners = _signers; + + emit MultiSigSignersUpdated(); + } + + /// @notice Updates the quorum + /// @param _quorum The new quorum + function updateQuorum(uint256 _quorum) external onlyMultisigAdmin { + if (_quorum > currentMultiSigSigners.length) revert QuorumGreaterThanSigners(); + if (_quorum == 0) revert QuorumCannotBeZero(); + + quorum = _quorum; + + emit QuorumUpdated(_quorum); + } + + /// @notice Initiates a transfer + /// @param _to The address of the recipient + /// @param _amount The amount of tokens to transfer + function initiateTransfer(address _to, uint256 _amount) external onlyMultisigSigner { + if (_to == address(0)) revert InvalidRecipient(); + if (_amount <= 0) revert InvalidAmount(); + + uint256 transferId = transfersCount++; + Transfer storage transfer = transfers[transferId]; + transfer.to = _to; + transfer.amount = _amount; + transfer.approvals = 0; + transfer.executed = false; + transfer.approved[msg.sender] = true; + + emit TransferInitiated(transferId, _to, _amount); + } + + /// @notice Approves a transfer + /// @param _transferId The ID of the transfer + function approveTransfer(uint256 _transferId) external onlyMultisigSigner { + Transfer storage transfer = transfers[_transferId]; + if (transfer.executed) revert TransferIsAlreadyExecuted(_transferId); + if (transfer.approved[msg.sender]) revert SignerAlreadyApproved(msg.sender); + + transfer.approvals++; + transfer.approved[msg.sender] = true; + + emit TransferApproved(_transferId, msg.sender); + } + + function executeTransfer(uint256 _transferId) external onlyMultisigSigner { + Transfer storage transfer = transfers[_transferId]; + if (transfer.approvals < quorum) revert QuorumHasNotBeenReached(_transferId); + if (transfer.executed) revert TransferIsAlreadyExecuted(_transferId); + + uint256 balance = address(this).balance; + if (transfer.amount >= balance) revert InsufficientBalance(balance, transfer.amount); + + (bool success,) = transfer.to.call{value: transfer.amount}(""); + if (!success) revert TransferFailed(_transferId); + + transfer.executed = true; + + emit TransferExecuted(_transferId); + } + + /// @notice Default fallback function for receiving ETH + receive() external payable {} + + /// @notice Gets the details of a transfer + /// @param _transferId The ID of the transfer + /// @return to The address of the recipient + /// @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) + { + Transfer storage transfer = transfers[_transferId]; + return (transfer.to, transfer.amount, transfer.approvals, transfer.executed); + } + + /// @notice Checks if a signer has signed a transfer + /// @param _transferId The ID of the transfer + /// @param _signer The address of the signer + /// @return hasSigned Whether the signer has signed the transfer + function hasSignedTransfer(uint256 _transferId, address _signer) external view returns (bool) { + Transfer storage transfer = transfers[_transferId]; + return transfer.approved[_signer]; + } + + /// @notice Gets the number of transfers + /// @return The number of transfers + function getTransferCount() external view returns (uint256) { + return transfersCount; + } +}