From 764c3cd15f804f42a91087ea0ef58becc8f4d1c0 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 02:34:06 +0000 Subject: [PATCH 01/10] feat: introduce LZ script to replay msg https://github.com/DanL0/integrations-foundry-tooling but with better logging in our context --- script/18_SimulateReceive.s.sol | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 script/18_SimulateReceive.s.sol diff --git a/script/18_SimulateReceive.s.sol b/script/18_SimulateReceive.s.sol new file mode 100644 index 00000000..eb75176f --- /dev/null +++ b/script/18_SimulateReceive.s.sol @@ -0,0 +1,66 @@ +// 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 setUp() public { + // 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(40_161)), ASSETS_PRECOMPILE_ADDRESS); + deployCodeTo("DelegationMock.sol", DELEGATION_PRECOMPILE_ADDRESS); + } + + 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"); + 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))); + } + +} From 20e14ecb60bbc286863cfdcc25d8ee6e1d25d984 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 02:35:04 +0000 Subject: [PATCH 02/10] feat: add faucet code + script + test --- script/19_DeployFaucet.s.sol | 66 ++++++ src/core/CombinedFaucet.sol | 150 +++++++++++++ test/foundry/unit/CombinedFaucet.t.sol | 298 +++++++++++++++++++++++++ 3 files changed, 514 insertions(+) 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/19_DeployFaucet.s.sol b/script/19_DeployFaucet.s.sol new file mode 100644 index 00000000..0be66d4c --- /dev/null +++ b/script/19_DeployFaucet.s.sol @@ -0,0 +1,66 @@ +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 prerequisities = vm.readFile("script/prerequisiteContracts.json"); + + tokenAddr = stdJson.readAddress(prerequisities, ".clientChain.erc20Token"); + require(tokenAddr != address(0), "restake 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 { + if (exoEthFaucet) { + vm.selectFork(clientChain); + vm.startBroadcast(exocoreValidatorSet.privateKey); + address exoEthProxyAdmin = address(new ProxyAdmin()); + CombinedFaucet exoEthFaucetLogic = new CombinedFaucet(); + CombinedFaucet exoEthFaucet = CombinedFaucet( + payable(address(new TransparentUpgradeableProxy(address(exoEthFaucetLogic), address(exoEthProxyAdmin), ""))) + ); + // give 1 exoETH per request + exoEthFaucet.initialize(exocoreValidatorSet.addr, tokenAddr, 1 ether); + vm.stopBroadcast(); + // do not store them as JSON since the address is intentionally kept private + console.log("exoEthFaucet", address(exoEthFaucet)); + console.log("exoEthFaucetLogic", address(exoEthFaucetLogic)); + console.log("exoEthProxyAdmin", address(exoEthProxyAdmin)); + } else { + vm.selectFork(exocore); + vm.startBroadcast(exocoreValidatorSet.privateKey); + address exoProxyAdmin = address(new ProxyAdmin()); + CombinedFaucet exoFaucetLogic = new CombinedFaucet(); + CombinedFaucet exoFaucet = CombinedFaucet( + payable(address(new TransparentUpgradeableProxy(address(exoFaucetLogic), address(exoProxyAdmin), ""))) + ); + // give 1 exo per request + exoFaucet.initialize(faucetOwner, address(0), 1 ether); + vm.stopBroadcast(); + // do not store them as JSON since the address is intentionally kept private + console.log("exoFaucet", address(exoFaucet)); + console.log("exoFaucetLogic", address(exoFaucetLogic)); + console.log("exoProxyAdmin", address(exoProxyAdmin)); + } + } + +} diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol new file mode 100644 index 00000000..4d27844e --- /dev/null +++ b/src/core/CombinedFaucet.sol @@ -0,0 +1,150 @@ +// 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 { + 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 { + require( + block.timestamp >= lastRequestTime[dst] + ONE_DAY || lastRequestTime[msg.sender] == 0, + "CombinedFaucet: 24h rate limit breached" + ); + 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 { + 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 == IERC165.supportsInterface.selector + || interfaceId == IERC721Receiver.onERC721Received.selector + || interfaceId == IERC1155Receiver.onERC1155Received.selector + ); + } + + // 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..29fa315e --- /dev/null +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -0,0 +1,298 @@ +// 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"; // For mock ERC20 token + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +// case of ERC20 faucet, separate from native token faucet +contract ERC20FaucetTest is Test { + + CombinedFaucet public faucet; + ERC20PresetMinterPauser public token; + address public owner; + address public user1; + address public user2; + + uint256 public tokenAmount = 1 ether; // Amount to be distributed in each request + + function setUp() public { + // Initialize the test environment + owner = address(0x3); + user1 = address(0x1); + user2 = address(0x2); + + // Deploy a mock ERC20 token + token = new ERC20PresetMinterPauser("Test Token", "TST"); + + // Mint some tokens to the faucet and owner for testing + token.mint(owner, tokenAmount * 10); + token.mint(user1, tokenAmount); + + // 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, address(token), tokenAmount); + + // Transfer 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 100 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: 24h rate limit breached"); + vm.prank(user1); + faucet.requestTokens(); + } + + function testRateLimit() public { + // Request tokens for the first time + vm.prank(user1); + faucet.requestTokens(); + + // Try again before 24 hours have passed + vm.expectRevert("CombinedFaucet: 24h rate limit breached"); + 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 0 extra tokens + assertEq(token.balanceOf(owner), tokenAmount * 10); + + // Call recoverTokens as the owner + vm.prank(owner); + faucet.recoverTokens(address(token), tokenAmount); + + // Owner should recover 100 TST tokens + assertEq(token.balanceOf(owner), tokenAmount * 11); + } + + 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 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 { + // Initialize the test environment + owner = address(0x3); + user1 = address(0x1); + user2 = address(0x2); + + vm.deal(owner, tokenAmount * 10); + + // 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, address(0), tokenAmount); + + // Transfer tokens to the faucet + 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 100 TST + 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: 24h rate limit breached"); + vm.prank(owner); + faucet.withdraw(user1); + } + + 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: 24h rate limit breached"); + 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 0 extra 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 100 TST 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, ""); + } + +} From 6d470f31ac15860f90670d268bb3e3c5af741d98 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 02:40:46 +0000 Subject: [PATCH 03/10] fix test and forge fmt --- script/18_SimulateReceive.s.sol | 7 +------ script/19_DeployFaucet.s.sol | 4 +++- src/core/CombinedFaucet.sol | 2 +- test/foundry/unit/CombinedFaucet.t.sol | 4 ++-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/script/18_SimulateReceive.s.sol b/script/18_SimulateReceive.s.sol index eb75176f..a67d8a0d 100644 --- a/script/18_SimulateReceive.s.sol +++ b/script/18_SimulateReceive.s.sol @@ -48,12 +48,7 @@ contract SimulateReceive is Script, StdCheats { bytes memory extraData = ""; vm.startBroadcast(); bytes memory encoded = abi.encodeWithSelector( - IOAppCore(receiver).endpoint().lzReceive.selector, - origin, - receiver, - guid, - payload, - extraData + IOAppCore(receiver).endpoint().lzReceive.selector, origin, receiver, guid, payload, extraData ); console.logBytes(encoded); IOAppCore(receiver).endpoint().lzReceive(origin, receiver, guid, payload, extraData); diff --git a/script/19_DeployFaucet.s.sol b/script/19_DeployFaucet.s.sol index 0be66d4c..4ab4bc4c 100644 --- a/script/19_DeployFaucet.s.sol +++ b/script/19_DeployFaucet.s.sol @@ -36,7 +36,9 @@ contract DeployScript is BaseScript { address exoEthProxyAdmin = address(new ProxyAdmin()); CombinedFaucet exoEthFaucetLogic = new CombinedFaucet(); CombinedFaucet exoEthFaucet = CombinedFaucet( - payable(address(new TransparentUpgradeableProxy(address(exoEthFaucetLogic), address(exoEthProxyAdmin), ""))) + payable( + address(new TransparentUpgradeableProxy(address(exoEthFaucetLogic), address(exoEthProxyAdmin), "")) + ) ); // give 1 exoETH per request exoEthFaucet.initialize(exocoreValidatorSet.addr, tokenAddr, 1 ether); diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol index 4d27844e..8362747e 100644 --- a/src/core/CombinedFaucet.sol +++ b/src/core/CombinedFaucet.sol @@ -64,7 +64,7 @@ contract CombinedFaucet is function _withdraw(address dst) internal { require( - block.timestamp >= lastRequestTime[dst] + ONE_DAY || lastRequestTime[msg.sender] == 0, + block.timestamp >= lastRequestTime[dst] + ONE_DAY || lastRequestTime[dst] == 0, "CombinedFaucet: 24h rate limit breached" ); lastRequestTime[dst] = block.timestamp; diff --git a/test/foundry/unit/CombinedFaucet.t.sol b/test/foundry/unit/CombinedFaucet.t.sol index 29fa315e..c7906894 100644 --- a/test/foundry/unit/CombinedFaucet.t.sol +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -218,10 +218,10 @@ contract NativeTokenFaucetTest is Test { vm.prank(owner); faucet.withdraw(user1); - // Fast forward time by 24 hours + // // Fast forward time by 24 hours vm.warp(block.timestamp + 1 days); - // Should work now + // // Should work now vm.prank(owner); faucet.withdraw(user1); assertEq(user1.balance, tokenAmount * 2); From 2203ad1d695e47279c5d593887cdb93775e1d128 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 03:27:44 +0000 Subject: [PATCH 04/10] fix: pacify slither --- src/core/CombinedFaucet.sol | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol index 8362747e..8124303c 100644 --- a/src/core/CombinedFaucet.sol +++ b/src/core/CombinedFaucet.sol @@ -40,6 +40,8 @@ contract CombinedFaucet is /// @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_; @@ -63,10 +65,9 @@ contract CombinedFaucet is } function _withdraw(address dst) internal { - require( - block.timestamp >= lastRequestTime[dst] + ONE_DAY || lastRequestTime[dst] == 0, - "CombinedFaucet: 24h rate limit breached" - ); + if (lastRequestTime[dst] != 0) { + require(block.timestamp >= lastRequestTime[dst] + ONE_DAY, "CombinedFaucet: 24h rate limit breached"); + } lastRequestTime[dst] = block.timestamp; if (token != address(0)) { bool success = IERC20(token).transfer(dst, tokenAmount); @@ -81,6 +82,8 @@ contract CombinedFaucet is /// @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_); } From f56f7c02f31c8d6977471ba6e44df1ebcae302e6 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 03:36:24 +0000 Subject: [PATCH 05/10] fix: use correct IERC165 logic --- src/core/CombinedFaucet.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol index 8124303c..10ee59f3 100644 --- a/src/core/CombinedFaucet.sol +++ b/src/core/CombinedFaucet.sol @@ -141,9 +141,8 @@ contract CombinedFaucet is /// Automatically derives the interface selectors for ERC165, ERC721Receiver, and ERC1155Receiver. function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return ( - interfaceId == IERC165.supportsInterface.selector - || interfaceId == IERC721Receiver.onERC721Received.selector - || interfaceId == IERC1155Receiver.onERC1155Received.selector + interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC721Receiver).interfaceId + || interfaceId == type(IERC1155Receiver).interfaceId ); } From 7f7565b7d89f59906adab10e8a902fd2e4f9e5e2 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 03:36:39 +0000 Subject: [PATCH 06/10] remove duplicated code --- script/19_DeployFaucet.s.sol | 52 +++++++++++------------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/script/19_DeployFaucet.s.sol b/script/19_DeployFaucet.s.sol index 4ab4bc4c..280823bb 100644 --- a/script/19_DeployFaucet.s.sol +++ b/script/19_DeployFaucet.s.sol @@ -16,10 +16,10 @@ contract DeployScript is BaseScript { function setUp() public virtual override { super.setUp(); - string memory prerequisities = vm.readFile("script/prerequisiteContracts.json"); + string memory prerequisites = vm.readFile("script/prerequisiteContracts.json"); - tokenAddr = stdJson.readAddress(prerequisities, ".clientChain.erc20Token"); - require(tokenAddr != address(0), "restake token address should not be empty"); + tokenAddr = stdJson.readAddress(prerequisites, ".clientChain.erc20Token"); + require(tokenAddr != address(0), "token address should not be empty"); clientChain = vm.createSelectFork(clientChainRPCURL); exocore = vm.createSelectFork(exocoreRPCURL); @@ -30,39 +30,19 @@ contract DeployScript is BaseScript { } function run() public { - if (exoEthFaucet) { - vm.selectFork(clientChain); - vm.startBroadcast(exocoreValidatorSet.privateKey); - address exoEthProxyAdmin = address(new ProxyAdmin()); - CombinedFaucet exoEthFaucetLogic = new CombinedFaucet(); - CombinedFaucet exoEthFaucet = CombinedFaucet( - payable( - address(new TransparentUpgradeableProxy(address(exoEthFaucetLogic), address(exoEthProxyAdmin), "")) - ) - ); - // give 1 exoETH per request - exoEthFaucet.initialize(exocoreValidatorSet.addr, tokenAddr, 1 ether); - vm.stopBroadcast(); - // do not store them as JSON since the address is intentionally kept private - console.log("exoEthFaucet", address(exoEthFaucet)); - console.log("exoEthFaucetLogic", address(exoEthFaucetLogic)); - console.log("exoEthProxyAdmin", address(exoEthProxyAdmin)); - } else { - vm.selectFork(exocore); - vm.startBroadcast(exocoreValidatorSet.privateKey); - address exoProxyAdmin = address(new ProxyAdmin()); - CombinedFaucet exoFaucetLogic = new CombinedFaucet(); - CombinedFaucet exoFaucet = CombinedFaucet( - payable(address(new TransparentUpgradeableProxy(address(exoFaucetLogic), address(exoProxyAdmin), ""))) - ); - // give 1 exo per request - exoFaucet.initialize(faucetOwner, address(0), 1 ether); - vm.stopBroadcast(); - // do not store them as JSON since the address is intentionally kept private - console.log("exoFaucet", address(exoFaucet)); - console.log("exoFaucetLogic", address(exoFaucetLogic)); - console.log("exoProxyAdmin", address(exoProxyAdmin)); - } + 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)); } } From a1c6aa76e855916507033e8fcc37c21351dcc50c Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 03:46:15 +0000 Subject: [PATCH 07/10] refactor: update test --- test/foundry/unit/CombinedFaucet.t.sol | 102 ++++++++++++++----------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/test/foundry/unit/CombinedFaucet.t.sol b/test/foundry/unit/CombinedFaucet.t.sol index c7906894..358893d6 100644 --- a/test/foundry/unit/CombinedFaucet.t.sol +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -8,39 +8,49 @@ import "forge-std/Test.sol"; // For mock ERC20 token import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -// case of ERC20 faucet, separate from native token faucet -contract ERC20FaucetTest is Test { +contract BaseFaucetTest is Test { CombinedFaucet public faucet; - ERC20PresetMinterPauser public token; address public owner; address public user1; address public user2; uint256 public tokenAmount = 1 ether; // Amount to be distributed in each request - function setUp() public { + function setUp() public virtual { // Initialize the test environment owner = address(0x3); user1 = address(0x1); user2 = address(0x2); + } - // Deploy a mock ERC20 token - token = new ERC20PresetMinterPauser("Test Token", "TST"); - - // Mint some tokens to the faucet and owner for testing - token.mint(owner, tokenAmount * 10); - token.mint(user1, tokenAmount); - + 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, address(token), tokenAmount); + 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); - // Transfer tokens to the faucet + _deploy(address(token)); + + // Mint tokens to the faucet token.mint(address(faucet), tokenAmount * 5); } @@ -51,7 +61,7 @@ contract ERC20FaucetTest is Test { } function testRequestTokens() public { - // Initial token balance of user1 should be 100 TST + // Initial token balance of user1 should be `tokenAmount` TST assertEq(token.balanceOf(user1), tokenAmount); // Simulate user1 requesting tokens from the faucet @@ -134,17 +144,24 @@ contract ERC20FaucetTest is Test { } function testRecoverTokens() public { - // Initially, owner has 0 extra tokens + // 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 100 TST tokens + // 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"); @@ -155,32 +172,12 @@ contract ERC20FaucetTest is Test { } // case of native token faucet, separate from ERC20 faucet -contract NativeTokenFaucetTest 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 { - // Initialize the test environment - owner = address(0x3); - user1 = address(0x1); - user2 = address(0x2); +contract NativeTokenFaucetTest is BaseFaucetTest { + function setUp() public override { + super.setUp(); vm.deal(owner, tokenAmount * 10); - - // 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, address(0), tokenAmount); - - // Transfer tokens to the faucet + _deploy(address(0)); vm.deal(address(faucet), tokenAmount * 5); } @@ -191,7 +188,7 @@ contract NativeTokenFaucetTest is Test { } function testRequestTokens() public { - // Initial token balance of user1 should be 100 TST + // Initial token balance of user1 should be 0 assertEq(user1.balance, 0); // Simulate user1 requesting tokens from the faucet @@ -208,6 +205,25 @@ contract NativeTokenFaucetTest is Test { faucet.withdraw(user1); } + 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); @@ -275,7 +291,7 @@ contract NativeTokenFaucetTest is Test { } function testRecoverTokens() public { - // Initially, owner has 0 extra tokens + // Initially, owner has 10 * `tokenAmount` native tokens assertEq(owner.balance, tokenAmount * 10); assertEq(address(faucet).balance, tokenAmount * 5); @@ -283,7 +299,7 @@ contract NativeTokenFaucetTest is Test { vm.prank(owner); faucet.recoverTokens(address(0), tokenAmount); - // Owner should recover 100 TST tokens + // Owner should recover `tokenAmount` native tokens assertEq(owner.balance, tokenAmount * 11); assertEq(address(faucet).balance, tokenAmount * 4); } From 897a08585c35ce2953329025dbec5a970b98f570 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 03:49:49 +0000 Subject: [PATCH 08/10] refactor: update error message --- src/core/CombinedFaucet.sol | 5 ++++- test/foundry/unit/CombinedFaucet.t.sol | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/core/CombinedFaucet.sol b/src/core/CombinedFaucet.sol index 10ee59f3..18838d0f 100644 --- a/src/core/CombinedFaucet.sol +++ b/src/core/CombinedFaucet.sol @@ -66,7 +66,10 @@ contract CombinedFaucet is function _withdraw(address dst) internal { if (lastRequestTime[dst] != 0) { - require(block.timestamp >= lastRequestTime[dst] + ONE_DAY, "CombinedFaucet: 24h rate limit breached"); + require( + block.timestamp >= lastRequestTime[dst] + ONE_DAY, + "CombinedFaucet: Rate limit exceeded. Please wait 24 hours." + ); } lastRequestTime[dst] = block.timestamp; if (token != address(0)) { diff --git a/test/foundry/unit/CombinedFaucet.t.sol b/test/foundry/unit/CombinedFaucet.t.sol index 358893d6..80c3ea62 100644 --- a/test/foundry/unit/CombinedFaucet.t.sol +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -73,7 +73,7 @@ contract ERC20FaucetTest is BaseFaucetTest { assertEq(token.balanceOf(address(faucet)), tokenAmount * 4); // Ensure 24h rate limit is enforced - vm.expectRevert("CombinedFaucet: 24h rate limit breached"); + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); vm.prank(user1); faucet.requestTokens(); } @@ -84,7 +84,7 @@ contract ERC20FaucetTest is BaseFaucetTest { faucet.requestTokens(); // Try again before 24 hours have passed - vm.expectRevert("CombinedFaucet: 24h rate limit breached"); + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); vm.prank(user1); faucet.requestTokens(); @@ -200,7 +200,7 @@ contract NativeTokenFaucetTest is BaseFaucetTest { assertEq(address(faucet).balance, tokenAmount * 4); // Ensure 24h rate limit is enforced - vm.expectRevert("CombinedFaucet: 24h rate limit breached"); + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); vm.prank(owner); faucet.withdraw(user1); } @@ -230,14 +230,14 @@ contract NativeTokenFaucetTest is BaseFaucetTest { faucet.withdraw(user1); // Try again before 24 hours have passed - vm.expectRevert("CombinedFaucet: 24h rate limit breached"); + vm.expectRevert("CombinedFaucet: Rate limit exceeded. Please wait 24 hours."); vm.prank(owner); faucet.withdraw(user1); - // // Fast forward time by 24 hours + // Fast forward time by 24 hours vm.warp(block.timestamp + 1 days); - // // Should work now + // Should work now vm.prank(owner); faucet.withdraw(user1); assertEq(user1.balance, tokenAmount * 2); From d58883e8eb3e8bd4d6d3f5acb796f55d1429d8fd Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 04:16:11 +0000 Subject: [PATCH 09/10] test: update scenario --- test/foundry/unit/CombinedFaucet.t.sol | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/foundry/unit/CombinedFaucet.t.sol b/test/foundry/unit/CombinedFaucet.t.sol index 80c3ea62..16ccb1d0 100644 --- a/test/foundry/unit/CombinedFaucet.t.sol +++ b/test/foundry/unit/CombinedFaucet.t.sol @@ -3,7 +3,7 @@ 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"; // For mock ERC20 token +import "forge-std/Test.sol"; import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; @@ -76,6 +76,12 @@ contract ERC20FaucetTest is BaseFaucetTest { 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 { @@ -203,6 +209,12 @@ contract NativeTokenFaucetTest is BaseFaucetTest { 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 { From c5837630fbcb7213c10f237c66da49fc7ef6d9d7 Mon Sep 17 00:00:00 2001 From: MaxMustermann2 <82761650+MaxMustermann2@users.noreply.github.com> Date: Mon, 14 Oct 2024 04:28:01 +0000 Subject: [PATCH 10/10] refactor: use Eid from json in mock --- script/18_SimulateReceive.s.sol | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/script/18_SimulateReceive.s.sol b/script/18_SimulateReceive.s.sol index a67d8a0d..65fd03be 100644 --- a/script/18_SimulateReceive.s.sol +++ b/script/18_SimulateReceive.s.sol @@ -19,18 +19,15 @@ contract SimulateReceive is Script, StdCheats { using stdJson for string; - function setUp() public { - // 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(40_161)), ASSETS_PRECOMPILE_ADDRESS); - deployCodeTo("DelegationMock.sol", DELEGATION_PRECOMPILE_ADDRESS); - } - 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"));