From 2385cc1121de6d1f8f3778edb91f780ca0c288e9 Mon Sep 17 00:00:00 2001 From: aalavandhan1984 <6264334+aalavandhan@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:23:02 -0500 Subject: [PATCH] setup unit tests --- .solhint.json | 3 +- package.json | 2 +- test/helper.js | 125 ----------- test/helper.ts | 92 ++++++++ test/staking.js | 307 ------------------------- test/staking.ts | 196 ++++++++++++++++ test/token_pool.js | 120 ---------- test/token_pool.ts | 137 ++++++++++++ test/token_unlock.js | 507 ------------------------------------------ test/token_unlock.ts | 519 +++++++++++++++++++++++++++++++++++++++++++ test/unstake.js | 395 -------------------------------- test/unstake.ts | 396 +++++++++++++++++++++++++++++++++ 12 files changed, 1343 insertions(+), 1456 deletions(-) delete mode 100644 test/helper.js create mode 100644 test/helper.ts delete mode 100644 test/staking.js create mode 100644 test/staking.ts delete mode 100644 test/token_pool.js create mode 100644 test/token_pool.ts delete mode 100644 test/token_unlock.js create mode 100644 test/token_unlock.ts delete mode 100644 test/unstake.js create mode 100644 test/unstake.ts diff --git a/.solhint.json b/.solhint.json index 30953ff..6e33819 100644 --- a/.solhint.json +++ b/.solhint.json @@ -5,6 +5,7 @@ "func-visibility": ["warn", { "ignoreConstructors": true }], "reason-string": ["warn", { "maxLength": 64 }], "not-rely-on-time": "off", - "max-states-count": ["warn", 17] + "max-states-count": ["warn", 17], + "custom-errors": "off" } } diff --git a/package.json b/package.json index 213f3a1..46cfcda 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "prettier": "prettier --config .prettierrc --write \"**/*.{js,json,md,sol,ts}\"", "prettier:list-different": "prettier --config .prettierrc --list-different \"**/*.{js,json,md,sol,ts}\"", "profile": "REPORT_GAS=true yarn hardhat test test/*.ts test/**/*.ts", - "test": "yarn hardhat test test/*.ts test/**/*.ts" + "test": "yarn hardhat test test/*.ts" }, "dependencies": { "@openzeppelin/contracts": "5.1.0", diff --git a/test/helper.js b/test/helper.js deleted file mode 100644 index 5325820..0000000 --- a/test/helper.js +++ /dev/null @@ -1,125 +0,0 @@ -const { BN } = require("@openzeppelin/test-helpers"); -const { promisify } = require("util"); -const { time } = require("@openzeppelin/test-helpers"); -const { web3 } = require("@openzeppelin/test-environment"); -const { expect } = require("chai"); - -const PERC_DECIMALS = 2; -const AMPL_DECIMALS = 9; - -function $AMPL(x) { - return new BN(x * 10 ** AMPL_DECIMALS); -} - -// Perc has to be a whole number -async function invokeRebase(ampl, perc) { - const s = await ampl.totalSupply.call(); - const ordinate = 10 ** PERC_DECIMALS; - const p_ = new BN(parseInt(perc * ordinate)).div(new BN(100)); - const s_ = s.mul(p_).div(new BN(ordinate)); - await ampl.rebase(1, s_); -} - -function checkAmplAprox(x, y) { - checkAprox(x, $AMPL(y), 10 ** 6); -} - -function checkSharesAprox(x, y) { - checkAprox(x, y, 10 ** 12); -} - -function checkAprox(x, y, delta_) { - const delta = new BN(parseInt(delta_)); - const upper = y.add(delta); - const lower = y.sub(delta); - expect(x).to.be.bignumber.at.least(lower).and.bignumber.at.most(upper); -} - -class TimeController { - async initialize() { - this.currentTime = await time.latest(); - } - async advanceTime(seconds) { - this.currentTime = this.currentTime.add(new BN(seconds)); - await setTimeForNextTransaction(this.currentTime); - } - async executeEmptyBlock() { - await time.advanceBlock(); - } - async executeAsBlock(Transactions) { - await this.pauseTime(); - Transactions(); - await this.resumeTime(); - await time.advanceBlock(); - } - async pauseTime() { - return promisify(web3.currentProvider.send.bind(web3.currentProvider))({ - jsonrpc: "2.0", - method: "miner_stop", - id: new Date().getTime(), - }); - } - async resumeTime() { - return promisify(web3.currentProvider.send.bind(web3.currentProvider))({ - jsonrpc: "2.0", - method: "miner_start", - id: new Date().getTime(), - }); - } -} - -async function printMethodOutput(r) { - console.log(r.logs); -} - -async function printStatus(dist) { - console.log("Total Locked: ", await dist.totalLocked.call().toString()); - console.log("Total UnLocked: ", await dist.totalUnlocked.call().toString()); - const c = (await dist.unlockScheduleCount.call()).toNumber(); - console.log(await dist.unlockScheduleCount.call().toString()); - - for (let i = 0; i < c; i++) { - console.log(await dist.unlockSchedules.call(i).toString()); - } - // TODO: Print the following variables: - // await dist.totalLocked.call() - // await dist.totalUnlocked.call() - // await dist.unlockScheduleCount.call() - // dist.updateAccounting.call() // and all the logs - // dist.unlockSchedules.call(1) -} - -async function increaseTimeForNextTransaction(diff) { - await promisify(web3.currentProvider.send.bind(web3.currentProvider))({ - jsonrpc: "2.0", - method: "evm_increaseTime", - params: [diff.toNumber()], - id: new Date().getTime(), - }); -} - -async function setTimeForNextTransaction(target) { - if (!BN.isBN(target)) { - target = new BN(target); - } - - const now = await time.latest(); - - if (target.lt(now)) - throw Error( - `Cannot increase current time (${now}) to a moment in the past (${target})`, - ); - const diff = target.sub(now); - increaseTimeForNextTransaction(diff); -} - -module.exports = { - checkAmplAprox, - checkSharesAprox, - invokeRebase, - $AMPL, - setTimeForNextTransaction, - TimeController, - printMethodOutput, - printStatus, -}; diff --git a/test/helper.ts b/test/helper.ts new file mode 100644 index 0000000..0af3e4a --- /dev/null +++ b/test/helper.ts @@ -0,0 +1,92 @@ +import { ethers } from "hardhat"; +import { promisify } from "util"; +import { expect } from "chai"; + +const AMPL_DECIMALS = 9; + +function $AMPL(x: number) { + return ethers.parseUnits(x.toFixed(AMPL_DECIMALS), AMPL_DECIMALS); +} + +// Perc has to be a whole number +async function invokeRebase(ampl, perc) { + await ampl.rebase(1, ((await ampl.totalSupply()) * BigInt(perc)) / 100n); +} + +function checkAmplAprox(x, y) { + checkAprox(x, $AMPL(y), BigInt(10 ** 7)); +} + +function checkSharesAprox(x, y) { + checkAprox(x, y, BigInt(10 ** 12)); +} + +function checkAprox(x, y, delta_) { + const delta = BigInt(delta_); + const upper = y + delta; + const lower = y - delta; + expect(x).to.gte(lower).to.lte(upper); +} + +export const TimeHelpers = { + secondsFromNow: async (secondsFromNow: number): Promise => { + return (await TimeHelpers.currentTime()) + secondsFromNow; + }, + + moveClock: async (seconds: number): Promise => { + await hre.network.provider.send("evm_increaseTime", [seconds]); + }, + + advanceBlock: async () => { + await hre.network.provider.send("evm_mine"); + }, + + increaseTime: async (seconds: number): Promise => { + await hre.network.provider.send("evm_increaseTime", [seconds]); + await hre.network.provider.send("evm_mine"); + }, + + setNextBlockTimestamp: async (timestamp: number): Promise => { + await ethers.provider.send("evm_setNextBlockTimestamp", [timestamp]); + await hre.network.provider.send("evm_mine"); + }, + + currentTime: async (): Promise => { + const res = await hre.network.provider.send("eth_getBlockByNumber", [ + "latest", + false, + ]); + const timestamp = parseInt(res.timestamp, 16); + return timestamp; + }, +}; + +async function printMethodOutput(r) { + console.log(r.logs); +} + +async function printStatus(dist) { + console.log("Total Locked: ", await dist.totalLocked.staticCall().toString()); + console.log("Total UnLocked: ", await dist.totalUnlocked.staticCall().toString()); + const c = (await dist.unlockScheduleCount.staticCall()).toNumber(); + console.log(await dist.unlockScheduleCount.staticCall().toString()); + + for (let i = 0; i < c; i++) { + console.log(await dist.unlockSchedules.staticCall(i).toString()); + } + // await dist.totalLocked.staticCall() + // await dist.totalUnlocked.staticCall() + // await dist.unlockScheduleCount.staticCall() + // dist.updateAccounting.staticCall() // and all the logs + // dist.unlockSchedules.staticCall(1) +} + +module.exports = { + checkAmplAprox, + checkSharesAprox, + invokeRebase, + $AMPL, + TimeHelpers, + printMethodOutput, + printStatus, +}; diff --git a/test/staking.js b/test/staking.js deleted file mode 100644 index 47df5fa..0000000 --- a/test/staking.js +++ /dev/null @@ -1,307 +0,0 @@ -const { contract, web3 } = require("@openzeppelin/test-environment"); -const { - expectRevert, - expectEvent, - BN, - constants, -} = require("@openzeppelin/test-helpers"); -const { expect } = require("chai"); - -const _require = require("app-root-path").require; -const BlockchainCaller = _require("/util/blockchain_caller"); -const chain = new BlockchainCaller(web3); -const { $AMPL, invokeRebase } = _require("/test/helper"); - -const MockERC20 = contract.fromArtifact("MockERC20"); -const AmpleforthErc20 = contract.fromArtifact("UFragments"); -const TokenGeyser = contract.fromArtifact("TokenGeyser"); -const InitialSharesPerToken = 10 ** 6; - -let ampl, dist, owner, anotherAccount; -describe("staking", function () { - beforeEach("setup contracts", async function () { - const accounts = await chain.getUserAccounts(); - owner = web3.utils.toChecksumAddress(accounts[0]); - anotherAccount = web3.utils.toChecksumAddress(accounts[8]); - - ampl = await AmpleforthErc20.new(); - await ampl.initialize(owner); - await ampl.setMonetaryPolicy(owner); - - const startBonus = 50; - const bonusPeriod = 86400; - dist = await TokenGeyser.new( - ampl.address, - ampl.address, - 10, - startBonus, - bonusPeriod, - InitialSharesPerToken, - ); - }); - - describe("when start bonus too high", function () { - it("should fail to construct", async function () { - await expectRevert( - TokenGeyser.new( - ampl.address, - ampl.address, - 10, - 101, - 86400, - InitialSharesPerToken, - ), - "TokenGeyser: start bonus too high", - ); - }); - }); - - describe("when bonus period is 0", function () { - it("should fail to construct", async function () { - await expectRevert( - TokenGeyser.new(ampl.address, ampl.address, 10, 50, 0, InitialSharesPerToken), - "TokenGeyser: bonus period is zero", - ); - }); - }); - - describe("getStakingToken", function () { - it("should return the staking token", async function () { - expect(await dist.getStakingToken.call()).to.equal(ampl.address); - }); - }); - - describe("token", function () { - it("should return the staking token", async function () { - expect(await dist.token.call()).to.equal(ampl.address); - }); - }); - - describe("supportsHistory", function () { - it("should return supportsHistory", async function () { - expect(await dist.supportsHistory.call()).to.be.false; - }); - }); - - describe("stake", function () { - describe("when the amount is 0", function () { - it("should fail", async function () { - await ampl.approve(dist.address, $AMPL(1000)); - await expectRevert.unspecified(dist.stake($AMPL(0), [])); - }); - }); - - describe("when token transfer has not been approved", function () { - it("should fail", async function () { - await ampl.approve(dist.address, $AMPL(10)); - await expectRevert.unspecified(dist.stake($AMPL(100), [])); - }); - }); - - describe("when totalStaked=0", function () { - beforeEach(async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - await ampl.approve(dist.address, $AMPL(100)); - }); - it("should updated the total staked", async function () { - await dist.stake($AMPL(100), []); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(100)); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(100)); - expect(await dist.totalStakingShares.call()).to.be.bignumber.equal( - $AMPL(100).mul(new BN(InitialSharesPerToken)), - ); - }); - it("should log Staked", async function () { - const r = await dist.stake($AMPL(100), []); - expectEvent(r, "Staked", { - user: owner, - amount: $AMPL(100), - total: $AMPL(100), - }); - }); - }); - - describe("when totalStaked>0", function () { - beforeEach(async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - await ampl.transfer(anotherAccount, $AMPL(50)); - await ampl.approve(dist.address, $AMPL(50), { from: anotherAccount }); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await ampl.approve(dist.address, $AMPL(150)); - await dist.stake($AMPL(150), []); - }); - it("should updated the total staked", async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(200)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(50), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(150)); - expect(await dist.totalStakingShares.call()).to.be.bignumber.equal( - $AMPL(200).mul(new BN(InitialSharesPerToken)), - ); - }); - }); - - describe("when totalStaked>0, rebase increases supply", function () { - beforeEach(async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - await ampl.transfer(anotherAccount, $AMPL(50)); - await ampl.approve(dist.address, $AMPL(50), { from: anotherAccount }); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await ampl.approve(dist.address, $AMPL(150)); - await invokeRebase(ampl, 100); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(100)); - await dist.stake($AMPL(150), []); - }); - it("should updated the total staked shares", async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(250)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(100), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(150)); - expect(await dist.totalStakingShares.call()).to.be.bignumber.equal( - $AMPL(125).mul(new BN(InitialSharesPerToken)), - ); - }); - }); - - describe("when totalStaked>0, when rebase increases supply", function () { - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(51)); - await dist.stake($AMPL(50), []); - }); - it("should fail if there are too few mintedStakingShares", async function () { - await invokeRebase(ampl, 100 * InitialSharesPerToken); - await expectRevert(dist.stake(1, []), "TokenGeyser: Stake amount is too small"); - }); - }); - - describe("when totalStaked>0, rebase decreases supply", function () { - beforeEach(async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - await ampl.transfer(anotherAccount, $AMPL(50)); - await ampl.approve(dist.address, $AMPL(50), { - from: anotherAccount, - }); - await dist.stake($AMPL(50), [], { - from: anotherAccount, - }); - await ampl.approve(dist.address, $AMPL(150)); - await invokeRebase(ampl, -50); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(25)); - await dist.stake($AMPL(150), []); - }); - it("should updated the total staked shares", async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(175)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(25), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(150)); - expect(await dist.totalStakingShares.call()).to.be.bignumber.equal( - $AMPL(350).mul(new BN(InitialSharesPerToken)), - ); - }); - }); - }); - - describe("stakeFor", function () { - describe("when the beneficiary is ZERO_ADDRESS", function () { - it("should fail", async function () { - await expectRevert( - dist.stakeFor(constants.ZERO_ADDRESS, $AMPL(100), []), - "TokenGeyser: beneficiary is zero address", - ); - }); - }); - - describe("when the beneficiary is a valid address", function () { - beforeEach(async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - await ampl.approve(dist.address, $AMPL(100)); - }); - it("should deduct ampls for the staker", async function () { - const b = await ampl.balanceOf.call(owner); - await dist.stakeFor(anotherAccount, $AMPL(100), []); - const b_ = await ampl.balanceOf.call(owner); - expect(b.sub(b_)).to.be.bignumber.equal($AMPL(100)); - }); - it("should updated the total staked on behalf of the beneficiary", async function () { - await dist.stakeFor(anotherAccount, $AMPL(100), []); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(100)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(100), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(0)); - expect(await dist.totalStakingShares.call()).to.be.bignumber.equal( - $AMPL(100).mul(new BN(InitialSharesPerToken)), - ); - }); - it("should log Staked", async function () { - const r = await dist.stakeFor(anotherAccount, $AMPL(100), []); - expectEvent(r, "Staked", { - user: anotherAccount, - amount: $AMPL(100), - total: $AMPL(100), - }); - }); - it("only callable by owner", async function () { - await ampl.transfer(anotherAccount, $AMPL(10)); - await ampl.approve(dist.address, $AMPL(10), { from: anotherAccount }); - // stakesFor only callable by owner - await dist.stakeFor(owner, $AMPL(1), [], { from: owner }); - await expectRevert( - dist.stakeFor(owner, $AMPL(1), [], { from: anotherAccount }), - "Ownable: caller is not the owner.", - ); - }); - }); - }); -}); - -describe("rescueFundsFromStakingPool", function () { - describe("when tokens gets air-dropped", function () { - it("should allow the owner to claim them", async function () { - const accounts = await chain.getUserAccounts(); - owner = web3.utils.toChecksumAddress(accounts[0]); - anotherAccount = web3.utils.toChecksumAddress(accounts[8]); - - ampl = await AmpleforthErc20.new(); - await ampl.initialize(owner); - await ampl.setMonetaryPolicy(owner); - - const startBonus = 50; - const bonusPeriod = 86400; - const dist = await TokenGeyser.new( - ampl.address, - ampl.address, - 10, - startBonus, - bonusPeriod, - InitialSharesPerToken, - ); - - await ampl.approve(dist.address, $AMPL(100)); - await dist.stake($AMPL(100), []); - - const transfers = await ampl.contract.getPastEvents("Transfer"); - const transferLog = transfers[transfers.length - 1]; - const stakingPool = transferLog.returnValues.to; - - expect(await ampl.balanceOf.call(stakingPool)).to.be.bignumber.equal($AMPL(100)); - - const token = await MockERC20.new(1000); - await token.transfer(stakingPool, 1000); - - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - await dist.rescueFundsFromStakingPool(token.address, anotherAccount, 1000); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("1000"); - - await expectRevert( - dist.rescueFundsFromStakingPool(ampl.address, anotherAccount, $AMPL(10)), - "TokenPool: Cannot claim token held by the contract", - ); - - expect(await ampl.balanceOf.call(stakingPool)).to.be.bignumber.equal($AMPL(100)); - }); - }); -}); diff --git a/test/staking.ts b/test/staking.ts new file mode 100644 index 0000000..0ba4a05 --- /dev/null +++ b/test/staking.ts @@ -0,0 +1,196 @@ +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { TimeHelpers, $AMPL, invokeRebase } from "../test/helper"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +let ampl: any, dist: any, owner: SignerWithAddress, anotherAccount: SignerWithAddress; +const InitialSharesPerToken = BigInt(10 ** 6); + +describe("staking", function () { + async function setupContracts() { + [owner, anotherAccount] = await ethers.getSigners(); + + const AmpleforthErc20 = await ethers.getContractFactory("UFragments"); + ampl = await AmpleforthErc20.deploy(); + await ampl.initialize(await owner.getAddress()); + await ampl.setMonetaryPolicy(await owner.getAddress()); + + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + dist = await TokenGeyser.deploy( + ampl.target, + ampl.target, + 10, + 50, + 86400, + InitialSharesPerToken, + ); + + return { ampl, dist, owner, anotherAccount }; + } + + beforeEach(async function () { + ({ ampl, dist, owner, anotherAccount } = await loadFixture(setupContracts)); + }); + + describe("when start bonus too high", function () { + it("should fail to construct", async function () { + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + await expect( + TokenGeyser.deploy( + ampl.target, + ampl.target, + 10, + 101, + 86400, + InitialSharesPerToken, + ), + ).to.be.revertedWith("TokenGeyser: start bonus too high"); + }); + }); + + describe("when bonus period is 0", function () { + it("should fail to construct", async function () { + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + await expect( + TokenGeyser.deploy(ampl.target, ampl.target, 10, 50, 0, InitialSharesPerToken), + ).to.be.revertedWith("TokenGeyser: bonus period is zero"); + }); + }); + + describe("getStakingToken", function () { + it("should return the staking token", async function () { + expect(await dist.getStakingToken()).to.equal(ampl.target); + }); + }); + + describe("token", function () { + it("should return the staking token", async function () { + expect(await dist.token()).to.equal(ampl.target); + }); + }); + + describe("supportsHistory", function () { + it("should return supportsHistory", async function () { + expect(await dist.supportsHistory()).to.be.false; + }); + }); + + describe("stake", function () { + describe("when the amount is 0", function () { + it("should fail", async function () { + await ampl.approve(dist.target, $AMPL(1000)); + await expect(dist.stake($AMPL(0), "0x")).to.be.revertedWith( + "TokenGeyser: stake amount is zero", + ); + }); + }); + + describe("when token transfer has not been approved", function () { + it("should fail", async function () { + await expect(dist.stake($AMPL(100), "0x")).to.be.reverted; + }); + }); + + describe("when totalStaked=0", function () { + beforeEach(async function () { + expect(await dist.totalStaked()).to.equal($AMPL(0)); + await ampl.approve(dist.target, $AMPL(100)); + }); + it("should update the total staked", async function () { + await dist.stake($AMPL(100), "0x"); + expect(await dist.totalStaked()).to.equal($AMPL(100)); + expect(await dist.totalStakedFor(await owner.getAddress())).to.equal($AMPL(100)); + expect(await dist.totalStakingShares()).to.equal( + $AMPL(100) * InitialSharesPerToken, + ); + }); + it("should log Staked", async function () { + const tx = await dist.stake($AMPL(100), "0x"); + await expect(tx) + .to.emit(dist, "Staked") + .withArgs(await owner.getAddress(), $AMPL(100), $AMPL(100), "0x"); + }); + }); + + describe("when totalStaked>0", function () { + beforeEach(async function () { + expect(await dist.totalStaked()).to.equal($AMPL(0)); + await ampl.transfer(await anotherAccount.getAddress(), $AMPL(50)); + await ampl.connect(anotherAccount).approve(dist.target, $AMPL(50)); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await ampl.approve(dist.target, $AMPL(150)); + await dist.stake($AMPL(150), "0x"); + }); + it("should update the total staked", async function () { + expect(await dist.totalStaked()).to.equal($AMPL(200)); + expect(await dist.totalStakedFor(await anotherAccount.getAddress())).to.equal( + $AMPL(50), + ); + expect(await dist.totalStakedFor(await owner.getAddress())).to.equal($AMPL(150)); + expect(await dist.totalStakingShares()).to.equal( + $AMPL(200) * InitialSharesPerToken, + ); + }); + }); + + describe("when totalStaked>0, rebase increases supply", function () { + beforeEach(async function () { + expect(await dist.totalStaked()).to.equal($AMPL(0)); + await ampl.transfer(await anotherAccount.getAddress(), $AMPL(50)); + await ampl.connect(anotherAccount).approve(dist.target, $AMPL(50)); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await ampl.approve(dist.target, $AMPL(150)); + await invokeRebase(ampl, 100); + expect(await dist.totalStaked()).to.equal($AMPL(100)); + await dist.stake($AMPL(150), "0x"); + }); + it("should updated the total staked shares", async function () { + expect(await dist.totalStaked()).to.equal($AMPL(250)); + expect(await dist.totalStakedFor(await anotherAccount.getAddress())).to.equal( + $AMPL(100), + ); + expect(await dist.totalStakedFor(await owner.getAddress())).to.equal($AMPL(150)); + expect(await dist.totalStakingShares()).to.equal( + $AMPL(125) * InitialSharesPerToken, + ); + }); + }); + + describe("when totalStaked>0, when rebase increases supply", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(51)); + await dist.stake($AMPL(50), "0x"); + }); + it("should fail if there are too few mintedStakingShares", async function () { + await invokeRebase(ampl, 100n * InitialSharesPerToken); + await expect(dist.stake(1, "0x")).to.be.revertedWith( + "TokenGeyser: Stake amount is too small", + ); + }); + }); + + describe("when totalStaked>0, rebase decreases supply", function () { + beforeEach(async function () { + expect(await dist.totalStaked()).to.equal($AMPL(0)); + await ampl.transfer(await anotherAccount.getAddress(), $AMPL(50)); + await ampl.connect(anotherAccount).approve(dist.target, $AMPL(50)); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await ampl.approve(dist.target, $AMPL(150)); + await invokeRebase(ampl, -50); + expect(await dist.totalStaked()).to.equal($AMPL(25)); + await dist.stake($AMPL(150), "0x"); + }); + it("should updated the total staked shares", async function () { + expect(await dist.totalStaked()).to.equal($AMPL(175)); + expect(await dist.totalStakedFor(await anotherAccount.getAddress())).to.equal( + $AMPL(25), + ); + expect(await dist.totalStakedFor(await owner.getAddress())).to.equal($AMPL(150)); + expect(await dist.totalStakingShares()).to.equal( + $AMPL(350) * InitialSharesPerToken, + ); + }); + }); + }); +}); diff --git a/test/token_pool.js b/test/token_pool.js deleted file mode 100644 index 687d0b0..0000000 --- a/test/token_pool.js +++ /dev/null @@ -1,120 +0,0 @@ -const { contract, web3 } = require("@openzeppelin/test-environment"); -const { expectRevert, expectEvent } = require("@openzeppelin/test-helpers"); -const { expect } = require("chai"); - -const _require = require("app-root-path").require; -const BlockchainCaller = _require("/util/blockchain_caller"); -const chain = new BlockchainCaller(web3); - -const MockERC20 = contract.fromArtifact("MockERC20"); -const TokenPool = contract.fromArtifact("TokenPool"); - -let token, otherToken, tokenPool, owner, anotherAccount; -describe("tokenPool", function () { - beforeEach("setup contracts", async function () { - const accounts = await chain.getUserAccounts(); - owner = web3.utils.toChecksumAddress(accounts[0]); - anotherAccount = web3.utils.toChecksumAddress(accounts[8]); - - token = await MockERC20.new(1000); - otherToken = await MockERC20.new(2000); - - tokenPool = await TokenPool.new(token.address); - }); - - describe("balance", function () { - it("should return the balance of the token pool", async function () { - await token.transfer(tokenPool.address, 123); - expect(await tokenPool.balance.call()).to.be.bignumber.equal("123"); - await tokenPool.transfer(owner, 99); - expect(await tokenPool.balance.call()).to.be.bignumber.equal("24"); - await tokenPool.transfer(owner, 24); - expect(await tokenPool.balance.call()).to.be.bignumber.equal("0"); - }); - }); - - describe("transfer", function () { - it("should let the owner transfer funds out", async function () { - await token.transfer(tokenPool.address, 1000); - - expect(await tokenPool.balance.call()).to.be.bignumber.equal("1000"); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - - await tokenPool.transfer(anotherAccount, 1000); - - expect(await tokenPool.balance.call()).to.be.bignumber.equal("0"); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("1000"); - }); - - it("should NOT let other users transfer funds out", async function () { - await token.transfer(tokenPool.address, 1000); - await expectRevert( - tokenPool.transfer(anotherAccount, 1000, { from: anotherAccount }), - "Ownable: caller is not the owner", - ); - }); - }); - - describe("rescueFunds", function () { - beforeEach(async function () { - await token.transfer(tokenPool.address, 1000); - await otherToken.transfer(tokenPool.address, 2000); - - expect(await tokenPool.balance.call()).to.be.bignumber.equal("1000"); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - expect(await otherToken.balanceOf.call(tokenPool.address)).to.be.bignumber.equal( - "2000", - ); - expect(await otherToken.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - }); - - it("should let owner users claim excess funds completely", async function () { - await tokenPool.rescueFunds(otherToken.address, anotherAccount, 2000); - - expect(await tokenPool.balance.call()).to.be.bignumber.equal("1000"); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - expect(await otherToken.balanceOf.call(tokenPool.address)).to.be.bignumber.equal( - "0", - ); - expect(await otherToken.balanceOf.call(anotherAccount)).to.be.bignumber.equal( - "2000", - ); - }); - - it("should let owner users claim excess funds partially", async function () { - await tokenPool.rescueFunds(otherToken.address, anotherAccount, 777); - - expect(await tokenPool.balance.call()).to.be.bignumber.equal("1000"); - expect(await token.balanceOf.call(anotherAccount)).to.be.bignumber.equal("0"); - expect(await otherToken.balanceOf.call(tokenPool.address)).to.be.bignumber.equal( - "1223", - ); - expect(await otherToken.balanceOf.call(anotherAccount)).to.be.bignumber.equal( - "777", - ); - }); - - it("should NOT let owner claim more than available excess funds", async function () { - await expectRevert( - tokenPool.rescueFunds(otherToken.address, anotherAccount, 2001), - "ERC20: transfer amount exceeds balance", - ); - }); - - it("should NOT let owner users claim held funds", async function () { - await expectRevert( - tokenPool.rescueFunds(token.address, anotherAccount, 1000), - "TokenPool: Cannot claim token held by the contract", - ); - }); - - it("should NOT let other users users claim excess funds", async function () { - await expectRevert( - tokenPool.rescueFunds(otherToken.address, anotherAccount, 2000, { - from: anotherAccount, - }), - "Ownable: caller is not the owner", - ); - }); - }); -}); diff --git a/test/token_pool.ts b/test/token_pool.ts new file mode 100644 index 0000000..67d2f4f --- /dev/null +++ b/test/token_pool.ts @@ -0,0 +1,137 @@ +import { ethers, waffle } from "hardhat"; +import { expect } from "chai"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +let owner: SignerWithAddress, anotherAccount: SignerWithAddress; + +describe("TokenPool", function () { + async function setupContracts() { + [owner, anotherAccount] = await ethers.getSigners(); + + const MockERC20 = await ethers.getContractFactory("MockERC20"); + const token = await MockERC20.deploy(1000); + const otherToken = await MockERC20.deploy(2000); + + const TokenPool = await ethers.getContractFactory("TokenPool"); + const tokenPool = await TokenPool.deploy(token.target); + + return { token, otherToken, tokenPool, owner, anotherAccount }; + } + + describe("balance", function () { + it("should return the balance of the token pool", async function () { + const { token, tokenPool, owner } = await loadFixture(setupContracts); + + await token.transfer(tokenPool.target, 123); + expect(await tokenPool.balance()).to.equal(123); + await tokenPool.transfer(await owner.getAddress(), 99); + expect(await tokenPool.balance()).to.equal(24); + await tokenPool.transfer(await owner.getAddress(), 24); + expect(await tokenPool.balance()).to.equal(0); + }); + }); + + describe("transfer", function () { + it("should let the owner transfer funds out", async function () { + const { token, tokenPool, anotherAccount } = await loadFixture(setupContracts); + + await token.transfer(tokenPool.target, 1000); + + expect(await tokenPool.balance()).to.equal(1000); + expect(await token.balanceOf(await anotherAccount.getAddress())).to.equal(0); + + await tokenPool.transfer(await anotherAccount.getAddress(), 1000); + + expect(await tokenPool.balance()).to.equal(0); + expect(await token.balanceOf(await anotherAccount.getAddress())).to.equal(1000); + }); + + it("should NOT let other users transfer funds out", async function () { + const { token, tokenPool, anotherAccount } = await loadFixture(setupContracts); + + await token.transfer(tokenPool.target, 1000); + await expect( + tokenPool + .connect(anotherAccount) + .transfer(await anotherAccount.getAddress(), 1000), + ).to.be.revertedWithCustomError(tokenPool, "OwnableUnauthorizedAccount"); + }); + }); + + describe("rescueFunds", function () { + it("should let owner users claim excess funds completely", async function () { + const { token, otherToken, tokenPool, anotherAccount } = await loadFixture( + setupContracts, + ); + + await token.transfer(tokenPool.target, 1000); + await otherToken.transfer(tokenPool.target, 2000); + + await tokenPool.rescueFunds( + otherToken.target, + await anotherAccount.getAddress(), + 2000, + ); + + expect(await tokenPool.balance()).to.equal(1000); + expect(await token.balanceOf(await anotherAccount.getAddress())).to.equal(0); + expect(await otherToken.balanceOf(tokenPool.target)).to.equal(0); + expect(await otherToken.balanceOf(await anotherAccount.getAddress())).to.equal( + 2000, + ); + }); + + it("should let owner users claim excess funds partially", async function () { + const { token, otherToken, tokenPool, anotherAccount } = await loadFixture( + setupContracts, + ); + + await token.transfer(tokenPool.target, 1000); + await otherToken.transfer(tokenPool.target, 2000); + + await tokenPool.rescueFunds( + otherToken.target, + await anotherAccount.getAddress(), + 777, + ); + + expect(await tokenPool.balance()).to.equal(1000); + expect(await token.balanceOf(await anotherAccount.getAddress())).to.equal(0); + expect(await otherToken.balanceOf(tokenPool.target)).to.equal(1223); + expect(await otherToken.balanceOf(await anotherAccount.getAddress())).to.equal(777); + }); + + it("should NOT let owner claim more than available excess funds", async function () { + const { otherToken, tokenPool, anotherAccount } = await loadFixture(setupContracts); + + await otherToken.transfer(tokenPool.target, 2000); + + await expect( + tokenPool.rescueFunds(otherToken.target, await anotherAccount.getAddress(), 2001), + ).to.be.revertedWithCustomError(otherToken, "ERC20InsufficientBalance"); + }); + + it("should NOT let owner users claim held funds", async function () { + const { token, tokenPool, anotherAccount } = await loadFixture(setupContracts); + + await token.transfer(tokenPool.target, 1000); + + await expect( + tokenPool.rescueFunds(token.target, await anotherAccount.getAddress(), 1000), + ).to.be.revertedWith("TokenPool: Cannot claim token held by the contract"); + }); + + it("should NOT let other users claim excess funds", async function () { + const { otherToken, tokenPool, anotherAccount } = await loadFixture(setupContracts); + + await otherToken.transfer(tokenPool.target, 2000); + + await expect( + tokenPool + .connect(anotherAccount) + .rescueFunds(otherToken.target, await anotherAccount.getAddress(), 2000), + ).to.be.revertedWithCustomError(tokenPool, "OwnableUnauthorizedAccount"); + }); + }); +}); diff --git a/test/token_unlock.js b/test/token_unlock.js deleted file mode 100644 index e7d363b..0000000 --- a/test/token_unlock.js +++ /dev/null @@ -1,507 +0,0 @@ -const { contract, web3 } = require("@openzeppelin/test-environment"); -const { expectRevert, BN, time, constants } = require("@openzeppelin/test-helpers"); -const { expect } = require("chai"); - -const _require = require("app-root-path").require; -const BlockchainCaller = _require("/util/blockchain_caller"); -const chain = new BlockchainCaller(web3); -const { - $AMPL, - invokeRebase, - checkAmplAprox, - checkSharesAprox, - setTimeForNextTransaction, - TimeController, -} = _require("/test/helper"); - -const AmpleforthErc20 = contract.fromArtifact("UFragments"); -const TokenGeyser = contract.fromArtifact("TokenGeyser"); - -const ONE_YEAR = 365 * 24 * 3600; -const START_BONUS = 50; -const BONUS_PERIOD = 86400; -const InitialSharesPerToken = 10 ** 6; - -let ampl, dist, owner, anotherAccount; -async function setupContractAndAccounts() { - const accounts = await chain.getUserAccounts(); - owner = web3.utils.toChecksumAddress(accounts[0]); - anotherAccount = web3.utils.toChecksumAddress(accounts[8]); - - ampl = await AmpleforthErc20.new(); - await ampl.initialize(owner); - await ampl.setMonetaryPolicy(owner); - - dist = await TokenGeyser.new( - ampl.address, - ampl.address, - 10, - START_BONUS, - BONUS_PERIOD, - InitialSharesPerToken, - ); -} - -async function checkAvailableToUnlock(dist, v) { - const u = await dist.totalUnlocked.call(); - const r = await dist.updateAccounting.call(); - // console.log('Total unlocked: ', u.toString(), 'total unlocked after: ', r[1].toString()); - checkAmplAprox(r[1].sub(u), v); -} - -describe("LockedPool", function () { - beforeEach("setup contracts", async function () { - await setupContractAndAccounts(); - }); - - describe("getDistributionToken", function () { - it("should return the staking token", async function () { - expect(await dist.getDistributionToken.call()).to.equal(ampl.address); - }); - }); - - describe("lockTokens", function () { - describe("when not approved", function () { - it("should fail", async function () { - const d = await TokenGeyser.new( - ampl.address, - ampl.address, - 5, - START_BONUS, - BONUS_PERIOD, - InitialSharesPerToken, - ); - await expectRevert.unspecified(d.lockTokens($AMPL(10), ONE_YEAR)); - }); - }); - - describe("when number of unlock schedules exceeds the maxUnlockSchedules", function () { - it("should fail", async function () { - const d = await TokenGeyser.new( - ampl.address, - ampl.address, - 5, - START_BONUS, - BONUS_PERIOD, - InitialSharesPerToken, - ); - await ampl.approve(d.address, $AMPL(100)); - await d.lockTokens($AMPL(10), ONE_YEAR); - await d.lockTokens($AMPL(10), ONE_YEAR); - await d.lockTokens($AMPL(10), ONE_YEAR); - await d.lockTokens($AMPL(10), ONE_YEAR); - await d.lockTokens($AMPL(10), ONE_YEAR); - await expectRevert( - d.lockTokens($AMPL(10), ONE_YEAR), - "TokenGeyser: reached maximum unlock schedules", - ); - }); - }); - - describe("when totalLocked=0", function () { - beforeEach(async function () { - checkAmplAprox(await dist.totalLocked.call(), 0); - await ampl.approve(dist.address, $AMPL(100)); - }); - it("should updated the locked pool balance", async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - checkAmplAprox(await dist.totalLocked.call(), 100); - }); - it("should create a schedule", async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - const s = await dist.unlockSchedules.call(0); - expect(s[0]).to.be.bignumber.equal($AMPL(100).mul(new BN(InitialSharesPerToken))); - expect(s[1]).to.be.bignumber.equal($AMPL(0)); - expect(s[2].add(s[4])).to.be.bignumber.equal(s[3]); - expect(s[4]).to.be.bignumber.equal(`${ONE_YEAR}`); - expect(await dist.unlockScheduleCount.call()).to.be.bignumber.equal("1"); - }); - it("should log TokensLocked", async function () { - const r = await dist.lockTokens($AMPL(100), ONE_YEAR); - const l = r.logs.filter(l => l.event === "TokensLocked")[0]; - checkAmplAprox(l.args.amount, 100); - checkAmplAprox(l.args.total, 100); - expect(l.args.durationSec).to.be.bignumber.equal(`${ONE_YEAR}`); - }); - it("should be protected", async function () { - await ampl.approve(dist.address, $AMPL(100)); - await expectRevert( - dist.lockTokens($AMPL(50), ONE_YEAR, { from: anotherAccount }), - "Ownable: caller is not the owner", - ); - await dist.lockTokens($AMPL(50), ONE_YEAR); - }); - }); - - describe("when totalLocked>0", function () { - const timeController = new TimeController(); - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(150)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - checkAmplAprox(await dist.totalLocked.call(), 100); - }); - it("should updated the locked and unlocked pool balance", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - await dist.lockTokens($AMPL(50), ONE_YEAR); - checkAmplAprox(await dist.totalLocked.call(), 100 * 0.9 + 50); - }); - it("should log TokensUnlocked and TokensLocked", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - const r = await dist.lockTokens($AMPL(50), ONE_YEAR); - - let l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 100 * 0.1); - checkAmplAprox(l.args.total, 100 * 0.9); - - l = r.logs.filter(l => l.event === "TokensLocked")[0]; - checkAmplAprox(l.args.amount, 50); - checkAmplAprox(l.args.total, 100 * 0.9 + 50); - expect(l.args.durationSec).to.be.bignumber.equal(`${ONE_YEAR}`); - }); - it("should create a schedule", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - await dist.lockTokens($AMPL(50), ONE_YEAR); - const s = await dist.unlockSchedules.call(1); - // struct UnlockSchedule { - // 0 uint256 initialLockedShares; - // 1 uint256 unlockedShares; - // 2 uint256 lastUnlockTimestampSec; - // 3 uint256 endAtSec; - // 4 uint256 durationSec; - // } - checkSharesAprox(s[0], $AMPL(50).mul(new BN(InitialSharesPerToken))); - checkSharesAprox(s[1], new BN(0)); - expect(s[2].add(s[4])).to.be.bignumber.equal(s[3]); - expect(s[4]).to.be.bignumber.equal(`${ONE_YEAR}`); - expect(await dist.unlockScheduleCount.call()).to.be.bignumber.equal("2"); - }); - }); - - describe("when totalLocked>0, rebase increases supply", function () { - const timeController = new TimeController(); - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(150)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - checkAmplAprox(await dist.totalLocked.call(), 100); - await invokeRebase(ampl, 100); - }); - it("should updated the locked pool balance", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - await dist.lockTokens($AMPL(50), ONE_YEAR); - checkAmplAprox(await dist.totalLocked.call(), 50 + 200 * 0.9); - }); - it("should updated the locked pool balance", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - await dist.lockTokens($AMPL(50), ONE_YEAR); - - checkAmplAprox(await dist.totalLocked.call(), 50 + 200 * 0.9); - }); - it("should log TokensUnlocked and TokensLocked", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - const r = await dist.lockTokens($AMPL(50), ONE_YEAR); - let l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 200 * 0.1); - checkAmplAprox(l.args.total, 200 * 0.9); - - l = r.logs.filter(l => l.event === "TokensLocked")[0]; - checkAmplAprox(l.args.amount, 50); - checkAmplAprox(l.args.total, 50.0 + 200.0 * 0.9); - expect(l.args.durationSec).to.be.bignumber.equal(`${ONE_YEAR}`); - }); - it("should create a schedule", async function () { - await timeController.advanceTime(ONE_YEAR / 10); - await dist.lockTokens($AMPL(50), ONE_YEAR); - const s = await dist.unlockSchedules.call(1); - checkSharesAprox(s[0], $AMPL(25).mul(new BN(InitialSharesPerToken))); - checkSharesAprox(s[1], new BN(0)); - expect(s[2].add(s[4])).to.be.bignumber.equal(s[3]); - expect(s[4]).to.be.bignumber.equal(`${ONE_YEAR}`); - expect(await dist.unlockScheduleCount.call()).to.be.bignumber.equal("2"); - }); - }); - - describe("when totalLocked>0, rebase decreases supply", function () { - let currentTime; - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(150)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - currentTime = await time.latest(); - checkAmplAprox(await dist.totalLocked.call(), 100); - await invokeRebase(ampl, -50); - }); - it("should updated the locked pool balance", async function () { - await dist.lockTokens($AMPL(50), ONE_YEAR); - checkAmplAprox(await dist.totalLocked.call(), 100); - }); - it("should log TokensUnlocked and TokensLocked", async function () { - currentTime = currentTime.add(new BN(ONE_YEAR / 10)); - await setTimeForNextTransaction(currentTime); - const r = await dist.lockTokens($AMPL(50), ONE_YEAR); - let l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 50 * 0.1); - checkAmplAprox(l.args.total, 50 * 0.9); - - l = r.logs.filter(l => l.event === "TokensLocked")[0]; - checkAmplAprox(l.args.amount, 50); - checkAmplAprox(l.args.total, 50 * 0.9 + 50); - expect(l.args.durationSec).to.be.bignumber.equal(`${ONE_YEAR}`); - }); - it("should create a schedule", async function () { - await dist.lockTokens($AMPL(50), ONE_YEAR); - const s = await dist.unlockSchedules.call(1); - - checkSharesAprox(s[0], $AMPL(100).mul(new BN(InitialSharesPerToken))); - expect(s[1]).to.be.bignumber.equal($AMPL(0)); - expect(s[2].add(s[4])).to.be.bignumber.equal(s[3]); - expect(s[4]).to.be.bignumber.equal(`${ONE_YEAR}`); - expect(await dist.unlockScheduleCount.call()).to.be.bignumber.equal("2"); - }); - }); - }); - - describe("unlockTokens", function () { - describe("single schedule", function () { - describe("after waiting for 1/2 the duration", function () { - const timeController = new TimeController(); - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(100)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 2); - }); - - describe("when supply is unchanged", function () { - it("should unlock 1/2 the tokens", async function () { - await timeController.executeEmptyBlock(); - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(100)); - expect(await dist.totalUnlocked.call()).to.be.bignumber.equal($AMPL(0)); - await checkAvailableToUnlock(dist, 50); - }); - it("should transfer tokens to unlocked pool", async function () { - await dist.updateAccounting(); - checkAmplAprox(await dist.totalLocked.call(), 50); - checkAmplAprox(await dist.totalUnlocked.call(), 50); - await checkAvailableToUnlock(dist, 0); - }); - it("should log TokensUnlocked and update state", async function () { - const r = await dist.updateAccounting(); - const l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 50); - checkAmplAprox(l.args.total, 50); - const s = await dist.unlockSchedules(0); - expect(s[0]).to.be.bignumber.equal( - $AMPL(100).mul(new BN(InitialSharesPerToken)), - ); - checkSharesAprox(s[1], $AMPL(50).mul(new BN(InitialSharesPerToken))); - }); - }); - - describe("when rebase increases supply", function () { - beforeEach(async function () { - await invokeRebase(ampl, 100); - }); - it("should unlock 1/2 the tokens", async function () { - await timeController.executeEmptyBlock(); - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(200)); - expect(await dist.totalUnlocked.call()).to.be.bignumber.equal($AMPL(0)); - await checkAvailableToUnlock(dist, 100); - }); - it("should transfer tokens to unlocked pool", async function () { - // printStatus(dist); - await dist.updateAccounting(); - - checkAmplAprox(await dist.totalLocked.call(), 100); - checkAmplAprox(await dist.totalUnlocked.call(), 100); - await checkAvailableToUnlock(dist, 0); - }); - }); - - describe("when rebase decreases supply", function () { - beforeEach(async function () { - await invokeRebase(ampl, -50); - }); - it("should unlock 1/2 the tokens", async function () { - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(50)); - await checkAvailableToUnlock(dist, 25); - }); - it("should transfer tokens to unlocked pool", async function () { - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(50)); - expect(await dist.totalUnlocked.call()).to.be.bignumber.equal($AMPL(0)); - await dist.updateAccounting(); - - checkAmplAprox(await dist.totalLocked.call(), 25); - checkAmplAprox(await dist.totalUnlocked.call(), 25); - await checkAvailableToUnlock(dist, 0); - }); - }); - }); - - describe("after waiting > the duration", function () { - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(100)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await time.increase(2 * ONE_YEAR); - }); - it("should unlock all the tokens", async function () { - await checkAvailableToUnlock(dist, 100); - }); - it("should transfer tokens to unlocked pool", async function () { - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(100)); - expect(await dist.totalUnlocked.call()).to.be.bignumber.equal($AMPL(0)); - await dist.updateAccounting(); - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(0)); - checkAmplAprox(await dist.totalUnlocked.call(), 100); - await checkAvailableToUnlock(dist, 0); - }); - it("should log TokensUnlocked and update state", async function () { - const r = await dist.updateAccounting(); - const l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 100); - checkAmplAprox(l.args.total, 0); - const s = await dist.unlockSchedules(0); - expect(s[0]).to.be.bignumber.equal( - $AMPL(100).mul(new BN(InitialSharesPerToken)), - ); - expect(s[1]).to.be.bignumber.equal( - $AMPL(100).mul(new BN(InitialSharesPerToken)), - ); - }); - }); - - describe("dust tokens due to division underflow", function () { - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(100)); - await dist.lockTokens($AMPL(1), 10 * ONE_YEAR); - }); - it("should unlock all tokens", async function () { - // 1 AMPL locked for 10 years. Almost all time passes upto the last minute. - // 0.999999809 AMPLs are unlocked. - // 1 minute passes, Now: all of the rest are unlocked: 191 - // before (#24): only 190 would have been unlocked and 0.000000001 AMPL would be - // locked. - await time.increase(10 * ONE_YEAR - 60); - const r1 = await dist.updateAccounting(); - const l1 = r1.logs.filter(l => l.event === "TokensUnlocked")[0]; - await time.increase(65); - const r2 = await dist.updateAccounting(); - const l2 = r2.logs.filter(l => l.event === "TokensUnlocked")[0]; - expect(l1.args.amount.add(l2.args.amount)).to.be.bignumber.equal($AMPL(1)); - }); - }); - }); - - describe("multi schedule", function () { - const timeController = new TimeController(); - beforeEach(async function () { - await ampl.approve(dist.address, $AMPL(200)); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 2); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.advanceTime(ONE_YEAR / 10); - }); - it("should return the remaining unlock value", async function () { - await time.advanceBlock(); - expect(await dist.totalLocked.call()).to.be.bignumber.equal($AMPL(150)); - expect(await dist.totalUnlocked.call()).to.be.bignumber.equal($AMPL(50)); - // 10 from each schedule for the period of ONE_YEAR / 10 - - await checkAvailableToUnlock(dist, 20); - }); - it("should transfer tokens to unlocked pool", async function () { - await dist.updateAccounting(); - checkAmplAprox(await dist.totalLocked.call(), 130); - checkAmplAprox(await dist.totalUnlocked.call(), 70); - await checkAvailableToUnlock(dist, 0); - }); - it("should log TokensUnlocked and update state", async function () { - const r = await dist.updateAccounting(); - - const l = r.logs.filter(l => l.event === "TokensUnlocked")[0]; - checkAmplAprox(l.args.amount, 20); - checkAmplAprox(l.args.total, 130); - - const s1 = await dist.unlockSchedules(0); - checkSharesAprox(s1[0], $AMPL(100).mul(new BN(InitialSharesPerToken))); - checkSharesAprox(s1[1], $AMPL(60).mul(new BN(InitialSharesPerToken))); - const s2 = await dist.unlockSchedules(1); - checkSharesAprox(s2[0], $AMPL(100).mul(new BN(InitialSharesPerToken))); - checkSharesAprox(s2[1], $AMPL(10).mul(new BN(InitialSharesPerToken))); - }); - it("should continue linear the unlock", async function () { - await dist.updateAccounting(); - await timeController.advanceTime(ONE_YEAR / 5); - await dist.updateAccounting(); - - checkAmplAprox(await dist.totalLocked.call(), 90); - checkAmplAprox(await dist.totalUnlocked.call(), 110); - await checkAvailableToUnlock(dist, 0); - await timeController.advanceTime(ONE_YEAR / 5); - await dist.updateAccounting(); - - checkAmplAprox(await dist.totalLocked.call(), 50); - checkAmplAprox(await dist.totalUnlocked.call(), 150); - await checkAvailableToUnlock(dist, 0); - }); - }); - }); - - describe("updateAccounting", function () { - let _r, _t; - beforeEach(async function () { - _r = await dist.updateAccounting.call({ from: owner }); - _t = await time.latest(); - await ampl.approve(dist.address, $AMPL(300)); - await dist.stake($AMPL(100), []); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await time.increase(ONE_YEAR / 2); - await dist.lockTokens($AMPL(100), ONE_YEAR); - await time.increase(ONE_YEAR / 10); - }); - - describe("when user history does exist", async function () { - it("should return the system state", async function () { - const r = await dist.updateAccounting.call({ from: owner }); - const t = await time.latest(); - checkAmplAprox(r[0], 130); - checkAmplAprox(r[1], 70); - const timeElapsed = t.sub(_t); - expect(r[2].div(new BN(100e9).mul(new BN(InitialSharesPerToken)))) - .to.be.bignumber.above(timeElapsed.sub(new BN(5))) - .and.bignumber.below(timeElapsed.add(new BN(5))); - expect(r[3].div(new BN(100e9).mul(new BN(InitialSharesPerToken)))) - .to.be.bignumber.above(timeElapsed.sub(new BN(5))) - .and.bignumber.below(timeElapsed.add(new BN(5))); - checkAmplAprox(r[4], 70); - checkAmplAprox(r[4], 70); - const delta = new BN(r[5]).sub(new BN(_r[5])); - expect(delta) - .to.be.bignumber.above(timeElapsed.sub(new BN(1))) - .and.bignumber.below(timeElapsed.add(new BN(1))); - }); - }); - - describe("when user history does not exist", async function () { - it("should return the system state", async function () { - const r = await dist.updateAccounting.call({ from: constants.ZERO_ADDRESS }); - const t = await time.latest(); - checkAmplAprox(r[0], 130); - checkAmplAprox(r[1], 70); - const timeElapsed = t.sub(_t); - expect( - r[2].div(new BN(100e9).mul(new BN(InitialSharesPerToken))), - ).to.be.bignumber.equal("0"); - expect(r[3].div(new BN(100e9).mul(new BN(InitialSharesPerToken)))) - .to.be.bignumber.above(timeElapsed.sub(new BN(5))) - .and.bignumber.below(timeElapsed.add(new BN(5))); - checkAmplAprox(r[4], 0); - const delta = new BN(r[5]).sub(new BN(_r[5])); - expect(delta) - .to.be.bignumber.above(timeElapsed.sub(new BN(1))) - .and.bignumber.below(timeElapsed.add(new BN(1))); - }); - }); - }); -}); diff --git a/test/token_unlock.ts b/test/token_unlock.ts new file mode 100644 index 0000000..cbd5e23 --- /dev/null +++ b/test/token_unlock.ts @@ -0,0 +1,519 @@ +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { + TimeHelpers, + $AMPL, + invokeRebase, + checkAmplAprox, + checkSharesAprox, + setTimeForNextTransaction, +} from "../test/helper"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +let ampl: any, dist: any, owner: SignerWithAddress, anotherAccount: SignerWithAddress; +const InitialSharesPerToken = BigInt(10 ** 6); +const ONE_YEAR = 365 * 24 * 3600; +const START_BONUS = 50; +const BONUS_PERIOD = 86400; + +async function setupContracts() { + [owner, anotherAccount] = await ethers.getSigners(); + + const AmpleforthErc20 = await ethers.getContractFactory("UFragments"); + ampl = await AmpleforthErc20.deploy(); + await ampl.initialize(await owner.getAddress()); + await ampl.setMonetaryPolicy(await owner.getAddress()); + + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + dist = await TokenGeyser.deploy( + ampl.target, + ampl.target, + 10, + START_BONUS, + BONUS_PERIOD, + InitialSharesPerToken, + ); + + return { ampl, dist, owner, anotherAccount }; +} + +async function checkAvailableToUnlock(dist, v) { + const u = await dist.totalUnlocked.staticCall(); + const r = await dist.updateAccounting.staticCall(); + // console.log('Total unlocked: ', u.toString(), 'total unlocked after: ', r[1].toString()); + checkAmplAprox(r[1] - u, v); +} + +describe("LockedPool", function () { + beforeEach("setup contracts", async function () { + ({ ampl, dist, owner, anotherAccount } = await loadFixture(setupContracts)); + }); + + describe("getDistributionToken", function () { + it("should return the staking token", async function () { + expect(await dist.getDistributionToken.staticCall()).to.equal(ampl.target); + }); + }); + + describe("lockTokens", function () { + describe("when not approved", function () { + it("should fail", async function () { + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + const d = await TokenGeyser.deploy( + ampl.target, + ampl.target, + 5n, + START_BONUS, + BONUS_PERIOD, + InitialSharesPerToken, + ); + await expect(d.lockTokens($AMPL(10), ONE_YEAR)).to.be.reverted; + }); + }); + + describe("when number of unlock schedules exceeds the maxUnlockSchedules", function () { + it("should fail", async function () { + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + const d = await TokenGeyser.deploy( + ampl.target, + ampl.target, + 5n, + START_BONUS, + BONUS_PERIOD, + InitialSharesPerToken, + ); + await ampl.approve(d.target, $AMPL(100)); + for (let i = 0; i < 5; i++) { + await d.lockTokens($AMPL(10), ONE_YEAR); + } + await expect(d.lockTokens($AMPL(10), ONE_YEAR)).to.be.revertedWith( + "TokenGeyser: reached maximum unlock schedules", + ); + }); + }); + + describe("when totalLocked=0", function () { + beforeEach(async function () { + checkAmplAprox(await dist.totalLocked(), 0); + await ampl.approve(dist.target, $AMPL(100)); + }); + it("should updated the locked pool balance", async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + checkAmplAprox(await dist.totalLocked(), 100); + }); + it("should create a schedule", async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + const s = await dist.unlockSchedules(0); + expect(s.initialLockedShares).to.equal( + $AMPL(100) * BigInt(InitialSharesPerToken), + ); + expect(s.unlockedShares).to.equal($AMPL(0)); + expect(s.lastUnlockTimestampSec + s.durationSec).to.equal(s.endAtSec); + expect(s.durationSec).to.equal(ONE_YEAR); + expect(await dist.unlockScheduleCount()).to.equal(1); + }); + it("should log TokensLocked", async function () { + const r = await dist.lockTokens($AMPL(100), ONE_YEAR); + await expect(r) + .to.emit(dist, "TokensLocked") + .withArgs($AMPL(100), ONE_YEAR, $AMPL(100)); + }); + it("should be protected", async function () { + await ampl.approve(dist.target, $AMPL(100)); + await expect( + dist.connect(anotherAccount).lockTokens($AMPL(50), ONE_YEAR), + ).to.be.revertedWithCustomError(dist, "OwnableUnauthorizedAccount"); + await dist.lockTokens($AMPL(50), ONE_YEAR); + }); + }); + + describe("when totalLocked>0", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(150)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + checkAmplAprox(await dist.totalLocked(), 100); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + }); + it("should update the locked and unlocked pool balance", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + checkAmplAprox(await dist.totalLocked(), 100 * 0.9 + 50); + }); + it("should log TokensUnlocked and TokensLocked", async function () { + const r = await dist.lockTokens($AMPL(50), ONE_YEAR); + const txR = await r.wait(); + let l = txR.logs.find(l => l.fragment?.name === "TokensUnlocked"); + checkAmplAprox(l.args.amount, 100 * 0.1); + checkAmplAprox(l.args.total, 100 * 0.9); + + l = txR.logs.find(l => l.fragment?.name === "TokensLocked"); + checkAmplAprox(l.args.amount, 50); + checkAmplAprox(l.args.total, 100 * 0.9 + 50); + expect(l.args.durationSec).to.eq(ONE_YEAR); + }); + it("should create a schedule", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + const s = await dist.unlockSchedules(1); + // struct UnlockSchedule { + // 0 uint256 initialLockedShares; + // 1 uint256 unlockedShares; + // 2 uint256 lastUnlockTimestampSec; + // 3 uint256 endAtSec; + // 4 uint256 durationSec; + // } + checkSharesAprox(s[0], $AMPL(50) * BigInt(InitialSharesPerToken)); + checkSharesAprox(s[1], 0n); + expect(s[2] + s[4]).to.equal(s[3]); + expect(s[4]).to.equal(ONE_YEAR); + expect(await dist.unlockScheduleCount()).to.equal(2); + }); + }); + + describe("when totalLocked>0", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(150)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 100); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + }); + it("should updated the locked and unlocked pool balance", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 100 * 0.9 + 50); + }); + it("should log TokensUnlocked and TokensLocked", async function () { + const r = await dist.lockTokens($AMPL(50), ONE_YEAR); + const txR = await r.wait(); + let l = txR.logs.find(l => l.fragment?.name === "TokensUnlocked"); + checkAmplAprox(l.args.amount, 100 * 0.1); + checkAmplAprox(l.args.total, 100 * 0.9); + + l = txR.logs.find(l => l.fragment?.name === "TokensLocked"); + checkAmplAprox(l.args.amount, 50); + checkAmplAprox(l.args.total, 100 * 0.9 + 50); + expect(l.args.durationSec).to.eq(ONE_YEAR); + }); + it("should create a schedule", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + const s = await dist.unlockSchedules.staticCall(1); + checkSharesAprox(s[0], $AMPL(50) * BigInt(InitialSharesPerToken)); + checkSharesAprox(s[1], 0n); + expect(s[2] + s[4]).to.equal(s[3]); + expect(s[4]).to.equal(ONE_YEAR); + expect(await dist.unlockScheduleCount()).to.equal(2); + }); + }); + + describe("when totalLocked>0, rebase increases supply", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(150)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 100); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + await invokeRebase(ampl, 100); + }); + it("should update the locked pool balance", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 50 + 200 * 0.9); + }); + it("should log TokensUnlocked and TokensLocked", async function () { + const r = await dist.lockTokens($AMPL(50), ONE_YEAR); + const txR = await r.wait(); + let l = txR.logs.find(l => l.fragment?.name === "TokensUnlocked"); + checkAmplAprox(l.args.amount, 200 * 0.1); + checkAmplAprox(l.args.total, 200 * 0.9); + + l = txR.logs.find(l => l.fragment?.name === "TokensLocked"); + checkAmplAprox(l.args.amount, 50); + checkAmplAprox(l.args.total, 50 + 200 * 0.9); + expect(l.args.durationSec).to.eq(ONE_YEAR); + }); + it("should create a schedule", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + const s = await dist.unlockSchedules.staticCall(1); + checkSharesAprox(s[0], $AMPL(25) * BigInt(InitialSharesPerToken)); + checkSharesAprox(s[1], 0n); + expect(s[2] + s[4]).to.equal(s[3]); + expect(s[4]).to.equal(ONE_YEAR); + expect(await dist.unlockScheduleCount()).to.equal(2); + }); + }); + + describe("when totalLocked>0, rebase decreases supply", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(150)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 100); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + await invokeRebase(ampl, -50); + }); + it("should updated the locked pool balance", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + checkAmplAprox(await dist.totalLocked.staticCall(), 0.9 * 50 + 50); + }); + it("should log TokensUnlocked and TokensLocked", async function () { + const r = await dist.lockTokens($AMPL(50), ONE_YEAR); + const txR = await r.wait(); + + let l = txR.logs.find(l => l.fragment?.name === "TokensUnlocked"); + checkAmplAprox(l.args.amount, 50 * 0.1); + checkAmplAprox(l.args.total, 50 * 0.9); + + l = txR.logs.find(l => l.fragment?.name === "TokensLocked"); + checkAmplAprox(l.args.amount, 50); + checkAmplAprox(l.args.total, 50 + 50 * 0.9); + expect(l.args.durationSec).to.eq(ONE_YEAR); + }); + it("should create a schedule", async function () { + await dist.lockTokens($AMPL(50), ONE_YEAR); + const s = await dist.unlockSchedules.staticCall(1); + checkSharesAprox(s[0], $AMPL(100) * BigInt(InitialSharesPerToken)); + checkSharesAprox(s[1], 0n); + expect(s[2] + s[4]).to.equal(s[3]); + expect(s[4]).to.equal(ONE_YEAR); + expect(await dist.unlockScheduleCount()).to.equal(2); + }); + }); + }); + + describe("unlockTokens", function () { + describe("single schedule", function () { + describe("after waiting for 1/2 the duration", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(100)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(ONE_YEAR / 2); + }); + + describe("when supply is unchanged", function () { + it("should unlock 1/2 the tokens", async function () { + expect(await dist.totalLocked()).to.eq($AMPL(100)); + expect(await dist.totalUnlocked()).to.eq($AMPL(0)); + await checkAvailableToUnlock(dist, 50); + }); + it("should transfer tokens to unlocked pool", async function () { + await dist.updateAccounting(); + checkAmplAprox(await dist.totalLocked(), 50); + checkAmplAprox(await dist.totalUnlocked(), 50); + await checkAvailableToUnlock(dist, 0); + }); + it("should log TokensUnlocked and update state", async function () { + const r = await dist.updateAccounting(); + const receipt = await r.wait(); + const event = receipt.events?.find(event => event.event === "TokensUnlocked"); + if (event && event.args) { + checkAmplAprox(event.args.amount, 50); + checkAmplAprox(event.args.total, 50); + } + const s = await dist.unlockSchedules(0); + expect(s[0]).to.eq($AMPL(100) * InitialSharesPerToken); + checkSharesAprox(s[1], $AMPL(50) * InitialSharesPerToken); + }); + }); + + describe("when rebase increases supply", function () { + beforeEach(async function () { + await invokeRebase(ampl, 100); + }); + it("should unlock 1/2 the tokens", async function () { + expect(await dist.totalLocked()).to.eq($AMPL(200)); + expect(await dist.totalUnlocked()).to.eq($AMPL(0)); + await checkAvailableToUnlock(dist, 100); + }); + it("should transfer tokens to unlocked pool", async function () { + await dist.updateAccounting(); + checkAmplAprox(await dist.totalLocked(), 100); + checkAmplAprox(await dist.totalUnlocked(), 100); + await checkAvailableToUnlock(dist, 0); + }); + }); + + describe("when rebase decreases supply", function () { + beforeEach(async function () { + await invokeRebase(ampl, -50); + }); + it("should unlock 1/2 the tokens", async function () { + expect(await dist.totalLocked()).to.eq($AMPL(50)); + await checkAvailableToUnlock(dist, 25); + }); + it("should transfer tokens to unlocked pool", async function () { + expect(await dist.totalLocked()).to.eq($AMPL(50)); + expect(await dist.totalUnlocked()).to.eq($AMPL(0)); + await dist.updateAccounting(); + checkAmplAprox(await dist.totalLocked(), 25); + checkAmplAprox(await dist.totalUnlocked(), 25); + await checkAvailableToUnlock(dist, 0); + }); + }); + }); + + describe("after waiting > the duration", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(100)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(2 * ONE_YEAR); + }); + it("should unlock all the tokens", async function () { + await checkAvailableToUnlock(dist, 100); + }); + it("should transfer tokens to unlocked pool", async function () { + expect(await dist.totalLocked()).to.eq($AMPL(100)); + expect(await dist.totalUnlocked()).to.eq($AMPL(0)); + await dist.updateAccounting(); + expect(await dist.totalLocked()).to.eq($AMPL(0)); + checkAmplAprox(await dist.totalUnlocked(), 100); + await checkAvailableToUnlock(dist, 0); + }); + it("should log TokensUnlocked and update state", async function () { + const r = await dist.updateAccounting(); + const receipt = await r.wait(); + const event = receipt.events?.find(event => event.event === "TokensUnlocked"); + if (event && event.args) { + checkAmplAprox(event.args.amount, 50); + checkAmplAprox(event.args.total, 50); + } + const s = await dist.unlockSchedules(0); + expect(s[0]).to.eq($AMPL(100) * InitialSharesPerToken); + checkSharesAprox(s[1], $AMPL(100) * InitialSharesPerToken); + }); + }); + + describe("dust tokens due to division underflow", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(100)); + await dist.lockTokens($AMPL(1), 10 * ONE_YEAR); + }); + it("should unlock all tokens", async function () { + await TimeHelpers.increaseTime(10 * ONE_YEAR - 60); + const r1 = await dist.updateAccounting(); + const receipt1 = await r1.wait(); + const l1 = receipt1.events?.find(event => event.event === "TokensUnlocked"); + await TimeHelpers.increaseTime(65); + const r2 = await dist.updateAccounting(); + const receipt2 = await r2.wait(); + const l2 = receipt2.events?.find(event => event.event === "TokensUnlocked"); + if (l1 && l2 && l1.args && l2.args) { + expect(l1.args.amount.add(l2.args.amount)).to.eq($AMPL(1)); + } + }); + }); + }); + + describe("multi schedule", function () { + beforeEach(async function () { + await ampl.approve(dist.target, $AMPL(200)); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(ONE_YEAR / 2); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + }); + + it("should return the remaining unlock value", async function () { + checkAmplAprox(await dist.totalLocked(), 150); + checkAmplAprox(await dist.totalUnlocked(), 50); + await checkAvailableToUnlock(dist, 20); + }); + + it("should transfer tokens to unlocked pool", async function () { + await dist.updateAccounting(); + checkAmplAprox(await dist.totalLocked(), 130); + checkAmplAprox(await dist.totalUnlocked(), 70); + await checkAvailableToUnlock(dist, 0); + }); + + it("should log TokensUnlocked and update state", async function () { + const r = await dist.updateAccounting(); + const receipt = await r.wait(); + const l = receipt.events?.find(event => event.event === "TokensUnlocked"); + if (l?.args) { + checkAmplAprox(l.args.amount, 20); + checkAmplAprox(l.args.total, 130); + } + + const s1 = await dist.unlockSchedules(0); + checkSharesAprox(s1[0], $AMPL(100) * InitialSharesPerToken); + checkSharesAprox(s1[1], $AMPL(60) * InitialSharesPerToken); + const s2 = await dist.unlockSchedules(1); + checkSharesAprox(s2[0], $AMPL(100) * InitialSharesPerToken); + checkSharesAprox(s2[1], $AMPL(10) * InitialSharesPerToken); + }); + + it("should continue linear the unlock", async function () { + await dist.updateAccounting(); + await TimeHelpers.increaseTime(ONE_YEAR / 5); + await dist.updateAccounting(); + + checkAmplAprox(await dist.totalLocked(), 90); + checkAmplAprox(await dist.totalUnlocked(), 110); + await checkAvailableToUnlock(dist, 0); + + await TimeHelpers.increaseTime(ONE_YEAR / 5); + await dist.updateAccounting(); + + checkAmplAprox(await dist.totalLocked(), 50); + checkAmplAprox(await dist.totalUnlocked(), 150); + await checkAvailableToUnlock(dist, 0); + }); + }); + }); + + describe("updateAccounting", function () { + let _r, _t; + beforeEach(async function () { + _r = await dist.updateAccounting.staticCall({ from: owner }); + _t = await TimeHelpers.currentTime(); + await ampl.approve(dist.target, $AMPL(300)); + await dist.stake($AMPL(100), "0x"); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(ONE_YEAR / 2); + await dist.lockTokens($AMPL(100), ONE_YEAR); + await TimeHelpers.increaseTime(ONE_YEAR / 10); + }); + + describe("when user history does exist", async function () { + it("should return the system state", async function () { + const r = await dist.updateAccounting.staticCall(); + const t = await TimeHelpers.currentTime(); + checkAmplAprox(r[0], 130); + checkAmplAprox(r[1], 70); + const timeElapsed = t - _t; + expect(r[2] / $AMPL(100) / InitialSharesPerToken) + .to.gte(timeElapsed - 5) + .to.lte(timeElapsed + 5); + expect(r[3] / $AMPL(100) / InitialSharesPerToken) + .to.gte(timeElapsed - 5) + .to.lte(timeElapsed + 5); + checkAmplAprox(r[4], 70); + checkAmplAprox(r[4], 70); + expect(r[5] - _r[5]) + .to.gte(timeElapsed - 1) + .to.lte(timeElapsed + 1); + }); + }); + + describe("when user history does not exist", async function () { + it("should return the system state", async function () { + const r = dist.interface.decodeFunctionResult( + "updateAccounting", + await ethers.provider.call({ + from: ethers.ZeroAddress, + to: dist.target, + data: dist.interface.encodeFunctionData("updateAccounting"), + }), + ); + + const t = await TimeHelpers.currentTime(); + checkAmplAprox(r[0], 130); + checkAmplAprox(r[1], 70); + const timeElapsed = t - _t; + expect(r[2] / $AMPL(100) / InitialSharesPerToken).to.eq(0n); + expect(r[3] / $AMPL(100) / InitialSharesPerToken) + .to.gte(timeElapsed - 5) + .to.lte(timeElapsed + 5); + checkAmplAprox(r[4], 0); + expect(r[5] - _r[5]) + .to.gte(timeElapsed - 1) + .to.lte(timeElapsed + 1); + }); + }); + }); +}); diff --git a/test/unstake.js b/test/unstake.js deleted file mode 100644 index aa9b008..0000000 --- a/test/unstake.js +++ /dev/null @@ -1,395 +0,0 @@ -const { contract, web3 } = require("@openzeppelin/test-environment"); -const { expectRevert, expectEvent, BN, time } = require("@openzeppelin/test-helpers"); -const { expect } = require("chai"); - -const _require = require("app-root-path").require; -const BlockchainCaller = _require("/util/blockchain_caller"); -const chain = new BlockchainCaller(web3); -const { $AMPL, invokeRebase, checkAmplAprox, TimeController } = _require("/test/helper"); - -const AmpleforthErc20 = contract.fromArtifact("UFragments"); -const TokenGeyser = contract.fromArtifact("TokenGeyser"); -const InitialSharesPerToken = 10 ** 6; - -const ONE_YEAR = 1 * 365 * 24 * 3600; - -let ampl, dist, owner, anotherAccount; -async function setupContractAndAccounts() { - const accounts = await chain.getUserAccounts(); - owner = web3.utils.toChecksumAddress(accounts[0]); - anotherAccount = web3.utils.toChecksumAddress(accounts[8]); - - ampl = await AmpleforthErc20.new(); - await ampl.initialize(owner); - await ampl.setMonetaryPolicy(owner); - - const startBonus = 50; // 50% - const bonusPeriod = 86400; // 1 Day - dist = await TokenGeyser.new( - ampl.address, - ampl.address, - 10, - startBonus, - bonusPeriod, - InitialSharesPerToken, - ); - - await ampl.transfer(anotherAccount, $AMPL(50000)); - await ampl.approve(dist.address, $AMPL(50000), { from: anotherAccount }); - await ampl.approve(dist.address, $AMPL(50000), { from: owner }); -} - -async function totalRewardsFor(account) { - return (await dist.updateAccounting.call({ from: account }))[4]; -} - -describe("unstaking", function () { - beforeEach("setup contracts", async function () { - await setupContractAndAccounts(); - }); - - describe("unstake", function () { - describe("when amount is 0", function () { - it("should fail", async function () { - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await expectRevert( - dist.unstake($AMPL(0), [], { from: anotherAccount }), - "TokenGeyser: unstake amount is zero", - ); - }); - }); - - describe("when rebase increases supply", function () { - beforeEach(async function () { - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await time.increase(1); - }); - it("should fail if user tries to unstake more than his balance", async function () { - await invokeRebase(ampl, +50); - await expectRevert( - dist.unstake($AMPL(85), [], { from: anotherAccount }), - "TokenGeyser: unstake amount is greater than total user stakes", - ); - }); - it("should NOT fail if user tries to unstake his balance", async function () { - await invokeRebase(ampl, +50); - await dist.unstake($AMPL(75), [], { from: anotherAccount }); - }); - it("should fail if there are too few stakingSharesToBurn", async function () { - await invokeRebase(ampl, 100 * InitialSharesPerToken); - await expectRevert( - dist.unstake(1, [], { from: anotherAccount }), - "TokenGeyser: Unable to unstake amount this small", - ); - }); - }); - - describe("when rebase decreases supply", function () { - beforeEach(async function () { - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await time.increase(1); - }); - it("should fail if user tries to unstake more than his balance", async function () { - await invokeRebase(ampl, -50); - await expectRevert( - dist.unstake($AMPL(50), [], { from: anotherAccount }), - "TokenGeyser: unstake amount is greater than total user stakes", - ); - }); - it("should NOT fail if user tries to unstake his balance", async function () { - await invokeRebase(ampl, -50); - await dist.unstake($AMPL(25), [], { from: anotherAccount }); - }); - }); - - describe("when single user stakes once", function () { - // 100 ampls locked for 1 year, user stakes 50 ampls for 1 year - // user is eligible for 100% of the reward, - // unstakes 30 ampls, gets 60% of the reward (60 ampl) - // user's final balance is 90 ampl, (20 remains staked), eligible rewards (40 ampl) - const timeController = new TimeController(); - beforeEach(async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await timeController.advanceTime(ONE_YEAR); - await dist.updateAccounting({ from: anotherAccount }); - checkAmplAprox(await totalRewardsFor(anotherAccount), 100); - }); - it("should update the total staked and rewards", async function () { - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(20)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(20), - ); - checkAmplAprox(await totalRewardsFor(anotherAccount), 40); - }); - it("should transfer back staked tokens + rewards", async function () { - const _b = await ampl.balanceOf.call(anotherAccount); - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - const b = await ampl.balanceOf.call(anotherAccount); - checkAmplAprox(b.sub(_b), 90); - }); - it("should log Unstaked", async function () { - const r = await dist.unstake($AMPL(30), [], { from: anotherAccount }); - expectEvent(r, "Unstaked", { - user: anotherAccount, - amount: $AMPL(30), - total: $AMPL(20), - }); - }); - it("should log TokensClaimed", async function () { - const r = await dist.unstake($AMPL(30), [], { from: anotherAccount }); - expectEvent(r, "TokensClaimed", { - user: anotherAccount, - amount: $AMPL(60), - }); - }); - }); - - describe("when single user unstake early with early bonus", function () { - // Start bonus = 50%, Bonus Period = 1 Day. - // 1000 ampls locked for 1 hour, so all will be unlocked by test-time. - // user stakes 500 ampls for 12 hours, half the period. - // user is eligible for 75% of the max reward, - // unstakes 250 ampls, gets .5 * .75 * 1000 ampls - // user's final balance is 625 ampl, (250 remains staked), eligible rewards (375 ampl) - const timeController = new TimeController(); - const ONE_HOUR = 3600; - beforeEach(async function () { - await dist.lockTokens($AMPL(1000), ONE_HOUR); - timeController.initialize(); - await dist.stake($AMPL(500), [], { from: anotherAccount }); - await timeController.advanceTime(12 * ONE_HOUR); - await dist.updateAccounting({ from: anotherAccount }); - checkAmplAprox(await totalRewardsFor(anotherAccount), 1000); - }); - it("should update the total staked and rewards", async function () { - await dist.unstake($AMPL(250), [], { from: anotherAccount }); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(250)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(250), - ); - checkAmplAprox(await totalRewardsFor(anotherAccount), 625); // (.5 * .75 * 1000) + 250 - }); - it("should transfer back staked tokens + rewards", async function () { - const _b = await ampl.balanceOf.call(anotherAccount); - await dist.unstake($AMPL(250), [], { from: anotherAccount }); - const b = await ampl.balanceOf.call(anotherAccount); - checkAmplAprox(b.sub(_b), 625); - }); - it("should log Unstaked", async function () { - const r = await dist.unstake($AMPL(250), [], { from: anotherAccount }); - expectEvent(r, "Unstaked", { - user: anotherAccount, - amount: $AMPL(250), - total: $AMPL(250), - }); - }); - it("should log TokensClaimed", async function () { - const r = await dist.unstake($AMPL(250), [], { from: anotherAccount }); - expectEvent(r, "TokensClaimed", { - user: anotherAccount, - amount: $AMPL(375), // .5 * .75 * 1000 - }); - }); - }); - - describe("when single user stakes many times", function () { - // 100 ampls locked for 1 year, - // user stakes 50 ampls for 1/2 year, 50 ampls for 1/4 year, [50 ampls unlocked in this time ] - // unstakes 30 ampls, gets 20% of the unlocked reward (10 ampl) ~ [30 * 0.25 / (50*0.25+50*0.5) * 50] - // user's final balance is 40 ampl - const timeController = new TimeController(); - beforeEach(async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 100); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.updateAccounting({ from: anotherAccount }); - }); - it("checkTotalRewards", async function () { - checkAmplAprox(await totalRewardsFor(anotherAccount), 51); - }); - it("should update the total staked and rewards", async function () { - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(70)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(70), - ); - checkAmplAprox(await totalRewardsFor(anotherAccount), 40.8); - }); - it("should transfer back staked tokens + rewards", async function () { - const _b = await ampl.balanceOf.call(anotherAccount); - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - const b = await ampl.balanceOf.call(anotherAccount); - checkAmplAprox(b.sub(_b), 40.2); - }); - }); - - describe("when single user performs unstake many times", function () { - // 100 ampls locked for 1 year, - // user stakes 10 ampls, waits 1 year, stakes 10 ampls, waits 1 year, - // unstakes 5 ampl, unstakes 5 ampl, unstakes 5 ampl - // 3rd unstake should be worth twice the first one - const timeController = new TimeController(); - beforeEach(async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await dist.stake($AMPL(10), [], { from: anotherAccount }); - await timeController.advanceTime(ONE_YEAR); - await dist.stake($AMPL(10), [], { from: anotherAccount }); - await timeController.advanceTime(ONE_YEAR); - await dist.updateAccounting({ from: anotherAccount }); - checkAmplAprox(await totalRewardsFor(anotherAccount), 100); - }); - - it("should use updated user accounting", async function () { - const r1 = await dist.unstake($AMPL(5), [], { from: anotherAccount }); - expectEvent(r1, "TokensClaimed", { - user: anotherAccount, - }); - const l1 = r1.logs.filter(l => l.event === "TokensClaimed")[0]; - const claim1 = l1.args.amount; - const r2 = await dist.unstake($AMPL(5), [], { from: anotherAccount }); - expectEvent(r2, "TokensClaimed", { - user: anotherAccount, - }); - const r3 = await dist.unstake($AMPL(5), [], { from: anotherAccount }); - expectEvent(r3, "TokensClaimed", { - user: anotherAccount, - }); - const l3 = r3.logs.filter(l => l.event === "TokensClaimed")[0]; - const claim3 = l3.args.amount; - const ratio = claim3.mul(new BN(100)).div(claim1); - expect(ratio).to.be.bignumber.gte("199").and.bignumber.below("201"); - }); - }); - - describe("when multiple users stake once", function () { - // 100 ampls locked for 1 year, - // userA stakes 50 ampls for 3/4 year, userb stakes 50 ampl for 1/2 year, total unlocked 75 ampl - // userA unstakes 30 ampls, gets 36% of the unlocked reward (27 ampl) ~ [30 * 0.75 / (50*0.75+50*0.5) * 75] - // user's final balance is 57 ampl - const timeController = new TimeController(); - beforeEach(async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 100); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.stake($AMPL(50), []); - await timeController.advanceTime(ONE_YEAR / 2); - await dist.updateAccounting({ from: anotherAccount }); - await dist.updateAccounting(); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(100)); - checkAmplAprox(await totalRewardsFor(anotherAccount), 45.6); - checkAmplAprox(await totalRewardsFor(owner), 30.4); - }); - it("checkTotalRewards", async function () { - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(100)); - checkAmplAprox(await totalRewardsFor(anotherAccount), 45.6); - checkAmplAprox(await totalRewardsFor(owner), 30.4); - }); - it("should update the total staked and rewards", async function () { - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(70)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(20), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(50)); - checkAmplAprox(await totalRewardsFor(anotherAccount), 18.24); - checkAmplAprox(await totalRewardsFor(owner), 30.4); - }); - it("should transfer back staked tokens + rewards", async function () { - const _b = await ampl.balanceOf.call(anotherAccount); - await dist.unstake($AMPL(30), [], { from: anotherAccount }); - const b = await ampl.balanceOf.call(anotherAccount); - checkAmplAprox(b.sub(_b), 57.36); - }); - }); - - describe("when multiple users stake many times", function () { - // 10000 ampls locked for 1 year, - // userA stakes 5000 ampls for 3/4 year, and 5000 ampls for 1/4 year - // userb stakes 5000 ampls for 1/2 year and 3000 ampls for 1/4 year - // userA unstakes 10000 ampls, gets 60.60% of the unlocked reward (4545 ampl) - // ~ [5000*0.75+5000*0.25 / (5000*0.75+5000*0.25+5000*0.5+3000*0.25) * 7500] - // user's final balance is 14545 ampl - // userb unstakes 8000 ampls, gets the 10955 ampl - const timeController = new TimeController(); - const rewardsAnotherAccount = 50000.0 / 11.0; - const rewardsOwner = 32500.0 / 11.0; - beforeEach(async function () { - await timeController.executeAsBlock(function () { - dist.lockTokens($AMPL(10000), ONE_YEAR); - dist.stake($AMPL(5000), [], { from: anotherAccount }); - }); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.stake($AMPL(5000), []); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.stake($AMPL(5000), [], { from: anotherAccount }); - await dist.stake($AMPL(3000), []); - await timeController.advanceTime(ONE_YEAR / 4); - await dist.updateAccounting({ from: anotherAccount }); - await dist.updateAccounting(); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(18000)); - checkAmplAprox(await totalRewardsFor(anotherAccount), rewardsAnotherAccount); - checkAmplAprox(await totalRewardsFor(owner), rewardsOwner); - }); - it("should update the total staked and rewards", async function () { - await dist.unstake($AMPL(10000), [], { from: anotherAccount }); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(8000)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(0), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(8000)); - checkAmplAprox(await totalRewardsFor(anotherAccount), 0); - checkAmplAprox(await totalRewardsFor(owner), rewardsOwner); - await dist.unstake($AMPL(8000), []); - expect(await dist.totalStaked.call()).to.be.bignumber.equal($AMPL(0)); - expect(await dist.totalStakedFor.call(anotherAccount)).to.be.bignumber.equal( - $AMPL(0), - ); - expect(await dist.totalStakedFor.call(owner)).to.be.bignumber.equal($AMPL(0)); - checkAmplAprox(await totalRewardsFor(anotherAccount), 0); - checkAmplAprox(await totalRewardsFor(owner), 0); - }); - it("should transfer back staked tokens + rewards", async function () { - const b1 = await ampl.balanceOf.call(anotherAccount); - await dist.unstake($AMPL(10000), [], { from: anotherAccount }); - const b2 = await ampl.balanceOf.call(anotherAccount); - checkAmplAprox(b2.sub(b1), 10000 + rewardsAnotherAccount); - const b3 = await ampl.balanceOf.call(owner); - await dist.unstake($AMPL(8000), []); - const b4 = await ampl.balanceOf.call(owner); - checkAmplAprox(b4.sub(b3), 8000 + rewardsOwner); - }); - }); - }); - - describe("unstakeQuery", function () { - // 100 ampls locked for 1 year, user stakes 50 ampls for 1 year - // user is eligible for 100% of the reward, - // unstakes 30 ampls, gets 60% of the reward (60 ampl) - const timeController = new TimeController(); - beforeEach(async function () { - await dist.lockTokens($AMPL(100), ONE_YEAR); - await dist.stake($AMPL(50), [], { from: anotherAccount }); - await timeController.initialize(); - await timeController.advanceTime(ONE_YEAR); - await dist.updateAccounting({ from: anotherAccount }); - }); - it("should return the reward amount", async function () { - checkAmplAprox(await totalRewardsFor(anotherAccount), 100); - const a = await dist.unstakeQuery.call($AMPL(30), { from: anotherAccount }); - checkAmplAprox(a, 60); - }); - }); -}); diff --git a/test/unstake.ts b/test/unstake.ts new file mode 100644 index 0000000..b0b3c2b --- /dev/null +++ b/test/unstake.ts @@ -0,0 +1,396 @@ +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; +import { $AMPL, invokeRebase, checkAmplAprox, TimeHelpers } from "../test/helper"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +let ampl: any, dist: any, owner: SignerWithAddress, anotherAccount: SignerWithAddress; +const InitialSharesPerToken = 10 ** 6; +const ONE_YEAR = 1 * 365 * 24 * 3600; + +async function setupContracts() { + [owner, anotherAccount] = await ethers.getSigners(); + + const AmpleforthErc20 = await ethers.getContractFactory("UFragments"); + ampl = await AmpleforthErc20.deploy(); + await ampl.initialize(await owner.getAddress()); + await ampl.setMonetaryPolicy(await owner.getAddress()); + + const TokenGeyser = await ethers.getContractFactory("TokenGeyser"); + const startBonus = 50; // 50% + const bonusPeriod = 86400; // 1 Day + dist = await TokenGeyser.deploy( + ampl.target, + ampl.target, + 10, + startBonus, + bonusPeriod, + InitialSharesPerToken, + ); + + await ampl.transfer(await anotherAccount.getAddress(), $AMPL(50000)); + await ampl.connect(anotherAccount).approve(dist.target, $AMPL(50000)); + await ampl.connect(owner).approve(dist.target, $AMPL(50000)); + + return { ampl, dist, owner, anotherAccount }; +} + +async function totalRewardsFor(account) { + const r = await dist.connect(account).updateAccounting.staticCall(); + return r[4]; +} + +async function expectEvent(tx, name, params) { + const txR = await tx.wait(); + const event = txR.logs?.find(event => event.fragment?.name === name); + expect(event.args).to.deep.equal(params); +} + +describe("unstaking", function () { + beforeEach("setup contracts", async function () { + ({ ampl, dist, owner, anotherAccount } = await loadFixture(setupContracts)); + }); + + describe("unstake", function () { + describe("when amount is 0", function () { + it("should fail", async function () { + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await expect( + dist.connect(anotherAccount).unstake($AMPL(0), "0x"), + ).to.be.revertedWith("TokenGeyser: unstake amount is zero"); + }); + }); + + describe("when rebase increases supply", function () { + beforeEach(async function () { + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await time.increase(1); + }); + it("should fail if user tries to unstake more than his balance", async function () { + await invokeRebase(ampl, +50); + await expect( + dist.connect(anotherAccount).unstake($AMPL(85), "0x"), + ).to.be.revertedWith( + "TokenGeyser: unstake amount is greater than total user stakes", + ); + }); + it("should NOT fail if user tries to unstake his balance", async function () { + await invokeRebase(ampl, +50); + await dist.connect(anotherAccount).unstake($AMPL(75), "0x"); + }); + it("should fail if there are too few stakingSharesToBurn", async function () { + await invokeRebase(ampl, 100 * InitialSharesPerToken); + await expect(dist.connect(anotherAccount).unstake(1, "0x")).to.be.revertedWith( + "TokenGeyser: Unable to unstake amount this small", + ); + }); + }); + + describe("when rebase decreases supply", function () { + beforeEach(async function () { + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await time.increase(1); + }); + it("should fail if user tries to unstake more than his balance", async function () { + await invokeRebase(ampl, -50); + await expect( + dist.connect(anotherAccount).unstake($AMPL(50), "0x"), + ).to.be.revertedWith( + "TokenGeyser: unstake amount is greater than total user stakes", + ); + }); + it("should NOT fail if user tries to unstake his balance", async function () { + await invokeRebase(ampl, -50); + await dist.connect(anotherAccount).unstake($AMPL(25), "0x"); + }); + }); + + describe("when single user stakes once", function () { + // 100 ampls locked for 1 year, user stakes 50 ampls for 1 year + // user is eligible for 100% of the reward, + // unstakes 30 ampls, gets 60% of the reward (60 ampl) + // user's final balance is 90 ampl, (20 remains staked), eligible rewards (40 ampl) + beforeEach(async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR); + await dist.connect(anotherAccount).updateAccounting(); + checkAmplAprox(await totalRewardsFor(anotherAccount), 100); + }); + it("should update the total staked and rewards", async function () { + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(20)); + expect( + await dist.totalStakedFor.staticCall(await anotherAccount.getAddress()), + ).to.eq($AMPL(20)); + checkAmplAprox(await totalRewardsFor(anotherAccount), 40); + }); + it("should transfer back staked tokens + rewards", async function () { + const _b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + const b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + checkAmplAprox(b - _b, 90); + }); + it("should log Unstaked", async function () { + const r = await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + await expectEvent(r, "Unstaked", [ + await anotherAccount.getAddress(), + $AMPL(30), + $AMPL(20), + "0x", + ]); + }); + it("should log TokensClaimed", async function () { + const r = await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + await expectEvent(r, "TokensClaimed", [ + await anotherAccount.getAddress(), + $AMPL(60), + ]); + }); + }); + + describe("when single user unstake early with early bonus", function () { + // Start bonus = 50%, Bonus Period = 1 Day. + // 1000 ampls locked for 1 hour, so all will be unlocked by test-time. + // user stakes 500 ampls for 12 hours, half the period. + // user is eligible for 75% of the max reward, + // unstakes 250 ampls, gets .5 * .75 * 1000 ampls + // user's final balance is 625 ampl, (250 remains staked), eligible rewards (375 ampl) + const ONE_HOUR = 3600; + beforeEach(async function () { + await dist.lockTokens($AMPL(1000), ONE_HOUR); + + await dist.connect(anotherAccount).stake($AMPL(500), "0x"); + await TimeHelpers.increaseTime(12 * ONE_HOUR); + await dist.connect(anotherAccount).updateAccounting(); + checkAmplAprox(await totalRewardsFor(anotherAccount), 1000); + }); + it("should update the total staked and rewards", async function () { + await dist.connect(anotherAccount).unstake($AMPL(250), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(250)); + expect( + await dist.totalStakedFor.staticCall(await anotherAccount.getAddress()), + ).to.eq($AMPL(250)); + checkAmplAprox(await totalRewardsFor(anotherAccount), 625); // (.5 * .75 * 1000) + 250 + }); + it("should transfer back staked tokens + rewards", async function () { + const _b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + await dist.connect(anotherAccount).unstake($AMPL(250), "0x"); + const b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + checkAmplAprox(b - _b, 625); + }); + it("should log Unstaked", async function () { + const r = await dist.connect(anotherAccount).unstake($AMPL(250), "0x"); + await expectEvent(r, "Unstaked", [ + await anotherAccount.getAddress(), + $AMPL(250), + $AMPL(250), + "0x", + ]); + }); + it("should log TokensClaimed", async function () { + const r = await dist.connect(anotherAccount).unstake($AMPL(250), "0x"); + await expectEvent(r, "TokensClaimed", [ + await anotherAccount.getAddress(), + $AMPL(375), // .5 * .75 * 1000 + ]); + }); + }); + + describe("when single user stakes many times", function () { + // 100 ampls locked for 1 year, + // user stakes 50 ampls for 1/2 year, 50 ampls for 1/4 year, [50 ampls unlocked in this time ] + // unstakes 30 ampls, gets 20% of the unlocked reward (10 ampl) ~ [30 * 0.25 / (50*0.25+50*0.5) * 50] + // user's final balance is 40 ampl + beforeEach(async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + + await TimeHelpers.increaseTime(ONE_YEAR / 100); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.connect(anotherAccount).updateAccounting(); + }); + it("checkTotalRewards", async function () { + checkAmplAprox(await totalRewardsFor(anotherAccount), 51); + }); + it("should update the total staked and rewards", async function () { + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(70)); + expect( + await dist.totalStakedFor.staticCall(await anotherAccount.getAddress()), + ).to.eq($AMPL(70)); + checkAmplAprox(await totalRewardsFor(anotherAccount), 40.8); + }); + it("should transfer back staked tokens + rewards", async function () { + const _b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + const b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + checkAmplAprox(b - _b, 40.2); + }); + }); + + describe("when single user performs unstake many times", function () { + // 100 ampls locked for 1 year, + // user stakes 10 ampls, waits 1 year, stakes 10 ampls, waits 1 year, + // unstakes 5 ampl, unstakes 5 ampl, unstakes 5 ampl + // 3rd unstake should be worth twice the first one + beforeEach(async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + + await dist.connect(anotherAccount).stake($AMPL(10), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR); + await dist.connect(anotherAccount).stake($AMPL(10), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR); + await dist.connect(anotherAccount).updateAccounting(); + checkAmplAprox(await totalRewardsFor(anotherAccount), 100); + }); + + it("should use updated user accounting", async function () { + const r1 = await dist.connect(anotherAccount).unstake($AMPL(5), "0x"); + await expectEvent(r1, "TokensClaimed", [ + await anotherAccount.getAddress(), + 16666666842n, + ]); + const claim1 = 16666666842n; + const r2 = await dist.connect(anotherAccount).unstake($AMPL(5), "0x"); + await expectEvent(r2, "TokensClaimed", [ + await anotherAccount.getAddress(), + 16666667054n, + ]); + const r3 = await dist.connect(anotherAccount).unstake($AMPL(5), "0x"); + await expectEvent(r3, "TokensClaimed", [ + await anotherAccount.getAddress(), + 33333333052n, + ]); + const claim3 = 33333333052n; + const ratio = (claim3 * 100n) / claim1; + expect(ratio).gte(199n).lt(201); + }); + }); + + describe("when multiple users stake once", function () { + // 100 ampls locked for 1 year, + // userA stakes 50 ampls for 3/4 year, userb stakes 50 ampl for 1/2 year, total unlocked 75 ampl + // userA unstakes 30 ampls, gets 36% of the unlocked reward (27 ampl) ~ [30 * 0.75 / (50*0.75+50*0.5) * 75] + // user's final balance is 57 ampl + beforeEach(async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + + await TimeHelpers.increaseTime(ONE_YEAR / 100); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.stake($AMPL(50), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR / 2); + await dist.connect(anotherAccount).updateAccounting(); + await dist.updateAccounting(); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(100)); + checkAmplAprox(await totalRewardsFor(anotherAccount), 45.6); + checkAmplAprox(await totalRewardsFor(owner), 30.4); + }); + it("checkTotalRewards", async function () { + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(100)); + checkAmplAprox(await totalRewardsFor(anotherAccount), 45.6); + checkAmplAprox(await totalRewardsFor(owner), 30.4); + }); + it("should update the total staked and rewards", async function () { + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(70)); + expect( + await dist.totalStakedFor.staticCall(await anotherAccount.getAddress()), + ).to.eq($AMPL(20)); + expect(await dist.totalStakedFor.staticCall(await owner.getAddress())).to.eq( + $AMPL(50), + ); + checkAmplAprox(await totalRewardsFor(anotherAccount), 18.24); + checkAmplAprox(await totalRewardsFor(owner), 30.4); + }); + it("should transfer back staked tokens + rewards", async function () { + const _b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + await dist.connect(anotherAccount).unstake($AMPL(30), "0x"); + const b = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + checkAmplAprox(b - _b, 57.36); + }); + }); + + describe("when multiple users stake many times", function () { + // 10000 ampls locked for 1 year, + // userA stakes 5000 ampls for 3/4 year, and 5000 ampls for 1/4 year + // userb stakes 5000 ampls for 1/2 year and 3000 ampls for 1/4 year + // userA unstakes 10000 ampls, gets 60.60% of the unlocked reward (4545 ampl) + // ~ [5000*0.75+5000*0.25 / (5000*0.75+5000*0.25+5000*0.5+3000*0.25) * 7500] + // user's final balance is 14545 ampl + // userb unstakes 8000 ampls, gets the 10955 ampl + const rewardsAnotherAccount = 50000.0 / 11.0; + const rewardsOwner = 32500.0 / 11.0; + beforeEach(async function () { + await dist.lockTokens($AMPL(10000), ONE_YEAR); + await dist.connect(anotherAccount).stake($AMPL(5000), "0x"); + + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.stake($AMPL(5000), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.connect(anotherAccount).stake($AMPL(5000), "0x"); + await dist.stake($AMPL(3000), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR / 4); + await dist.connect(anotherAccount).updateAccounting(); + await dist.updateAccounting(); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(18000)); + checkAmplAprox(await totalRewardsFor(anotherAccount), rewardsAnotherAccount); + checkAmplAprox(await totalRewardsFor(owner), rewardsOwner); + }); + it("should update the total staked and rewards", async function () { + await dist.connect(anotherAccount).unstake($AMPL(10000), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(8000)); + expect(await dist.totalStakedFor.staticCall(ethers.ZeroAddress)).to.eq($AMPL(0)); + expect(await dist.totalStakedFor.staticCall(await owner.getAddress())).to.eq( + $AMPL(8000), + ); + checkAmplAprox(await totalRewardsFor(anotherAccount), 0); + checkAmplAprox(await totalRewardsFor(owner), rewardsOwner); + await dist.unstake($AMPL(8000), "0x"); + expect(await dist.totalStaked.staticCall()).to.eq($AMPL(0)); + expect( + await dist.totalStakedFor.staticCall(await anotherAccount.getAddress()), + ).to.eq($AMPL(0)); + expect(await dist.totalStakedFor.staticCall(await owner.getAddress())).to.eq( + $AMPL(0), + ); + checkAmplAprox(await totalRewardsFor(anotherAccount), 0); + checkAmplAprox(await totalRewardsFor(owner), 0); + }); + it("should transfer back staked tokens + rewards", async function () { + const b1 = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + await dist.connect(anotherAccount).unstake($AMPL(10000), "0x"); + const b2 = await ampl.balanceOf.staticCall(await anotherAccount.getAddress()); + checkAmplAprox(b2 - b1, 10000 + rewardsAnotherAccount); + const b3 = await ampl.balanceOf.staticCall(await owner.getAddress()); + await dist.unstake($AMPL(8000), "0x"); + const b4 = await ampl.balanceOf.staticCall(await owner.getAddress()); + checkAmplAprox(b4 - b3, 8000 + rewardsOwner); + }); + }); + }); + + describe("unstakeQuery", function () { + // 100 ampls locked for 1 year, user stakes 50 ampls for 1 year + // user is eligible for 100% of the reward, + // unstakes 30 ampls, gets 60% of the reward (60 ampl) + beforeEach(async function () { + await dist.lockTokens($AMPL(100), ONE_YEAR); + await dist.connect(anotherAccount).stake($AMPL(50), "0x"); + await TimeHelpers.increaseTime(ONE_YEAR); + await dist.connect(anotherAccount).updateAccounting(); + }); + it("should return the reward amount", async function () { + checkAmplAprox(await totalRewardsFor(anotherAccount), 100); + checkAmplAprox( + await dist.connect(anotherAccount).unstakeQuery.staticCall($AMPL(30)), + 60, + ); + }); + }); +});