diff --git a/contracts/mock/MockDelegate.sol b/contracts/mock/MockDelegate.sol new file mode 100644 index 0000000..a04d133 --- /dev/null +++ b/contracts/mock/MockDelegate.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.7.6; + +import {IRageQuit, IUniversalVault} from "../crucible/Crucible.sol"; + +contract MockDelegate is IRageQuit { + enum DelegateType {Succeed, Revert, RevertWithMessage, OOG} + + DelegateType private _delegateType; + + function setDelegateType(DelegateType delegateType) external { + _delegateType = delegateType; + } + + function rageQuit() external view override { + if (_delegateType == DelegateType.Succeed) { + return; + } else if (_delegateType == DelegateType.Revert) { + revert(); + } else if (_delegateType == DelegateType.RevertWithMessage) { + require(false, "MockDelegate: revert with message"); + } else if (_delegateType == DelegateType.OOG) { + while (true) {} + } + } + + function lock( + address vault, + address token, + uint256 amount, + bytes memory permission + ) external { + IUniversalVault(vault).lock(token, amount, permission); + } + + function unlock( + address vault, + address token, + uint256 amount, + bytes memory permission + ) external { + IUniversalVault(vault).unlock(token, amount, permission); + } +} diff --git a/contracts/mock/MockERC1271.sol b/contracts/mock/MockERC1271.sol new file mode 100644 index 0000000..2bc041b --- /dev/null +++ b/contracts/mock/MockERC1271.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.7.6; + +import {ERC1271} from "../crucible/ERC1271.sol"; + +contract MockERC1271 is ERC1271 { + address public owner; + + constructor(address _owner) { + owner = _owner; + } + + function _getOwner() internal view override returns (address) { + return owner; + } +} diff --git a/contracts/mock/MockPowered.sol b/contracts/mock/MockPowered.sol new file mode 100644 index 0000000..a581ec0 --- /dev/null +++ b/contracts/mock/MockPowered.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.7.6; + +import {Powered} from "../aludel/Powered.sol"; + +contract MockPowered is Powered { + constructor(address powerSwitch) { + Powered._setPowerSwitch(powerSwitch); + } + + function onlyOnlineCall() public view onlyOnline { + return; + } + + function onlyOfflineCall() public view onlyOffline { + return; + } + + function notShutdownCall() public view notShutdown { + return; + } + + function onlyShutdownCall() public view onlyShutdown { + return; + } +} diff --git a/contracts/mock/MockSmartWallet.sol b/contracts/mock/MockSmartWallet.sol new file mode 100644 index 0000000..af356ee --- /dev/null +++ b/contracts/mock/MockSmartWallet.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.7.6; + +import {ERC1271} from "../crucible/ERC1271.sol"; + +contract MockSmartWallet is ERC1271 { + address private _owner; + + constructor(address owner) { + _owner = owner; + } + + function _getOwner() internal view override returns (address) { + return _owner; + } +} diff --git a/contracts/mock/MockStakeHelper.sol b/contracts/mock/MockStakeHelper.sol new file mode 100644 index 0000000..4d5d972 --- /dev/null +++ b/contracts/mock/MockStakeHelper.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.7.6; +pragma abicoder v2; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {Aludel} from "../aludel/Aludel.sol"; + +contract MockStakeHelper { + function flashStake( + address geyser, + address vault, + uint256 amount, + bytes calldata lockPermission, + bytes calldata unstakePermission + ) external { + Aludel(geyser).stake(vault, amount, lockPermission); + Aludel(geyser).unstakeAndClaim(vault, amount, unstakePermission); + } + + function stakeBatch( + address[] calldata geysers, + address[] calldata vaults, + uint256[] calldata amounts, + bytes[] calldata permissions + ) external { + for (uint256 index = 0; index < vaults.length; index++) { + Aludel(geysers[index]).stake(vaults[index], amounts[index], permissions[index]); + } + } +} diff --git a/test/Access/ERC1271.ts b/test/Access/ERC1271.ts new file mode 100644 index 0000000..233c769 --- /dev/null +++ b/test/Access/ERC1271.ts @@ -0,0 +1,47 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract } from 'ethers' +import { hashMessage, keccak256 } from 'ethers/lib/utils' +import { ethers } from 'hardhat' +import { deployContract } from '../utils' + +describe('ERC1271', function () { + let accounts: SignerWithAddress[] + let MockERC1271: Contract + const message = hashMessage('ERC1271 test message') + let VALID_SIG: string + const INVALID_SIG = '0x00000000' + + function toEthSignedMessageHash(messageHex: string) { + const messageBuffer = Buffer.from(messageHex.substring(2), 'hex') + const prefix = Buffer.from(`\u0019Ethereum Signed Message:\n${messageBuffer.length}`) + return keccak256(Buffer.concat([prefix, messageBuffer])) + } + + beforeEach(async function () { + // prepare signers + accounts = await ethers.getSigners() + // deploy mock + MockERC1271 = await deployContract('MockERC1271', [accounts[0].address]) + VALID_SIG = MockERC1271.interface.getSighash('isValidSignature(bytes32,bytes)') + }) + + describe('isValidSignature', function () { + it('should return error value if signed by account other than owner', async function () { + const sig = await accounts[1].signMessage(message) + expect(await MockERC1271.isValidSignature(toEthSignedMessageHash(message), sig)).to.eq(INVALID_SIG) + }) + + it('should revert if signature has incorrect length', async function () { + const sig = await accounts[0].signMessage(message) + expect(MockERC1271.isValidSignature(toEthSignedMessageHash(message), sig.slice(0, 10))).to.be.revertedWith( + 'ECDSA: invalid signature length', + ) + }) + + it('should return success value if signed by owner', async function () { + const sig = await accounts[0].signMessage(message) + expect(await MockERC1271.isValidSignature(hashMessage(message), sig)).to.eq(VALID_SIG) + }) + }) +}) diff --git a/test/Geyser.ts b/test/Geyser.ts new file mode 100644 index 0000000..46ea7b0 --- /dev/null +++ b/test/Geyser.ts @@ -0,0 +1,2355 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { BigNumber, BigNumberish, Contract, Wallet } from 'ethers' +import { ethers, network } from 'hardhat' +import { revertAfter } from './shared-before-each/revert-after' +import { sharedBeforeEach } from './shared-before-each/shared-before-each' +import { + createInstance, + deployContract, + deployAludel, + getTimestamp, + increaseTime, + signPermission, + deployMist, +} from './utils' + +/* + + Dev note: This test file suffers from indeterminancy based on timestamp manipulation. + + If you are encountering tests which fail unexpectedly, make sure increaseTime() is + correctly setting the timestamp of the next block. + + Issue is being tracked here: https://github.com/nomiclabs/hardhat/issues/1079 + +*/ + +/* + + Note: the implementation of increaseTime() was changed to only use `evm_mine` with the + timestamp passed as a parameter, instead of using a combination of `evm_increaseTime` and `evm_mine` + + This implementation does not seem to run into the same problem as it previously did. + As a result, the indeterminancy issue is fixed + +*/ + +describe('Aludel', function () { + let accounts: SignerWithAddress[], admin: SignerWithAddress + let user: Wallet + + let powerSwitchFactory: Contract, + rewardPoolFactory: Contract, + vaultTemplate: Contract, + vaultFactory: Contract, + stakingToken: Contract, + rewardToken: Contract, + bonusToken: Contract + + const mockTokenSupply = ethers.utils.parseEther('1000') + const BASE_SHARES_PER_WEI = 1000000 + const DAY = 24 * 3600 + const YEAR = 365 * DAY + const rewardScaling = { floor: 33, ceiling: 100, time: 60 * DAY } + + let amplInitialSupply: BigNumber + + const stake = async ( + user: Wallet, + geyser: Contract, + vault: Contract, + stakingToken: Contract, + amount: BigNumberish, + vaultNonce?: BigNumberish, + ) => { + // sign permission + const signedPermission = await signPermission( + 'Lock', + vault, + user, + geyser.address, + stakingToken.address, + amount, + vaultNonce, + ) + // stake on geyser + return geyser.stake(vault.address, amount, signedPermission) + } + + const unstakeAndClaim = async ( + user: Wallet, + geyser: Contract, + vault: Contract, + stakingToken: Contract, + amount: BigNumberish, + vaultNonce?: BigNumberish, + ) => { + // sign permission + const signedPermission = await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + amount, + vaultNonce, + ) + // unstake on geyser + return geyser.unstakeAndClaim(vault.address, amount, signedPermission) + } + + function calculateExpectedReward( + stakeAmount: BigNumber, + stakeDuration: BigNumberish, + rewardAvailable: BigNumber, + otherStakeUnits: BigNumberish, + ) { + const stakeUnits = stakeAmount.mul(stakeDuration) + const baseReward = rewardAvailable.mul(stakeUnits).div(stakeUnits.add(otherStakeUnits)) + const minReward = baseReward.mul(rewardScaling.floor).div(100) + const bonusReward = baseReward + .mul(rewardScaling.ceiling - rewardScaling.floor) + .mul(stakeDuration) + .div(rewardScaling.time) + .div(100) + return stakeDuration >= rewardScaling.time ? baseReward : minReward.add(bonusReward) + } + + revertAfter(); + + before(async function () { + // prepare signers + accounts = await ethers.getSigners() + admin = accounts[1] + user = Wallet.createRandom().connect(ethers.provider) + await accounts[2].sendTransaction({ + to: user.address, + value: (await accounts[2].getBalance()).mul(9).div(10), + }) + + }) + + sharedBeforeEach(async function () { + powerSwitchFactory = await deployContract('PowerSwitchFactory') + rewardPoolFactory = await deployContract('RewardPoolFactory') + vaultTemplate = await deployContract('Crucible') + vaultFactory = await deployContract('CrucibleFactory', [vaultTemplate.address]) + + // deploy mock tokens + stakingToken = await deployContract('MockERC20', [admin.address, mockTokenSupply]); + + // unpack object + ({ mist: rewardToken, initialSupply: amplInitialSupply } = await deployMist(admin)) + + bonusToken = await deployContract('MockERC20', [admin.address, mockTokenSupply]) + }); + + describe('initialize', function () { + describe('when rewardScaling.floor > rewardScaling.ceiling', function () { + it('should fail', async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + [rewardScaling.ceiling + 1, rewardScaling.ceiling, rewardScaling.time], + ] + await expect(deployAludel(args)).to.be.reverted + }) + }) + describe('when rewardScalingTime = 0', function () { + it('should fail', async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + [rewardScaling.floor, rewardScaling.ceiling, 0], + ] + await expect(deployAludel(args)).to.be.reverted + }) + }) + describe('when parameters are valid', function () { + it('should set contract variables', async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + [rewardScaling.floor, rewardScaling.ceiling, rewardScaling.time], + ] + const geyser = await deployAludel(args) + + const data = await geyser.getAludelData() + + expect(data.stakingToken).to.eq(stakingToken.address) + expect(data.rewardToken).to.eq(rewardToken.address) + expect(data.rewardPool).to.not.eq(ethers.constants.AddressZero) + expect(data.rewardScaling.floor).to.eq(33) + expect(data.rewardScaling.ceiling).to.eq(100) + expect(data.rewardSharesOutstanding).to.eq(0) + expect(data.totalStake).to.eq(0) + expect(data.totalStakeUnits).to.eq(0) + expect(data.lastUpdate).to.eq(0) + expect(data.rewardSchedules).to.deep.eq([]) + expect(await geyser.getBonusTokenSetLength()).to.eq(0) + expect(await geyser.owner()).to.eq(admin.address) + expect(await geyser.getPowerSwitch()).to.not.eq(ethers.constants.AddressZero) + expect(await geyser.getPowerController()).to.eq(admin.address) + expect(await geyser.isOnline()).to.eq(true) + expect(await geyser.isOffline()).to.eq(false) + expect(await geyser.isShutdown()).to.eq(false) + }) + }) + }) + + describe('admin functions', function () { + let geyser: Contract, powerSwitch: Contract, rewardPool: Contract + beforeEach(async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + [rewardScaling.floor, rewardScaling.ceiling, rewardScaling.time], + ] + geyser = await deployAludel(args) + powerSwitch = await ethers.getContractAt('PowerSwitch', await geyser.getPowerSwitch()) + rewardPool = await ethers.getContractAt('RewardPool', (await geyser.getAludelData()).rewardPool) + }) + describe('fundAludel', function () { + describe('with insufficient approval', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply, YEAR)).to.be.reverted + }) + }) + describe('with duration of zero', function () { + it('should fail', async function () { + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + await expect(geyser.connect(admin).fund(amplInitialSupply, 0)).to.be.revertedWith( + 'Aludel: invalid duration', + ) + }) + }) + describe('as user', function () { + it('should fail', async function () { + await rewardToken.connect(admin).transfer(user.address, amplInitialSupply) + await rewardToken.connect(user).approve(geyser.address, amplInitialSupply) + await expect(geyser.connect(user).fund(amplInitialSupply, YEAR)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + }) + describe('when offline', function () { + it('should fail', async function () { + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + await powerSwitch.connect(admin).powerOff() + await expect(geyser.connect(admin).fund(amplInitialSupply, YEAR)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('when shutdown', function () { + it('should fail', async function () { + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + await powerSwitch.connect(admin).emergencyShutdown() + await expect(geyser.connect(admin).fund(amplInitialSupply, YEAR)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('when online', function () { + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + }) + describe('at first funding', function () { + it('should succeed', async function () { + await geyser.connect(admin).fund(amplInitialSupply, YEAR) + }) + it('should update state correctly', async function () { + await geyser.connect(admin).fund(amplInitialSupply, YEAR) + + const data = await geyser.getAludelData() + + expect(data.rewardSharesOutstanding).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI)) + expect(data.rewardSchedules.length).to.eq(1) + expect(data.rewardSchedules[0].duration).to.eq(YEAR) + expect(data.rewardSchedules[0].start).to.eq(await getTimestamp()) + expect(data.rewardSchedules[0].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI)) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply, YEAR)) + .to.emit(geyser, 'AludelFunded') + .withArgs(amplInitialSupply, YEAR) + }) + it('should transfer tokens', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply, YEAR)) + .to.emit(rewardToken, 'Transfer') + .withArgs(admin.address, rewardPool.address, amplInitialSupply) + }) + }) + describe('at second funding', function () { + beforeEach(async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), YEAR) + }) + describe('with no rebase', function () { + it('should succeed', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), YEAR) + }) + it('should update state correctly', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), YEAR) + + const data = await geyser.getAludelData() + + expect(data.rewardSharesOutstanding).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI)) + expect(data.rewardSchedules.length).to.eq(2) + expect(data.rewardSchedules[0].duration).to.eq(YEAR) + expect(data.rewardSchedules[0].start).to.eq((await getTimestamp()) - 1) + expect(data.rewardSchedules[0].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + expect(data.rewardSchedules[1].duration).to.eq(YEAR) + expect(data.rewardSchedules[1].start).to.eq(await getTimestamp()) + expect(data.rewardSchedules[1].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), YEAR)) + .to.emit(geyser, 'AludelFunded') + .withArgs(amplInitialSupply.div(2), YEAR) + }) + it('should transfer tokens', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), YEAR)) + .to.emit(rewardToken, 'Transfer') + .withArgs(admin.address, rewardPool.address, amplInitialSupply.div(2)) + }) + }) + }) + describe('after unstake', function () { + const stakeAmount = ethers.utils.parseEther('100') + + let vault: Contract + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + await geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time) + }) + describe('with partial rewards exausted', function () { + beforeEach(async function () { + await increaseTime(rewardScaling.time / 2) + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should succeed', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time) + }) + it('should update state correctly', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time) + + const data = await geyser.getAludelData() + + expect(data.rewardSharesOutstanding).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).mul(3).div(4)) + expect(data.rewardSchedules.length).to.eq(2) + expect(data.rewardSchedules[0].duration).to.eq(rewardScaling.time) + expect(data.rewardSchedules[0].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + expect(data.rewardSchedules[1].duration).to.eq(rewardScaling.time) + expect(data.rewardSchedules[1].start).to.eq(await getTimestamp()) + expect(data.rewardSchedules[1].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time)) + .to.emit(geyser, 'AludelFunded') + .withArgs(amplInitialSupply.div(2), rewardScaling.time) + }) + it('should transfer tokens', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time)) + .to.emit(rewardToken, 'Transfer') + .withArgs(admin.address, rewardPool.address, amplInitialSupply.div(2)) + }) + }) + describe('with full rewards exausted', function () { + beforeEach(async function () { + await increaseTime(rewardScaling.time) + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should succeed', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time) + }) + it('should update state correctly', async function () { + await geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time) + + const data = await geyser.getAludelData() + + expect(data.rewardSharesOutstanding).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + expect(data.rewardSchedules.length).to.eq(2) + expect(data.rewardSchedules[0].duration).to.eq(rewardScaling.time) + expect(data.rewardSchedules[0].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + expect(data.rewardSchedules[1].duration).to.eq(rewardScaling.time) + expect(data.rewardSchedules[1].start).to.eq(await getTimestamp()) + expect(data.rewardSchedules[1].shares).to.eq(amplInitialSupply.mul(BASE_SHARES_PER_WEI).div(2)) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time)) + .to.emit(geyser, 'AludelFunded') + .withArgs(amplInitialSupply.div(2), rewardScaling.time) + }) + it('should transfer tokens', async function () { + await expect(geyser.connect(admin).fund(amplInitialSupply.div(2), rewardScaling.time)) + .to.emit(rewardToken, 'Transfer') + .withArgs(admin.address, rewardPool.address, amplInitialSupply.div(2)) + }) + }) + }) + }) + }) + + describe('isValidVault', function () { + let vault: Contract + beforeEach(async function () { + vault = await createInstance('Crucible', vaultFactory, user) + }) + describe('when no factory registered', function () { + it('should be false', async function () { + expect(await geyser.isValidVault(vault.address)).to.be.false + }) + }) + describe('when vault from factory registered', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should be true', async function () { + expect(await geyser.isValidVault(vault.address)).to.be.true + }) + }) + describe('when vault from factory removed', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + }) + it('should be false', async function () { + expect(await geyser.isValidVault(vault.address)).to.be.false + }) + }) + describe('when vault not from factory registered', function () { + let secondFactory: Contract + let secondVault: Contract + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + secondFactory = await deployContract('CrucibleFactory', [vaultTemplate.address]) + secondVault = await createInstance('Crucible', secondFactory, user) + }) + it('should be false', async function () { + expect(await geyser.isValidVault(secondVault.address)).to.be.false + }) + }) + describe('when vaults from multiple factory registered', function () { + let secondFactory: Contract + let secondVault: Contract + beforeEach(async function () { + secondFactory = await deployContract('CrucibleFactory', [vaultTemplate.address]) + secondVault = await createInstance('Crucible', secondFactory, user) + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await geyser.connect(admin).registerVaultFactory(secondFactory.address) + }) + it('should be true', async function () { + expect(await geyser.isValidVault(vault.address)).to.be.true + expect(await geyser.isValidVault(secondVault.address)).to.be.true + }) + }) + }) + + describe('registerVaultFactory', function () { + describe('as user', function () { + it('should fail', async function () { + await expect(geyser.connect(user).registerVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + }) + describe('when online', function () { + it('should succeed', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(1) + expect(await geyser.getVaultFactoryAtIndex(0)).to.be.eq(vaultFactory.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRegistered') + .withArgs(vaultFactory.address) + }) + }) + describe('when offline', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).powerOff() + }) + it('should succeed', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(1) + expect(await geyser.getVaultFactoryAtIndex(0)).to.be.eq(vaultFactory.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRegistered') + .withArgs(vaultFactory.address) + }) + }) + describe('when shutdown', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).emergencyShutdown() + }) + it('should fail', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Powered: is shutdown', + ) + }) + }) + describe('when already added', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should fail', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Aludel: vault factory already registered', + ) + }) + }) + describe('when removed', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + }) + it('should succeed', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(1) + expect(await geyser.getVaultFactoryAtIndex(0)).to.be.eq(vaultFactory.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRegistered') + .withArgs(vaultFactory.address) + }) + }) + describe('with second factory', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(admin.address) + }) + it('should succeed', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(2) + expect(await geyser.getVaultFactoryAtIndex(1)).to.be.eq(vaultFactory.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRegistered') + .withArgs(vaultFactory.address) + }) + }) + }) + describe('removeVaultFactory', function () { + describe('as user', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should fail', async function () { + await expect(geyser.connect(user).removeVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + }) + describe('when online', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + }) + it('should succeed', async function () { + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(0) + await expect(geyser.getVaultFactoryAtIndex(0)).to.be.reverted + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).removeVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRemoved') + .withArgs(vaultFactory.address) + }) + }) + describe('when offline', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await powerSwitch.connect(admin).powerOff() + }) + it('should succeed', async function () { + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + }) + it('should update state', async function () { + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + + expect(await geyser.getVaultFactorySetLength()).to.be.eq(0) + await expect(geyser.getVaultFactoryAtIndex(0)).to.be.reverted + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).removeVaultFactory(vaultFactory.address)) + .to.emit(geyser, 'VaultFactoryRemoved') + .withArgs(vaultFactory.address) + }) + }) + describe('when shutdown', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await powerSwitch.connect(admin).emergencyShutdown() + }) + it('should fail', async function () { + await expect(geyser.connect(admin).removeVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Powered: is shutdown', + ) + }) + }) + describe('when never added', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).removeVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Aludel: vault factory not registered', + ) + }) + }) + describe('when already removed', function () { + beforeEach(async function () { + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + }) + it('should fail', async function () { + await expect(geyser.connect(admin).removeVaultFactory(vaultFactory.address)).to.be.revertedWith( + 'Aludel: vault factory not registered', + ) + }) + }) + }) + + describe('registerBonusToken', function () { + describe('as user', function () { + it('should fail', async function () { + await expect(geyser.connect(user).registerBonusToken(bonusToken.address)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + }) + describe('when online', function () { + describe('on first call', function () { + describe('with address zero', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(ethers.constants.AddressZero)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with geyser address', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(geyser.address)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with staking token', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(stakingToken.address)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with reward token', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(rewardToken.address)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with rewardPool address', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(rewardPool.address)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with bonus token', function () { + it('should succeed', async function () { + await geyser.connect(admin).registerBonusToken(bonusToken.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerBonusToken(bonusToken.address) + expect(await geyser.getBonusTokenSetLength()).to.eq(1) + expect(await geyser.getBonusTokenAtIndex(0)).to.eq(bonusToken.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerBonusToken(bonusToken.address)) + .to.emit(geyser, 'BonusTokenRegistered') + .withArgs(bonusToken.address) + }) + }) + }) + describe('on second call', function () { + beforeEach(async function () { + await geyser.connect(admin).registerBonusToken(bonusToken.address) + }) + describe('with same token', function () { + it('should fail', async function () { + await expect(geyser.connect(admin).registerBonusToken(bonusToken.address)).to.be.revertedWith( + 'Aludel: invalid address', + ) + }) + }) + describe('with different bonus token', function () { + let secondBonusToken: Contract + beforeEach(async function () { + secondBonusToken = await deployContract('MockERC20', [admin.address, mockTokenSupply]) + }) + it('should succeed', async function () { + await geyser.connect(admin).registerBonusToken(secondBonusToken.address) + }) + it('should update state', async function () { + await geyser.connect(admin).registerBonusToken(secondBonusToken.address) + expect(await geyser.getBonusTokenSetLength()).to.eq(2) + expect(await geyser.getBonusTokenAtIndex(0)).to.eq(bonusToken.address) + expect(await geyser.getBonusTokenAtIndex(1)).to.eq(secondBonusToken.address) + }) + it('should emit event', async function () { + await expect(geyser.connect(admin).registerBonusToken(secondBonusToken.address)) + .to.emit(geyser, 'BonusTokenRegistered') + .withArgs(secondBonusToken.address) + }) + }) + }) + }) + describe('when offline', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).powerOff() + await expect(geyser.connect(admin).registerBonusToken(bonusToken.address)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('when shutdown', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).emergencyShutdown() + await expect(geyser.connect(admin).registerBonusToken(bonusToken.address)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + }) + + describe('rescueTokensFromRewardPool', function () { + let otherToken: Contract + beforeEach(async function () { + otherToken = await deployContract('MockERC20', [admin.address, mockTokenSupply]) + await otherToken.connect(admin).transfer(rewardPool.address, mockTokenSupply) + await geyser.connect(admin).registerBonusToken(bonusToken.address) + }) + describe('as user', function () { + it('should fail', async function () { + await expect( + geyser.connect(user).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply), + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + }) + describe('with reward token', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(rewardToken.address, admin.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with bonus token', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(bonusToken.address, admin.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with staking token', function () { + beforeEach(async function () { + await stakingToken.connect(admin).transfer(rewardPool.address, mockTokenSupply) + }) + it('should succeed', async function () { + await geyser.connect(admin).rescueTokensFromRewardPool(stakingToken.address, admin.address, mockTokenSupply) + }) + it('should transfer tokens', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(stakingToken.address, admin.address, mockTokenSupply), + ) + .to.emit(stakingToken, 'Transfer') + .withArgs(rewardPool.address, admin.address, mockTokenSupply) + }) + }) + describe('with geyser as recipient', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, geyser.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with staking token as recipient', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, stakingToken.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with reward token as recipient', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, rewardToken.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with rewardPool as recipient', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, rewardPool.address, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with address 0 as recipient', function () { + it('should fail', async function () { + await expect( + geyser + .connect(admin) + .rescueTokensFromRewardPool(otherToken.address, ethers.constants.AddressZero, mockTokenSupply), + ).to.be.revertedWith('Aludel: invalid address') + }) + }) + describe('with other address as recipient', function () { + it('should succeed', async function () { + await geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, user.address, mockTokenSupply) + }) + it('should transfer tokens', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, user.address, mockTokenSupply), + ) + .to.emit(otherToken, 'Transfer') + .withArgs(rewardPool.address, user.address, mockTokenSupply) + }) + }) + describe('with zero amount', function () { + it('should succeed', async function () { + await geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, 0) + }) + it('should transfer tokens', async function () { + await expect(geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, 0)) + .to.emit(otherToken, 'Transfer') + .withArgs(rewardPool.address, admin.address, 0) + }) + }) + describe('with partial amount', function () { + it('should succeed', async function () { + await geyser + .connect(admin) + .rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply.div(2)) + }) + it('should transfer tokens', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply.div(2)), + ) + .to.emit(otherToken, 'Transfer') + .withArgs(rewardPool.address, admin.address, mockTokenSupply.div(2)) + }) + }) + describe('with full amount', function () { + it('should succeed', async function () { + await geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply) + }) + it('should transfer tokens', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply), + ) + .to.emit(otherToken, 'Transfer') + .withArgs(rewardPool.address, admin.address, mockTokenSupply) + }) + }) + describe('with excess amount', function () { + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply.mul(2)), + ).to.be.revertedWith('') + }) + }) + describe('when online', function () { + it('should succeed', async function () { + await geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply) + }) + it('should transfer tokens', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply), + ) + .to.emit(otherToken, 'Transfer') + .withArgs(rewardPool.address, admin.address, mockTokenSupply) + }) + }) + describe('when offline', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).powerOff() + }) + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply), + ).to.be.revertedWith('Powered: is not online') + }) + }) + describe('when shutdown', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).emergencyShutdown() + }) + it('should fail', async function () { + await expect( + geyser.connect(admin).rescueTokensFromRewardPool(otherToken.address, admin.address, mockTokenSupply), + ).to.be.revertedWith('Powered: is not online') + }) + }) + }) + }) + + describe('user functions', function () { + let geyser: Contract, powerSwitch: Contract, rewardPool: Contract + beforeEach(async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + [rewardScaling.floor, rewardScaling.ceiling, rewardScaling.time], + ] + geyser = await deployAludel(args) + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + powerSwitch = await ethers.getContractAt('PowerSwitch', await geyser.getPowerSwitch()) + rewardPool = await ethers.getContractAt('RewardPool', (await geyser.getAludelData()).rewardPool) + }) + + describe('stake', function () { + const stakeAmount = mockTokenSupply.div(100) + let vault: Contract + + beforeEach(async function () { + vault = await createInstance('Crucible', vaultFactory, user) + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + }) + describe('when offline', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).powerOff() + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('when shutdown', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).emergencyShutdown() + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('to invalid vault', function () { + it('should fail', async function () { + await geyser.connect(admin).removeVaultFactory(vaultFactory.address) + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)).to.be.revertedWith( + 'Aludel: vault is not registered', + ) + }) + }) + describe('with amount of zero', function () { + it('should fail', async function () { + await expect(stake(user, geyser, vault, stakingToken, '0')).to.be.revertedWith('Aludel: no amount staked') + }) + }) + describe('with insufficient balance', function () { + it('should fail', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount.mul(2))).to.be.revertedWith( + 'UniversalVault: insufficient balance', + ) + }) + }) + describe('when not funded', function () { + it('should succeed', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + }) + }) + describe('when funded', function () { + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, amplInitialSupply) + await geyser.connect(admin).fund(amplInitialSupply, YEAR) + }) + describe('on first stake', function () { + describe('as vault owner', function () { + it('should succeed', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.totalStake).to.eq(stakeAmount) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + + expect(vaultData.totalStake).to.eq(stakeAmount) + expect(vaultData.stakes.length).to.eq(1) + expect(vaultData.stakes[0].amount).to.eq(stakeAmount) + expect(vaultData.stakes[0].timestamp).to.eq(await getTimestamp()) + }) + it('should emit event', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(geyser, 'Staked') + .withArgs(vault.address, stakeAmount) + }) + it('should lock tokens', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Locked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + }) + describe('on second stake', function () { + beforeEach(async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(2)) + }) + it('should succeed', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(2)) + }) + it('should update state', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(2)) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.totalStake).to.eq(stakeAmount) + expect(geyserData.totalStakeUnits).to.eq(stakeAmount.div(2)) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + + expect(vaultData.totalStake).to.eq(stakeAmount) + expect(vaultData.stakes.length).to.eq(2) + expect(vaultData.stakes[0].amount).to.eq(stakeAmount.div(2)) + expect(vaultData.stakes[0].timestamp).to.eq((await getTimestamp()) - 1) + expect(vaultData.stakes[1].amount).to.eq(stakeAmount.div(2)) + expect(vaultData.stakes[1].timestamp).to.eq(await getTimestamp()) + }) + it('should emit event', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount.div(2))) + .to.emit(geyser, 'Staked') + .withArgs(vault.address, stakeAmount.div(2)) + }) + it('should lock tokens', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount.div(2))) + .to.emit(vault, 'Locked') + .withArgs(geyser.address, stakingToken.address, stakeAmount.div(2)) + }) + }) + describe('when MAX_STAKES_PER_VAULT reached', function () { + let quantity: number + beforeEach(async function () { + quantity = (await geyser.MAX_STAKES_PER_VAULT()).toNumber() + for (let index = 0; index < quantity; index++) { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(quantity)) + } + }) + it('should fail', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount.div(quantity))).to.be.revertedWith( + 'Aludel: MAX_STAKES_PER_VAULT reached', + ) + }) + }) + }) + describe('when stakes reset', function () { + beforeEach(async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should succeed', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.totalStake).to.eq(stakeAmount) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + + expect(vaultData.totalStake).to.eq(stakeAmount) + expect(vaultData.stakes.length).to.eq(1) + expect(vaultData.stakes[0].amount).to.eq(stakeAmount) + expect(vaultData.stakes[0].timestamp).to.eq(await getTimestamp()) + }) + it('should emit event', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(geyser, 'Staked') + .withArgs(vault.address, stakeAmount) + }) + it('should lock tokens', async function () { + await expect(stake(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Locked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + }) + + describe('unstake', function () { + const stakeAmount = ethers.utils.parseEther('100') + const rewardAmount = ethers.utils.parseUnits('1000', 9) + + describe('with default config', function () { + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + }) + describe('when offline', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).powerOff() + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('when shutdown', function () { + it('should fail', async function () { + await powerSwitch.connect(admin).emergencyShutdown() + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + }) + describe('with invalid vault', function () { + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + }) + describe('with permissioned not signed by owner', function () { + it('should fail', async function () { + await expect( + unstakeAndClaim(Wallet.createRandom().connect(ethers.provider), geyser, vault, stakingToken, stakeAmount), + ).to.be.revertedWith('ERC1271: Invalid signature') + }) + }) + describe('with amount of zero', function () { + it('should fail', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, 0)).to.be.revertedWith( + 'Aludel: no amount unstaked', + ) + }) + }) + describe('with amount greater than stakes', function () { + it('should fail', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.add(1))).to.be.revertedWith( + 'Aludel: insufficient vault stake', + ) + }) + }) + }) + describe('with fully vested stake', function () { + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, rewardAmount) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, rewardAmount) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with partially vested stake', function () { + const stakeDuration = rewardScaling.time / 2 + const expectedReward = calculateExpectedReward(stakeAmount, stakeDuration, rewardAmount, 0) + + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(stakeDuration) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with floor and ceiling scaled up', function () { + const stakeDuration = rewardScaling.time / 2 + const expectedReward = calculateExpectedReward(stakeAmount, stakeDuration, rewardAmount, 0) + + let vault: Contract + beforeEach(async function () { + const args = [ + admin.address, + rewardPoolFactory.address, + powerSwitchFactory.address, + stakingToken.address, + rewardToken.address, + + [rewardScaling.floor * 2, rewardScaling.ceiling * 2, rewardScaling.time], + ] + geyser = await deployAludel(args) + await geyser.connect(admin).registerVaultFactory(vaultFactory.address) + powerSwitch = await ethers.getContractAt('PowerSwitch', await geyser.getPowerSwitch()) + rewardPool = await ethers.getContractAt('RewardPool', (await geyser.getAludelData()).rewardPool) + + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(stakeDuration) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with no reward', function () { + let vault: Contract + beforeEach(async function () { + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with partially vested reward', function () { + const expectedReward = calculateExpectedReward(stakeAmount, rewardScaling.time, rewardAmount.div(2), 0) + + let vault: Contract + beforeEach(async function () { + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time / 2) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with flash stake', function () { + let vault: Contract, MockStakeHelper: Contract + + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + MockStakeHelper = await deployContract('MockStakeHelper') + }) + it('should succeed', async function () { + await MockStakeHelper.flashStake( + geyser.address, + vault.address, + stakeAmount, + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + stakeAmount, + (await vault.getNonce()).add(1), + ), + ) + }) + it('should update state', async function () { + await MockStakeHelper.flashStake( + geyser.address, + vault.address, + stakeAmount, + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + stakeAmount, + (await vault.getNonce()).add(1), + ), + ) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = MockStakeHelper.flashStake( + geyser.address, + vault.address, + stakeAmount, + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + stakeAmount, + (await vault.getNonce()).add(1), + ), + ) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + }) + it('should lock tokens', async function () { + await expect( + MockStakeHelper.flashStake( + geyser.address, + vault.address, + stakeAmount, + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + stakeAmount, + (await vault.getNonce()).add(1), + ), + ), + ) + .to.emit(vault, 'Locked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + it('should unlock tokens', async function () { + await expect( + MockStakeHelper.flashStake( + geyser.address, + vault.address, + stakeAmount, + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + await signPermission( + 'Unlock', + vault, + user, + geyser.address, + stakingToken.address, + stakeAmount, + (await vault.getNonce()).add(1), + ), + ), + ) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with one second stake', function () { + const stakeDuration = 1 + const expectedReward = calculateExpectedReward(stakeAmount, stakeDuration, rewardAmount, 0) + + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await network.provider.request({ + method: 'evm_increaseTime', + params: [1], + }) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with partial amount from single stake', function () { + const expectedReward = calculateExpectedReward( + stakeAmount.div(2), + rewardScaling.time, + rewardAmount, + stakeAmount.div(2).mul(rewardScaling.time), + ) + + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.div(2)) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.div(2)) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(stakeAmount.div(2)) + expect(geyserData.totalStakeUnits).to.eq(stakeAmount.div(2).mul(rewardScaling.time)) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(stakeAmount.div(2)) + expect(vaultData.stakes.length).to.eq(1) + expect(vaultData.stakes[0].amount).to.eq(stakeAmount.div(2)) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.div(2)) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount.div(2)) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.div(2))) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount.div(2))) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount.div(2)) + }) + }) + describe('with partial amount from multiple stakes', function () { + const currentStake = ethers.utils.parseEther('99') + const unstakedAmount = currentStake.div(2) + const expectedReward = calculateExpectedReward( + unstakedAmount, + rewardScaling.time, + rewardAmount, + currentStake.div(2).mul(rewardScaling.time), + ).sub(1) // account for division dust + const quantity = 3 + + let vault: Contract + beforeEach(async function () { + // fund geyser + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + // deploy vault and transfer stake + vault = await createInstance('Crucible', vaultFactory, user) + await stakingToken.connect(admin).transfer(vault.address, currentStake) + + // perform multiple stakes in same block + const permissions = [] + for (let index = 0; index < quantity; index++) { + permissions.push( + await signPermission( + 'Lock', + vault, + user, + geyser.address, + stakingToken.address, + currentStake.div(quantity), + index, + ), + ) + } + const MockStakeHelper = await deployContract('MockStakeHelper') + await MockStakeHelper.stakeBatch( + new Array(quantity).fill(undefined).map(() => geyser.address), + new Array(quantity).fill(undefined).map(() => vault.address), + new Array(quantity).fill(undefined).map(() => currentStake.div(quantity)), + permissions, + ) + + // increase time to the end of reward scaling + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(currentStake.sub(unstakedAmount)) + expect(geyserData.totalStakeUnits).to.eq(currentStake.sub(unstakedAmount).mul(rewardScaling.time)) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(currentStake.sub(unstakedAmount)) + expect(vaultData.stakes.length).to.eq(2) + expect(vaultData.stakes[0].amount).to.eq(currentStake.div(3)) + expect(vaultData.stakes[1].amount).to.eq(currentStake.div(6)) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, unstakedAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, unstakedAmount) + }) + }) + describe('with full amount of the last of multiple stakes', function () { + const currentStake = ethers.utils.parseEther('99') + const unstakedAmount = currentStake.div(3) + const expectedReward = calculateExpectedReward( + unstakedAmount, + rewardScaling.time, + rewardAmount, + currentStake.sub(unstakedAmount).mul(rewardScaling.time), + ) + + const quantity = 3 + + let vault: Contract + beforeEach(async function () { + // fund geyser + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + // deploy vault and transfer stake + vault = await createInstance('Crucible', vaultFactory, user) + await stakingToken.connect(admin).transfer(vault.address, currentStake) + + // perform multiple stakes in same block + const permissions = [] + for (let index = 0; index < quantity; index++) { + permissions.push( + await signPermission( + 'Lock', + vault, + user, + geyser.address, + stakingToken.address, + currentStake.div(quantity), + index, + ), + ) + } + const MockStakeHelper = await deployContract('MockStakeHelper') + await MockStakeHelper.stakeBatch( + new Array(quantity).fill(geyser.address), + new Array(quantity).fill(vault.address), + new Array(quantity).fill(currentStake.div(quantity)), + permissions, + ) + + // increase time to the end of reward scaling + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(currentStake.sub(unstakedAmount)) + expect(geyserData.totalStakeUnits).to.eq(currentStake.sub(unstakedAmount).mul(rewardScaling.time)) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(currentStake.sub(unstakedAmount)) + expect(vaultData.stakes.length).to.eq(2) + expect(vaultData.stakes[0].amount).to.eq(currentStake.div(3)) + expect(vaultData.stakes[1].amount).to.eq(currentStake.div(3)) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, unstakedAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, unstakedAmount) + }) + }) + describe('with full amount of multiple stakes', function () { + const currentStake = ethers.utils.parseEther('99') + const unstakedAmount = currentStake + const expectedReward = calculateExpectedReward(unstakedAmount, rewardScaling.time, rewardAmount, 0) + const quantity = 3 + + let vault: Contract + beforeEach(async function () { + // fund geyser + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + // deploy vault and transfer stake + vault = await createInstance('Crucible', vaultFactory, user) + await stakingToken.connect(admin).transfer(vault.address, currentStake) + + // perform multiple stakes in same block + const permissions = [] + for (let index = 0; index < quantity; index++) { + permissions.push( + await signPermission( + 'Lock', + vault, + user, + geyser.address, + stakingToken.address, + currentStake.div(quantity), + index, + ), + ) + } + const MockStakeHelper = await deployContract('MockStakeHelper') + await MockStakeHelper.stakeBatch( + new Array(quantity).fill(geyser.address), + new Array(quantity).fill(vault.address), + new Array(quantity).fill(currentStake.div(quantity)), + permissions, + ) + + // increase time to the end of reward scaling + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, unstakedAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, expectedReward) + }) + it('should transfer tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, unstakedAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, unstakedAmount) + }) + }) + describe('when one bonus token', function () { + let vault: Contract + beforeEach(async function () { + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + await geyser.connect(admin).registerBonusToken(bonusToken.address) + + vault = await createInstance('Crucible', vaultFactory, user) + + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + }) + describe('with no bonus token balance', function () { + beforeEach(async function () { + await stake(user, geyser, vault, stakingToken, stakeAmount) + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, rewardAmount) + }) + it('should transfer tokens', async function () { + const txPromise = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(txPromise) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, rewardAmount) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with fully vested stake', function () { + beforeEach(async function () { + await bonusToken.connect(admin).transfer(rewardPool.address, mockTokenSupply) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, rewardToken.address, rewardAmount) + await expect(tx) + .to.emit(geyser, 'RewardClaimed') + .withArgs(vault.address, bonusToken.address, mockTokenSupply) + }) + it('should transfer tokens', async function () { + const txPromise = unstakeAndClaim( + user, + + geyser, + vault, + stakingToken, + stakeAmount, + ) + await expect(txPromise) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, rewardAmount) + await expect(txPromise) + .to.emit(bonusToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, mockTokenSupply) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + describe('with partially vested stake', function () { + const stakeDuration = rewardScaling.time / 2 + const expectedReward = calculateExpectedReward(stakeAmount, stakeDuration, rewardAmount, 0) + const expectedBonus = calculateExpectedReward(stakeAmount, stakeDuration, mockTokenSupply, 0) + beforeEach(async function () { + await bonusToken.connect(admin).transfer(rewardPool.address, mockTokenSupply) + + await stake(user, geyser, vault, stakingToken, stakeAmount) + + await increaseTime(stakeDuration) + }) + it('should succeed', async function () { + await unstakeAndClaim( + user, + + geyser, + vault, + stakingToken, + stakeAmount, + ) + }) + it('should update state', async function () { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.sub(expectedReward).mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + it('should emit event', async function () { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx) + .to.emit(geyser, 'RewardClaimed') + .withArgs(vault.address, rewardToken.address, expectedReward) + await expect(tx).to.emit(geyser, 'RewardClaimed').withArgs(vault.address, bonusToken.address, expectedBonus) + }) + it('should transfer tokens', async function () { + const txPromise = unstakeAndClaim( + user, + + geyser, + vault, + stakingToken, + stakeAmount, + ) + await expect(txPromise) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedReward) + await expect(txPromise) + .to.emit(bonusToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, expectedBonus) + }) + it('should unlock tokens', async function () { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + }) + }) + }) + describe('with multiple vaults', function () { + const stakeAmount = ethers.utils.parseEther('1') + const rewardAmount = ethers.utils.parseUnits('1000', 9) + const quantity = 10 + + let vaults: Array + beforeEach(async function () { + // fund geyser + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + await increaseTime(rewardScaling.time) + + // create vaults + vaults = [] + const permissions = [] + for (let index = 0; index < quantity; index++) { + const vault = await createInstance('Crucible', vaultFactory, user) + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + + vaults.push(vault) + + permissions.push( + await signPermission('Lock', vault, user, geyser.address, stakingToken.address, stakeAmount), + ) + } + + // stake in same block + const MockStakeHelper = await deployContract('MockStakeHelper') + await MockStakeHelper.stakeBatch( + new Array(quantity).fill(geyser.address), + vaults.map((vault) => vault.address), + new Array(quantity).fill(stakeAmount), + permissions, + ) + + // increase time to end of reward scaling + await increaseTime(rewardScaling.time) + }) + it('should succeed', async function () { + for (const vault of vaults) { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + } + }) + it('should update state', async function () { + for (const vault of vaults) { + await unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + } + + const geyserData = await geyser.getAludelData() + + expect(geyserData.rewardSharesOutstanding).to.eq(0) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + }) + it('should emit event', async function () { + for (const vault of vaults) { + const tx = unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount) + await expect(tx).to.emit(geyser, 'Unstaked').withArgs(vault.address, stakeAmount) + await expect(tx) + .to.emit(geyser, 'RewardClaimed') + .withArgs(vault.address, rewardToken.address, rewardAmount.div(quantity)) + } + }) + it('should transfer tokens', async function () { + for (const vault of vaults) { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(rewardToken, 'Transfer') + .withArgs(rewardPool.address, vault.address, rewardAmount.div(quantity)) + } + }) + it('should unlock tokens', async function () { + for (const vault of vaults) { + await expect(unstakeAndClaim(user, geyser, vault, stakingToken, stakeAmount)) + .to.emit(vault, 'Unlocked') + .withArgs(geyser.address, stakingToken.address, stakeAmount) + } + }) + }) + }) + + describe('rageQuit', function () { + const stakeAmount = ethers.utils.parseEther('100') + const rewardAmount = ethers.utils.parseUnits('1000', 9) + const gasLimit = 600_000 + + let vault: Contract + beforeEach(async function () { + // fund geyser + await rewardToken.connect(admin).approve(geyser.address, rewardAmount) + await geyser.connect(admin).fund(rewardAmount, rewardScaling.time) + + // create vault + vault = await createInstance('Crucible', vaultFactory, user) + + // stake + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + await stake(user, geyser, vault, stakingToken, stakeAmount) + }) + describe('when online', function () { + it('should succeed', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + }) + it('should update state', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + }) + describe('when offline', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).powerOff() + }) + it('should succeed', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + }) + it('should update state', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + }) + describe('when shutdown', function () { + beforeEach(async function () { + await powerSwitch.connect(admin).emergencyShutdown() + }) + it('should succeed', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + }) + it('should update state', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + }) + describe('with unknown vault', function () { + it('should fail', async function () { + await expect( + geyser.connect(user).rageQuit({ + gasLimit, + }), + ).to.be.revertedWith('Aludel: no stake') + }) + }) + describe('when no stake', function () { + it('should fail', async function () { + const secondVault = await createInstance('Crucible', vaultFactory, user) + await expect( + secondVault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }), + ).to.be.revertedWith('UniversalVault: missing lock') + }) + }) + describe('when insufficient gas', function () { + it('should fail', async function () { + await expect( + vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit: await vault.RAGEQUIT_GAS(), + }), + ).to.be.revertedWith('UniversalVault: insufficient gas') + }) + }) + describe('when insufficient gas with multiple stakes', function () { + let quantity: number + beforeEach(async function () { + quantity = (await geyser.MAX_STAKES_PER_VAULT()).toNumber() - 1 + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + for (let index = 0; index < quantity; index++) { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(quantity)) + } + }) + it('should fail', async function () { + await expect( + vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit: await vault.RAGEQUIT_GAS(), + }), + ).to.be.revertedWith('UniversalVault: insufficient gas') + }) + }) + describe('when single stake', function () { + it('should succeed', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + }) + it('should update state', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + }) + describe('when multiple stakes', function () { + let quantity: number + + beforeEach(async function () { + quantity = (await geyser.MAX_STAKES_PER_VAULT()).toNumber() - 1 + await stakingToken.connect(admin).transfer(vault.address, stakeAmount) + for (let index = 0; index < quantity; index++) { + await stake(user, geyser, vault, stakingToken, stakeAmount.div(quantity)) + } + }) + it('should succeed', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + }) + it('should update state', async function () { + await vault.connect(user).rageQuit(geyser.address, stakingToken.address, { + gasLimit, + }) + + const geyserData = await geyser.getAludelData() + const vaultData = await geyser.getVaultData(vault.address) + + expect(geyserData.rewardSharesOutstanding).to.eq(rewardAmount.mul(BASE_SHARES_PER_WEI)) + expect(geyserData.totalStake).to.eq(0) + expect(geyserData.totalStakeUnits).to.eq(0) + expect(geyserData.lastUpdate).to.eq(await getTimestamp()) + expect(vaultData.totalStake).to.eq(0) + expect(vaultData.stakes.length).to.eq(0) + }) + }) + }) + }) +}) diff --git a/test/PowerSwitch/PowerSwitch.ts b/test/PowerSwitch/PowerSwitch.ts new file mode 100644 index 0000000..cd85e58 --- /dev/null +++ b/test/PowerSwitch/PowerSwitch.ts @@ -0,0 +1,103 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract } from 'ethers' +import { ethers } from 'hardhat' +import { deployContract } from '../utils' + +describe('PowerSwitch', function () { + let accounts: SignerWithAddress[] + let Mock: Contract + + beforeEach(async function () { + // prepare signers + accounts = await ethers.getSigners() + // deploy mock + Mock = await deployContract('PowerSwitch', [accounts[0].address]) + }) + + describe('powerOn', function () { + it('should fail if msg.sender is not admin', async function () { + expect(await Mock.getPowerController()).to.eq(accounts[0].address) + await Mock.connect(accounts[0]).powerOff() + await expect(Mock.connect(accounts[1]).powerOn()).to.be.revertedWith('Ownable: caller is not the owner') + }) + it('should succeed if in offline state', async function () { + await Mock.connect(accounts[0]).powerOff() + await Mock.connect(accounts[0]).powerOn() + }) + it('should fail if in online state', async function () { + await expect(Mock.connect(accounts[0]).powerOn()).to.be.revertedWith('PowerSwitch: cannot power on') + }) + it('should fail if in shutdown state', async function () { + await Mock.connect(accounts[0]).emergencyShutdown() + await expect(Mock.connect(accounts[0]).powerOn()).to.be.revertedWith('PowerSwitch: cannot power on') + }) + it('should succeed and emit event', async function () { + await Mock.connect(accounts[0]).powerOff() + const txPromise = Mock.connect(accounts[0]).powerOn() + // validate event + await expect(txPromise).to.emit(Mock, 'PowerOn') + // validate state + expect(await Mock.isOnline()).to.eq(true) + expect(await Mock.isOffline()).to.eq(false) + expect(await Mock.isShutdown()).to.eq(false) + expect(await Mock.getStatus()).to.eq(0) + }) + }) + + describe('powerOff', function () { + it('should fail if msg.sender is not admin', async function () { + expect(await Mock.getPowerController()).to.eq(accounts[0].address) + await expect(Mock.connect(accounts[1]).powerOff()).to.be.revertedWith('Ownable: caller is not the owner') + }) + it('should succeed if in online state', async function () { + await Mock.connect(accounts[0]).powerOff() + }) + it('should fail if in offline state', async function () { + await Mock.connect(accounts[0]).powerOff() + await expect(Mock.connect(accounts[0]).powerOff()).to.be.revertedWith('PowerSwitch: cannot power off') + }) + it('should fail if in shutdown state', async function () { + await Mock.connect(accounts[0]).emergencyShutdown() + await expect(Mock.connect(accounts[0]).powerOff()).to.be.revertedWith('PowerSwitch: cannot power off') + }) + it('should succeed and emit event', async function () { + const txPromise = Mock.connect(accounts[0]).powerOff() + // validate event + await expect(txPromise).to.emit(Mock, 'PowerOff') + // validate state + expect(await Mock.isOnline()).to.eq(false) + expect(await Mock.isOffline()).to.eq(true) + expect(await Mock.isShutdown()).to.eq(false) + expect(await Mock.getStatus()).to.eq(1) + }) + }) + + describe('emergencyShutdown', function () { + it('should fail if msg.sender is not admin', async function () { + expect(await Mock.getPowerController()).to.eq(accounts[0].address) + await expect(Mock.connect(accounts[1]).emergencyShutdown()).to.be.revertedWith('Ownable: caller is not the owner') + }) + it('should succeed if in online state', async function () { + await Mock.connect(accounts[0]).emergencyShutdown() + }) + it('should succeed if in offline state', async function () { + await Mock.connect(accounts[0]).powerOff() + await Mock.connect(accounts[0]).emergencyShutdown() + }) + it('should fail if in shutdown state', async function () { + await Mock.connect(accounts[0]).emergencyShutdown() + await expect(Mock.connect(accounts[0]).emergencyShutdown()).to.be.revertedWith('PowerSwitch: cannot shutdown') + }) + it('should succeed and emit event', async function () { + const txPromise = Mock.connect(accounts[0]).emergencyShutdown() + // validate event + await expect(txPromise).to.emit(Mock, 'EmergencyShutdown') + // validate state + expect(await Mock.isOnline()).to.eq(false) + expect(await Mock.isOffline()).to.eq(false) + expect(await Mock.isShutdown()).to.eq(true) + expect(await Mock.getStatus()).to.eq(2) + }) + }) +}) diff --git a/test/PowerSwitch/Powered.ts b/test/PowerSwitch/Powered.ts new file mode 100644 index 0000000..42b7f4c --- /dev/null +++ b/test/PowerSwitch/Powered.ts @@ -0,0 +1,99 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract } from 'ethers' +import { ethers } from 'hardhat' +import { deployContract } from '../utils' + +describe('Powered', function () { + let accounts: SignerWithAddress[] + let PowerSwitch: Contract + let Mock: Contract + + beforeEach(async function () { + // prepare signers + accounts = await ethers.getSigners() + // deploy mock + PowerSwitch = await deployContract('PowerSwitch', [accounts[0].address]) + Mock = await deployContract('MockPowered', [PowerSwitch.address]) + }) + + describe('getPowerSwitch', function () { + it('should succeed', async function () { + expect(await Mock.getPowerSwitch()).to.eq(PowerSwitch.address) + }) + }) + + describe('getPowerController', function () { + it('should succeed', async function () { + expect(await Mock.getPowerController()).to.eq(accounts[0].address) + }) + }) + + describe('onlyOnline', function () { + it('should succeed if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + await Mock.onlyOnlineCall() + }) + it('should fail if offline', async function () { + await PowerSwitch.connect(accounts[0]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await expect(Mock.onlyOnlineCall()).to.be.revertedWith('Powered: is not online') + }) + it('should fail if shutdown', async function () { + await PowerSwitch.connect(accounts[0]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await expect(Mock.onlyOnlineCall()).to.be.revertedWith('Powered: is not online') + }) + }) + + describe('onlyOffline', function () { + it('should fail if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + await expect(Mock.onlyOfflineCall()).to.be.revertedWith('Powered: is not offline') + }) + it('should succeed if offline', async function () { + await PowerSwitch.connect(accounts[0]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await Mock.onlyOfflineCall() + }) + it('should fail if shutdown', async function () { + await PowerSwitch.connect(accounts[0]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await expect(Mock.onlyOfflineCall()).to.be.revertedWith('Powered: is not offline') + }) + }) + + describe('notShutdown', function () { + it('should succeed if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + await Mock.notShutdownCall() + }) + it('should succeed if offline', async function () { + await PowerSwitch.connect(accounts[0]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await Mock.notShutdownCall() + }) + it('should fail if shutdown', async function () { + await PowerSwitch.connect(accounts[0]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await expect(Mock.notShutdownCall()).to.be.revertedWith('Powered: is shutdown') + }) + }) + + describe('onlyShutdown', function () { + it('should fail if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + await expect(Mock.onlyShutdownCall()).to.be.revertedWith('Powered: is not shutdown') + }) + it('should fail if offline', async function () { + await PowerSwitch.connect(accounts[0]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await expect(Mock.onlyShutdownCall()).to.be.revertedWith('Powered: is not shutdown') + }) + it('should succeed if shutdown', async function () { + await PowerSwitch.connect(accounts[0]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await Mock.onlyShutdownCall() + }) + }) +}) diff --git a/test/RewardPool.ts b/test/RewardPool.ts new file mode 100644 index 0000000..1207998 --- /dev/null +++ b/test/RewardPool.ts @@ -0,0 +1,124 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract } from 'ethers' +import { ethers } from 'hardhat' +import { deployContract } from './utils' + +describe('RewardPool', function () { + let accounts: SignerWithAddress[] + let PowerSwitch: Contract + let ERC20: Contract + let Mock: Contract + const amount = ethers.utils.parseEther('10') + + beforeEach(async function () { + // prepare signers + accounts = await ethers.getSigners() + // deploy mock + PowerSwitch = await deployContract('PowerSwitch', [accounts[1].address]) + Mock = await deployContract('RewardPool', [PowerSwitch.address]) + ERC20 = await deployContract('MockERC20', [Mock.address, amount]) + }) + + describe('sendERC20', function () { + it('should succeed if msg.sender is admin', async function () { + await Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount) + }) + it('should fail if msg.sender is controller', async function () { + await expect(Mock.connect(accounts[1]).sendERC20(ERC20.address, accounts[0].address, amount)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + it('should succeed if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + await Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount) + }) + it('should fail if offline', async function () { + await PowerSwitch.connect(accounts[1]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await expect(Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + it('should fail if shutdown', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await expect(Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount)).to.be.revertedWith( + 'Powered: is not online', + ) + }) + it('should succeed with full balance', async function () { + let txPromise = Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount) + await expect(txPromise).to.emit(ERC20, 'Transfer').withArgs(Mock.address, accounts[0].address, amount) + }) + it('should succeed with partial balance', async function () { + let txPromise = Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, amount.div(2)) + await expect(txPromise).to.emit(ERC20, 'Transfer').withArgs(Mock.address, accounts[0].address, amount.div(2)) + }) + it('should succeed with no balance', async function () { + let txPromise = Mock.connect(accounts[0]).sendERC20(ERC20.address, accounts[0].address, '0') + await expect(txPromise).to.emit(ERC20, 'Transfer').withArgs(Mock.address, accounts[0].address, '0') + }) + }) + + describe('rescueERC20', function () { + it('should fail if msg.sender is admin', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + await expect(Mock.connect(accounts[0]).rescueERC20([ERC20.address], accounts[0].address)).to.be.revertedWith( + 'RewardPool: only controller can withdraw after shutdown', + ) + }) + it('should succeed if msg.sender is controller', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + await Mock.connect(accounts[1]).rescueERC20([ERC20.address], accounts[0].address) + }) + it('should fail if online', async function () { + expect(await Mock.isOnline()).to.eq(true) + expect(await PowerSwitch.isOnline()).to.eq(true) + await expect(Mock.connect(accounts[1]).rescueERC20([ERC20.address], accounts[0].address)).to.be.revertedWith( + 'Powered: is not shutdown', + ) + }) + it('should fail if offline', async function () { + await PowerSwitch.connect(accounts[1]).powerOff() + expect(await Mock.isOffline()).to.eq(true) + await expect(Mock.connect(accounts[1]).rescueERC20([ERC20.address], accounts[0].address)).to.be.revertedWith( + 'Powered: is not shutdown', + ) + }) + it('should succeed if shutdown', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await Mock.connect(accounts[1]).rescueERC20([ERC20.address], accounts[0].address) + }) + it('should fail if recipient is not defined', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + await expect( + Mock.connect(accounts[1]).rescueERC20([ERC20.address], ethers.constants.AddressZero), + ).to.be.revertedWith('RewardPool: recipient not defined') + }) + it('should succeed with single token', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + let txPromise = Mock.connect(accounts[1]).rescueERC20([ERC20.address], accounts[0].address) + await expect(txPromise).to.emit(ERC20, 'Transfer').withArgs(Mock.address, accounts[0].address, amount) + }) + it('should succeed with 100 tokens', async function () { + await PowerSwitch.connect(accounts[1]).emergencyShutdown() + expect(await Mock.isShutdown()).to.eq(true) + let num = 100 + let tokens = [] + for (let index = 0; index < num; index++) { + tokens.push(await deployContract('MockERC20', [Mock.address, amount])) + } + let txPromise = Mock.connect(accounts[1]).rescueERC20( + tokens.map((token) => token.address), + accounts[0].address, + ) + for (let index = 0; index < num; index++) { + await expect(txPromise).to.emit(tokens[index], 'Transfer').withArgs(Mock.address, accounts[0].address, amount) + } + }) + }) +}) diff --git a/test/UniversalVault.ts b/test/UniversalVault.ts new file mode 100644 index 0000000..6865d7f --- /dev/null +++ b/test/UniversalVault.ts @@ -0,0 +1,613 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract, Wallet } from 'ethers' +import { ethers } from 'hardhat' +import { revertAfter } from './shared-before-each/revert-after' +import { sharedBeforeEach } from './shared-before-each/shared-before-each' +import { createInstance, deployContract, ETHER, signPermission } from './utils' + +enum DelegateType { + Succeed, + Revert, + RevertWithMessage, + OOG, +} + +describe('Crucible', function () { + let accounts: SignerWithAddress[], admin: SignerWithAddress, recipient: SignerWithAddress, delegate: SignerWithAddress + let owner: Wallet + let factory: Contract, vault: Contract + + revertAfter(); + + before(async function () { + // prepare signers + accounts = await ethers.getSigners() + admin = accounts[0] + recipient = accounts[1] + delegate = accounts[2] + owner = Wallet.createRandom().connect(ethers.provider) + await delegate.sendTransaction({ + to: owner.address, + value: (await delegate.getBalance()).mul(9).div(10), + }) + }) + + sharedBeforeEach(async function () { + // deploy template + const template = await deployContract('Crucible') + + // deploy factory + factory = await deployContract('CrucibleFactory', [template.address]) + + // deploy instance + vault = await createInstance('Crucible', factory, owner) + }); + + describe('nft', function () { + it('should succeed', async function () { + expect(await vault.nft()).to.eq(factory.address) + }) + }) + + describe('getNonce', function () { + it('should succeed', async function () { + expect(await vault.getNonce()).to.eq(0) + }) + }) + + describe('owner', function () { + it('should succeed', async function () { + expect(await vault.owner()).to.eq(owner.address) + }) + }) + + describe('getLockSetCount', function () { + it('should succeed', async function () { + expect(await vault.getLockSetCount()).to.eq(0) + }) + }) + + describe('getLockAt', function () { + it('should fail when no locks', async function () { + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + }) + }) + + describe('getBalanceDelegated', function () { + it('should succeed', async function () { + expect(await vault.getBalanceDelegated(ethers.constants.AddressZero, ethers.constants.AddressZero)).to.deep.eq(0) + }) + }) + + describe('getBalanceLocked', function () { + it('should succeed', async function () { + expect(await vault.getBalanceLocked(ethers.constants.AddressZero)).to.deep.eq(0) + }) + }) + + describe('checkBalances', function () { + it('should succeed', async function () { + expect(await vault.checkBalances()).to.be.true + }) + }) + + describe('lock', function () { + let ERC20: Contract + const totalSupply = ethers.utils.parseEther('10') + beforeEach(async function () { + ERC20 = await deployContract('MockERC20', [owner.address, totalSupply]) + await ERC20.connect(owner).transfer(vault.address, ETHER) + }) + describe('with incorrect permission', function () { + it('should fail when wrong signer', async function () { + const permission = await signPermission( + 'Lock', + vault, + Wallet.createRandom(), + delegate.address, + ERC20.address, + ETHER, + ) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong function signature', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong delegate', async function () { + const permission = await signPermission('Lock', vault, owner, recipient.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong token', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, recipient.address, ETHER) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong amount', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong nonce', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER, 10) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + }) + describe('with correct permission', function () { + it('should succeed', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + }) + it('should create lock if new delegate-token pair', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + + expect(await vault.getLockSetCount()).to.be.eq(1) + const lockData = await vault.getLockAt(0) + expect(lockData.delegate).to.be.eq(delegate.address) + expect(lockData.token).to.be.eq(ERC20.address) + expect(lockData.balance).to.be.eq(ETHER) + expect(await vault.getBalanceDelegated(ERC20.address, delegate.address)).to.be.eq(ETHER) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(ETHER) + }) + it('should update lock if existing delegate-token pair', async function () { + const permission1 = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await vault.connect(delegate).lock(ERC20.address, ETHER.div(2), permission1) + + const permission2 = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await vault.connect(delegate).lock(ERC20.address, ETHER.div(2), permission2) + + expect(await vault.getLockSetCount()).to.be.eq(1) + const lockData = await vault.getLockAt(0) + expect(lockData.delegate).to.be.eq(delegate.address) + expect(lockData.token).to.be.eq(ERC20.address) + expect(lockData.balance).to.be.eq(ETHER) + expect(await vault.getBalanceDelegated(ERC20.address, delegate.address)).to.be.eq(ETHER) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(ETHER) + }) + it('should fail if insufficient vault balance on new lock', async function () { + const permission1 = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission1) + + const permission2 = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER.div(2), permission2)).to.be.revertedWith( + 'UniversalVault: insufficient balance', + ) + }) + it('should fail if insufficient vault balance on existing lock', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.mul(2)) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER.mul(2), permission)).to.be.revertedWith( + 'UniversalVault: insufficient balance', + ) + }) + it('should bump nonce', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + + expect(await vault.getNonce()).to.be.eq(1) + }) + it('should emit event', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)) + .to.emit(vault, 'Locked') + .withArgs(delegate.address, ERC20.address, ETHER) + }) + }) + describe('when owner is ERC1271 compatible smart contract', function () { + let MockSmartWallet: Contract + beforeEach(async function () { + MockSmartWallet = await deployContract('MockSmartWallet', [owner.address]) + await factory.connect(owner).transferFrom(owner.address, MockSmartWallet.address, vault.address) + }) + describe('with valid wallet', function () { + it('should succeed', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + }) + it('should emit event', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)) + .to.emit(vault, 'Locked') + .withArgs(delegate.address, ERC20.address, ETHER) + }) + }) + describe('with invalid wallet', function () { + it('should fail', async function () { + const permission = await signPermission( + 'Lock', + vault, + Wallet.createRandom(), + delegate.address, + ERC20.address, + ETHER, + ) + await expect(vault.connect(delegate).lock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + }) + }) + }) + + describe('unlock', function () { + let ERC20: Contract + const totalSupply = ethers.utils.parseEther('10') + beforeEach(async function () { + ERC20 = await deployContract('MockERC20', [owner.address, totalSupply]) + await ERC20.connect(owner).transfer(vault.address, ETHER) + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + }) + describe('with incorrect permission', function () { + it('should fail when wrong signer', async function () { + const permission = await signPermission( + 'Unlock', + vault, + Wallet.createRandom(), + delegate.address, + ERC20.address, + ETHER, + ) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong function signature', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong delegate', async function () { + const permission = await signPermission('Unlock', vault, owner, recipient.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong token', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, recipient.address, ETHER) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong amount', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + it('should fail when wrong nonce', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER, 10) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + }) + describe('with correct permission', function () { + it('should succeed', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).unlock(ERC20.address, ETHER, permission) + }) + it('should fail if lock does not exist', async function () { + const permission = await signPermission('Unlock', vault, owner, recipient.address, ERC20.address, ETHER) + await expect(vault.connect(recipient).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'UniversalVault: missing lock', + ) + }) + it('should update lock balance if amount < balance', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await vault.connect(delegate).unlock(ERC20.address, ETHER.div(2), permission) + + expect(await vault.getLockSetCount()).to.be.eq(1) + const lockData = await vault.getLockAt(0) + expect(lockData.delegate).to.be.eq(delegate.address) + expect(lockData.token).to.be.eq(ERC20.address) + expect(lockData.balance).to.be.eq(ETHER.div(2)) + expect(await vault.getBalanceDelegated(ERC20.address, delegate.address)).to.be.eq(ETHER.div(2)) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(ETHER.div(2)) + }) + it('should delete lock if amount >= balance', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER.mul(2)) + await vault.connect(delegate).unlock(ERC20.address, ETHER.mul(2), permission) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, delegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should bump nonce', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).unlock(ERC20.address, ETHER, permission) + + expect(await vault.getNonce()).to.be.eq(2) + }) + it('should emit event', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)) + .to.emit(vault, 'Unlocked') + .withArgs(delegate.address, ERC20.address, ETHER) + }) + }) + describe('when owner is ERC1271 compatible smart contract', function () { + let MockSmartWallet: Contract + beforeEach(async function () { + MockSmartWallet = await deployContract('MockSmartWallet', [owner.address]) + await factory.connect(owner).transferFrom(owner.address, MockSmartWallet.address, vault.address) + }) + describe('with valid wallet', function () { + it('should succeed', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).unlock(ERC20.address, ETHER, permission) + }) + it('should emit event', async function () { + const permission = await signPermission('Unlock', vault, owner, delegate.address, ERC20.address, ETHER) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)) + .to.emit(vault, 'Unlocked') + .withArgs(delegate.address, ERC20.address, ETHER) + }) + }) + describe('with invalid signature', function () { + it('should fail', async function () { + const permission = await signPermission( + 'Unlock', + vault, + Wallet.createRandom(), + delegate.address, + ERC20.address, + ETHER, + ) + await expect(vault.connect(delegate).unlock(ERC20.address, ETHER, permission)).to.be.revertedWith( + 'ERC1271: Invalid signature', + ) + }) + }) + }) + }) + + describe('rageQuit', function () { + let ERC20: Contract, MockDelegate: Contract + const totalSupply = ethers.utils.parseEther('10') + beforeEach(async function () { + ERC20 = await deployContract('MockERC20', [owner.address, totalSupply]) + await ERC20.connect(owner).transfer(vault.address, ETHER) + MockDelegate = await deployContract('MockDelegate') + const permission = await signPermission('Lock', vault, owner, MockDelegate.address, ERC20.address, ETHER) + await MockDelegate.lock(vault.address, ERC20.address, ETHER, permission) + }) + describe('as non-owner', function () { + beforeEach(async function () { + await MockDelegate.setDelegateType(DelegateType.Succeed) + }) + it('should fail', async function () { + await expect(vault.connect(recipient).rageQuit(delegate.address, ERC20.address)).to.be.revertedWith( + 'OwnableERC721: caller is not the owner', + ) + }) + }) + describe('with insufficient gas forwarded', function () { + let gasLimit: number + beforeEach(async function () { + gasLimit = (await vault.RAGEQUIT_GAS()).toNumber() + await MockDelegate.setDelegateType(DelegateType.Succeed) + }) + it('should fail', async function () { + await expect( + vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address, { gasLimit }), + ).to.be.revertedWith('UniversalVault: insufficient gas') + }) + }) + describe('delegate with success', function () { + beforeEach(async function () { + await MockDelegate.setDelegateType(DelegateType.Succeed) + }) + it('should succeed', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + }) + it('should fail when lock does not exist', async function () { + await expect(vault.connect(owner).rageQuit(recipient.address, ERC20.address)).to.be.revertedWith( + 'UniversalVault: missing lock', + ) + }) + it('should delete lock data', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, MockDelegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should emit event', async function () { + await expect(vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address)) + .to.emit(vault, 'RageQuit') + .withArgs(MockDelegate.address, ERC20.address, true, '') + }) + it('should return data', async function () { + const returnData = await vault.connect(owner).callStatic.rageQuit(MockDelegate.address, ERC20.address) + + expect(returnData.notified).to.be.true + expect(returnData.error).to.be.eq('') + }) + }) + describe('delegate with revert', function () { + beforeEach(async function () { + await MockDelegate.setDelegateType(DelegateType.Revert) + }) + it('should succeed', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + }) + it('should delete lock data', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, MockDelegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should emit event', async function () { + await expect(vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address)) + .to.emit(vault, 'RageQuit') + .withArgs(MockDelegate.address, ERC20.address, false, '') + }) + it('should return data', async function () { + const returnData = await vault.connect(owner).callStatic.rageQuit(MockDelegate.address, ERC20.address) + + expect(returnData.notified).to.be.false + expect(returnData.error).to.be.eq('') + }) + }) + describe('delegate with revert message', function () { + beforeEach(async function () { + await MockDelegate.setDelegateType(DelegateType.RevertWithMessage) + }) + it('should succeed', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + }) + it('should delete lock data', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, MockDelegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should emit event', async function () { + await expect(vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address)) + .to.emit(vault, 'RageQuit') + .withArgs(MockDelegate.address, ERC20.address, false, 'MockDelegate: revert with message') + }) + it('should return data', async function () { + const returnData = await vault.connect(owner).callStatic.rageQuit(MockDelegate.address, ERC20.address) + + expect(returnData.notified).to.be.false + expect(returnData.error).to.be.eq('MockDelegate: revert with message') + }) + }) + describe('delegate with out of gas error', function () { + beforeEach(async function () { + await MockDelegate.setDelegateType(DelegateType.OOG) + }) + it('should succeed', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + }) + it('should delete lock data', async function () { + await vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, MockDelegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should emit event', async function () { + await expect(vault.connect(owner).rageQuit(MockDelegate.address, ERC20.address)) + .to.emit(vault, 'RageQuit') + .withArgs(MockDelegate.address, ERC20.address, false, '') + }) + it('should return data', async function () { + const returnData = await vault.connect(owner).callStatic.rageQuit(MockDelegate.address, ERC20.address) + + expect(returnData.notified).to.be.false + expect(returnData.error).to.be.eq('') + }) + }) + describe('delegate is EOA', function () { + beforeEach(async function () { + await MockDelegate.unlock( + vault.address, + ERC20.address, + ETHER, + await signPermission('Unlock', vault, owner, MockDelegate.address, ERC20.address, ETHER), + ) + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER) + await vault.connect(delegate).lock(ERC20.address, ETHER, permission) + }) + it('should succeed', async function () { + await vault.connect(owner).rageQuit(delegate.address, ERC20.address) + }) + it('should delete lock data', async function () { + await vault.connect(owner).rageQuit(delegate.address, ERC20.address) + + expect(await vault.getLockSetCount()).to.be.eq(0) + await expect(vault.getLockAt(0)).to.be.revertedWith('EnumerableSet: index out of bounds') + expect(await vault.getBalanceDelegated(ERC20.address, delegate.address)).to.be.eq(0) + expect(await vault.getBalanceLocked(ERC20.address)).to.be.eq(0) + }) + it('should emit event', async function () { + await expect(vault.connect(owner).rageQuit(delegate.address, ERC20.address)) + .to.emit(vault, 'RageQuit') + .withArgs(delegate.address, ERC20.address, false, '') + }) + it('should return data', async function () { + const returnData = await vault.connect(owner).callStatic.rageQuit(delegate.address, ERC20.address) + + expect(returnData.notified).to.be.false + expect(returnData.error).to.be.eq('') + }) + }) + }) + describe('ERC20', function () { + let ERC20: Contract + const totalSupply = ethers.utils.parseEther('10') + beforeEach(async function () { + ERC20 = await deployContract('MockERC20', [owner.address, totalSupply]) + await ERC20.connect(owner).transfer(vault.address, ETHER) + }) + describe('ERC20:transfer', function () { + it('should succeed', async function () { + await vault.connect(owner).transferERC20(ERC20.address, recipient.address, ETHER) + }) + it('should transfer tokens', async function () { + await vault.connect(owner).transferERC20(ERC20.address, recipient.address, ETHER) + + expect(await ERC20.balanceOf(recipient.address)).to.be.eq(ETHER) + }) + it('should fail if insufficient unlocked balance', async function () { + const permission = await signPermission('Lock', vault, owner, delegate.address, ERC20.address, ETHER.div(2)) + await vault.connect(delegate).lock(ERC20.address, ETHER.div(2), permission) + + await expect(vault.connect(owner).transferERC20(ERC20.address, recipient.address, ETHER)).to.be.revertedWith( + 'UniversalVault: insufficient balance', + ) + }) + }) + }) + describe('ETH', function () { + describe('ETH:receive', function () { + it('should succeed', async function () { + await owner.sendTransaction({ + to: vault.address, + value: ETHER, + }) + }) + it('should receive correct amount', async function () { + await owner.sendTransaction({ + to: vault.address, + value: ETHER, + }) + expect(await ethers.provider.getBalance(vault.address)).to.eq(ETHER) + }) + }) + describe('ETH:send', function () { + it('should succeed', async function () { + await vault.connect(owner).transferETH(recipient.address, ETHER, { value: ETHER }) + }) + it('should send correct amount', async function () { + await vault.connect(owner).transferETH(recipient.address, ETHER, { value: ETHER }) + expect(await ethers.provider.getBalance(vault.address)).to.eq(0) + }) + it('should fail if insufficient amount', async function () { + await expect(vault.connect(owner).transferETH(recipient.address, ETHER)).to.be.revertedWith('le') + }) + }) + }) +}) diff --git a/test/VaultFactory.ts b/test/VaultFactory.ts new file mode 100644 index 0000000..25c92f0 --- /dev/null +++ b/test/VaultFactory.ts @@ -0,0 +1,43 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { expect } from 'chai' +import { Contract } from 'ethers' +import { ethers } from 'hardhat' +import { create2Instance, createInstance, deployContract } from './utils' + +describe('VaultFactory', function () { + let accounts: SignerWithAddress[] + let factory: Contract, template: Contract + + beforeEach(async function () { + // prepare signers + accounts = await ethers.getSigners() + // deploy template + template = await deployContract('Crucible') + // deploy factory + factory = await deployContract('CrucibleFactory', [template.address]) + }) + + describe('getTemplate', function () { + it('should succeed', async function () { + expect(await factory.getTemplate()).to.be.eq(template.address) + }) + }) + describe('create', function () { + it('should succeed', async function () { + await createInstance('Crucible', factory, accounts[0]) + }) + it('should successfully call owner', async function () { + const vault = await createInstance('Crucible', factory, accounts[0]) + expect(await vault.owner()).to.eq(accounts[0].address) + }) + }) + describe('create2', function () { + it('should succeed', async function () { + await create2Instance('Crucible', factory, accounts[0], ethers.utils.randomBytes(32)) + }) + it('should successfully call owner', async function () { + const vault = await create2Instance('Crucible', factory, accounts[0], ethers.utils.randomBytes(32)) + expect(await vault.owner()).to.eq(accounts[0].address) + }) + }) +}) diff --git a/test/shared-before-each/revert-after.ts b/test/shared-before-each/revert-after.ts new file mode 100644 index 0000000..5611866 --- /dev/null +++ b/test/shared-before-each/revert-after.ts @@ -0,0 +1,25 @@ +import { EthereumProvider } from "hardhat/types"; + +import { wrapWithTitle, takeSnapshot, revert, getProvider } from "./utils"; + +/** + * ThisMocha helper reverts all your state modifications in an `after` hook. + * + * @param title A title that's included in all the hooks that this helper uses. + * @param provider The network provider. + */ +export function revertAfter(title?: string, provider?: EthereumProvider) { + let snapshotId: string | undefined; + before( + wrapWithTitle(title, "resetAfter: taking snapshot"), + async function () { + snapshotId = await takeSnapshot(await getProvider(provider)); + } + ); + + after(wrapWithTitle(title, "resetAfter: reverting state"), async function () { + if (snapshotId !== undefined) { + await revert(await getProvider(provider), snapshotId); + } + }); +} \ No newline at end of file diff --git a/test/shared-before-each/shared-before-each.ts b/test/shared-before-each/shared-before-each.ts new file mode 100644 index 0000000..7fa0873 --- /dev/null +++ b/test/shared-before-each/shared-before-each.ts @@ -0,0 +1,75 @@ +import { EthereumProvider } from "hardhat/types"; + +import { wrapWithTitle, takeSnapshot, revert, getProvider } from "./utils"; + +const SNAPSHOTS: string[] = []; + +/** + * This Mocha helper acts as a `beforeEach`, but executes the initializer + * just once. It internally uses Hardhat Network and Ganache's snapshots + * and revert instead of re-executing the initializer. + * + * Note that after the last test is run, the state doesn't get reverted. + * + * @param title A title that's included in all the hooks that this helper uses. + * @param initializer The initializer to be run before the tests. + * @param provider The network provider. + */ +export function sharedBeforeEach( + title: string, + initializer: Mocha.AsyncFunc, + provider?: EthereumProvider +): void; +export function sharedBeforeEach( + initializer: Mocha.AsyncFunc, + provider?: EthereumProvider +): void; +export function sharedBeforeEach( + titleOrInitializer: string | Mocha.AsyncFunc, + initializerOrProvider?: Mocha.AsyncFunc | EthereumProvider, + optionalProvider?: EthereumProvider +) { + const title = + typeof titleOrInitializer === "string" ? titleOrInitializer : undefined; + + let initializer: Mocha.AsyncFunc; + let maybeProvider: EthereumProvider | undefined; + if (typeof titleOrInitializer === "function") { + initializer = titleOrInitializer; + maybeProvider = initializerOrProvider as EthereumProvider | undefined; + } else { + initializer = initializerOrProvider as Mocha.AsyncFunc; + maybeProvider = optionalProvider; + } + + let initialized = false; + + beforeEach( + wrapWithTitle(title, "Running shared before each or reverting"), + async function () { + const provider = await getProvider(maybeProvider); + if (!initialized) { + const prevSnapshot = SNAPSHOTS.pop(); + if (prevSnapshot !== undefined) { + await revert(provider, prevSnapshot); + SNAPSHOTS.push(await takeSnapshot(provider)); + } + + await initializer.call(this); + + SNAPSHOTS.push(await takeSnapshot(provider)); + initialized = true; + } else { + const snapshotId = SNAPSHOTS.pop()!; + await revert(provider, snapshotId); + SNAPSHOTS.push(await takeSnapshot(provider)); + } + } + ); + + after(async function () { + if (initialized) { + SNAPSHOTS.pop(); + } + }); +} diff --git a/test/shared-before-each/utils.ts b/test/shared-before-each/utils.ts new file mode 100644 index 0000000..bf37992 --- /dev/null +++ b/test/shared-before-each/utils.ts @@ -0,0 +1,33 @@ +import { EthereumProvider } from "hardhat/types"; + +export async function takeSnapshot(provider: EthereumProvider) { + return (await provider.request({ + method: "evm_snapshot", + })) as string; +} + +export async function revert(provider: EthereumProvider, snapshotId: string) { + await provider.request({ + method: "evm_revert", + params: [snapshotId], + }); +} + +export async function getProvider( + provider?: EthereumProvider +): Promise { + if (provider !== undefined) { + return provider; + } + + const hre = await import("hardhat"); + return hre.network.provider; +} + +export function wrapWithTitle(title: string | undefined, str: string) { + if (title === undefined) { + return str; + } + + return `${title} at step "${str}"`; +} diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..dc78a1c --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,141 @@ +import { TypedDataField } from '@ethersproject/abstract-signer' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' +import { BigNumber, BigNumberish, BytesLike, Contract, Signer, Wallet } from 'ethers' +import { ethers, network } from 'hardhat' +import '@openzeppelin/hardhat-upgrades' +import { parseEther } from 'ethers/lib/utils' + +const DAY = 60 * 60 * 24 + +export async function getTimestamp() { + return (await ethers.provider.getBlock('latest')).timestamp +} + +export async function increaseTime(seconds: number) { + const time = await getTimestamp() + // instead of using evm_increaseTime, we can pass in the timestamp + // the next block should setup as the mining time + const expectedEndTime = time + seconds - 1 + await network.provider.request({ + method: 'evm_mine', + params: [expectedEndTime], + }) + if (expectedEndTime !== (await getTimestamp())) { + throw new Error('evm_mine failed') + } +} + +export async function deployContract(name: string, args: Array = []) { + const factory = await ethers.getContractFactory(name) + const contract = await factory.deploy(...args) + return contract.deployed() +} + +export async function deployMist(admin: SignerWithAddress) { + const factory = await ethers.getContractFactory('Alchemist') + + // todo : use the token manager + const tokenManager = await (await ethers.getContractFactory('TokenManager', admin)).deploy() + + const now = new Date() + const mist = await factory.deploy( + admin.address, + admin.address, + 1000, + BigNumber.from(14).mul(DAY), + BigNumber.from(60).mul(DAY), + parseEther("1000"), + Math.round(now.getTime() / 1000) + ) + + await mist.connect(admin) + const initialSupply = await mist.balanceOf(admin.address) + + return { + mist, + initialSupply + } +} + + export async function deployAludel(args: Array) { + const factory = await ethers.getContractFactory('Aludel') + return factory.deploy(...args) + } + +export async function createInstance(instanceName: string, factory: Contract, signer: Signer, args: string = '0x') { + // get contract class + const instance = await ethers.getContractAt( + instanceName, + await factory.connect(signer).callStatic['create(bytes)'](args), + ) + // deploy vault + await factory.connect(signer)['create(bytes)'](args) + // return contract class + return instance +} + +export async function create2Instance( + instanceName: string, + factory: Contract, + signer: Signer, + salt: BytesLike, + args: string = '0x', +) { + // get contract class + const instance = await ethers.getContractAt( + instanceName, + await factory.connect(signer).callStatic['create2(bytes,bytes32)'](args, salt), + ) + // deploy vault + await factory.connect(signer)['create2(bytes,bytes32)'](args, salt) + // return contract class + return instance +} + +export const signPermission = async ( + method: string, + vault: Contract, + owner: Wallet, + delegateAddress: string, + tokenAddress: string, + amount: BigNumberish, + vaultNonce?: BigNumberish, + chainId?: BigNumberish, +) => { + // get nonce + vaultNonce = vaultNonce || (await vault.getNonce()) + // get chainId + chainId = chainId || (await vault.provider.getNetwork()).chainId + // craft permission + const domain = { + name: 'UniversalVault', + version: '1.0.0', + chainId, + verifyingContract: vault.address, + } + const types = {} as Record + types[method] = [ + { name: 'delegate', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + ] + const value = { + delegate: delegateAddress, + token: tokenAddress, + amount: amount, + nonce: vaultNonce, + } + // sign permission + const signedPermission = await owner._signTypedData(domain, types, value) + // return + return signedPermission +} + +export const transferNFT = async (nft: Contract, signer: Signer, owner: string, recipient: string, tokenId: string) => { + return nft.connect(signer)['safeTransferFrom(address,address,uint256)'](owner, recipient, tokenId) +} + +export const ERC1271_VALID_SIG = '0x1626ba7e' +export const ERC1271_INVALID_SIG = '0xffffffff' +export const ETHER = ethers.utils.parseEther('1')