diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index be98d8cf..386c3688 100755 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,6 +14,8 @@ jobs: with: node-version: 18 - run: yarn + - name: clean cache + run: yarn clean - name: build run: yarn build - name: lint diff --git a/contracts/root/VTSQToken.sol b/contracts/root/VTSQToken.sol new file mode 100644 index 00000000..16942973 --- /dev/null +++ b/contracts/root/VTSQToken.sol @@ -0,0 +1,37 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import '@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol'; +import '@openzeppelin/contracts/access/Ownable.sol'; + +contract VTSQToken is ERC20, Ownable, ERC20Burnable { + using SafeERC20 for IERC20; + address public minter; + + modifier isMinter() { + require(minter == msg.sender, 'Not minter'); + _; + } + + constructor(address _minter) ERC20('VTSubQueryToken', 'vtSQT') Ownable() { + minter = _minter; + } + + function mint(address destination, uint256 amount) external isMinter { + _mint(destination, amount); + } + + /// #if_succeeds {:msg "minter should be set"} minter == _minter; + /// #if_succeeds {:msg "owner functionality"} old(msg.sender == address(owner)); + function setMinter(address _minter) external onlyOwner { + minter = _minter; + } + + function getMinter() external view returns (address) { + return minter; + } +} diff --git a/contracts/Vesting.sol b/contracts/root/Vesting.sol similarity index 84% rename from contracts/Vesting.sol rename to contracts/root/Vesting.sol index b5432cb0..f204ee76 100644 --- a/contracts/Vesting.sol +++ b/contracts/root/Vesting.sol @@ -6,6 +6,7 @@ import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../interfaces/ISQToken.sol"; contract Vesting is Ownable { using SafeERC20 for IERC20; @@ -17,6 +18,7 @@ contract Vesting is Ownable { } address public token; + address public vtToken; uint256 public vestingStartDate; uint256 public totalAllocation; uint256 public totalClaimed; @@ -30,8 +32,10 @@ contract Vesting is Ownable { event VestingAllocated(address indexed user, uint256 planId, uint256 allocation); event VestingClaimed(address indexed user, uint256 amount); - constructor(address _token) Ownable() { + constructor(address _token, address _vtToken) Ownable() { require(_token != address(0x0), "G009"); + require(_vtToken != address(0x0), "G009"); + vtToken = _vtToken; token = _token; } @@ -58,6 +62,8 @@ contract Vesting is Ownable { allocations[addr] = allocation; totalAllocation += allocation; + ISQToken(vtToken).mint(addr, allocation); + emit VestingAllocated(addr, planId, allocation); } @@ -94,15 +100,24 @@ contract Vesting is Ownable { function claim() external { require(allocations[msg.sender] != 0, "V011"); - uint256 claimAmount = claimableAmount(msg.sender); - claimed[msg.sender] += claimAmount; - totalClaimed += claimAmount; + uint256 amount = claimableAmount(msg.sender); + require(amount > 0, "V012"); + + ISQToken(vtToken).burnFrom(msg.sender, amount); + claimed[msg.sender] += amount; + totalClaimed += amount; - require(IERC20(token).transfer(msg.sender, claimAmount), "V008"); - emit VestingClaimed(msg.sender, claimAmount); + require(IERC20(token).transfer(msg.sender, amount), "V008"); + emit VestingClaimed(msg.sender, amount); } function claimableAmount(address user) public view returns (uint256) { + uint256 amount = unlockedAmount(user); + uint256 vtSQTAmount = IERC20(vtToken).balanceOf(user); + return vtSQTAmount >= amount ? amount : vtSQTAmount; + } + + function unlockedAmount(address user) public view returns (uint256) { // vesting start date is not set or allocation is empty if (vestingStartDate == 0 || allocations[user] == 0) { return 0; diff --git a/hardhat.config.ts b/hardhat.config.ts index 01346d79..98c8b074 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -91,6 +91,13 @@ task('publishRoot', "verify and publish contracts on etherscan") address: deployment.Vesting.address, constructorArguments: [deployment.SQToken.address], }); + //VTSQToken + console.log(`verify VTSQToken`); + await hre.run("verify:verify", { + address: deployment.VTSQToken.address, + contract: 'contracts/root/VTSQToken.sol:VTSQToken', + constructorArguments: [constants.AddressZero], + }); //Settings console.log(`verify Settings`); await hre.run("verify:verify", { diff --git a/package.json b/package.json index 41285663..43ee9b58 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:ts": "scripts/build.sh", "build:abi": "ts-node --transpileOnly scripts/abi.ts", "build": "yarn build:contract && yarn build:ts && yarn build:abi", + "clean": "rm -rf build artifacts", "lint": "solhint contracts/**/*.sol --fix", "test": "hardhat test", "test:all": "hardhat test ./test/*.test.ts", diff --git a/publish/ABI/VTSQToken.json b/publish/ABI/VTSQToken.json new file mode 100644 index 00000000..8078486c --- /dev/null +++ b/publish/ABI/VTSQToken.json @@ -0,0 +1,423 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_minter", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burnFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256" + } + ], + "name": "decreaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getMinter", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256" + } + ], + "name": "increaseAllowance", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "destination", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "minter", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_minter", + "type": "address" + } + ], + "name": "setMinter", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/publish/ABI/Vesting.json b/publish/ABI/Vesting.json index b1ea9e22..fcc50779 100644 --- a/publish/ABI/Vesting.json +++ b/publish/ABI/Vesting.json @@ -5,6 +5,11 @@ "internalType": "address", "name": "_token", "type": "address" + }, + { + "internalType": "address", + "name": "_vtToken", + "type": "address" } ], "stateMutability": "nonpayable", @@ -364,6 +369,25 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "unlockedAmount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -396,6 +420,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "vtToken", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "withdrawAllByAdmin", diff --git a/publish/revertcode.json b/publish/revertcode.json index bf1242eb..2563bf63 100644 --- a/publish/revertcode.json +++ b/publish/revertcode.json @@ -170,6 +170,7 @@ "V009": "vesting start date must in the future", "V010": "balance not enough for allocation", "V011": "vesting is not set on the account", + "V012": "no token available to claim", "OR001": "invalid asset price", "OR002": "not meet the block number limitation", "OR003": "invalid price size change", diff --git a/publish/testnet.json b/publish/testnet.json index 6076603f..56bb1ec7 100644 --- a/publish/testnet.json +++ b/publish/testnet.json @@ -41,6 +41,12 @@ "address": "0x3519c8939b73EAA440A5b626D6090275add4bD69", "bytecodeHash": "895a73f782b2930ee15d1ae0ccbfa751df049bd647ed5a23b7f42d22c441ba8b", "lastUpdate": "Fri, 15 Dec 2023 00:26:14 GMT" + }, + "VTSQToken": { + "innerAddress": "", + "address": "0x0D5A4266573975222292601686f2C3CF02E2120A", + "bytecodeHash": "1a3fdb466834f7139e72a865a759a82189da9dd342ec1fbfbbc9d62397bd109f", + "lastUpdate": "Tue, 19 Dec 2023 05:12:39 GMT" } }, "child": { diff --git a/scripts/abi.ts b/scripts/abi.ts index 292a97e8..a18e8475 100644 --- a/scripts/abi.ts +++ b/scripts/abi.ts @@ -20,7 +20,6 @@ const main = async () => { 'StateChannel', 'Airdropper', 'PermissionedExchange', - 'Vesting', 'ConsumerHost', 'DisputeManager', 'ConsumerRegistry', @@ -33,6 +32,8 @@ const main = async () => { ] const rootContracts = [ 'SQToken', + 'Vesting', + 'VTSQToken', 'InflationController', ] const proxyContracts = [ diff --git a/scripts/config/contracts.config.ts b/scripts/config/contracts.config.ts index 8980873b..2027ac3d 100644 --- a/scripts/config/contracts.config.ts +++ b/scripts/config/contracts.config.ts @@ -4,6 +4,7 @@ export default { mainnet: { InflationController: [10000, '0x34c35136ECe9CBD6DfDf2F896C6e29be01587c0C'], // inflationRate, inflationDestination SQToken: [utils.parseEther("10000000000")], // initial supply 10 billion + VTSQToken: [], // initial supply 0 Staking: [1209600, 1e3], // lockPeriod, unbondFeeRate Airdropper: ['0x34c35136ECe9CBD6DfDf2F896C6e29be01587c0C'], // settle destination EraManager: [604800], // 7 day @@ -28,6 +29,7 @@ export default { testnet: { InflationController: [10000, '0x4ae8fcdddc859e2984ce0b8f4ef490d61a7a9b7f'], // inflationRate, inflationDestination SQToken: [utils.parseEther("10000000000")], // initial supply 10 billion + VTSQToken: [], // initial supply 0 Staking: [1000, 1e3], // lockPeriod, unbondFeeRate Airdropper: ['0x4ae8fcdddc859e2984ce0b8f4ef490d61a7a9b7f'], // settle destination EraManager: [3600], // 1 hour @@ -41,6 +43,7 @@ export default { local: { InflationController: [1000, '0x4ae8fcdddc859e2984ce0b8f4ef490d61a7a9b7f'], // inflationRate, inflationDestination SQToken: [utils.parseEther("10000000000")], // initial supply 10 billion + VTSQToken: [], // initial supply 0 billion Staking: [1000, 1e3], // lockPeriod, unbondFeeRate Airdropper: ['0x4ae8fcdddc859e2984ce0b8f4ef490d61a7a9b7f'], // settle destination EraManager: [60 * 60], // 1 hour diff --git a/scripts/contracts.ts b/scripts/contracts.ts index 98181dd1..a8e302ee 100644 --- a/scripts/contracts.ts +++ b/scripts/contracts.ts @@ -1,6 +1,6 @@ import { Provider } from '@ethersproject/abstract-provider'; import { Wallet } from '@ethersproject/wallet'; -import { BaseContract, BigNumber, ContractFactory, Signer } from 'ethers'; +import { BaseContract, ContractFactory, Signer } from 'ethers'; import CONTRACTS from '../src/contracts'; @@ -64,6 +64,8 @@ import { PolygonDestination, PolygonDestination__factory, ChildERC20__factory, + VTSQToken, + VTSQToken__factory, } from '../src'; export interface FactoryContstructor { @@ -97,6 +99,7 @@ export type Contracts = { permissionedExchange: PermissionedExchange; tokenExchange: TokenExchange; vesting: Vesting; + vtSQToken: VTSQToken; consumerHost: ConsumerHost; disputeManager: DisputeManager; consumerRegistry: ConsumerRegistry; @@ -138,6 +141,7 @@ export const CONTRACT_FACTORY: Record = { VSQToken: VSQToken__factory, Airdropper: Airdropper__factory, Vesting: Vesting__factory, + VTSQToken: VTSQToken__factory, Staking: Staking__factory, StakingManager: StakingManager__factory, EraManager: EraManager__factory, diff --git a/scripts/deployContracts.ts b/scripts/deployContracts.ts index 4ab78832..c0b54fdf 100644 --- a/scripts/deployContracts.ts +++ b/scripts/deployContracts.ts @@ -43,6 +43,7 @@ import { TokenExchange, PolygonDestination, RootChainManager__factory, + VTSQToken, } from '../src'; import { CONTRACT_FACTORY, @@ -223,19 +224,33 @@ export async function deployRootContracts( }); logger?.info('🤞 SQToken'); + // deploy InflationController const inflationController = await deployContract('InflationController', 'root', { initConfig: [settingsAddress], proxyAdmin, }); logger?.info('🤞 InflationController'); + // setup minter let tx = await sqtToken.setMinter(inflationController.address); await tx.wait(confirms); + logger?.info('🤞 Set SQToken minter'); + + // deploy VTSQToken + const vtSQToken = await deployContract('VTSQToken', 'root', { + deployConfig: [constants.AddressZero], + }); + logger?.info('🤞 VTSQToken'); //deploy vesting contract - const vesting = await deployContract('Vesting', 'root', { deployConfig: [deployment.root.SQToken.address] }); + const vesting = await deployContract('Vesting', 'root', { deployConfig: [sqtToken.address, vtSQToken.address] }); logger?.info('🤞 Vesting'); + // set vesting contract as the minter of vtSQToken + tx = await vtSQToken.setMinter(vesting.address); + await tx.wait(confirms); + logger?.info('🤞 Set VTSQToken minter'); + //deploy PolygonDestination contract const polygonDestination = await deployContract('PolygonDestination' as any, 'root', { deployConfig: [settingsAddress, constants.AddressZero] }); @@ -268,6 +283,7 @@ export async function deployRootContracts( { inflationController, rootToken: sqtToken, + vtSQToken, proxyAdmin, vesting, polygonDestination, diff --git a/src/contracts.ts b/src/contracts.ts index 8f89c6f0..ec7af94e 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -28,7 +28,8 @@ import StakingManager from './artifacts/contracts/StakingManager.sol/StakingMana import StateChannel from './artifacts/contracts/StateChannel.sol/StateChannel.json'; import VSQToken from './artifacts/contracts/VSQToken.sol/VSQToken.json'; import ChildERC20 from './artifacts/contracts/polygon/ChildERC20.sol/ChildERC20.json'; -import Vesting from './artifacts/contracts/Vesting.sol/Vesting.json'; +import Vesting from './artifacts/contracts/root/Vesting.sol/Vesting.json'; +import VTSQToken from './artifacts/contracts/root/VTSQToken.sol/VTSQToken.json'; import PolygonDestination from './artifacts/contracts/root/PolygonDestination.sol/PolygonDestination.json'; export default { @@ -55,6 +56,7 @@ export default { PermissionedExchange, TokenExchange, Vesting, + VTSQToken, ConsumerHost, DisputeManager, PriceOracle, diff --git a/src/index.ts b/src/index.ts index 7763f910..cae52949 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later export * from './sdk'; +export * from './rootSdk'; export * from './polygonSDK'; export * from './typechain'; export * from './types'; diff --git a/src/rootSdk.ts b/src/rootSdk.ts new file mode 100644 index 00000000..b63ff103 --- /dev/null +++ b/src/rootSdk.ts @@ -0,0 +1,60 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import type {Provider as AbstractProvider} from '@ethersproject/abstract-provider'; +import {Signer} from 'ethers'; +import {DEPLOYMENT_DETAILS} from './deployments'; +import {ERC20, SQToken, Vesting} from './typechain'; +import {CONTRACT_FACTORY, ContractDeploymentInner, ContractName, FactoryContstructor, SdkOptions} from './types'; +import assert from "assert"; + +// HOTFIX: Contract names are not consistent between deployments and privous var names +const contractNameConversion: Record = { + sQToken: 'sqToken', + vTSQToken: 'vtSQToken', +}; + +const ROOT_CONTRACTS = ['SQToken', 'Vesting', 'VTSQToken']; + + +export class RootContractSDK { + private _contractDeployments: ContractDeploymentInner; + + readonly sqToken!: SQToken; + readonly vtSQToken!: ERC20; + readonly vesting!: Vesting; + + constructor(private readonly signerOrProvider: AbstractProvider | Signer, public readonly options: SdkOptions) { + assert(this.options.deploymentDetails || DEPLOYMENT_DETAILS[options.network], ' missing contract deployment info'); + this._contractDeployments = this.options.deploymentDetails ?? DEPLOYMENT_DETAILS[options.network]!.root; + this._init(); + } + + static create(signerOrProvider: AbstractProvider | Signer, options: SdkOptions) { + return new RootContractSDK(signerOrProvider, options); + } + + private async _init() { + const contracts = Object.entries(this._contractDeployments).filter( ([name]) => + ROOT_CONTRACTS.includes(name) + ).map(([name, contract]) => ({ + address: contract.address, + factory: CONTRACT_FACTORY[name as ContractName] as FactoryContstructor, + name: name as ContractName, + })); + + for (const {name, factory, address} of contracts) { + if (!factory) continue; + const contractInstance = factory.connect(address, this.signerOrProvider); + if (contractInstance) { + const key = name.charAt(0).toLowerCase() + name.slice(1); + const contractName = contractNameConversion[key] ?? key; + Object.defineProperty(this, contractName, { + get: () => contractInstance, + }); + } else { + throw new Error(`${name} contract not found`); + } + } + } +} diff --git a/src/types.ts b/src/types.ts index 8054a552..3ceb68a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ import { StateChannel__factory, VSQToken__factory, Vesting__factory, + VTSQToken__factory, ChildERC20__factory, TokenExchange__factory, PolygonDestination__factory, @@ -101,6 +102,7 @@ export const CONTRACT_FACTORY: Record = { VSQToken: VSQToken__factory, Airdropper: Airdropper__factory, Vesting: Vesting__factory, + VTSQToken: VTSQToken__factory, Staking: Staking__factory, StakingManager: StakingManager__factory, EraManager: EraManager__factory, diff --git a/test/Vesting.test.ts b/test/Vesting.test.ts index 0799961c..ffeb31f3 100644 --- a/test/Vesting.test.ts +++ b/test/Vesting.test.ts @@ -6,20 +6,23 @@ import { expect } from 'chai'; import { BigNumber } from "ethers"; import { ethers, waffle } from 'hardhat'; import { SQToken, Vesting } from '../src'; -import { eventFrom } from "./helper"; +import { etherParse, eventFrom } from "./helper"; import { deployRootContracts } from './setup'; +import { VTSQToken } from "build"; describe('Vesting Contract', () => { const mockProvider = waffle.provider; const [wallet, wallet1, wallet2, wallet3, wallet4] = mockProvider.getWallets(); - let token: SQToken; + let sqToken: SQToken; + let vtSQToken: VTSQToken; let vestingContract: Vesting; let lockPeriod: number; let vestingPeriod: number; let initialUnlockPercent = 10; async function claimVesting(wallet: Wallet): Promise<{user: string, amount: BigNumber}> { + await vtSQToken.connect(wallet).increaseAllowance(vestingContract.address, parseEther(10000)); const tx = await vestingContract.connect(wallet).claim(); const evt = await eventFrom(tx, vestingContract, 'VestingClaimed(address,uint256)'); return evt as any; @@ -57,18 +60,20 @@ describe('Vesting Contract', () => { const checkAllocation = async (planId: number, user: string, allocation: number) => { expect(await vestingContract.userPlanId(user)).to.equal(planId); expect(await vestingContract.allocations(user)).to.equal(parseEther(allocation)); + expect(await vtSQToken.balanceOf(user)).to.equal(parseEther(allocation)); }; const deployer = ()=>deployRootContracts(wallet, wallet1); beforeEach(async () => { const deployment = await waffle.loadFixture(deployer); - token = deployment.rootToken; + sqToken = deployment.rootToken; vestingContract = deployment.vesting; + vtSQToken = deployment.vtSQToken; lockPeriod = 86400 * 30; // 2 month vestingPeriod = 86400 * 365; // 1 year - await token.approve(vestingContract.address, parseEther(4000)); + await sqToken.approve(vestingContract.address, parseEther(4000)); }); describe('Vesting Plan', () => { @@ -96,9 +101,18 @@ describe('Vesting Contract', () => { 'V001' ); }); + + it('non admin should fail', async () => { + await expect(vestingContract.connect(wallet1).addVestingPlan(lockPeriod, vestingPeriod, 0)) + .to.revertedWith('Ownable: caller is not the owner'); + await vestingContract.renounceOwnership(); + + await expect(vestingContract.addVestingPlan(lockPeriod, vestingPeriod, 0)) + .to.revertedWith('Ownable: caller is not the owner'); + }); }); - describe('Allocate Vestring', () => { + describe('Allocate Vesting', () => { beforeEach(async () => { await vestingContract.addVestingPlan(lockPeriod, vestingPeriod, 10); }); @@ -161,10 +175,12 @@ describe('Vesting Contract', () => { describe('Token Manangement By Admin', () => { it('deposit and widthdraw all by admin should work', async () => { await vestingContract.depositByAdmin(1000); - expect(await token.balanceOf(vestingContract.address)).to.eq(1000); + expect(await sqToken.balanceOf(vestingContract.address)).to.eq(1000); + expect(await vtSQToken.totalSupply()).to.eq(0); await vestingContract.withdrawAllByAdmin(); - expect(await token.balanceOf(vestingContract.address)).to.eq(parseEther(0)); + expect(await sqToken.balanceOf(vestingContract.address)).to.eq(parseEther(0)); + expect(await vtSQToken.totalSupply()).to.eq(0); }); it('deposit and widthdraw without owner should fail', async () => { @@ -188,6 +204,12 @@ describe('Vesting Contract', () => { ); }); + it('mint vtSQToken should work', async () => { + expect(await vtSQToken.totalSupply()).to.equal(parseEther(4000)); + expect(await vtSQToken.balanceOf(wallet1.address)).to.equal(parseEther(1000)); + expect(await vtSQToken.balanceOf(wallet2.address)).to.equal(parseEther(3000)); + }); + it('set incorrect vesting date should fail', async () => { const latestBlock = await mockProvider.getBlock('latest'); await expect(vestingContract.startVesting(latestBlock.timestamp)).to.be.revertedWith( @@ -196,7 +218,7 @@ describe('Vesting Contract', () => { }); it('start vesting without enough balance should fail', async () => { - expect(await token.balanceOf(vestingContract.address)).to.equal(parseEther(0)); + expect(await sqToken.balanceOf(vestingContract.address)).to.equal(parseEther(0)); const latestBlock = await mockProvider.getBlock('latest'); await expect(vestingContract.startVesting(latestBlock.timestamp + 1000)).to.be.revertedWith( 'V010' @@ -225,6 +247,7 @@ describe('Vesting Contract', () => { describe('Vesting Claim', () => { const wallet1Allocation = parseEther(1000); const wallet2Allocation = parseEther(3000); + beforeEach(async () => { await vestingContract.depositByAdmin(parseEther(4000)); const planId = await createPlan(lockPeriod, vestingPeriod); @@ -233,6 +256,9 @@ describe('Vesting Contract', () => { [wallet1.address, wallet2.address], [wallet1Allocation, wallet2Allocation] ); + + await vtSQToken.connect(wallet1).increaseAllowance(vestingContract.address, parseEther(1000)); + await vtSQToken.connect(wallet2).increaseAllowance(vestingContract.address, parseEther(3000)); }); it('no claimable amount for invalid condition', async () => { @@ -249,14 +275,6 @@ describe('Vesting Contract', () => { expect(await vestingContract.claimableAmount(wallet2.address)).to.equal(0); }); - it('claim before lock period', async () => {// start vesting - await startVesting(); - let claimable = await vestingContract.claimableAmount(wallet1.address); - expect(claimable).to.eq(0); - const evt = await claimVesting(wallet1); - expect(evt.amount).to.eq(0); - }) - it('claim during vesting period', async () => {// start vesting await startVesting(); await timeTravel(lockPeriod + 1001); @@ -279,7 +297,15 @@ describe('Vesting Contract', () => { evt = await claimVesting(wallet1); expect(evt.amount).to.gte(claimable); expect(evt.amount.sub(claimable)).to.lt(errorTolerance); - }) + for (let i=0;i<9;i++) { + await timeTravel(vestingPeriod/10); + await claimVesting(wallet1); + } + claimable = await vestingContract.claimableAmount(wallet1.address); + expect(claimable).to.eq(0); + const claimed = await sqToken.balanceOf(wallet1.address); + expect(claimed).to.eq(wallet1Allocation); + }); it('claim all together in once', async () => {// start vesting await startVesting(); @@ -307,7 +333,7 @@ describe('Vesting Contract', () => { // wallet1 claim await vestingContract.connect(wallet1).claim(); - const balance1 = await token.balanceOf(wallet1.address); + const balance1 = await sqToken.balanceOf(wallet1.address); expect(balance1).to.gt(claimable1); expect(balance1).to.lt(claimable1.add(parseEther(0.001))); // claim after half vesting period @@ -316,18 +342,63 @@ describe('Vesting Contract', () => { expect(claimable1).to.gte(parseEther(450)); // wallet1 claim await vestingContract.connect(wallet1).claim(); - expect(await token.balanceOf(wallet1.address)).to.gt(balance1.add(claimable1)); - expect(await token.balanceOf(wallet1.address)).to.lt(balance1.add(claimable1).add(parseEther(0.001))); + expect(await sqToken.balanceOf(wallet1.address)).to.gt(balance1.add(claimable1)); + expect(await sqToken.balanceOf(wallet1.address)).to.lt(balance1.add(claimable1).add(parseEther(0.001))); // claim after vesting period await timeTravel(vestingPeriod / 2); await vestingContract.connect(wallet1).claim(); - expect(await token.balanceOf(wallet1.address)).to.eq(parseEther(1000)); + expect(await sqToken.balanceOf(wallet1.address)).to.eq(parseEther(1000)); }); - it('claim on non-vesting account should fail', async () => { + it('should burn equal amount of vtSQToken for claimed SQT', async () => { + // start vesting + await startVesting(); + await timeTravel(lockPeriod + 1001); + // wallet1 claim + expect(await sqToken.balanceOf(wallet1.address)).to.eq(0); + expect(await vtSQToken.balanceOf(wallet1.address)).to.eq(parseEther(1000)); + await vestingContract.connect(wallet1).claim(); + const sqtBalance = await sqToken.balanceOf(wallet1.address); + expect(await vtSQToken.balanceOf(wallet1.address)).to.eq(parseEther(1000).sub(sqtBalance)); + }); + + it('should only claim max amount of VTSQToken', async () => { + // start vesting + await startVesting(); + await timeTravel(lockPeriod + 1001); + // wallet1 + expect(await sqToken.balanceOf(wallet1.address)).to.eq(0); + const unlockAmount = await vestingContract.unlockedAmount(wallet1.address); + // transfer VTSQToken to wallet2 + await vtSQToken.connect(wallet1).transfer(wallet2.address, etherParse('999')); + // unlockAmount > 1 SQT, vtSQToken balance = 1 vtSQT + expect(unlockAmount.gt(etherParse('1'))).to.be.true; + const claimableAmount = etherParse('1'); + expect(await vestingContract.claimableAmount(wallet1.address)).to.eq(claimableAmount); + + // check SQT and VTSQT balance + await vestingContract.connect(wallet1).claim(); + expect(await sqToken.balanceOf(wallet1.address)).to.eq(claimableAmount); + expect(await vtSQToken.balanceOf(wallet1.address)).to.eq(0); + }); + + it('claim with invalid condition should fail', async () => { + // claim on non-vesting account should fail await expect(vestingContract.connect(wallet3).claim()).to.be.revertedWith( 'V011' ); + // claim with zero claimable amount should fail + // # case 1 (not start vesting) + await expect(vestingContract.connect(wallet1).claim()).to.be.revertedWith( + 'V012' + ); + // # case 2 (not enough vtSQT) + await startVesting(); + await timeTravel(lockPeriod + 1001); + await vtSQToken.connect(wallet1).transfer(wallet2.address, etherParse('1000')); + await expect(vestingContract.connect(wallet1).claim()).to.be.revertedWith( + 'V012' + ); }); }); }); diff --git a/test/setup.ts b/test/setup.ts index d5e9a7e3..1c5bf7bd 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -56,6 +56,7 @@ export const deployRootContracts = async (wallet: Wallet, wallet1: Wallet) => { { InflationController: [1000, wallet1.address], SQToken: [etherParse("10000000000").toString()], + VTSQToken: [0], } );