From fe09cfb79402c9fb73219a123bf8787bbf37ab83 Mon Sep 17 00:00:00 2001 From: "hickuphh3@gmail.com" Date: Fri, 28 Jan 2022 13:12:51 +0800 Subject: [PATCH] Allow for new token sale, check for buyer vest schedule, cap sale recipient amount --- .../{ArenaTokenSale.sol => TokenSale.sol} | 85 +++++++-- interfaces/ITokenLockVestReader.sol | 16 ++ scripts/deploy/config.ts | 42 ++--- scripts/deploy/deployTokenSale.ts | 6 +- test/TokenSale.spec.ts | 162 ++++++++++++++++-- 5 files changed, 251 insertions(+), 60 deletions(-) rename contracts/{ArenaTokenSale.sol => TokenSale.sol} (62%) create mode 100644 interfaces/ITokenLockVestReader.sol diff --git a/contracts/ArenaTokenSale.sol b/contracts/TokenSale.sol similarity index 62% rename from contracts/ArenaTokenSale.sol rename to contracts/TokenSale.sol index 9e54289..ebf4939 100644 --- a/contracts/ArenaTokenSale.sol +++ b/contracts/TokenSale.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.10; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "../interfaces/IRevokableTokenLock.sol"; +import "../interfaces/ITokenLockVestReader.sol"; /** * @dev Sells a token at a predetermined price to whitelisted buyers. The number of tokens each address can buy can be regulated. @@ -15,15 +15,19 @@ contract TokenSale is Ownable { /// token to give out (ARENA) ERC20 public immutable tokenOut; /// time when tokens can be first purchased - uint64 public immutable saleStart; + uint64 public saleStart; /// duration of the token sale, cannot purchase afterwards uint64 public immutable saleDuration; - /// address receiving the proceeds of the sale - address internal saleRecipient; + /// address receiving a defined portion proceeds of the sale + address internal immutable saleRecipient; + /// amount receivable by sale recipient + uint256 public remainingSaleRecipientAmount; + /// DAO receives the remaining proceeds of the sale + address internal immutable timelockController; /// vesting contract - IRevokableTokenLock public tokenLock; + ITokenLockVestReader public immutable tokenLock; /// vesting duration - uint256 public vestDuration; + uint256 public immutable vestDuration; /// how many `tokenOut`s each address may buy mapping(address => uint256) public whitelistedBuyersAmount; @@ -40,9 +44,11 @@ contract TokenSale is Ownable { * @param _saleStart The time when tokens can be first purchased * @param _saleDuration The duration of the token sale * @param _tokenOutPrice The tokenIn per tokenOut price. precision should be in tokenInDecimals - tokenOutDecimals + 18 - * @param _saleRecipient The address receiving the proceeds of the sale + * @param _saleRecipient The address receiving a portion proceeds of the sale * @param _tokenLock The contract in which _tokenOut will be vested in + * @param _timelockController The address receiving the remaining proceeds of the sale. Should be C4 timelock controller * @param _vestDuration Token vesting duration + * @param _remainingSaleRecipientAmount Amount receivable by sale recipient */ constructor( ERC20 _tokenIn, @@ -52,7 +58,9 @@ contract TokenSale is Ownable { uint256 _tokenOutPrice, address _saleRecipient, address _tokenLock, - uint256 _vestDuration + address _timelockController, + uint256 _vestDuration, + uint256 _remainingSaleRecipientAmount ) { require(block.timestamp <= _saleStart, "TokenSale: start date may not be in the past"); require(_saleDuration > 0, "TokenSale: the sale duration must not be zero"); @@ -63,6 +71,7 @@ contract TokenSale is Ownable { "TokenSale: sale recipient should not be zero or this" ); require(_tokenLock != address(0), "Address cannot be 0x"); + require(_timelockController != address(0), "Address cannot be 0x"); tokenIn = _tokenIn; tokenOut = _tokenOut; @@ -70,9 +79,10 @@ contract TokenSale is Ownable { saleDuration = _saleDuration; tokenOutPrice = _tokenOutPrice; saleRecipient = _saleRecipient; - - tokenLock = IRevokableTokenLock(_tokenLock); + tokenLock = ITokenLockVestReader(_tokenLock); + timelockController = _timelockController; vestDuration = _vestDuration; + remainingSaleRecipientAmount = _remainingSaleRecipientAmount; } /** @@ -86,10 +96,31 @@ contract TokenSale is Ownable { require(_tokenOutAmount > 0, "TokenSale: non-whitelisted purchaser or have already bought"); whitelistedBuyersAmount[msg.sender] = 0; tokenInAmount_ = (_tokenOutAmount * tokenOutPrice) / 1e18; - require( - tokenIn.transferFrom(msg.sender, saleRecipient, tokenInAmount_), - "TokenSale: tokenIn transfer failed" - ); + + // saleRecipient will receive proceeds first, until fully allocated + if (tokenInAmount_ <= remainingSaleRecipientAmount) { + remainingSaleRecipientAmount -= tokenInAmount_; + require( + tokenIn.transferFrom(msg.sender, saleRecipient, tokenInAmount_), + "TokenSale: tokenIn transfer failed" + ); + } else { + // saleRecipient will either be receiving or have received full allocation + // portion will go to timelock + uint256 timelockControllerAmount = tokenInAmount_ - remainingSaleRecipientAmount; + require( + tokenIn.transferFrom(msg.sender, timelockController, timelockControllerAmount), + "TokenSale: tokenIn transfer failed" + ); + if (remainingSaleRecipientAmount > 0) { + uint256 saleRecipientAmount = remainingSaleRecipientAmount; + remainingSaleRecipientAmount = 0; + require( + tokenIn.transferFrom(msg.sender, saleRecipient, saleRecipientAmount), + "TokenSale: tokenIn transfer failed" + ); + } + } uint256 claimableAmount = (_tokenOutAmount * 2_000) / 10_000; uint256 remainingAmount; @@ -103,7 +134,7 @@ contract TokenSale is Ownable { "TokenSale: tokenOut transfer failed" ); - // if we use same tokenLock instance as airdrop, we make sure that + // we use same tokenLock instance as airdrop, we make sure that // the claimers and buyers are distinct to not reinitialize vesting tokenLock.setupVesting( msg.sender, @@ -131,18 +162,38 @@ contract TokenSale is Ownable { _buyers.length == _newTokenOutAmounts.length, "TokenSale: parameter length mismatch" ); - require(block.timestamp < saleStart, "TokenSale: sale already started"); + require(block.timestamp < saleStart, "TokenSale: ongoing sale"); for (uint256 i = 0; i < _buyers.length; i++) { + // Does not cover the case that the buyer has not claimed his airdrop + // So it will have to be somewhat manually checked + ITokenLockVestReader.VestingParams memory vestParams = tokenLock.vesting(_buyers[i]); + require(vestParams.unlockBegin == 0, "TokenSale: buyer has existing vest schedule"); whitelistedBuyersAmount[_buyers[i]] = _newTokenOutAmounts[i]; } } + /** + * @dev Modifies the start time of the sale. Enables a new sale to be created assuming one is not ongoing + * @dev A new list of buyers and tokenAmounts can be done by calling changeWhiteList() + * @param _newSaleStart The new start time of the token sale + */ + function setNewSaleStart(uint64 _newSaleStart) external { + require(msg.sender == owner() || msg.sender == saleRecipient, "TokenSale: not authorized"); + // can only change if there is no ongoing sale + require( + block.timestamp < saleStart || block.timestamp > saleStart + saleDuration, + "TokenSale: ongoing sale" + ); + require(block.timestamp < _newSaleStart, "TokenSale: new sale too early"); + saleStart = _newSaleStart; + } + /** * @dev Transfers out any remaining `tokenOut` after the sale to owner */ function sweepTokenOut() external { - require(saleStart + saleDuration < block.timestamp, "TokenSale: sale did not end yet"); + require(saleStart + saleDuration < block.timestamp, "TokenSale: ongoing sale"); uint256 tokenOutBalance = tokenOut.balanceOf(address(this)); require(tokenOut.transfer(owner(), tokenOutBalance), "TokenSale: transfer failed"); diff --git a/interfaces/ITokenLockVestReader.sol b/interfaces/ITokenLockVestReader.sol new file mode 100644 index 0000000..1565a2a --- /dev/null +++ b/interfaces/ITokenLockVestReader.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.10; + +import "./IRevokableTokenLock.sol"; + +interface ITokenLockVestReader is IRevokableTokenLock { + struct VestingParams { + uint256 unlockBegin; + uint256 unlockCliff; + uint256 unlockEnd; + uint256 lockedAmounts; + uint256 claimedAmounts; + } + + function vesting(address) external view returns (VestingParams memory); +} diff --git a/scripts/deploy/config.ts b/scripts/deploy/config.ts index c319801..ffc3adc 100644 --- a/scripts/deploy/config.ts +++ b/scripts/deploy/config.ts @@ -19,29 +19,22 @@ type TokenSaleConfig = { TOKEN_SALE_ARENA_PRICE: BN; TOKEN_SALE_RECIPIENT: string; TOKEN_SALE_WHITELIST: typeof TOKEN_SALE_WHITELIST; + RECIPIENT_AMOUNT: BN; }; +// TODO: update values as they are in USDC when they should be in ARENA tokens const TOKEN_SALE_WHITELIST = [ - {buyer: '0x0f4Aeb1847B7F1a735f4a5Af7E8C299b793c1a9A', arenaAmount: BN.from(`10000`).mul(ONE_18)}, - {buyer: '0x3Ab0029e1C4515134464b267557cB80A39902699', arenaAmount: BN.from(`10001`).mul(ONE_18)}, - {buyer: '0x4F3F7ca7E91D869180EBbA55e4322845a8Dc6862', arenaAmount: BN.from(`10002`).mul(ONE_18)}, - {buyer: '0x5dcEb6f4dc5b64Af6271A5Ab3297DbE3C01dd57B', arenaAmount: BN.from(`10003`).mul(ONE_18)}, - {buyer: '0x62641eAE546835813B56EC7b544756A532275Dd3', arenaAmount: BN.from(`10004`).mul(ONE_18)}, - {buyer: '0x670f9e8B37d5816c2eB93A1D94841C66652a8E26', arenaAmount: BN.from(`10005`).mul(ONE_18)}, - {buyer: '0x691Cbab55CC1806d29994784Ba9d9e679c03f164', arenaAmount: BN.from(`10006`).mul(ONE_18)}, - {buyer: '0x697ccd97C8419EBba7347CEF03a0CD02804EbF54', arenaAmount: BN.from(`10007`).mul(ONE_18)}, - {buyer: '0x6c422839E7EceDb6d2A86F3F2bFd03aDd154Fc27', arenaAmount: BN.from(`10008`).mul(ONE_18)}, - {buyer: '0x7C0fb88c87c30eBF70340E25fe47763e53b907cF', arenaAmount: BN.from(`10009`).mul(ONE_18)}, - {buyer: '0x8498EAb53e03E3143d77B2303eDBdAC6C9041D33', arenaAmount: BN.from(`10010`).mul(ONE_18)}, - {buyer: '0x8D31BAC0870e323354eAF6F98277860772FFB2d4', arenaAmount: BN.from(`10011`).mul(ONE_18)}, - {buyer: '0xA432F83d8054F5F859cAcb86574baC5e07DD6529', arenaAmount: BN.from(`10012`).mul(ONE_18)}, - {buyer: '0xD3488b8C87416946D82CC957178B0863A1F089b2', arenaAmount: BN.from(`10013`).mul(ONE_18)}, - {buyer: '0xD5388291EAbe96b56069440C97046791E2F72573', arenaAmount: BN.from(`10014`).mul(ONE_18)}, - {buyer: '0xF20eb7eAf52712EA0Aa80467741f34E6b0dB18F8', arenaAmount: BN.from(`10015`).mul(ONE_18)}, - {buyer: '0xa1fA3C686C9c4E5e8407b32B67191B079a65ffD2', arenaAmount: BN.from(`10016`).mul(ONE_18)}, - {buyer: '0xbB79597641483Ed96BCE9fc24b4D63F720898b8A', arenaAmount: BN.from(`10017`).mul(ONE_18)}, - {buyer: '0xe552C6A88E71B2A5069Dec480507F54321Dc65F3', arenaAmount: BN.from(`10018`).mul(ONE_18)}, - {buyer: '0xf4290941dBc8b31c277E30deFF3fC59979FC6757', arenaAmount: BN.from(`10019`).mul(ONE_18)}, + {buyer: '0x1aa1F9f80f4c5dCe34d0f4faB4F66AAF562330bd', arenaAmount: BN.from(1_000_000).mul(ONE_18)}, + {buyer: '0x3a5c572aE7a806c661970058450dC90D9eF0f353', arenaAmount: BN.from(400_000).mul(ONE_18)}, + {buyer: '0xcfc50541c3dEaf725ce738EF87Ace2Ad778Ba0C5', arenaAmount: BN.from(305_000).mul(ONE_18)}, + {buyer: '0xC02ad7b9a9121fc849196E844DC869D2250DF3A6', arenaAmount: BN.from(250_000).mul(ONE_18)}, + {buyer: '0xCfCA53C4b6d3f763969c9A9C36DBCAd61F11F36D', arenaAmount: BN.from(200_000).mul(ONE_18)}, + {buyer: '0x636EDa86F6EC324347Bd560c1045192586b9DEE8', arenaAmount: BN.from(200_000).mul(ONE_18)}, + {buyer: '0xDbBB1bD4cbDA95dd2f1477be139C3D6cb9d2B349', arenaAmount: BN.from(100_000).mul(ONE_18)}, + {buyer: '0x4dA94e682326BD14997D1E1c62350654D8e44c5d', arenaAmount: BN.from(75_000).mul(ONE_18)}, + {buyer: '0x20392b9607dc8cC49BEa5B7B90E65d6251617538', arenaAmount: BN.from(35_000).mul(ONE_18)}, + // {buyer: '0x45d28aA363fF215B4c6b6a212DC610f004272bb5', arenaAmount: BN.from(25_000).mul(ONE_18)}, note: this address is in airdrop + {buyer: '0x7fCAf93cc92d51c490FFF701fb2C6197497a80db', arenaAmount: BN.from(25_000).mul(ONE_18)}, ]; export const allConfigs: {[key: number]: Config} = { @@ -72,11 +65,12 @@ export const allConfigs: {[key: number]: Config} = { export const tokenSaleConfigs: {[key: number]: TokenSaleConfig} = { // polygon mainnet 137: { - TOKEN_SALE_START: Math.floor(new Date(`2022-01-12T00:00:00.000Z`).getTime() / 1000), - TOKEN_SALE_DURATION: 14 * ONE_DAY, + TOKEN_SALE_START: Math.floor(new Date(`2022-01-12T00:00:00.000Z`).getTime() / 1000), // TODO: modify to new date + TOKEN_SALE_DURATION: 10 * ONE_DAY, TOKEN_SALE_USDC: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', TOKEN_SALE_ARENA_PRICE: BN.from(30_000).mul(ONE_18).div(ONE_18), // 0.03 USDC * 1e18 / 1.0 ARENA - TOKEN_SALE_RECIPIENT: '0x670f9e8B37d5816c2eB93A1D94841C66652a8E26', // TODO: change to intended recipient - TOKEN_SALE_WHITELIST, // TODO: update value + TOKEN_SALE_RECIPIENT: '0x7f0049597056E37B4B1f887196E44CAc050D4863 ', // C4 Polygon multisig + TOKEN_SALE_WHITELIST, + RECIPIENT_AMOUNT: BN.from(1_750_000).mul(ONE.pow(6)), // 1.75M USDC, rest to treasury }, }; diff --git a/scripts/deploy/deployTokenSale.ts b/scripts/deploy/deployTokenSale.ts index 07ecb60..c5aaf22 100644 --- a/scripts/deploy/deployTokenSale.ts +++ b/scripts/deploy/deployTokenSale.ts @@ -77,7 +77,9 @@ export async function deployTokenSale(hre: HardhatRuntimeEnvironment) { config.TOKEN_SALE_ARENA_PRICE, config.TOKEN_SALE_RECIPIENT, tokenLock.address, - allConfigs[networkId].VEST_DURATION + timelock.address, + allConfigs[networkId].VEST_DURATION, + config.RECIPIENT_AMOUNT ); await tokenSale.deployed(); console.log(`tokenSale address: ${tokenSale.address}`); @@ -120,8 +122,6 @@ export async function deployTokenSale(hre: HardhatRuntimeEnvironment) { // ACCESS CONTROL VERIFICATION // ///////////////////////////////// console.log('verifying access control settings...'); - // check tokenSale's tokenlock has been set - expect(await tokenSale.tokenLock()).to.be.eq(tokenLock.address); // tokenSale's owner should be timelock expect(await tokenSale.owner()).to.be.eq(timelock.address); diff --git a/test/TokenSale.spec.ts b/test/TokenSale.spec.ts index c2413f5..40d5634 100644 --- a/test/TokenSale.spec.ts +++ b/test/TokenSale.spec.ts @@ -9,8 +9,8 @@ import {setNextBlockTimeStamp, resetNetwork} from '../shared/TimeManipulation'; const {solidity, loadFixture} = waffle; chai.use(solidity); -let tokenIn: IERC20; -let tokenOut: IERC20; +let tokenIn: TestERC20; +let tokenOut: TestERC20; let tokenLock: RevokableTokenLock; let tokenSale: TokenSale; // hardhat implicitly uses now as block time, so need to make tests relative to now @@ -23,17 +23,22 @@ const ONE_USDC = ethers.utils.parseUnits(`1`, TOKEN_IN_DECIMALS); // 0.05 USDC per 1.0 ARENA (0.05e6 * 1e18 / 1*1e18) const TOKEN_OUT_PRICE = ethers.utils.parseUnits(`0.05`, TOKEN_IN_DECIMALS).mul(ONE_18).div(ONE_ARENA); const SALE_DURATION = 7 * ONE_DAY; +// 75 USDC +const SALE_RECIPIENT_AMOUNT = ONE_USDC.mul(75); const WHITELISTED_ACCOUNTS: string[] = []; -const WHITELISTED_AMOUNTS = [BN.from(`1000`).mul(ONE_ARENA), BN.from(`500`).mul(ONE_ARENA)]; +// each buyer allowed 50 USDC worth of ARENA tokens (1000) +const BUYER_USDC_AMOUNT = ONE_USDC.mul(50); +const BUYER_ARENA_AMOUNT = BN.from(`1000`).mul(ONE_ARENA); +const WHITELISTED_AMOUNTS = Array(3).fill(BUYER_ARENA_AMOUNT); describe('TokenSale', async () => { - const [user, admin, saleRecipient, buyer1, buyer2, other] = waffle.provider.getWallets(); - WHITELISTED_ACCOUNTS.push(buyer1.address, buyer2.address); + const [user, admin, saleRecipient, buyer1, buyer2, buyer3, timelock, other] = waffle.provider.getWallets(); + WHITELISTED_ACCOUNTS.push(buyer1.address, buyer2.address, buyer3.address); async function fixture() { let TokenFactory = await ethers.getContractFactory('TestERC20'); - let tokenIn = (await TokenFactory.connect(admin).deploy('USDC', 'USDC')) as IERC20; - let tokenOut = (await TokenFactory.connect(admin).deploy('ARENA', 'ARENA')) as IERC20; + let tokenIn = await TokenFactory.connect(admin).deploy('USDC', 'USDC'); + let tokenOut = await TokenFactory.connect(admin).deploy('ARENA', 'ARENA'); let TokenLockFactory = await ethers.getContractFactory('RevokableTokenLock'); let tokenLock = (await TokenLockFactory.connect(admin).deploy( tokenOut.address, @@ -49,7 +54,9 @@ describe('TokenSale', async () => { TOKEN_OUT_PRICE, saleRecipient.address, tokenLock.address, - ONE_DAY + timelock.address, + ONE_DAY, + SALE_RECIPIENT_AMOUNT )) as TokenSale; await tokenLock.setTokenSale(tokenSale.address); await tokenSale.changeWhiteList(WHITELISTED_ACCOUNTS, WHITELISTED_AMOUNTS); @@ -59,7 +66,7 @@ describe('TokenSale', async () => { tokenSale.address, WHITELISTED_AMOUNTS.reduce((acc, amount) => acc.add(amount), BN.from(`0`)) ); - for (const buyer of [buyer1, buyer2]) { + for (const buyer of [buyer1, buyer2, buyer3]) { await tokenIn.connect(admin).transfer(buyer.address, BN.from(1_000_000).mul(ONE_USDC)); await tokenIn.connect(buyer).approve(tokenSale.address, MAX_UINT); } @@ -102,18 +109,72 @@ describe('TokenSale', async () => { } }); - it('should revert if sale already started', async () => { + it('should revert if sale is ongoing', async () => { await setNextBlockTimeStamp(SALE_START); await expect( tokenSale.connect(admin).changeWhiteList(WHITELISTED_ACCOUNTS, WHITELISTED_AMOUNTS) - ).to.be.revertedWith('TokenSale: sale already started'); + ).to.be.revertedWith('TokenSale: ongoing sale'); + }); + + it('should revert if a buyer has an existing vesting schedule', async () => { + await tokenLock.connect(admin).setupVesting(buyer1.address, 1, 2, 3); + await expect( + tokenSale.connect(admin).changeWhiteList(WHITELISTED_ACCOUNTS, WHITELISTED_AMOUNTS) + ).to.be.revertedWith('TokenSale: buyer has existing vest schedule'); + }); + }); + + describe('#setNewSaleStart', async () => { + it('should revert if caller is not owner or seller', async () => { + await expect(tokenSale.connect(user).setNewSaleStart(SALE_START + SALE_DURATION - 1)).to.be.revertedWith( + 'TokenSale: not authorized' + ); + await expect(tokenSale.connect(other).setNewSaleStart(SALE_START + SALE_DURATION - 1)).to.be.revertedWith( + 'TokenSale: not authorized' + ); + }); + + it('should revert if sale is ongoing', async () => { + await setNextBlockTimeStamp(SALE_START); + await expect(tokenSale.connect(admin).setNewSaleStart(SALE_START + 2 * SALE_DURATION)).to.be.revertedWith( + 'TokenSale: ongoing sale' + ); + await setNextBlockTimeStamp(SALE_START + SALE_DURATION); + await expect(tokenSale.connect(saleRecipient).setNewSaleStart(SALE_START + 2 * SALE_DURATION)).to.be.revertedWith( + 'TokenSale: ongoing sale' + ); + }); + + it('will revert if sale start time is set before block.timestamp', async () => { + await setNextBlockTimeStamp(SALE_START - 2 * SALE_DURATION); + await expect(tokenSale.connect(admin).setNewSaleStart(SALE_START - 2 * SALE_DURATION - 1)).to.be.revertedWith( + 'TokenSale: new sale too early' + ); + + await setNextBlockTimeStamp(SALE_START - SALE_DURATION); + await expect(tokenSale.connect(admin).setNewSaleStart(SALE_START - SALE_DURATION)).to.be.revertedWith( + 'TokenSale: new sale too early' + ); + }); + + it('should bring forward sale start time before sale starts', async () => { + await setNextBlockTimeStamp(SALE_START - 1.5 * SALE_DURATION); + expect(await tokenSale.saleStart()).to.eq(SALE_START); + await tokenSale.connect(admin).setNewSaleStart(SALE_START - SALE_DURATION); + expect(await tokenSale.saleStart()).to.eq(SALE_START - SALE_DURATION); + }); + + it('should set new sale start after current one ends', async () => { + await setNextBlockTimeStamp(SALE_START + SALE_DURATION + 1); + await tokenSale.connect(admin).setNewSaleStart(SALE_START + 2 * SALE_DURATION); + expect(await tokenSale.saleStart()).to.eq(SALE_START + 2 * SALE_DURATION); }); }); describe('#sweepTokenOut', async () => { - it('should revert if called before token end', async () => { + it('should revert if called before token sale end', async () => { await setNextBlockTimeStamp(SALE_START + SALE_DURATION - 1); - await expect(tokenSale.connect(user).sweepTokenOut()).to.be.revertedWith('TokenSale: sale did not end yet'); + await expect(tokenSale.connect(user).sweepTokenOut()).to.be.revertedWith('TokenSale: ongoing sale'); }); it('should send back any remaining tokenOut', async () => { @@ -122,17 +183,17 @@ describe('TokenSale', async () => { let preTokenOut = await tokenOut.balanceOf(admin.address); await tokenSale.connect(other).sweepTokenOut(); let postTokenOut = await tokenOut.balanceOf(admin.address); - await expect(postTokenOut.sub(preTokenOut)).to.eq( + expect(postTokenOut.sub(preTokenOut)).to.eq( WHITELISTED_AMOUNTS.reduce((acc, amount) => acc.add(amount), BN.from(`0`)) ); }); }); describe('#sweep', async () => { - let otherToken: IERC20; + let otherToken: TestERC20; beforeEach('distribute other token', async () => { let TokenFactory = await ethers.getContractFactory('TestERC20'); - otherToken = (await TokenFactory.connect(admin).deploy('OTHER', 'OTHER')) as IERC20; + otherToken = await TokenFactory.connect(admin).deploy('OTHER', 'OTHER'); await otherToken.transfer(tokenSale.address, ONE_18); }); @@ -213,6 +274,75 @@ describe('TokenSale', async () => { await tokenIn.connect(buyer1).transfer(buyer2.address, await tokenIn.balanceOf(buyer1.address)); await expect(tokenSale.connect(buyer1).buy()).to.be.revertedWith('ERC20: transfer amount exceeds balance'); }); + + it('should transfer correct amounts to saleRecipient and timelock depending on remaining sale recipient', async () => { + // not exceeded, transfer fully to saleRecipient + await expect(() => tokenSale.connect(buyer1).buy()).to.changeTokenBalances( + tokenIn, + [buyer1, saleRecipient], + [BUYER_USDC_AMOUNT.mul(-1), BUYER_USDC_AMOUNT] + ); + + // will be exceeded, half to saleRecipient, remaining to timelock + await expect(() => tokenSale.connect(buyer2).buy()).to.changeTokenBalances( + tokenIn, + [buyer2, saleRecipient, timelock], + [BUYER_USDC_AMOUNT.mul(-1), BUYER_USDC_AMOUNT.div(2), BUYER_USDC_AMOUNT.div(2)] + ); + + // have been exceeded, all to timelock + await expect(() => tokenSale.connect(buyer3).buy()).to.changeTokenBalances( + tokenIn, + [buyer3, timelock], + [BUYER_USDC_AMOUNT.mul(-1), BUYER_USDC_AMOUNT] + ); + }); + + it('should allow new buyers (distinct from the current one) to participate in a subsequent token sale', async () => { + // set current sale to only buyer1 + await setNextBlockTimeStamp(SALE_START - 10); + await tokenSale.connect(admin).changeWhiteList([buyer1.address], [BUYER_ARENA_AMOUNT]); + await setNextBlockTimeStamp(SALE_START); + await tokenSale.connect(buyer1).buy(); + await setNextBlockTimeStamp(SALE_START + SALE_DURATION + 1); + + // start new sale for buyer2, buyer3 and other + await tokenSale.connect(admin).setNewSaleStart(SALE_START + 2 * SALE_DURATION); + // should fail if buyer partipicated in previous sale + await expect( + tokenSale.connect(admin).changeWhiteList(WHITELISTED_ACCOUNTS, WHITELISTED_ACCOUNTS) + ).to.be.revertedWith('TokenSale: buyer has existing vest schedule'); + + // set new sale for buyer2, buyer3 and other + await tokenSale + .connect(admin) + .changeWhiteList([buyer2.address, buyer3.address, other.address], WHITELISTED_AMOUNTS); + await tokenOut.transfer(tokenSale.address, BUYER_ARENA_AMOUNT); + + // buyer1 should fail because sale hasnt started + await expect(tokenSale.connect(buyer1).buy()).to.be.revertedWith('TokenSale: not started'); + + await setNextBlockTimeStamp(SALE_START + 2 * SALE_DURATION); + // buyer1 should fail because not whitelisted anymore + await expect(tokenSale.connect(buyer1).buy()).to.be.revertedWith( + 'TokenSale: non-whitelisted purchaser or have already bought' + ); + + // make purchases, expect correct USDC amounts to be transferred to saleRecipient / timelock + // will exceed remainingSaleRecipient amount, half to saleRecipient, remaining to timelock + await expect(() => tokenSale.connect(buyer2).buy()).to.changeTokenBalances( + tokenIn, + [buyer2, saleRecipient, timelock], + [BUYER_USDC_AMOUNT.mul(-1), BUYER_USDC_AMOUNT.div(2), BUYER_USDC_AMOUNT.div(2)] + ); + + // remainingSaleRecipient exceeded, all to timelock + await expect(() => tokenSale.connect(buyer3).buy()).to.changeTokenBalances( + tokenIn, + [buyer3, timelock], + [BUYER_USDC_AMOUNT.mul(-1), BUYER_USDC_AMOUNT] + ); + }); }); after('reset network', async () => {