From e60588cd5d455d0237ba7f7860d575a727f52103 Mon Sep 17 00:00:00 2001 From: D <51912515+adaki2004@users.noreply.github.com> Date: Tue, 13 Feb 2024 13:56:10 +0530 Subject: [PATCH] feat(protocol): add ERC20Airdrop test and deployment script (#15752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Keszey Dániel --- .../protocol/script/DeployERC20Airdrop.s.sol | 67 ++++++++ .../test/team/airdrop/ERC20Airdrop.t.sol | 152 ++++++++++++++++-- .../team/airdrop/LibDelegationSigUtil.sol | 67 ++++++++ 3 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 packages/protocol/script/DeployERC20Airdrop.s.sol create mode 100644 packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol diff --git a/packages/protocol/script/DeployERC20Airdrop.s.sol b/packages/protocol/script/DeployERC20Airdrop.s.sol new file mode 100644 index 00000000000..a355b0d0f2b --- /dev/null +++ b/packages/protocol/script/DeployERC20Airdrop.s.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +// _____ _ _ _ _ +// |_ _|_ _(_) |_____ | | __ _| |__ ___ +// | |/ _` | | / / _ \ | |__/ _` | '_ (_-< +// |_|\__,_|_|_\_\___/ |____\__,_|_.__/__/ +// +// Email: security@taiko.xyz +// Website: https://taiko.xyz +// GitHub: https://github.com/taikoxyz +// Discord: https://discord.gg/taikoxyz +// Twitter: https://twitter.com/taikoxyz +// Blog: https://mirror.xyz/labs.taiko.eth +// Youtube: https://www.youtube.com/@taikoxyz + +pragma solidity 0.8.24; + +import "../test/DeployCapability.sol"; +import "forge-std/console2.sol"; + +import "../contracts/team/airdrop/ERC20Airdrop.sol"; + +// @KorbinianK , @2manslkh +// As written also in the tests the workflow shall be the following (checklist): +// 1. Is Vault - which will store the tokens - deployed ? +// 2. Is (bridged) TKO token existing ? +// 3. Is ERC20Airdrop contract is 'approved operator' on the TKO token ? +// 4. Proof (merkle root) and minting window related variabes (start, end) set ? +// If YES the answer to all above, we can go live with airdrop, which is like: +// 1. User go to website. -> For sake of simplicity he is eligible +// 2. User wants to mint, but first site established the delegateHash (user sets a delegatee) which +// the user signs +// 3. Backend retrieves the proof and together with signature in the input params, user fires away +// the claimAndDelegate() transaction. +contract DeployERC20Airdrop is DeployCapability { + uint256 public deployerPrivKey = vm.envUint("PRIVATE_KEY"); // Owner of the ERC20 airdrop + // contract + address public bridgedTko = vm.envAddress("BRIDGED_TKO_ADDRESS"); + address public vaultAddress = vm.envAddress("VAULT_ADDRESS"); + + function setUp() external { } + + function run() external { + require(deployerPrivKey != 0, "invalid deployer priv key"); + require(vaultAddress != address(0), "invalid vault address"); + require(bridgedTko != address(0), "invalid bridged tko address"); + + vm.startBroadcast(deployerPrivKey); + + ERC20Airdrop( + deployProxy({ + name: "ERC20Airdrop", + impl: address(new ERC20Airdrop()), + data: abi.encodeCall(ERC20Airdrop.init, (0, 0, bytes32(0), bridgedTko, vaultAddress)) + }) + ); + + /// @dev Once the Vault is done, we need to have a contract in that vault through which we + /// authorize the airdrop contract to be a spender of the vault. + // example: + // + // SOME_VAULT_CONTRACT(vaultAddress).approveAirdropContractAsSpender( + // bridgedTko, address(ERC20Airdrop), 50_000_000_000e18 + // ); + + vm.stopBroadcast(); + } +} diff --git a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol index c9f458e0f00..24349eff307 100644 --- a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol +++ b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol @@ -2,6 +2,8 @@ pragma solidity 0.8.24; import "../../TaikoTest.sol"; +import "./LibDelegationSigUtil.sol"; +import "lib/openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; contract MockERC20Airdrop is ERC20Airdrop { function _verifyMerkleProof( @@ -18,6 +20,47 @@ contract MockERC20Airdrop is ERC20Airdrop { } } +// Simple mock - so that we do not need to deploy AddressManager (for these tests). With this +// contract we mock an ERC20Vault which mints tokens into a Vault (which holds the TKO). +contract MockAddressManager { + address mockERC20Vault; + + constructor(address _mockERC20Vault) { + mockERC20Vault = _mockERC20Vault; + } + + function getAddress(uint64, /*chainId*/ bytes32 /*name*/ ) public view returns (address) { + return mockERC20Vault; + } +} + +// It does nothing but: +// - stores the tokens for the airdrop +// - owner can call approve() on token, and approving the AirdropERC20.sol contract so it acts on +// behalf +// - funds can later be withdrawn by the user +contract SimpleERC20Vault is OwnableUpgradeable { + /// @notice Initializes the vault. + function init() external initializer { + __Ownable_init(); + } + + function approveAirdropContract( + address token, + address approvedActor, + uint256 amount + ) + public + onlyOwner + { + BridgedERC20(token).approve(approvedActor, amount); + } + + function withdrawFunds(address token, address to) public onlyOwner { + BridgedERC20(token).transfer(to, BridgedERC20(token).balanceOf(address(this))); + } +} + contract TestERC20Airdrop is TaikoTest { address public owner = randAddress(); @@ -29,41 +72,124 @@ contract TestERC20Airdrop is TaikoTest { uint64 public claimStart; uint64 public claimEnd; - TaikoToken token; - ERC20Airdrop airdrop; + BridgedERC20 token; + MockERC20Airdrop airdrop; + MockAddressManager addressManager; + SimpleERC20Vault vault; function setUp() public { - claimStart = uint64(block.timestamp + 10); - claimEnd = uint64(block.timestamp + 10_000); - merkleProof = new bytes32[](3); + vm.startPrank(owner); + + // 1. We need to have a vault + vault = SimpleERC20Vault( + deployProxy({ + name: "vault", + impl: address(new SimpleERC20Vault()), + data: abi.encodeCall(SimpleERC20Vault.init, ()) + }) + ); + + // 2. Need to add it to the AddressManager (below here i'm just mocking it) so that we can + // mint TKO. Basically this step only required in this test. Only thing we need to be sure + // on testnet/mainnet. Vault (which Aridrop transfers from) HAVE tokens. + addressManager = new MockAddressManager(address(vault)); - token = TaikoToken( + // 3. Deploy a bridged TKO token (but on mainnet it will be just a bridged token from L1 to + // L2) - not necessary step on mainnet. + token = BridgedERC20( deployProxy({ - name: "taiko_token", - impl: address(new TaikoToken()), - data: abi.encodeCall(TaikoToken.init, ("Taiko Token", "TKO", owner)) + name: "tko", + impl: address(new BridgedERC20()), + data: abi.encodeCall( + BridgedERC20.init, + (address(addressManager), randAddress(), 100, 18, "TKO", "Taiko Token") + ) }) ); - airdrop = ERC20Airdrop( + vm.stopPrank(); + + // 5. Mint (AKA transfer) to the vault. This step on mainnet will be done by Taiko Labs. For + // testing on A6 the imporatnt thing is: HAVE tokens in this vault! + vm.prank(address(vault), owner); + BridgedERC20(token).mint(address(vault), 1_000_000_000e18); + + // 6. Deploy the airdrop contract, and set the claimStart, claimEnd and merkleRoot -> On + // mainnet it will be separated into 2 tasks obviously, because first we deploy, then we set + // those variables. On testnet (e.g. A6) it shall also be 2 steps easily. Deploy a contract, + // then set merkle. + claimStart = uint64(block.timestamp + 10); + claimEnd = uint64(block.timestamp + 10_000); + merkleProof = new bytes32[](3); + + vm.startPrank(owner); + airdrop = MockERC20Airdrop( deployProxy({ name: "MockERC20Airdrop", impl: address(new MockERC20Airdrop()), data: abi.encodeCall( - ERC20Airdrop.init, (claimStart, claimEnd, merkleRoot, address(token), owner) + ERC20Airdrop.init, + (claimStart, claimEnd, merkleRoot, address(token), address(vault)) ) }) ); + vm.stopPrank(); + + // 7. Approval (Vault approves Airdrop contract to be the spender!) Has to be done on + // testnet and mainnet too, obviously. + vm.prank(address(vault), owner); + BridgedERC20(token).approve(address(airdrop), 1_000_000_000e18); + + // Vault shall have the balance + assertEq(BridgedERC20(token).balanceOf(address(vault)), 1_000_000_000e18); + vm.roll(block.number + 1); } + function getAliceDelegatesToBobSignature() + public + view + returns (address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) + { + // Query user's nonce + nonce = BridgedERC20(token).nonces(Bob); + expiry = block.timestamp + 1_000_000; + delegatee = Bob; + + LibDelegationSigUtil.Delegate memory delegate; + delegate.delegatee = delegatee; + delegate.nonce = nonce; + delegate.expiry = expiry; + bytes32 hash = LibDelegationSigUtil.getTypedDataHash(delegate, address(token)); + + // 0x2 is Alice's private key + (v, r, s) = vm.sign(0x1, hash); + } + + function test_claimAndDelegate() public { + vm.warp(claimStart); + + // 1. Alice puts together the HASH (for delegating BOB) and signs it too. + (address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) = + getAliceDelegatesToBobSignature(); + // 2. Encode data + bytes memory delegationData = abi.encode(delegatee, nonce, expiry, v, r, s); + vm.prank(Alice, Alice); + airdrop.claimAndDelegate(Alice, 100, merkleProof, delegationData); + + // Check Alice balance + assertEq(token.balanceOf(Alice), 100); + // Check who is delegatee, shall be Bob + assertEq(token.delegates(Alice), Bob); + } + function test_claimAndDelegate_with_wrong_delegation_data() public { vm.warp(claimStart); bytes memory delegation = bytes(""); - vm.expectRevert("ERC20: insufficient allowance"); // no allowance + vm.expectRevert(); //invalid delegate signature vm.prank(Lily, Lily); airdrop.claimAndDelegate(Lily, 100, merkleProof, delegation); @@ -86,7 +212,5 @@ contract TestERC20Airdrop is TaikoTest { vm.expectRevert(); // signature invalid vm.prank(Lily, Lily); airdrop.claimAndDelegate(Lily, 100, merkleProof, delegation); - - // TODO(daniel): add a new test by initializing the right value for the above 6 variables. } } diff --git a/packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol b/packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol new file mode 100644 index 00000000000..c934901ad5d --- /dev/null +++ b/packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @notice Creating the TypedDataV4 hash +// NOTE: This contract implements the version of the encoding known as "v4", as implemented by the +// JSON RPC method +// https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask]. +/// @dev IMPORTANT!! This is for testing, but we need this in the UI for recreating the same hash +/// (to be signed by the user). +// A good resource on how-to: +// link: +// https://medium.com/@javaidea/how-to-sign-and-verify-eip-712-signatures-with-solidity-and-typescript-part-1-5118fdda1fe7 + +library LibDelegationSigUtil { + // EIP712 TYPES_HASH. + bytes32 private constant _TYPE_HASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + // For delegation - this TYPES_HASH is fixed. + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + function getDomainSeparator(address verifierContract) public view returns (bytes32) { + // This is how we create a contract level domain separator! + // todo (@KorbinianK , @2manslkh): Do it off-chain, in the UI + return keccak256( + abi.encode( + _TYPE_HASH, + keccak256(bytes("Taiko Token")), + keccak256(bytes("1")), + block.chainid, + verifierContract + ) + ); + } + + struct Delegate { + address delegatee; + uint256 nonce; + uint256 expiry; + } + + // computes the hash of a delegation + function getStructHash(Delegate memory _delegate) internal pure returns (bytes32) { + return keccak256( + abi.encode(_DELEGATION_TYPEHASH, _delegate.delegatee, _delegate.nonce, _delegate.expiry) + ); + } + + // computes the hash of the fully encoded EIP-712 message for the domain, which can be used to + // recover the signer + function getTypedDataHash( + Delegate memory _permit, + address verifierContract + ) + public + view + returns (bytes32) + { + return keccak256( + abi.encodePacked( + "\x19\x01", getDomainSeparator(verifierContract), getStructHash(_permit) + ) + ); + } +}