From e4699110dcf78ac5da0bfee22ad84a6824ddaf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Keszey=20D=C3=A1niel?= Date: Mon, 12 Feb 2024 22:17:54 +0530 Subject: [PATCH 1/3] Add ERC20Airdrop test and deployment --- .../protocol/script/DeployERC20Airdrop.s.sol | 67 ++++++++ .../test/team/airdrop/ERC20Airdrop.t.sol | 157 ++++++++++++++++-- .../test/team/airdrop/utils/SigUtil.sol | 56 +++++++ 3 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 packages/protocol/script/DeployERC20Airdrop.s.sol create mode 100644 packages/protocol/test/team/airdrop/utils/SigUtil.sol diff --git a/packages/protocol/script/DeployERC20Airdrop.s.sol b/packages/protocol/script/DeployERC20Airdrop.s.sol new file mode 100644 index 00000000000..bd4ae30c0c6 --- /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"; + +// @Keng, @Korbi +// 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..a11a9222d94 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 "./utils/SigUtil.sol"; +import "lib/openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; contract MockERC20Airdrop is ERC20Airdrop { function _verifyMerkleProof( @@ -18,9 +20,53 @@ 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(); + // Helper for getting back the hashed data which needs to be signed for delegation + SigUtil hashTypedDataV4; + // Private Key: 0x1dc880d28041a41132437eae90c9e09c3b9e13438c2d0f6207804ceece623395 address public Lily = 0x3447b15c1b0a27D339C812b98881eC64051068b3; @@ -29,35 +75,122 @@ 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( + // 4. Deploy helper -> this is for testing to create the hash (to be signed)! @Keng, @Korbi + // will need to be recreated on the UI!! + hashTypedDataV4 = new SigUtil(address(token)); + + 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; + + SigUtil.Delegate memory delegate; + delegate.delegatee = delegatee; + delegate.nonce = nonce; + delegate.expiry = expiry; + bytes32 hash = hashTypedDataV4.getTypedDataHash(delegate); + + // 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); @@ -86,7 +219,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/utils/SigUtil.sol b/packages/protocol/test/team/airdrop/utils/SigUtil.sol new file mode 100644 index 00000000000..03f84e8a4ae --- /dev/null +++ b/packages/protocol/test/team/airdrop/utils/SigUtil.sol @@ -0,0 +1,56 @@ +// 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 + +contract SigUtil { + bytes32 private constant _TYPE_HASH = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 internal DOMAIN_SEPARATOR; + + constructor(address verifierContract) { + // This is how we create a contract level domain separator! + // todo (@Keng, @Korbi): Do it off-chain, in the UI + DOMAIN_SEPARATOR = keccak256( + abi.encode( + _TYPE_HASH, + keccak256(bytes("Taiko Token")), + keccak256(bytes("1")), + block.chainid, + verifierContract + ) + ); + } + + // For delegation - this TYPES_HASH is fixed. + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + 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) public view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_permit))); + } +} From 574af76750402bb2cb4758631b854f1600085078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Keszey=20D=C3=A1niel?= Date: Mon, 12 Feb 2024 22:38:42 +0530 Subject: [PATCH 2/3] fix revert --- packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol index a11a9222d94..69cdc078fa3 100644 --- a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol +++ b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol @@ -196,7 +196,7 @@ contract TestERC20Airdrop is TaikoTest { 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); From f98549d9e9e61d3bcb66b2474397e9c548f1aa49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Keszey=20D=C3=A1niel?= Date: Tue, 13 Feb 2024 10:38:41 +0530 Subject: [PATCH 3/3] Util contract to Lib --- .../protocol/script/DeployERC20Airdrop.s.sol | 2 +- .../test/team/airdrop/ERC20Airdrop.t.sol | 13 ++------ .../SigUtil.sol => LibDelegationSigUtil.sol} | 33 ++++++++++++------- 3 files changed, 26 insertions(+), 22 deletions(-) rename packages/protocol/test/team/airdrop/{utils/SigUtil.sol => LibDelegationSigUtil.sol} (75%) diff --git a/packages/protocol/script/DeployERC20Airdrop.s.sol b/packages/protocol/script/DeployERC20Airdrop.s.sol index bd4ae30c0c6..a355b0d0f2b 100644 --- a/packages/protocol/script/DeployERC20Airdrop.s.sol +++ b/packages/protocol/script/DeployERC20Airdrop.s.sol @@ -19,7 +19,7 @@ import "forge-std/console2.sol"; import "../contracts/team/airdrop/ERC20Airdrop.sol"; -// @Keng, @Korbi +// @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 ? diff --git a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol index 69cdc078fa3..24349eff307 100644 --- a/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol +++ b/packages/protocol/test/team/airdrop/ERC20Airdrop.t.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.24; import "../../TaikoTest.sol"; -import "./utils/SigUtil.sol"; +import "./LibDelegationSigUtil.sol"; import "lib/openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; contract MockERC20Airdrop is ERC20Airdrop { @@ -64,9 +64,6 @@ contract SimpleERC20Vault is OwnableUpgradeable { contract TestERC20Airdrop is TaikoTest { address public owner = randAddress(); - // Helper for getting back the hashed data which needs to be signed for delegation - SigUtil hashTypedDataV4; - // Private Key: 0x1dc880d28041a41132437eae90c9e09c3b9e13438c2d0f6207804ceece623395 address public Lily = 0x3447b15c1b0a27D339C812b98881eC64051068b3; @@ -110,10 +107,6 @@ contract TestERC20Airdrop is TaikoTest { }) ); - // 4. Deploy helper -> this is for testing to create the hash (to be signed)! @Keng, @Korbi - // will need to be recreated on the UI!! - hashTypedDataV4 = new SigUtil(address(token)); - vm.stopPrank(); // 5. Mint (AKA transfer) to the vault. This step on mainnet will be done by Taiko Labs. For @@ -164,11 +157,11 @@ contract TestERC20Airdrop is TaikoTest { expiry = block.timestamp + 1_000_000; delegatee = Bob; - SigUtil.Delegate memory delegate; + LibDelegationSigUtil.Delegate memory delegate; delegate.delegatee = delegatee; delegate.nonce = nonce; delegate.expiry = expiry; - bytes32 hash = hashTypedDataV4.getTypedDataHash(delegate); + bytes32 hash = LibDelegationSigUtil.getTypedDataHash(delegate, address(token)); // 0x2 is Alice's private key (v, r, s) = vm.sign(0x1, hash); diff --git a/packages/protocol/test/team/airdrop/utils/SigUtil.sol b/packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol similarity index 75% rename from packages/protocol/test/team/airdrop/utils/SigUtil.sol rename to packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol index 03f84e8a4ae..c934901ad5d 100644 --- a/packages/protocol/test/team/airdrop/utils/SigUtil.sol +++ b/packages/protocol/test/team/airdrop/LibDelegationSigUtil.sol @@ -11,16 +11,20 @@ pragma solidity 0.8.24; // link: // https://medium.com/@javaidea/how-to-sign-and-verify-eip-712-signatures-with-solidity-and-typescript-part-1-5118fdda1fe7 -contract SigUtil { +library LibDelegationSigUtil { + // EIP712 TYPES_HASH. bytes32 private constant _TYPE_HASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ); - bytes32 internal DOMAIN_SEPARATOR; - constructor(address verifierContract) { + // 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 (@Keng, @Korbi): Do it off-chain, in the UI - DOMAIN_SEPARATOR = keccak256( + // todo (@KorbinianK , @2manslkh): Do it off-chain, in the UI + return keccak256( abi.encode( _TYPE_HASH, keccak256(bytes("Taiko Token")), @@ -31,10 +35,6 @@ contract SigUtil { ); } - // For delegation - this TYPES_HASH is fixed. - bytes32 private constant _DELEGATION_TYPEHASH = - keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); - struct Delegate { address delegatee; uint256 nonce; @@ -50,7 +50,18 @@ contract SigUtil { // 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) public view returns (bytes32) { - return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_permit))); + function getTypedDataHash( + Delegate memory _permit, + address verifierContract + ) + public + view + returns (bytes32) + { + return keccak256( + abi.encodePacked( + "\x19\x01", getDomainSeparator(verifierContract), getStructHash(_permit) + ) + ); } }