diff --git a/contracts/tge/HATAirdrop.sol b/contracts/tge/HATAirdrop.sol new file mode 100644 index 00000000..945e8318 --- /dev/null +++ b/contracts/tge/HATAirdrop.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.16; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.sol"; +import "../tokenlock/ITokenLockFactory.sol"; +import "./interfaces/IHATAirdrop.sol"; + +/* +An airdrop contract that transfers tokens based on a merkle tree. +*/ +contract HATAirdrop is IHATAirdrop, Initializable { + error CannotRedeemBeforeStartTime(); + error CannotRedeemAfterDeadline(); + error LeafAlreadyRedeemed(); + error InvalidMerkleProof(); + error CannotRecoverBeforeDeadline(); + error RedeemerMustBeBeneficiary(); + + using SafeERC20Upgradeable for IERC20Upgradeable; + + bytes32 public root; + uint256 public startTime; + uint256 public deadline; + uint256 public lockEndTime; + uint256 public periods; + IERC20Upgradeable public token; + ITokenLockFactory public tokenLockFactory; + address public factory; + + mapping (bytes32 => bool) public leafRedeemed; + + event MerkleTreeSet(string _merkleTreeIPFSRef, bytes32 _root, uint256 _startTime, uint256 _deadline); + event TokensRedeemed(address indexed _account, address indexed _tokenLock, uint256 _amount); + + constructor () { + _disableInitializers(); + } + + /** + * @notice Initialize a HATAirdrop instance + * @param _merkleTreeIPFSRef new merkle tree ipfs reference. + * @param _root new merkle tree root to use for verifying airdrop data. + * @param _startTime start of the redeem period and of the token lock (if exists) + * @param _deadline end time to redeem from the contract + * @param _lockEndTime end time for the token lock contract. If this date is in the past, the tokens will be transferred directly to the user and no token lock will be created + * @param _periods number of periods of the token lock contract (if exists) + * @param _token the token to be airdropped + * @param _tokenLockFactory the token lock factory to use to deploy the token locks + */ + function initialize( + string memory _merkleTreeIPFSRef, + bytes32 _root, + uint256 _startTime, + uint256 _deadline, + uint256 _lockEndTime, + uint256 _periods, + IERC20Upgradeable _token, + ITokenLockFactory _tokenLockFactory + ) external initializer { + root = _root; + startTime = _startTime; + deadline = _deadline; + lockEndTime = _lockEndTime; + periods = _periods; + token = _token; + tokenLockFactory = _tokenLockFactory; + factory = msg.sender; + emit MerkleTreeSet(_merkleTreeIPFSRef, _root, _startTime, _deadline); + } + + function redeem(address _account, uint256 _amount, bytes32[] calldata _proof) external { + if (msg.sender != _account && msg.sender != factory) { + revert RedeemerMustBeBeneficiary(); + } + // solhint-disable-next-line not-rely-on-time + if (block.timestamp < startTime) revert CannotRedeemBeforeStartTime(); + // solhint-disable-next-line not-rely-on-time + if (block.timestamp > deadline) revert CannotRedeemAfterDeadline(); + bytes32 leaf = _leaf(_account, _amount); + if (leafRedeemed[leaf]) revert LeafAlreadyRedeemed(); + if(!_verify(_proof, leaf)) revert InvalidMerkleProof(); + leafRedeemed[leaf] = true; + + address _tokenLock = address(0); + // solhint-disable-next-line not-rely-on-time + if (lockEndTime > block.timestamp) { + _tokenLock = tokenLockFactory.createTokenLock( + address(token), + 0x0000000000000000000000000000000000000000, + _account, + _amount, + startTime, + lockEndTime, + periods, + 0, + 0, + false, + true + ); + token.safeTransferFrom(factory, _tokenLock, _amount); + } else { + token.safeTransferFrom(factory, _account, _amount); + } + + emit TokensRedeemed(_account, _tokenLock, _amount); + } + + function _verify(bytes32[] calldata proof, bytes32 leaf) internal view returns (bool) { + return MerkleProofUpgradeable.verifyCalldata(proof, root, leaf); + } + + function _leaf(address _account, uint256 _amount) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(_account, _amount)); + } +} diff --git a/contracts/tge/HATAirdropFactory.sol b/contracts/tge/HATAirdropFactory.sol new file mode 100644 index 00000000..23c33f12 --- /dev/null +++ b/contracts/tge/HATAirdropFactory.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Disclaimer https://github.com/hats-finance/hats-contracts/blob/main/DISCLAIMER.md + +pragma solidity 0.8.16; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./interfaces/IHATAirdrop.sol"; + +contract HATAirdropFactory is Ownable { + error RedeemDataArraysLengthMismatch(); + error ContractIsNotHATAirdrop(); + error HATAirdropInitializationFailed(); + + using SafeERC20 for IERC20; + + mapping(address => bool) public isAirdrop; + + event TokensWithdrawn(address indexed _owner, uint256 _amount); + event HATAirdropCreated(address indexed _hatAirdrop, bytes _initData, IERC20 _token, uint256 _totalAmount); + + function withdrawTokens(IERC20 _token, uint256 _amount) external onlyOwner { + address owner = msg.sender; + _token.safeTransfer(owner, _amount); + emit TokensWithdrawn(owner, _amount); + } + + function redeemMultipleAirdrops(IHATAirdrop[] calldata _airdrops, uint256[] calldata _amounts, bytes32[][] calldata _proofs) external { + if (_airdrops.length != _amounts.length || _airdrops.length != _proofs.length) { + revert RedeemDataArraysLengthMismatch(); + } + + address caller = msg.sender; + for (uint256 i = 0; i < _airdrops.length;) { + if (!isAirdrop[address(_airdrops[i])]) { + revert ContractIsNotHATAirdrop(); + } + + _airdrops[i].redeem(caller, _amounts[i], _proofs[i]); + + unchecked { + ++i; + } + } + } + + function createHATAirdrop( + address _implementation, + bytes calldata _initData, + IERC20 _token, + uint256 _totalAmount + ) external onlyOwner returns (address result) { + result = Clones.cloneDeterministic(_implementation, keccak256(_initData)); + + // solhint-disable-next-line avoid-low-level-calls + (bool success,) = result.call(_initData); + + if (!success) { + revert HATAirdropInitializationFailed(); + } + + isAirdrop[result] = true; + + _token.safeApprove(result, _totalAmount); + + emit HATAirdropCreated(result, _initData, _token, _totalAmount); + } + + function predictHATAirdropAddress( + address _implementation, + bytes calldata _initData + ) external view returns (address) { + return Clones.predictDeterministicAddress(_implementation, keccak256(_initData)); + } +} diff --git a/contracts/tge/interfaces/IHATAirdrop.sol b/contracts/tge/interfaces/IHATAirdrop.sol new file mode 100644 index 00000000..a9c5f2f1 --- /dev/null +++ b/contracts/tge/interfaces/IHATAirdrop.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Disclaimer https://github.com/hats-finance/hats-contracts/blob/main/DISCLAIMER.md + +pragma solidity 0.8.16; + + +interface IHATAirdrop { + function redeem(address _account, uint256 _amount, bytes32[] calldata _proof) external; +} \ No newline at end of file diff --git a/docs/dodoc/elin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.md b/docs/dodoc/elin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.md new file mode 100644 index 00000000..91f11e5f --- /dev/null +++ b/docs/dodoc/elin/contracts-upgradeable/utils/cryptography/MerkleProofUpgradeable.md @@ -0,0 +1,12 @@ +# MerkleProofUpgradeable + + + + + + + +*These functions deal with verification of Merkle Tree proofs. The tree and the proofs can be generated using our https://github.com/OpenZeppelin/merkle-tree[JavaScript library]. You will find a quickstart guide in the readme. WARNING: You should avoid using leaf values that are 64 bytes long prior to hashing, or use a hash function other than keccak256 for hashing leaves. This is because the concatenation of a sorted pair of internal nodes in the merkle tree could be reinterpreted as a leaf value. OpenZeppelin's JavaScript library generates merkle trees that are safe against this attack out of the box.* + + + diff --git a/docs/dodoc/tge/HATAirdrop.md b/docs/dodoc/tge/HATAirdrop.md new file mode 100644 index 00000000..50e00dfa --- /dev/null +++ b/docs/dodoc/tge/HATAirdrop.md @@ -0,0 +1,339 @@ +# HATAirdrop + + + + + + + + + +## Methods + +### deadline + +```solidity +function deadline() external view returns (uint256) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### factory + +```solidity +function factory() external view returns (address) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +### initialize + +```solidity +function initialize(string _merkleTreeIPFSRef, bytes32 _root, uint256 _startTime, uint256 _deadline, uint256 _lockEndTime, uint256 _periods, contract IERC20Upgradeable _token, contract ITokenLockFactory _tokenLockFactory) external nonpayable +``` + +Initialize a HATAirdrop instance + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _merkleTreeIPFSRef | string | new merkle tree ipfs reference. | +| _root | bytes32 | new merkle tree root to use for verifying airdrop data. | +| _startTime | uint256 | start of the redeem period and of the token lock (if exists) | +| _deadline | uint256 | end time to redeem from the contract | +| _lockEndTime | uint256 | end time for the token lock contract (if exists) | +| _periods | uint256 | number of periods of the token lock contract (if exists) | +| _token | contract IERC20Upgradeable | the token to be airdropped | +| _tokenLockFactory | contract ITokenLockFactory | the token lock factory to use to deploy the token locks | + +### leafRedeemed + +```solidity +function leafRedeemed(bytes32) external view returns (bool) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _0 | bytes32 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + +### lockEndTime + +```solidity +function lockEndTime() external view returns (uint256) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### periods + +```solidity +function periods() external view returns (uint256) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### redeem + +```solidity +function redeem(address _account, uint256 _amount, bytes32[] _proof) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _account | address | undefined | +| _amount | uint256 | undefined | +| _proof | bytes32[] | undefined | + +### root + +```solidity +function root() external view returns (bytes32) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bytes32 | undefined | + +### startTime + +```solidity +function startTime() external view returns (uint256) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### token + +```solidity +function token() external view returns (contract IERC20Upgradeable) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | contract IERC20Upgradeable | undefined | + +### tokenLockFactory + +```solidity +function tokenLockFactory() external view returns (contract ITokenLockFactory) +``` + + + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | contract ITokenLockFactory | undefined | + + + +## Events + +### Initialized + +```solidity +event Initialized(uint8 version) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| version | uint8 | undefined | + +### MerkleTreeSet + +```solidity +event MerkleTreeSet(string _merkleTreeIPFSRef, bytes32 _root, uint256 _startTime, uint256 _deadline) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _merkleTreeIPFSRef | string | undefined | +| _root | bytes32 | undefined | +| _startTime | uint256 | undefined | +| _deadline | uint256 | undefined | + +### TokensRedeemed + +```solidity +event TokensRedeemed(address indexed _account, address indexed _tokenLock, uint256 _amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _account `indexed` | address | undefined | +| _tokenLock `indexed` | address | undefined | +| _amount | uint256 | undefined | + + + +## Errors + +### CannotRecoverBeforeDeadline + +```solidity +error CannotRecoverBeforeDeadline() +``` + + + + + + +### CannotRedeemAfterDeadline + +```solidity +error CannotRedeemAfterDeadline() +``` + + + + + + +### CannotRedeemBeforeStartTime + +```solidity +error CannotRedeemBeforeStartTime() +``` + + + + + + +### InvalidMerkleProof + +```solidity +error InvalidMerkleProof() +``` + + + + + + +### LeafAlreadyRedeemed + +```solidity +error LeafAlreadyRedeemed() +``` + + + + + + +### RedeemerMustBeBeneficiary + +```solidity +error RedeemerMustBeBeneficiary() +``` + + + + + + + diff --git a/docs/dodoc/tge/HATAirdropFactory.md b/docs/dodoc/tge/HATAirdropFactory.md new file mode 100644 index 00000000..7a5a5b5c --- /dev/null +++ b/docs/dodoc/tge/HATAirdropFactory.md @@ -0,0 +1,256 @@ +# HATAirdropFactory + + + + + + + + + +## Methods + +### createHATAirdrop + +```solidity +function createHATAirdrop(address _implementation, bytes _initData, contract IERC20 _token, uint256 _totalAmount) external nonpayable returns (address result) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _implementation | address | undefined | +| _initData | bytes | undefined | +| _token | contract IERC20 | undefined | +| _totalAmount | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| result | address | undefined | + +### isAirdrop + +```solidity +function isAirdrop(address) external view returns (bool) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + +### owner + +```solidity +function owner() external view returns (address) +``` + + + +*Returns the address of the current owner.* + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +### predictHATAirdropAddress + +```solidity +function predictHATAirdropAddress(address _implementation, bytes _initData) external view returns (address) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _implementation | address | undefined | +| _initData | bytes | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | undefined | + +### redeemMultipleAirdrops + +```solidity +function redeemMultipleAirdrops(contract IHATAirdrop[] _airdrops, uint256[] _amounts, bytes32[][] _proofs) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _airdrops | contract IHATAirdrop[] | undefined | +| _amounts | uint256[] | undefined | +| _proofs | bytes32[][] | undefined | + +### renounceOwnership + +```solidity +function renounceOwnership() external nonpayable +``` + + + +*Leaves the contract without owner. It will not be possible to call `onlyOwner` functions anymore. Can only be called by the current owner. NOTE: Renouncing ownership will leave the contract without an owner, thereby removing any functionality that is only available to the owner.* + + +### transferOwnership + +```solidity +function transferOwnership(address newOwner) external nonpayable +``` + + + +*Transfers ownership of the contract to a new account (`newOwner`). Can only be called by the current owner.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newOwner | address | undefined | + +### withdrawTokens + +```solidity +function withdrawTokens(contract IERC20 _token, uint256 _amount) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _token | contract IERC20 | undefined | +| _amount | uint256 | undefined | + + + +## Events + +### HATAirdropCreated + +```solidity +event HATAirdropCreated(address indexed _hatAirdrop, bytes _initData, contract IERC20 _token, uint256 _totalAmount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _hatAirdrop `indexed` | address | undefined | +| _initData | bytes | undefined | +| _token | contract IERC20 | undefined | +| _totalAmount | uint256 | undefined | + +### OwnershipTransferred + +```solidity +event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| previousOwner `indexed` | address | undefined | +| newOwner `indexed` | address | undefined | + +### TokensWithdrawn + +```solidity +event TokensWithdrawn(address indexed _owner, uint256 _amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _owner `indexed` | address | undefined | +| _amount | uint256 | undefined | + + + +## Errors + +### ContractIsNotHATAirdrop + +```solidity +error ContractIsNotHATAirdrop() +``` + + + + + + +### HATAirdropInitializationFailed + +```solidity +error HATAirdropInitializationFailed() +``` + + + + + + +### RedeemDataArraysLengthMismatch + +```solidity +error RedeemDataArraysLengthMismatch() +``` + + + + + + + diff --git a/docs/dodoc/tge/interfaces/IHATAirdrop.md b/docs/dodoc/tge/interfaces/IHATAirdrop.md new file mode 100644 index 00000000..3edd6a87 --- /dev/null +++ b/docs/dodoc/tge/interfaces/IHATAirdrop.md @@ -0,0 +1,33 @@ +# IHATAirdrop + + + + + + + + + +## Methods + +### redeem + +```solidity +function redeem(address _account, uint256 _amount, bytes32[] _proof) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| _account | address | undefined | +| _amount | uint256 | undefined | +| _proof | bytes32[] | undefined | + + + + diff --git a/test/tge/airdropData.json b/test/tge/airdropData.json new file mode 100644 index 00000000..d29ca428 --- /dev/null +++ b/test/tge/airdropData.json @@ -0,0 +1,8 @@ +{ + "0xc783df8a850f42e7F7e57013759C285caa701eB6": "10", + "0xeAD9C93b79Ae7C1591b1FB5323BD777E86e150d4": "25", + "0xE5904695748fe4A84b40b3fc79De2277660BD1D3": "25", + "0x92561F28Ec438Ee9831D00D1D59fbDC981b762b2": "15", + "0x2fFd013AaA7B5a7DA93336C2251075202b33FB2B": "30", + "0x9FC9C2DfBA3b6cF204C37a5F690619772b926e39": "20" +} diff --git a/test/tge/hatairdrop.js b/test/tge/hatairdrop.js new file mode 100644 index 00000000..69eac049 --- /dev/null +++ b/test/tge/hatairdrop.js @@ -0,0 +1,466 @@ +const utils = require("../utils.js"); +const ERC20Mock = artifacts.require("./ERC20Mock.sol"); +const TokenLockFactory = artifacts.require("./TokenLockFactory.sol"); +const HATTokenLock = artifacts.require("./HATTokenLock.sol"); +const HATAirdrop = artifacts.require("./HATAirdrop.sol"); +const HATAirdropFactory = artifacts.require("./HATAirdropFactory.sol"); +const IHATAirdrop = new ethers.utils.Interface(HATAirdrop.abi); +const { contract, web3 } = require("hardhat"); +const { + assertFunctionRaisesException, assertVMException, ZERO_ADDRESS, +} = require("../common.js"); +const airdropData = require('./airdropData.json'); +const { assert } = require("chai"); +const { default: MerkleTree } = require("merkletreejs"); +const keccak256 = require("keccak256"); + +function hashTokens(account, amount) { + return Buffer.from( + ethers.utils.solidityKeccak256( + ['address', 'uint256'], + [account, amount] + ).slice(2), + 'hex' + ); +} + +contract("HATAirdrop", (accounts) => { + + let hatAirdropFactory; + let hatAirdrop; + let hatAirdropImplementation; + let token; + let merkleTree; + let hashes = []; + let totalAmount = 0; + let tokenLockFactory; + let startTime; + let endTime; + let lockEndTime; + let periods; + let initData; + + async function setupHATAirdrop(useLock = true) { + token = await ERC20Mock.new("Staking", "STK"); + + totalAmount = 0; + + for (const [account, amount] of Object.entries(airdropData)) { + totalAmount += parseInt(amount); + hashes.push(hashTokens(account, amount)); + } + + merkleTree = new MerkleTree(hashes, keccak256, { sortPairs: true }); + tokenLockFactory = await TokenLockFactory.new((await HATTokenLock.new()).address, accounts[0]); + startTime = (await web3.eth.getBlock("latest")).timestamp + 7 * 24 * 3600; + endTime = (await web3.eth.getBlock("latest")).timestamp + (7 + 365) * 24 * 3600; + if (useLock) { + lockEndTime = (await web3.eth.getBlock("latest")).timestamp + 14 * 24 * 3600; + } else { + lockEndTime = 0; + } + periods = 90; + + hatAirdropImplementation = await HATAirdrop.new(); + hatAirdropFactory = await HATAirdropFactory.new(); + + initData = IHATAirdrop.encodeFunctionData("initialize", [ + "QmSUXfYsk9HgrMBa7tgp3MBm8FGwDF9hnVaR9C1PMoFdS3", + merkleTree.getHexRoot(), + startTime, + endTime, + lockEndTime, + periods, + token.address, + tokenLockFactory.address + ]); + + let airdropAddress = await hatAirdropFactory.predictHATAirdropAddress( + hatAirdropImplementation.address, + initData + ); + + let tx = await hatAirdropFactory.createHATAirdrop( + hatAirdropImplementation.address, + initData, + token.address, + totalAmount + ); + + assert.equal(tx.logs[0].event, "HATAirdropCreated"); + assert.equal(tx.logs[0].args._hatAirdrop, airdropAddress); + assert.equal(tx.logs[0].args._initData, initData); + assert.equal(tx.logs[0].args._token, token.address); + assert.equal(tx.logs[0].args._totalAmount, totalAmount); + hatAirdrop = await HATAirdrop.at(airdropAddress); + + await token.mint(hatAirdropFactory.address, totalAmount); + } + + it("Only owner can create airdrops", async () => { + await setupHATAirdrop(); + + await assertFunctionRaisesException( + hatAirdropFactory.createHATAirdrop( + hatAirdropImplementation.address, + initData, + token.address, + totalAmount, + { from: accounts[1] } + ), + "Ownable: caller is not the owner" + ); + }); + + it("Cannot initialize twice", async () => { + await setupHATAirdrop(); + + try { + await hatAirdrop.initialize( + "QmSUXfYsk9HgrMBa7tgp3MBm8FGwDF9hnVaR9C1PMoFdS3", + await hatAirdrop.root(), + await hatAirdrop.startTime(), + await hatAirdrop.deadline(), + await hatAirdrop.lockEndTime(), + await hatAirdrop.periods(), + await hatAirdrop.token(), + await hatAirdrop.tokenLockFactory() + ); + assert(false, "cannot initialize twice"); + } catch (ex) { + assertVMException(ex, "Initializable: contract is already initialized"); + } + }); + + it("Redeem all", async () => { + await setupHATAirdrop(); + + await utils.increaseTime(7 * 24 * 3600); + + let i = 0; + for (const [account, amount] of Object.entries(airdropData)) { + let currentBalance = await token.balanceOf(hatAirdropFactory.address); + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + let tx = await hatAirdrop.redeem(accounts[i], amount, proof, { from: accounts[i] }); + i++; + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), currentBalance.sub(web3.utils.toBN(amount)).toString()); + + let tokenLock = await HATTokenLock.at(tx.logs[0].args._tokenLock); + assert.equal(await tokenLock.startTime(), startTime); + assert.equal(await tokenLock.endTime(), lockEndTime); + assert.equal(await tokenLock.periods(), periods); + assert.equal(await tokenLock.owner(), "0x0000000000000000000000000000000000000000"); + assert.equal((await tokenLock.beneficiary()).toLowerCase(), account.toLowerCase()); + assert.equal(await tokenLock.managedAmount(), amount); + assert.equal(await tokenLock.token(), token.address); + assert.equal(await tokenLock.releaseStartTime(), 0); + assert.equal(await tokenLock.vestingCliffTime(), 0); + assert.equal(await tokenLock.revocable(), false); + assert.equal(await tokenLock.canDelegate(), true); + } + + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), "0"); + }); + + it("Redeem all from multiple airdrops", async () => { + await setupHATAirdrop(); + + const initData2 = IHATAirdrop.encodeFunctionData("initialize", [ + "QmSUXfYsk9HgrMBa7tgp3MBm8FGwDF9hnVaR9C1PMoFdS3", + merkleTree.getHexRoot(), + startTime, + endTime, + lockEndTime + 1, + periods, + token.address, + tokenLockFactory.address + ]); + + let tx = await hatAirdropFactory.createHATAirdrop( + hatAirdropImplementation.address, + initData2, + token.address, + totalAmount + ); + + const hatAirdrop2 = await HATAirdrop.at(tx.logs[0].args._hatAirdrop); + + await token.mint(hatAirdropFactory.address, totalAmount); + + await utils.increaseTime(7 * 24 * 3600); + + let i = 0; + for (const [account, amount] of Object.entries(airdropData)) { + let currentBalance = await token.balanceOf(hatAirdropFactory.address); + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + await hatAirdropFactory.redeemMultipleAirdrops([hatAirdrop.address, hatAirdrop2.address], [amount, amount], [proof, proof], { from: accounts[i] }); + i++; + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), currentBalance.sub(web3.utils.toBN(amount * 2)).toString()); + } + + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), "0"); + }); + + it("Factory deployment fails if init fails", async () => { + await setupHATAirdrop(); + + await assertFunctionRaisesException( + hatAirdropFactory.createHATAirdrop( + hatAirdropFactory.address, + initData, + token.address, + totalAmount + ), + "HATAirdropInitializationFailed" + ); + }); + + it("Cannot redeem with factory if contract is not deployed by the factory", async () => { + await setupHATAirdrop(); + + await utils.increaseTime(7 * 24 * 3600); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await assertFunctionRaisesException( + hatAirdropFactory.redeemMultipleAirdrops([hatAirdrop.address, accounts[0]], [amount, amount], [proof, proof], { from: accounts[0] }), + "ContractIsNotHATAirdrop" + ); + }); + + it("Cannot redeem with factory if arrays lenngths mismatch", async () => { + await setupHATAirdrop(); + + await utils.increaseTime(7 * 24 * 3600); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await assertFunctionRaisesException( + hatAirdropFactory.redeemMultipleAirdrops([hatAirdrop.address], [amount, amount], [proof, proof], { from: accounts[0] }), + "RedeemDataArraysLengthMismatch" + ); + + await assertFunctionRaisesException( + hatAirdropFactory.redeemMultipleAirdrops([hatAirdrop.address], [amount], [proof, proof], { from: accounts[0] }), + "RedeemDataArraysLengthMismatch" + ); + }); + + it("Redeem all no lock", async () => { + await setupHATAirdrop(false); + + await utils.increaseTime(7 * 24 * 3600); + + let i = 0; + for (const [account, amount] of Object.entries(airdropData)) { + let currentBalance = await token.balanceOf(hatAirdropFactory.address); + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + let tx = await hatAirdrop.redeem(accounts[i], amount, proof, { from: accounts[i] }); + i++; + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._tokenLock, ZERO_ADDRESS); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(account), amount); + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), currentBalance.sub(web3.utils.toBN(amount)).toString()); + } + + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), "0"); + }); + + it("Redeem after lock ended does not deploy lock", async () => { + await setupHATAirdrop(); + + await utils.increaseTime(7 * 24 * 3600); + + const dataLength = Object.entries(airdropData).length; + let i = 0; + for (const [account, amount] of Object.entries(airdropData).slice(0, dataLength / 2)) { + let currentBalance = await token.balanceOf(hatAirdropFactory.address); + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + let tx = await hatAirdrop.redeem(accounts[i], amount, proof, { from: accounts[i] }); + i++; + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), currentBalance.sub(web3.utils.toBN(amount)).toString()); + + let tokenLock = await HATTokenLock.at(tx.logs[0].args._tokenLock); + assert.equal(await tokenLock.startTime(), startTime); + assert.equal(await tokenLock.endTime(), lockEndTime); + assert.equal(await tokenLock.periods(), periods); + assert.equal(await tokenLock.owner(), "0x0000000000000000000000000000000000000000"); + assert.equal((await tokenLock.beneficiary()).toLowerCase(), account.toLowerCase()); + assert.equal(await tokenLock.managedAmount(), amount); + assert.equal(await tokenLock.token(), token.address); + assert.equal(await tokenLock.releaseStartTime(), 0); + assert.equal(await tokenLock.vestingCliffTime(), 0); + assert.equal(await tokenLock.revocable(), false); + assert.equal(await tokenLock.canDelegate(), true); + } + + await utils.increaseTime(7 * 24 * 3600); + + for (const [account, amount] of Object.entries(airdropData).slice(dataLength / 2)) { + let currentBalance = await token.balanceOf(hatAirdropFactory.address); + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + let tx = await hatAirdrop.redeem(accounts[i], amount, proof, { from: accounts[i] }); + i++; + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._tokenLock, ZERO_ADDRESS); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(account), amount); + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), currentBalance.sub(web3.utils.toBN(amount)).toString()); + } + + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), "0"); + }); + + it("Cannot redeem before start time", async () => { + await setupHATAirdrop(); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[0], amount, proof), + "CannotRedeemBeforeStartTime" + ); + + await utils.increaseTime(7 * 24 * 3600); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + }); + + it("Cannot redeem after deadline", async () => { + await setupHATAirdrop(); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await utils.increaseTime(7 * 24 * 3600); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + + await utils.increaseTime(365 * 24 * 3600); + + const [account2, amount2] = Object.entries(airdropData)[1]; + const proof2 = merkleTree.getHexProof(hashTokens(account2, amount2)); + + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[1], amount2, proof2, { from: accounts[1] }), + "CannotRedeemAfterDeadline" + ); + }); + + it("Cannot redeem for another account", async () => { + await setupHATAirdrop(); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await utils.increaseTime(7 * 24 * 3600); + + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[0], amount, proof, { from: accounts[1] }), + "RedeemerMustBeBeneficiary" + ); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + + }); + + it("Cannot redeem twice", async () => { + await setupHATAirdrop(); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await utils.increaseTime(7 * 24 * 3600); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[0], amount, proof), + "LeafAlreadyRedeemed" + ); + }); + + it("Cannot redeem invalid proof", async () => { + await setupHATAirdrop(); + + const [account, amount] = Object.entries(airdropData)[0]; + + await utils.increaseTime(7 * 24 * 3600); + + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[0], amount, [web3.utils.randomHex(32)]), + "InvalidMerkleProof" + ); + + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + await assertFunctionRaisesException( + hatAirdrop.redeem(accounts[0], "0", proof), + "InvalidMerkleProof" + ); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + }); + + it("Withdraw tokens", async () => { + await setupHATAirdrop(); + + await assertFunctionRaisesException( + hatAirdropFactory.withdrawTokens(token.address, 1, { from: accounts[1] }), + "Ownable: caller is not the owner" + ); + + await utils.increaseTime(7 * 24 * 3600); + + const [account, amount] = Object.entries(airdropData)[0]; + const proof = merkleTree.getHexProof(hashTokens(account, amount)); + + let tx = await hatAirdrop.redeem(accounts[0], amount, proof); + assert.equal(tx.logs[0].event, "TokensRedeemed"); + assert.equal(tx.logs[0].args._account.toLowerCase(), account.toLowerCase()); + assert.equal(tx.logs[0].args._amount, amount); + assert.equal(await token.balanceOf(tx.logs[0].args._tokenLock), amount); + + await utils.increaseTime(365 * 24 * 3600); + + tx = await hatAirdropFactory.withdrawTokens(token.address, web3.utils.toBN((totalAmount - parseInt(amount)))); + + assert.equal(tx.logs[0].event, "TokensWithdrawn"); + assert.equal(tx.logs[0].args._owner.toLowerCase(), accounts[0].toLowerCase()); + assert.equal(tx.logs[0].args._amount.toString(), web3.utils.toBN((totalAmount - parseInt(amount))).toString()); + assert.equal((await token.balanceOf(hatAirdropFactory.address)).toString(), "0"); + }); +});