From 09197a81c2edebca8cd690de10ad1a32194d0c97 Mon Sep 17 00:00:00 2001 From: Max <82761650+MaxMustermann2@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:01:10 +0530 Subject: [PATCH] feat: add faucet contract (#110) * feat: introduce LZ script to replay msg https://github.com/DanL0/integrations-foundry-tooling but with better logging in our context * feat: add faucet code + script + test * fix test and forge fmt * fix: pacify slither * fix: use correct IERC165 logic * remove duplicated code * refactor: update test * refactor: update error message * test: update scenario * refactor: use Eid from json in mock --- script/18_SimulateReceive.s.sol | 58 +++++ script/19_DeployFaucet.s.sol | 48 ++++ src/core/CombinedFaucet.sol | 155 ++++++++++++ test/foundry/unit/CombinedFaucet.t.sol | 326 +++++++++++++++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 script/18_SimulateReceive.s.sol create mode 100644 script/19_DeployFaucet.s.sol create mode 100644 src/core/CombinedFaucet.sol create mode 100644 test/foundry/unit/CombinedFaucet.t.sol diff --git a/script/18_SimulateReceive.s.sol b/script/18_SimulateReceive.s.sol new file mode 100644 index 00000000..65fd03be --- /dev/null +++ b/script/18_SimulateReceive.s.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import "../test/mocks/AssetsMock.sol"; +import "../test/mocks/DelegationMock.sol"; + +import "../src/interfaces/precompiles/IAssets.sol"; +import "../src/interfaces/precompiles/IDelegation.sol"; + +import {Script, console} from "forge-std/Script.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import "forge-std/StdJson.sol"; + +import {IOAppCore} from "@layerzero-v2/oapp/contracts/oapp/interfaces/IOAppCore.sol"; + +import {Origin} from "../src/lzApp/OAppReceiverUpgradeable.sol"; + +contract SimulateReceive is Script, StdCheats { + + using stdJson for string; + + function run() public { + // https://scan-testnet.layerzero-api.com/v1/messages/tx/ + string memory json = vm.readFile("./scanApiResponse.json"); + uint32 srcEid = uint32(json.readUint(".data[0].pathway.srcEid")); + require(srcEid != 0, "srcEid should not be empty"); + // always monkey-patch a precompile, since with LZ we need them + // TODO: AssetsMock may still complain about a few things. + deployCodeTo("AssetsMock.sol", abi.encode(uint16(srcEid)), ASSETS_PRECOMPILE_ADDRESS); + deployCodeTo("DelegationMock.sol", DELEGATION_PRECOMPILE_ADDRESS); + address senderAddress = json.readAddress(".data[0].pathway.sender.address"); + require(senderAddress != address(0), "senderAddress should not be empty"); + uint64 nonce = uint64(json.readUint(".data[0].pathway.nonce")); + require(nonce != 0, "nonce should not be empty"); + bytes32 sender = addressToBytes32(senderAddress); + require(sender != bytes32(0), "sender should not be empty"); + address receiver = json.readAddress(".data[0].pathway.receiver.address"); + require(receiver != address(0), "receiver should not be empty"); + bytes32 guid = json.readBytes32(".data[0].guid"); + require(guid != bytes32(0), "guid should not be empty"); + bytes memory payload = json.readBytes(".data[0].source.tx.payload"); + require(payload.length != 0, "payload should not be empty"); + + Origin memory origin = Origin({srcEid: srcEid, sender: sender, nonce: nonce}); + bytes memory extraData = ""; + vm.startBroadcast(); + bytes memory encoded = abi.encodeWithSelector( + IOAppCore(receiver).endpoint().lzReceive.selector, origin, receiver, guid, payload, extraData + ); + console.logBytes(encoded); + IOAppCore(receiver).endpoint().lzReceive(origin, receiver, guid, payload, extraData); + } + + function addressToBytes32(address _addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(_addr))); + } + +} diff --git a/script/19_DeployFaucet.s.sol b/script/19_DeployFaucet.s.sol new file mode 100644 index 00000000..280823bb --- /dev/null +++ b/script/19_DeployFaucet.s.sol @@ -0,0 +1,48 @@ +pragma solidity ^0.8.19; + +import {CombinedFaucet} from "../src/core/CombinedFaucet.sol"; +import {BaseScript} from "./BaseScript.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import "forge-std/Script.sol"; + +contract DeployScript is BaseScript { + + address tokenAddr; + bool exoEthFaucet; + address faucetOwner; + + function setUp() public virtual override { + super.setUp(); + + string memory prerequisites = vm.readFile("script/prerequisiteContracts.json"); + + tokenAddr = stdJson.readAddress(prerequisites, ".clientChain.erc20Token"); + require(tokenAddr != address(0), "token address should not be empty"); + + clientChain = vm.createSelectFork(clientChainRPCURL); + exocore = vm.createSelectFork(exocoreRPCURL); + + exoEthFaucet = vm.envBool("EXO_ETH_FAUCET"); + // for native token, using a different owner is better since the private key is exposed on the backend + faucetOwner = vm.envAddress("FAUCET_OWNER"); + } + + function run() public { + vm.selectFork(exoEthFaucet ? clientChain : exocore); + vm.startBroadcast(exocoreValidatorSet.privateKey); + address proxyAdmin = address(new ProxyAdmin()); + CombinedFaucet faucetLogic = new CombinedFaucet(); + CombinedFaucet faucet = CombinedFaucet( + payable(address(new TransparentUpgradeableProxy(address(faucetLogic), address(proxyAdmin), ""))) + ); + faucet.initialize(exocoreValidatorSet.addr, exoEthFaucet ? tokenAddr : address(0), 1 ether); + vm.stopBroadcast(); + // do not store them as JSON since the address is intentionally kept private + console.log("faucet", address(faucet)); + console.log("faucetLogic", address(faucetLogic)); + console.log("proxyAdmin", address(proxyAdmin)); + } + +} diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol new file mode 100644 index 00000000..18838d0f --- /dev/null +++ b/src/core/CombinedFaucet.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +contract CombinedFaucet is + IERC165, + IERC1155Receiver, + IERC721Receiver, + Initializable, + PausableUpgradeable, + OwnableUpgradeable, + ReentrancyGuardUpgradeable +{ + + address public token; + uint256 public tokenAmount; + uint256 public constant ONE_DAY = 1 days; + + mapping(address => uint256) public lastRequestTime; + + event TokenAddressUpdated(address newTokenAddress); + event TokenAmountUpdated(uint256 newTokenAmount); + event TokensRequested(address indexed user, uint256 amount); + + constructor() { + _disableInitializers(); + } + + /// @dev Initialize the contract, set the owner, token address, and token amount + /// @param owner_ The owner of the contract + /// @param token_ The address of the token to distribute + /// @param tokenAmount_ The amount of tokens to distribute at each request + function initialize(address owner_, address token_, uint256 tokenAmount_) public initializer { + // The token address can be 0 to support native token + // slither-disable-next-line missing-zero-check + token = token_; + tokenAmount = tokenAmount_; + + _transferOwnership(owner_); + __Pausable_init_unchained(); + __ReentrancyGuard_init_unchained(); + } + + /// @dev Request tokens from the faucet + /// @notice Users can request tokens once every 24 hours + function requestTokens() external whenNotPaused nonReentrant { + require(token != address(0), "CombinedFaucet: not for native tokens"); + _withdraw(msg.sender); + } + + /// @dev Give native tokens to a user (who doesn't have any to pay for gas) + /// @param user The user to give tokens to + function withdraw(address user) external whenNotPaused onlyOwner { + require(token == address(0), "CombinedFaucet: only for native tokens"); + _withdraw(user); + } + + function _withdraw(address dst) internal { + if (lastRequestTime[dst] != 0) { + require( + block.timestamp >= lastRequestTime[dst] + ONE_DAY, + "CombinedFaucet: Rate limit exceeded. Please wait 24 hours." + ); + } + lastRequestTime[dst] = block.timestamp; + if (token != address(0)) { + bool success = IERC20(token).transfer(dst, tokenAmount); + require(success, "CombinedFaucet: token transfer failed"); + } else { + (bool success,) = payable(dst).call{value: tokenAmount}(""); + require(success, "CombinedFaucet: wei transfer failed"); + } + emit TokensRequested(dst, tokenAmount); + } + + /// @dev Update the token address (Only owner can update) + /// @param token_ The new token address + function setTokenAddress(address token_) external onlyOwner { + // The token address can be 0 to support native token + // slither-disable-next-line missing-zero-check + token = token_; + emit TokenAddressUpdated(token_); + } + + /// @dev Update the token amount to distribute (Only owner can update) + /// @param tokenAmount_ The new token amount + function setTokenAmount(uint256 tokenAmount_) external onlyOwner { + tokenAmount = tokenAmount_; + emit TokenAmountUpdated(tokenAmount_); + } + + /// @dev Pause the contract (Only owner can pause) + function pause() external onlyOwner { + _pause(); + } + + /// @dev Unpause the contract (Only owner can unpause) + function unpause() external onlyOwner { + _unpause(); + } + + /// @dev Recover any tokens sent to the contract by mistake (Only owner) + /// @param token_ The token address to recover + /// @param amount_ The amount to recover + function recoverTokens(address token_, uint256 amount_) external nonReentrant onlyOwner { + if (token_ != address(0)) { + bool success = IERC20(token_).transfer(owner(), amount_); + require(success, "CombinedFaucet: token transfer failed"); + } else { + (bool success,) = payable(owner()).call{value: amount_}(""); + require(success, "CombinedFaucet: wei transfer failed"); + } + } + + /// @dev Always revert when ERC721 tokens are sent to this contract. + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + revert("Faucet: ERC721 tokens not accepted"); + } + + /// @dev Always revert when ERC1155 tokens are sent to this contract. + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) { + revert("Faucet: ERC1155 tokens not accepted"); + } + + /// @dev Always revert when ERC1155 batch tokens are sent to this contract. + function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata) + external + pure + returns (bytes4) + { + revert("Faucet: ERC1155 batch tokens not accepted"); + } + + /// @dev ERC165 interface support check. + /// Automatically derives the interface selectors for ERC165, ERC721Receiver, and ERC1155Receiver. + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return ( + interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC721Receiver).interfaceId + || interfaceId == type(IERC1155Receiver).interfaceId + ); + } + + // Allow the contract to receive native token + receive() external payable {} + +} diff --git a/test/foundry/unit/CombinedFaucet.t.sol b/test/foundry/unit/CombinedFaucet.t.sol new file mode 100644 index 00000000..16ccb1d0 --- /dev/null +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {CombinedFaucet} from "../../../src/core/CombinedFaucet.sol"; +import {ERC20PresetMinterPauser} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import "forge-std/Test.sol"; + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +contract BaseFaucetTest is Test { + + CombinedFaucet public faucet; + address public owner; + address public user1; + address public user2; + + uint256 public tokenAmount = 1 ether; // Amount to be distributed in each request + + function setUp() public virtual { + // Initialize the test environment + owner = address(0x3); + user1 = address(0x1); + user2 = address(0x2); + } + + function _deploy(address token) internal { + // Deploy the faucet and initialize it + address proxyAdmin = address(new ProxyAdmin()); + CombinedFaucet faucetLogic = new CombinedFaucet(); + faucet = CombinedFaucet( + payable(address(new TransparentUpgradeableProxy(address(faucetLogic), address(proxyAdmin), ""))) + ); + faucet.initialize(owner, token, tokenAmount); + } + +} + +// case of ERC20 faucet, separate from native token faucet +contract ERC20FaucetTest is BaseFaucetTest { + + ERC20PresetMinterPauser public token; + + function setUp() public override { + super.setUp(); + // Deploy a mock ERC20 token + token = new ERC20PresetMinterPauser("Test Token", "TST"); + token.mint(owner, tokenAmount * 10); + token.mint(user1, tokenAmount); + + _deploy(address(token)); + + // Mint tokens to the faucet + token.mint(address(faucet), tokenAmount * 5); + } + + function testInitialization() public { + // Check if the initialization is correct + assertEq(faucet.tokenAmount(), tokenAmount); + assertEq(faucet.token(), address(token)); + } + + function testRequestTokens() public { + // Initial token balance of user1 should be `tokenAmount` TST + assertEq(token.balanceOf(user1), tokenAmount); + + // Simulate user1 requesting tokens from the faucet + vm.prank(user1); + faucet.requestTokens(); + + // Check if the tokens were transferred + assertEq(token.balanceOf(user1), tokenAmount * 2); + assertEq(token.balanceOf(address(faucet)), tokenAmount * 4); + + // Ensure 24h rate limit is enforced + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); + vm.prank(user1); + faucet.requestTokens(); + + // Should work now + vm.warp(block.timestamp + 1 days); + vm.prank(user1); + faucet.requestTokens(); + assertEq(token.balanceOf(user1), tokenAmount * 3); + } + + function testRateLimit() public { + // Request tokens for the first time + vm.prank(user1); + faucet.requestTokens(); + + // Try again before 24 hours have passed + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); + vm.prank(user1); + faucet.requestTokens(); + + // Fast forward time by 24 hours + vm.warp(block.timestamp + 1 days); + + // Should work now + vm.prank(user1); + faucet.requestTokens(); + assertEq(token.balanceOf(user1), tokenAmount * 3); + } + + function testOnlyOwnerCanSetTokenAddress() public { + // Try setting the token address as a non-owner + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + faucet.setTokenAddress(address(0xdead)); + + // Set the token address as the owner + vm.prank(owner); + faucet.setTokenAddress(address(0xdead)); + assertEq(faucet.token(), address(0xdead)); + } + + function testOnlyOwnerCanSetTokenAmount() public { + // Try setting the token amount as a non-owner + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + faucet.setTokenAmount(50 ether); + + // Set the token amount as the owner + vm.prank(owner); + faucet.setTokenAmount(50 ether); + assertEq(faucet.tokenAmount(), 50 ether); + } + + function testPauseUnpause() public { + // Pause the contract as the owner + vm.prank(owner); + faucet.pause(); + assertEq(faucet.paused(), true); + + // Try requesting tokens while paused + vm.prank(user1); + vm.expectRevert("Pausable: paused"); + faucet.requestTokens(); + + // Unpause the contract + vm.prank(owner); + faucet.unpause(); + assertEq(faucet.paused(), false); + + // Request tokens should work now + vm.prank(user1); + faucet.requestTokens(); + assertEq(token.balanceOf(user1), tokenAmount * 2); + } + + function testRecoverTokens() public { + // Initially, owner has 10 * `tokenAmount` TST tokens + assertEq(token.balanceOf(owner), tokenAmount * 10); + + // Call recoverTokens as the owner + vm.prank(owner); + faucet.recoverTokens(address(token), tokenAmount); + + // Owner should recover `tokenAmount` TST tokens + assertEq(token.balanceOf(owner), tokenAmount * 11); + } + + function testCannotWithdrawNativeTokens() public { + // Try withdrawing native tokens from the faucet + vm.expectRevert("CombinedFaucet: only for native tokens"); + vm.prank(owner); + faucet.withdraw(user1); + } + + function testRejectERC721() public { + // Try sending an ERC721 token, it should revert + vm.expectRevert("Faucet: ERC721 tokens not accepted"); + vm.prank(user1); + faucet.onERC721Received(user1, user2, 1, ""); + } + +} + +// case of native token faucet, separate from ERC20 faucet +contract NativeTokenFaucetTest is BaseFaucetTest { + + function setUp() public override { + super.setUp(); + vm.deal(owner, tokenAmount * 10); + _deploy(address(0)); + vm.deal(address(faucet), tokenAmount * 5); + } + + function testInitialization() public { + // Check if the initialization is correct + assertEq(faucet.tokenAmount(), tokenAmount); + assertEq(faucet.token(), address(0)); + } + + function testRequestTokens() public { + // Initial token balance of user1 should be 0 + assertEq(user1.balance, 0); + + // Simulate user1 requesting tokens from the faucet + vm.prank(owner); + faucet.withdraw(user1); + + // Check if the tokens were transferred + assertEq(user1.balance, tokenAmount * 1); + assertEq(address(faucet).balance, tokenAmount * 4); + + // Ensure 24h rate limit is enforced + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); + vm.prank(owner); + faucet.withdraw(user1); + + // Should work now + vm.warp(block.timestamp + 1 days); + vm.prank(owner); + faucet.withdraw(user1); + assertEq(user1.balance, tokenAmount * 2); + } + + function testOnlyOwnerCanRequestTokens() public { + // Try requesting tokens as a non-owner + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + faucet.withdraw(user1); + + // Request tokens as the owner + vm.prank(owner); + faucet.withdraw(user1); + assertEq(user1.balance, tokenAmount); + } + + function testCannotRequestNativeTokens() public { + // Try requesting tokens from the faucet + vm.expectRevert("CombinedFaucet: not for native tokens"); + vm.prank(owner); + faucet.requestTokens(); + } + + function testRateLimit() public { + // Request tokens for the first time + vm.prank(owner); + faucet.withdraw(user1); + + // Try again before 24 hours have passed + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); + vm.prank(owner); + faucet.withdraw(user1); + + // Fast forward time by 24 hours + vm.warp(block.timestamp + 1 days); + + // Should work now + vm.prank(owner); + faucet.withdraw(user1); + assertEq(user1.balance, tokenAmount * 2); + } + + function testOnlyOwnerCanSetTokenAddress() public { + // Try setting the token address as a non-owner + vm.deal(user1, 1 ether); // for gas + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + faucet.setTokenAddress(address(0xdead)); + + // Set the token address as the owner + vm.prank(owner); + faucet.setTokenAddress(address(0xdead)); + assertEq(faucet.token(), address(0xdead)); + } + + function testOnlyOwnerCanSetTokenAmount() public { + // Try setting the token amount as a non-owner + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + faucet.setTokenAmount(50 ether); + + // Set the token amount as the owner + vm.prank(owner); + faucet.setTokenAmount(50 ether); + assertEq(faucet.tokenAmount(), 50 ether); + } + + function testPauseUnpause() public { + // Pause the contract as the owner + vm.prank(owner); + faucet.pause(); + assertEq(faucet.paused(), true); + + // Try requesting tokens while paused + vm.prank(owner); + vm.expectRevert("Pausable: paused"); + faucet.withdraw(user1); + + // Unpause the contract + vm.prank(owner); + faucet.unpause(); + assertEq(faucet.paused(), false); + + // Request tokens should work now + vm.prank(owner); + faucet.withdraw(user1); + assertEq(user1.balance, tokenAmount); + } + + function testRecoverTokens() public { + // Initially, owner has 10 * `tokenAmount` native tokens + assertEq(owner.balance, tokenAmount * 10); + assertEq(address(faucet).balance, tokenAmount * 5); + + // Call recoverTokens as the owner + vm.prank(owner); + faucet.recoverTokens(address(0), tokenAmount); + + // Owner should recover `tokenAmount` native tokens + assertEq(owner.balance, tokenAmount * 11); + assertEq(address(faucet).balance, tokenAmount * 4); + } + + function testRejectERC721() public { + // Try sending an ERC721 token, it should revert + vm.expectRevert("Faucet: ERC721 tokens not accepted"); + vm.prank(user1); + faucet.onERC721Received(user1, user2, 1, ""); + } + +}