diff --git a/.gitmodules b/.gitmodules index 6aa60a52c0..7ebd195c02 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,12 @@ [submodule "contracts/lib/openzeppelin-contracts-upgradeable"] path = contracts/lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "claim_contracts/lib/forge-std"] + path = claim_contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "claim_contracts/lib/openzeppelin-contracts-upgradeable"] + path = claim_contracts/lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "claim_contracts/lib/openzeppelin-contracts"] + path = claim_contracts/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/claim_contracts/.gitignore b/claim_contracts/.gitignore new file mode 100644 index 0000000000..85198aaa55 --- /dev/null +++ b/claim_contracts/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/claim_contracts/README.md b/claim_contracts/README.md new file mode 100644 index 0000000000..e9838808ef --- /dev/null +++ b/claim_contracts/README.md @@ -0,0 +1,36 @@ +## AlignedToken + +## Requirements + +- Foundry + +## Local deploying + +To deploy the contracts, set the following environment variables: + +- `DEPLOYER_PRIVATE_KEY`: The private key of the account that's going to deploy the contracts. +- `SAFE_ADDRESS`: The address of the safe that's going to own the Proxy admin that in turn owns the token and airdrop contracts. +- `OWNER1_ADDRESS`, `OWNER2_ADDRESS`, and `OWNER3_ADDRESS`: The three owners of the token. +- `MINT_AMOUNT`: The amount to mint to each account (the contract actually supports minting different amounts of the token to each owner, but in the deploy script we simplified it). +- `RPC_URL`: The url of the network to deploy to. +- `CLAIM_TIME_LIMIT`: The claim time limit timestamp. +- `MERKLE_ROOT`: The merkle root of all valid token claims. + +Example: +``` +export DEPLOYER_PRIVATE_KEY= +export SAFE_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +export OWNER1_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +export OWNER2_ADDRESS=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC +export OWNER3_ADDRESS=0x90F79bf6EB2c4f870365E785982E1f101E93b906 +export MINT_AMOUNT=100 +export RPC_URL=http://localhost:8545 +export CLAIM_TIME_LIMIT=2733247661 +export MERKLE_ROOT=0x90076b5fb9a6c81d9fce83dfd51760987b8c49e7c861ea25b328e6e63d2cd3df +``` + +Then run the following script: + +``` +./deployClaim.sh +``` diff --git a/claim_contracts/deployClaim.sh b/claim_contracts/deployClaim.sh new file mode 100755 index 0000000000..1f7136bf5c --- /dev/null +++ b/claim_contracts/deployClaim.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +forge --version >/dev/null 2>&1 +if [ $? != 0 ]; then + echo "Error: Please make sure you have forge installed and in your PATH" + exit 2 +fi + +safe=${SAFE_ADDRESS:-$1} +owner1=${OWNER1_ADDRESS:-$2} +owner2=${OWNER2_ADDRESS:-$3} +owner3=${OWNER3_ADDRESS:-$4} +mint_amount=${MINT_AMOUNT:-$5} +rpc_url=${RPC_URL:-$6} +claim_time_limit=${CLAIM_TIME_LIMIT:-2733247661} +merkle_root=${MERKLE_ROOT:-$7} + +cd script && forge script DeployScript $safe $owner1 $owner2 $owner3 $mint_amount $claim_time_limit $merkle_root --sig "run(address,address,address,address,uint256,uint256,bytes32)" --fork-url $rpc_url --broadcast diff --git a/claim_contracts/foundry.toml b/claim_contracts/foundry.toml new file mode 100644 index 0000000000..25b918f9c9 --- /dev/null +++ b/claim_contracts/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/claim_contracts/lib/forge-std b/claim_contracts/lib/forge-std new file mode 160000 index 0000000000..1eea5bae12 --- /dev/null +++ b/claim_contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 1eea5bae12ae557d589f9f0f0edae2faa47cb262 diff --git a/claim_contracts/lib/openzeppelin-contracts b/claim_contracts/lib/openzeppelin-contracts new file mode 160000 index 0000000000..69c8def5f2 --- /dev/null +++ b/claim_contracts/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 69c8def5f222ff96f2b5beff05dfba996368aa79 diff --git a/claim_contracts/lib/openzeppelin-contracts-upgradeable b/claim_contracts/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000000..fa525310e4 --- /dev/null +++ b/claim_contracts/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit fa525310e45f91eb20a6d3baa2644be8e0adba31 diff --git a/claim_contracts/remappings.txt b/claim_contracts/remappings.txt new file mode 100644 index 0000000000..b9a3c8f216 --- /dev/null +++ b/claim_contracts/remappings.txt @@ -0,0 +1,2 @@ +@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ diff --git a/claim_contracts/script/Utils.sol b/claim_contracts/script/Utils.sol new file mode 100644 index 0000000000..b7851126c7 --- /dev/null +++ b/claim_contracts/script/Utils.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Vm} from "forge-std/Vm.sol"; + +library Utils { + // Cheatcodes address, 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D. + address internal constant VM_ADDRESS = + address(uint160(uint256(keccak256("hevm cheat code")))); + Vm internal constant vm = Vm(VM_ADDRESS); + + /// @notice Address of the deterministic create2 factory. + /// @dev This address corresponds to a contracts that is set in the storage + /// in the genesis file. The same contract with the same address is deployed + /// in every testnet, so if this script is run in a testnet instead of in a + /// local environment, it should work. + address constant DETERMINISTIC_CREATE2_ADDRESS = + 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + function deployWithCreate2( + bytes memory bytecode, + bytes32 salt, + address create2Factory, + uint256 signerPrivateKey + ) internal returns (address) { + if (bytecode.length == 0) { + revert("Bytecode is not set"); + } + address contractAddress = vm.computeCreate2Address( + salt, + keccak256(bytecode), + create2Factory + ); + if (contractAddress.code.length != 0) { + revert("Contract already deployed"); + } + + vm.broadcast(signerPrivateKey); + (bool success, bytes memory data) = create2Factory.call( + abi.encodePacked(salt, bytecode) + ); + contractAddress = bytesToAddress(data); + + if (!success) { + revert( + "Failed to deploy contract via create2: create2Factory call failed" + ); + } + + if (contractAddress == address(0)) { + revert( + "Failed to deploy contract via create2: contract address is zero" + ); + } + + if (contractAddress.code.length == 0) { + revert( + "Failed to deploy contract via create2: contract code is empty" + ); + } + + return contractAddress; + } + + function bytesToAddress( + bytes memory addressOffset + ) internal pure returns (address addr) { + assembly { + addr := mload(add(addressOffset, 20)) + } + } +} diff --git a/claim_contracts/script/deploy.sol b/claim_contracts/script/deploy.sol new file mode 100644 index 0000000000..701ad6ccb7 --- /dev/null +++ b/claim_contracts/script/deploy.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {AlignedToken} from "../src/AlignedToken.sol"; +import {ClaimableAirdrop} from "../src/ClaimableAirdrop.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {Utils} from "./Utils.sol"; + +contract DeployScript is Script { + struct Supply { + address beneficiary; + uint256 amount; + } + + function setUp() public {} + + function run( + address safe, + address owner1, + address owner2, + address owner3, + uint256 mintAmount, + uint256 limitTimestamp, + bytes32 merkleRoot + ) public { + console.log("Deploying contracts"); + uint256 deployer = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + ProxyAdmin contractsProxyAdmin = deployProxyAdmin(safe, deployer); + + TransparentUpgradeableProxy tokenContractProxy = deployTokenContractProxy( + address(contractsProxyAdmin), + owner1, + owner2, + owner3, + mintAmount, + deployer + ); + + TransparentUpgradeableProxy airdropContractProxy = deployAirdropContractProxy( + address(contractsProxyAdmin), + address(tokenContractProxy), + owner3, + limitTimestamp, + merkleRoot, + deployer + ); + + vm.startBroadcast(); + (deployer); + (bool success, bytes memory data) = address(tokenContractProxy).call( + abi.encodeCall( + IERC20.approve, + (address(airdropContractProxy), mintAmount) + ) + ); + bool approved; + assembly { + approved := mload(add(data, 0x20)) + } + + if (!success || !approved) { + revert("Failed to give approval to airdrop contract"); + } + vm.stopBroadcast(); + + console.log("Succesfully gave approval to airdrop contract"); + } + + function deployProxyAdmin( + address tokenContractProxyAdminOwner, + uint256 signerPrivateKey + ) internal returns (ProxyAdmin) { + bytes memory bytecode = abi.encodePacked( + type(ProxyAdmin).creationCode, + abi.encode(tokenContractProxyAdminOwner) + ); + bytes32 salt = bytes32(0); + address proxyAdminAddress = Utils.deployWithCreate2( + bytecode, + salt, + Utils.DETERMINISTIC_CREATE2_ADDRESS, + signerPrivateKey + ); + console.log( + "Aligned Proxy Admin deployed at:", + proxyAdminAddress, + "with owner:", + tokenContractProxyAdminOwner + ); + return ProxyAdmin(proxyAdminAddress); + } + + function deployTokenContractProxy( + address tokenContractProxyAdmin, + address owner1, + address owner2, + address owner3, + uint256 mintAmount, + uint256 signerPrivateKey + ) internal returns (TransparentUpgradeableProxy) { + vm.broadcast(signerPrivateKey); + AlignedToken tokenContract = new AlignedToken(); + bytes memory bytecode = abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode( + address(tokenContract), + tokenContractProxyAdmin, + abi.encodeCall( + tokenContract.initialize, + (owner1, mintAmount, owner2, mintAmount, owner3, mintAmount) + ) + ) + ); + bytes32 salt = bytes32(0); + address tokenContractProxyAddress = Utils.deployWithCreate2( + bytecode, + salt, + Utils.DETERMINISTIC_CREATE2_ADDRESS, + signerPrivateKey + ); + console.log( + "Aligned Token Proxy deployed at:", + tokenContractProxyAddress, + "with admin:", + tokenContractProxyAdmin + ); + return TransparentUpgradeableProxy(payable(tokenContractProxyAddress)); + } + + function deployAirdropContractProxy( + address airdropContractProxyAdmin, + address tokenContractProxyAddress, + address tokenOwnerAddress, + uint256 limitTimestamp, + bytes32 merkleRoot, + uint256 signerPrivateKey + ) internal returns (TransparentUpgradeableProxy) { + vm.broadcast(signerPrivateKey); + ClaimableAirdrop airdropContract = new ClaimableAirdrop(); + bytes memory bytecode = abi.encodePacked( + type(TransparentUpgradeableProxy).creationCode, + abi.encode( + address(airdropContract), + airdropContractProxyAdmin, + abi.encodeCall( + airdropContract.initialize, + ( + tokenContractProxyAddress, + tokenOwnerAddress, + limitTimestamp, + merkleRoot + ) + ) + ) + ); + bytes32 salt = bytes32(0); + address airdropContractProxy = Utils.deployWithCreate2( + bytecode, + salt, + Utils.DETERMINISTIC_CREATE2_ADDRESS, + signerPrivateKey + ); + console.log( + "Airdrop Proxy deployed at:", + airdropContractProxy, + "with admin:", + airdropContractProxyAdmin + ); + return TransparentUpgradeableProxy(payable(airdropContractProxy)); + } +} diff --git a/claim_contracts/src/AlignedToken.sol b/claim_contracts/src/AlignedToken.sol new file mode 100644 index 0000000000..3a3d0b7b40 --- /dev/null +++ b/claim_contracts/src/AlignedToken.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract AlignedToken is Initializable, ERC20Upgradeable, ReentrancyGuard { + struct Supply { + address beneficiary; + uint256 amount; + } + + constructor() { + // Ensure that initialization methods are run only once. + // This is a safeguard against accidental reinitialization. + _disableInitializers(); + } + + function initialize( + address beneficiary1, + uint256 amount1, + address beneficiary2, + uint256 amount2, + address beneficiary3, + uint256 amount3 + ) public initializer nonReentrant { + __ERC20_init("AlignedToken", "ALI"); + _mint(beneficiary1, amount1); + _mint(beneficiary2, amount2); + _mint(beneficiary3, amount3); + } +} diff --git a/claim_contracts/src/ClaimableAirdrop.sol b/claim_contracts/src/ClaimableAirdrop.sol new file mode 100644 index 0000000000..dc6d4fa638 --- /dev/null +++ b/claim_contracts/src/ClaimableAirdrop.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; + +contract ClaimableAirdrop is ReentrancyGuard, Initializable { + address public tokenContractAddress; + address public tokenOwnerAddress; + uint256 public limitTimestampToClaim; + bytes32 public claimMerkleRoot; + + mapping(address => bool) public hasClaimed; + + event TokenClaimed(address indexed to, uint256 indexed amount); + + function initialize( + address _tokenContractAddress, + address _tokenOwnerAddress, + uint256 _limitTimestampToClaim, + bytes32 _claimMerkleRoot + ) public initializer nonReentrant { + tokenContractAddress = _tokenContractAddress; + tokenOwnerAddress = _tokenOwnerAddress; + limitTimestampToClaim = _limitTimestampToClaim; + claimMerkleRoot = _claimMerkleRoot; + } + + function claim( + uint256 amount, + bytes32[] calldata merkleProof + ) public nonReentrant { + require( + !hasClaimed[msg.sender], + "Account has already claimed the drop" + ); + require( + block.timestamp <= limitTimestampToClaim, + "Drop is no longer claimable" + ); + + bytes32 leaf = keccak256( + bytes.concat(keccak256(abi.encode(msg.sender, amount))) + ); + bool verifies = MerkleProof.verify(merkleProof, claimMerkleRoot, leaf); + + require(verifies, "Invalid Merkle proof"); + + hasClaimed[msg.sender] = true; + + bool success = IERC20(tokenContractAddress).transferFrom( + tokenOwnerAddress, + msg.sender, + amount + ); + + require(success, "Failed to transfer funds"); + + emit TokenClaimed(msg.sender, amount); + } +}