diff --git a/test/governance/attacks.spec.ts b/test/governance/attacks.spec.ts new file mode 100644 index 0000000..3850d80 --- /dev/null +++ b/test/governance/attacks.spec.ts @@ -0,0 +1,130 @@ +import {expect, use} from 'chai'; +import {ipfsBytes32Hash, MAX_UINT_AMOUNT, ZERO_ADDRESS} from '../../helpers/constants'; +import { + makeSuite, + TestEnv, + deployPhase2, +} from '../test-helpers/make-suite'; +import { solidity } from 'ethereum-waffle'; +import {BytesLike} from 'ethers/lib/utils'; +import {BigNumberish, BigNumber} from 'ethers'; +import {advanceBlockTo, DRE, latestBlock, waitForTx} from '../../helpers/misc-utils'; +import { FlashAttacks } from '../../types/FlashAttacks'; +import { deployFlashAttacks } from '../../helpers/contracts-deployments'; +import { deployTestExecutor } from '../test-helpers/gov-utils'; +import { Executor } from '../../types/Executor'; + +makeSuite('dYdX Governance attack test cases', deployPhase2, (testEnv: TestEnv) => { + let votingDelay: BigNumber; + let votingDuration: BigNumber; + let executionDelay: BigNumber; + let currentVote: BigNumber; + let minimumPower: BigNumber; + let minimumCreatePower: BigNumber; + let flashAttacks: FlashAttacks; + let executor: Executor; + + before(async () => { + const { + governor, + strategy, + dydxToken, + users: [user1, user2], + deployer, + } = testEnv; + + executor = await deployTestExecutor(testEnv); + + votingDelay = BigNumber.from(100); + + // set governance delay to 100 blocks to speed up local tests + await waitForTx(await governor.setVotingDelay(votingDelay)); + + votingDuration = await executor.VOTING_DURATION(); + executionDelay = await executor.getDelay(); + + // Supply does not change during the tests + minimumPower = await executor.getMinimumVotingPowerNeeded( + await strategy.getTotalVotingSupplyAt(await latestBlock()) + ); + + minimumCreatePower = await executor.getMinimumPropositionPowerNeeded( + governor.address, + await latestBlock(), + ); + // Add some funds to user1 + await dydxToken.connect(deployer.signer).transfer(user1.address, minimumPower.add('1')); + await dydxToken.connect(deployer.signer).transfer(user2.address, minimumPower.div('2')); + + // Deploy flash attacks contract and approve from distributor address + flashAttacks = await deployFlashAttacks(dydxToken.address, deployer.address, governor.address); + await dydxToken.connect(deployer.signer).approve(flashAttacks.address, MAX_UINT_AMOUNT); + }); + + beforeEach(async () => { + const {governor} = testEnv; + const currentCount = await governor.getProposalsCount(); + currentVote = currentCount.eq('0') ? currentCount : currentCount.sub('1'); + }); + + it('Should not allow Flash proposal', async () => { + const { + users: [user], + } = testEnv; + + // Params for proposal + const params: [ + BigNumberish, + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [ + minimumCreatePower, + executor.address, + [ZERO_ADDRESS], + ['0'], + [''], + ['0x'], + [false], + ipfsBytes32Hash, + ]; + + // Try to create proposal + await expect(flashAttacks.connect(user.signer).flashProposal(...params)).to.be.revertedWith( + 'PROPOSITION_CREATION_INVALID' + ); + }); + + it('Should not allow Flash vote: the voting power should be zero', async () => { + const { + governor, + users: [user], + dydxToken, + } = testEnv; + + // Transfer funds to user to create proposal and vote + await dydxToken.transfer(user.address, minimumPower.add('1')); + + // Create proposal + const tx1 = await waitForTx( + await governor + .connect(user.signer) + .create(executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash) + ); + + // Check ProposalCreated event + const proposalId = tx1.events?.[0].args?.id; + const startBlock = BigNumber.from(tx1.blockNumber).add(votingDelay); + const support = true; + await advanceBlockTo(Number(startBlock.toString())); + + // Vote + await expect(flashAttacks.connect(user.signer).flashVote(minimumPower, proposalId, support)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, flashAttacks.address, support, '0'); + }); +}); diff --git a/test/governance/governance-no-delay.spec.ts b/test/governance/governance-no-delay.spec.ts new file mode 100644 index 0000000..2e7b2ba --- /dev/null +++ b/test/governance/governance-no-delay.spec.ts @@ -0,0 +1,720 @@ +import {expect, use} from 'chai'; +import {ipfsBytes32Hash, ZERO_ADDRESS} from '../../helpers/constants'; +import { + makeSuite, + TestEnv, + deployPhase2, +} from '../test-helpers/make-suite'; +import { solidity } from 'ethereum-waffle'; +import {BytesLike} from 'ethers/lib/utils'; +import {BigNumberish, BigNumber} from 'ethers'; +import { + evmRevert, + evmSnapshot, + waitForTx, + advanceBlockTo, + DRE, + latestBlock, +} from '../../helpers/misc-utils'; +import { + emptyBalances, + getInitContractData, + setBalance, + expectProposalState, + getLastProposalId, + deployTestExecutor, +} from '../test-helpers/gov-utils'; +import {buildPermitParams, getSignatureFromTypedData} from '../test-helpers/permit'; +import { fail } from 'assert'; +import { Executor } from '../../types/Executor'; + +const proposalStates = { + PENDING: 0, + CANCELED: 1, + ACTIVE: 2, + FAILED: 3, + SUCCEEDED: 4, + QUEUED: 5, + EXPIRED: 6, + EXECUTED: 7, +}; + +const snapshots = new Map(); + +makeSuite('dYdX Governance no voting delay', deployPhase2, (testEnv: TestEnv) => { + let votingDelay: BigNumber; + let votingDuration: BigNumber; + let executionDelay: BigNumber; + let minimumPower: BigNumber; + let minimumCreatePower: BigNumber; + let proposalId: BigNumber; + let startBlock: BigNumber; + let endBlock: BigNumber; + let executionTime: BigNumber; + let gracePeriod: BigNumber; + let executor: Executor; + + // Snapshoting main states as entry for later testing + // Then will test by last snap shot first. + before(async () => { + const {governor, strategy, dydxToken, users} = testEnv; + const [user1, user2, user3, user4, user5] = users; + + executor = await deployTestExecutor(testEnv); + + ({ + votingDuration, + executionDelay, + minimumPower, + minimumCreatePower, + gracePeriod, + } = await getInitContractData(testEnv, executor)); + + votingDelay = BigNumber.from(100); + + // set governance delay to 100 blocks to speed up local tests + await waitForTx(await governor.setVotingDelay(votingDelay)); + + // Cleaning users balances + await emptyBalances(users, testEnv); + + // SNAPSHOT: EMPTY GOVERNANCE + snapshots.set('start', await evmSnapshot()); + + // Preparing users with different powers for test + // user 1: 50% min voting power + 2 = 10%+ total power + await setBalance(user1, minimumPower.div('2').add('2'), testEnv); + // user 2: 50% min voting power + 2 = 10%+ total power + await setBalance(user2, minimumPower.div('2').add('2'), testEnv); + // user 3: 2 % min voting power, will be used to swing the vote + await setBalance(user3, minimumPower.mul('2').div('100').add('10'), testEnv); + // user 4: 75% min voting power + 10 : = 15%+ total power, can barely make fail differential + await setBalance(user4, minimumPower.mul('75').div('100').add('10'), testEnv); + // user 5: 50% min voting power + 2 = 10%+ total power. + await setBalance(user5, minimumPower.div('2').add('2'), testEnv); + let block = await latestBlock(); + expect(await strategy.getVotingPowerAt(user5.address, block)).to.be.equal( + minimumPower.div('2').add('2') + ); + // user 5 delegates to user 2 => user 2 reached quorum + await waitForTx(await dydxToken.connect(user5.signer).delegate(user2.address)); + block = await latestBlock(); + // checking delegation worked + expect(await strategy.getVotingPowerAt(user5.address, block)).to.be.equal('0'); + expect(await strategy.getVotingPowerAt(user2.address, block)).to.be.equal( + minimumPower.div('2').add('2').mul(2) + ); + + //Creating first proposal + const tx1 = await waitForTx( + await governor + .connect(user1.signer) + .create(executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash) + ); + + // fixing constants + proposalId = tx1.events?.[0].args?.id; + startBlock = BigNumber.from(tx1.blockNumber).add(votingDelay); + endBlock = BigNumber.from(tx1.blockNumber).add(votingDelay).add(votingDuration); + // delay = 0, should be active + await expectProposalState(proposalId, proposalStates.PENDING, testEnv); + + // SNAPSHOT PENDING + snapshots.set('active', await evmSnapshot()); + + const balanceAfter = await dydxToken.connect(user1.signer).balanceOf(user1.address); + + // Pending => Active + // => go tto start block + await advanceBlockTo(Number(startBlock.add(1).toString())); + await expectProposalState(proposalId, proposalStates.ACTIVE, testEnv); + + // SNAPSHOT: ACTIVE PROPOSAL + snapshots.set('active', await evmSnapshot()); + + // Active => Succeeded, user 1 + user 2 votes > threshold + await expect(governor.connect(user1.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user1.address, true, balanceAfter); + await expect(governor.connect(user2.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user2.address, true, balanceAfter.mul('2')); + + // go to end of voting period + await advanceBlockTo(Number(endBlock.add('3').toString())); + await expectProposalState(proposalId, proposalStates.SUCCEEDED, testEnv); + + // SNAPSHOT: SUCCEEDED PROPOSAL + snapshots.set('succeeded', await evmSnapshot()); + + // Succeeded => Queued: + await (await governor.connect(user1.signer).queue(proposalId)).wait(); + await expectProposalState(proposalId, proposalStates.QUEUED, testEnv); + + // SNAPSHOT: QUEUED PROPOSAL + executionTime = (await governor.getProposalById(proposalId)).executionTime; + snapshots.set('queued', await evmSnapshot()); + }); + + describe('Testing queue function', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('succeeded') || '1'); + proposalId = await getLastProposalId(testEnv); + await expectProposalState(proposalId, proposalStates.SUCCEEDED, testEnv); + snapshots.set('succeeded', await evmSnapshot()); + }); + + it('Queue a proposal', async () => { + const { + governor, + users: [user], + } = testEnv; + // Queue + const queueTx = await governor.connect(user.signer).queue(proposalId); + const queueTxResponse = await waitForTx(queueTx); + const blockTime = await DRE.ethers.provider.getBlock(queueTxResponse.blockNumber); + + const executionTime = blockTime.timestamp + Number(executionDelay.toString()); + + await expect(Promise.resolve(queueTx)) + .to.emit(governor, 'ProposalQueued') + .withArgs(proposalId, executionTime, user.address); + await expectProposalState(proposalId, proposalStates.QUEUED, testEnv); + }); + }); + + describe('Testing voting functions', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('active') || '1'); + proposalId = await getLastProposalId(testEnv); + await expectProposalState(proposalId, proposalStates.ACTIVE, testEnv); + snapshots.set('active', await evmSnapshot()); + }); + + it('Vote a proposal without quorum => proposal failed', async () => { + // User 1 has 50% min power, should fail + const { + governor, + users: [user1], + dydxToken, + } = testEnv; + + // user 1 has only half of enough voting power + const balance = await dydxToken.connect(user1.signer).balanceOf(user1.address); + await expect(governor.connect(user1.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user1.address, true, balance); + + await advanceBlockTo(Number(endBlock.add('9').toString())); + expect(await executor.isQuorumValid(governor.address, proposalId)).to.be.equal(false); + expect(await executor.isVoteDifferentialValid(governor.address, proposalId)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposalId)).to.be.equal( + proposalStates.FAILED + ); + }); + + it('Vote a proposal with quorum => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2], + dydxToken, + } = testEnv; + // User 1 + User 2 power > voting po succeeded + + await advanceBlockTo(Number(endBlock.add('10').toString())); + expect(await executor.isQuorumValid(governor.address, proposalId)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposalId)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposalId)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + it('Vote a proposal with quorum via delegation => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, , , user5], + dydxToken, + } = testEnv; + // user 5 has delegated to user 2 + const balance2 = await dydxToken.connect(user1.signer).balanceOf(user2.address); + const balance5 = await dydxToken.connect(user2.signer).balanceOf(user5.address); + expect(await strategy.getVotingPowerAt(user2.address, startBlock)).to.be.equal( + balance2.add(balance5) + ); + await expect(governor.connect(user2.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user2.address, true, balance2.add(balance5)); + // active => succeeded + await advanceBlockTo(Number(endBlock.add('11').toString())); + expect(await executor.isQuorumValid(governor.address, proposalId)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposalId)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposalId)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + + it('Vote a proposal with quorum but not vote dif => proposal failed', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, user3, user4], + dydxToken, + } = testEnv; + // User 2 + User 5 delegation = 20% power, voting yes + // user 2 has received delegation from user 5 + const power2 = await strategy.getVotingPowerAt(user2.address, startBlock); + await expect(governor.connect(user2.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user2.address, true, power2); + + // User 4 = 15% Power, voting no + const balance4 = await dydxToken.connect(user4.signer).balanceOf(user4.address); + await expect(governor.connect(user4.signer).submitVote(proposalId, false)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user4.address, false, balance4); + + await advanceBlockTo(Number(endBlock.add('12').toString())); + expect(await executor.isQuorumValid(governor.address, proposalId)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposalId)).to.be.equal(false); + expect(await governor.connect(user1.signer).getProposalState(proposalId)).to.be.equal( + proposalStates.FAILED + ); + }); + + it('Vote a proposal with quorum and vote dif => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, user3, user4], + dydxToken, + } = testEnv; + // User 2 + User 5 delegation = 20% power, voting yes + // user 2 has received delegation from user 5 + const power2 = await strategy.getVotingPowerAt(user2.address, startBlock); + await expect(governor.connect(user2.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user2.address, true, power2); + + // User 4 = 15% Power, voting no + const balance4 = await dydxToken.connect(user4.signer).balanceOf(user4.address); + await expect(governor.connect(user4.signer).submitVote(proposalId, false)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user4.address, false, balance4); + + // User 3 makes the vote swing + const balance3 = await dydxToken.connect(user3.signer).balanceOf(user3.address); + await expect(governor.connect(user3.signer).submitVote(proposalId, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user3.address, true, balance3); + + await advanceBlockTo(Number(endBlock.add('13').toString())); + expect(await executor.isQuorumValid(governor.address, proposalId)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposalId)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposalId)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + + it('Vote a proposal by permit', async () => { + const { + users: [, , user3], + deployer, + dydxToken, + governor, + } = testEnv; + const {chainId} = await DRE.ethers.provider.getNetwork(); + const configChainId = DRE.network.config.chainId; + // ChainID must exist in current provider to work + expect(configChainId).to.be.equal(chainId); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + + // Prepare signature + const msgParams = buildPermitParams(chainId, governor.address, proposalId.toString(), true); + const ownerPrivateKey = require('../../test-wallets.js').accounts[3].secretKey; // deployer, user1, user2, user3 + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + const balance = await dydxToken.connect(deployer.signer).balanceOf(user3.address); + + // Publish vote by signature using other address as relayer + const votePermitTx = await governor + .connect(user3.signer) + .submitVoteBySignature(proposalId, true, v, r, s); + + await expect(Promise.resolve(votePermitTx)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposalId, user3.address, true, balance); + }); + }); + + describe('Testing create function', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('start') || '1'); + snapshots.set('start', await evmSnapshot()); + const {governor} = testEnv; + let currentCount = await governor.getProposalsCount(); + proposalId = currentCount.eq('0') ? currentCount : currentCount.sub('1'); + }); + + it('should not create a proposal when proposer has not enough power', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give not enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'PROPOSITION_CREATION_INVALID' + ); + }); + it('should create proposal when enough power', async () => { + const { + governor, + users: [user], + strategy, + } = testEnv; + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // give enough power + await setBalance(user, minimumCreatePower, testEnv); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + const tx = await governor.connect(user.signer).create(...params); + // Check ProposalCreated event + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params; + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + await expectProposalState(count, proposalStates.PENDING, testEnv); + }); + it('should create proposal when enough power via delegation', async () => { + const { + governor, + users: [user, user2], + dydxToken, + strategy, + } = testEnv; + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // give enough power + await setBalance(user, minimumCreatePower.div('2').add('1'), testEnv); + await setBalance(user2, minimumCreatePower.div('2').add('1'), testEnv); + await waitForTx(await dydxToken.connect(user2.signer).delegate(user.address)); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + const tx = await governor.connect(user.signer).create(...params); + // Check ProposalCreated event + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params; + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + await expectProposalState(count, proposalStates.PENDING, testEnv); + }); + it('should not create a proposal without targets', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with no target + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + }); + + it('should not create a proposal with unauthorized executor', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with not authorized user as executor + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [user.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'EXECUTOR_NOT_AUTHORIZED' + ); + }); + + it('should not create a proposal with less targets than calldata', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with no target + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + }); + + it('should not create a proposal with inconsistent data', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + const params: ( + targetsLength: number, + valuesLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [string, string[], BigNumberish[], string[], BytesLike[], boolean[], BytesLike] = ( + targetsLength: number, + valueLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [ + executor.address, + Array(targetsLength).fill(ZERO_ADDRESS), + Array(valueLength).fill('0'), + Array(signaturesLength).fill(''), + Array(calldataLength).fill('0x'), + Array(withDelegatesLength).fill(false), + ipfsBytes32Hash, + ]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params(2, 1, 1, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 2, 1, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(0, 1, 1, 1, 1))).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 2, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 1, 2, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 1, 1, 2))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + }); + + it('should create a proposals with different data lengths', async () => { + const { + governor, + users: [user], + strategy, + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + const params: ( + targetsLength: number, + valuesLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [string, string[], BigNumberish[], string[], BytesLike[], boolean[], BytesLike] = ( + targetsLength: number, + valueLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [ + executor.address, + Array(targetsLength).fill(ZERO_ADDRESS), + Array(valueLength).fill('0'), + Array(signaturesLength).fill(''), + Array(calldataLength).fill('0x'), + Array(withDelegatesLength).fill(false), + ipfsBytes32Hash, + ]; + for (let i = 1; i < 12; i++) { + const count = await governor.connect(user.signer).getProposalsCount(); + const tx = await governor.connect(user.signer).create(...params(i, i, i, i, i)); + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params(i, i, i, i, i); + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + } + }); + }); +}); diff --git a/test/governance/governance.spec.ts b/test/governance/governance.spec.ts new file mode 100644 index 0000000..30a5aa9 --- /dev/null +++ b/test/governance/governance.spec.ts @@ -0,0 +1,1223 @@ +import {expect, use} from 'chai'; +import {ipfsBytes32Hash, ZERO_ADDRESS} from '../../helpers/constants'; +import { + makeSuite, + TestEnv, + deployPhase2, +} from '../test-helpers/make-suite'; +import { solidity } from 'ethereum-waffle'; +import {BytesLike } from 'ethers/lib/utils'; +import {BigNumberish, BigNumber} from 'ethers'; +import { + evmRevert, + evmSnapshot, + waitForTx, + advanceBlockTo, + DRE, + advanceBlock, + latestBlock, +} from '../../helpers/misc-utils'; +import { + emptyBalances, + getInitContractData, + setBalance, + expectProposalState, + encodeSetDelay, + deployTestExecutor, +} from '../test-helpers/gov-utils'; +import { deployGovernanceStrategy } from '../../helpers/contracts-deployments'; +import {buildPermitParams, getSignatureFromTypedData} from '../test-helpers/permit'; +import { fail } from 'assert'; +import { Executor } from '../../types/Executor'; + +const proposalStates = { + PENDING: 0, + CANCELED: 1, + ACTIVE: 2, + FAILED: 3, + SUCCEEDED: 4, + QUEUED: 5, + EXPIRED: 6, + EXECUTED: 7, +}; + +const snapshots = new Map(); + +makeSuite('dYdX Governance tests', deployPhase2, (testEnv: TestEnv) => { + let votingDelay: BigNumber; + let votingDuration: BigNumber; + let executionDelay: BigNumber; + let minimumPower: BigNumber; + let minimumCreatePower: BigNumber; + let proposal1Id: BigNumber; + let proposal2Id: BigNumber; + let proposal3Id: BigNumber; + let startBlock: BigNumber; + let endBlock: BigNumber; + let executionTime: BigNumber; + let gracePeriod: BigNumber; + let executor: Executor; + + // Snapshoting main states as entry for later testing + // Then will test by last snap shot first. + before(async () => { + const {governor, strategy, dydxToken, users } = testEnv; + const [user1, user2, user3, user4, user5] = users; + + executor = await deployTestExecutor(testEnv); + + ({ + votingDuration, + executionDelay, + minimumPower, + minimumCreatePower, + gracePeriod, + } = await getInitContractData(testEnv, executor)); + + votingDelay = BigNumber.from(100); + + // set governance delay to 100 blocks to speed up local tests + await waitForTx(await governor.setVotingDelay(votingDelay)); + + // Cleaning users balances + await emptyBalances(users, testEnv); + + // SNAPSHOT: EMPTY GOVERNANCE + snapshots.set('start', await evmSnapshot()); + + // Giving user 1 enough power to propose + await setBalance(user1, minimumPower, testEnv); + + const callData = await encodeSetDelay('400', testEnv); + + // Creating first proposal: Changing delay to 400 via no sig + calldata + const tx1 = await waitForTx( + await governor + .connect(user1.signer) + .create(executor.address, [governor.address], ['0'], [''], [callData], [false], ipfsBytes32Hash) + ); + // Creating 2nd proposal: Changing delay to 300 via sig + argument data + const encodedArgument2 = DRE.ethers.utils.defaultAbiCoder.encode(['uint'], [300]); + const tx2 = await waitForTx( + await governor + .connect(user1.signer) + .create( + executor.address, + [governor.address], + ['0'], + ['setVotingDelay(uint256)'], + [encodedArgument2], + [false], + ipfsBytes32Hash + ) + ); + + const encodedArgument3 = DRE.ethers.utils.defaultAbiCoder.encode(['address'], [user1.address]); + const tx3 = await waitForTx( + await governor + .connect(user1.signer) + .create( + executor.address, + [executor.address], + ['0'], + ['setPendingAdmin(address)'], + [encodedArgument3], + [false], + ipfsBytes32Hash + ) + ); + // cleaning up user1 balance + await emptyBalances([user1], testEnv); + + // fixing constants + proposal1Id = tx1.events?.[0].args?.id; + proposal2Id = tx2.events?.[0].args?.id; + proposal3Id = tx3.events?.[0].args?.id; + startBlock = BigNumber.from(tx2.blockNumber).add(votingDelay); + endBlock = BigNumber.from(tx2.blockNumber).add(votingDelay).add(votingDuration); + await expectProposalState(proposal2Id, proposalStates.PENDING, testEnv); + + // SNAPSHOT: PENDING PROPOSAL + snapshots.set('pending', await evmSnapshot()); + + // Preparing users with different powers for test + // user 1: 50% min voting power + 2 = 10%+ total power + await setBalance(user1, minimumPower.div('2').add('2'), testEnv); + // user 2: 50% min voting power + 2 = 10%+ total power + await setBalance(user2, minimumPower.div('2').add('2'), testEnv); + // user 3: 2 % min voting power, will be used to swing the vote + await setBalance(user3, minimumPower.mul('2').div('100').add('10'), testEnv); + // user 4: 75% min voting power + 10 : = 15%+ total power, can barely make fail differential + await setBalance(user4, minimumPower.mul('75').div('100').add('10'), testEnv); + // user 5: 50% min voting power + 2 = 10%+ total power. + await setBalance(user5, minimumPower.div('2').add('2'), testEnv); + let block = await latestBlock(); + expect(await strategy.getVotingPowerAt(user5.address, block)).to.be.equal( + minimumPower.div('2').add('2') + ); + // user 5 delegates to user 2 => user 2 reached quorum + await waitForTx(await dydxToken.connect(user5.signer).delegate(user2.address)); + block = await latestBlock(); + // checking delegation worked + expect(await strategy.getVotingPowerAt(user5.address, block)).to.be.equal('0'); + expect(await strategy.getVotingPowerAt(user2.address, block)).to.be.equal( + minimumPower.div('2').add('2').mul(2) + ); + await expectProposalState(proposal3Id, proposalStates.PENDING, testEnv); + await expectProposalState(proposal2Id, proposalStates.PENDING, testEnv); + await expectProposalState(proposal1Id, proposalStates.PENDING, testEnv); + const balanceAfter = await dydxToken.connect(user1.signer).balanceOf(user1.address); + // Pending => Active + // => go to start block + await advanceBlockTo(Number(startBlock.add(2).toString())); + await expectProposalState(proposal3Id, proposalStates.ACTIVE, testEnv); + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + await expectProposalState(proposal1Id, proposalStates.ACTIVE, testEnv); + + // SNAPSHOT: ACTIVE PROPOSAL + snapshots.set('active', await evmSnapshot()); + + // Active => Succeeded, user 2 votes + delegated from 5 > threshold + await expect(governor.connect(user2.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user2.address, true, balanceAfter.mul('2')); + await expect(governor.connect(user2.signer).submitVote(proposal1Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal1Id, user2.address, true, balanceAfter.mul('2')); + await expect(governor.connect(user2.signer).submitVote(proposal3Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal3Id, user2.address, true, balanceAfter.mul('2')); + // go to end of voting period + await advanceBlockTo(Number(endBlock.add('3').toString())); + await expectProposalState(proposal3Id, proposalStates.SUCCEEDED, testEnv); + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + await expectProposalState(proposal1Id, proposalStates.SUCCEEDED, testEnv); + + // SNAPSHOT: SUCCEEDED PROPOSAL + snapshots.set('succeeded', await evmSnapshot()); + + // Succeeded => Queued: + await (await governor.connect(user1.signer).queue(proposal1Id)).wait(); + await (await governor.connect(user1.signer).queue(proposal2Id)).wait(); + await (await governor.connect(user1.signer).queue(proposal3Id)).wait(); + await expectProposalState(proposal1Id, proposalStates.QUEUED, testEnv); + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + await expectProposalState(proposal3Id, proposalStates.QUEUED, testEnv); + // SNAPSHOT: QUEUED PROPOSAL + executionTime = (await governor.getProposalById(proposal2Id)).executionTime; + snapshots.set('queued', await evmSnapshot()); + }); + + describe('Testing cancel function on queued proposal on gov + exec', async function () { + beforeEach(async () => { + // Revert to queued state + await evmRevert(snapshots.get('queued') || '1'); + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // EVM Snapshots are consumed, need to snapshot again for next test + snapshots.set('queued', await evmSnapshot()); + }); + + it('should not cancel when Threshold is higher than minimum', async () => { + const { + governor, + users: [user], + deployer, + } = testEnv; + // giving threshold power + await setBalance(user, minimumCreatePower, testEnv); + // no threshold + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'PROPOSITION_CANCELLATION_INVALID' + ); + }); + + it('should cancel a queued proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // cancel the proposal + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + + it('should not cancel when proposition already canceled', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // cancel the proposal + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'ONLY_BEFORE_EXECUTED' + ); + + }); + }); + + describe('Testing execute function', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('queued') || '1'); + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + snapshots.set('queued', await evmSnapshot()); + }); + + it('should not execute a canceled prop', async () => { + const { + governor, + deployer, + shortTimelock, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // cancel the proposal + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + await advanceBlock(Number(executionTime.toString())); + + // Execute the propoal + const executeTx = governor.connect(user.signer).execute(proposal2Id); + + await expect(Promise.resolve(executeTx)).to.be.revertedWith('ONLY_QUEUED_PROPOSALS'); + }); + + it('should not execute a queued prop before timelock', async () => { + const { + governor, + users: [user], + } = testEnv; + + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // 5 sec before delay reached + await advanceBlock(Number(executionTime.sub(5).toString())); + + // Execute the propoal + const executeTx = governor.connect(user.signer).execute(proposal2Id); + + await expect(Promise.resolve(executeTx)).to.be.revertedWith('TIMELOCK_NOT_FINISHED'); + }); + + it('should not execute a queued prop after grace period (expired)', async () => { + const { + governor, + users: [user], + } = testEnv; + + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + // 5 sec before delay reached + await advanceBlock(Number(executionTime.add(gracePeriod).add(5).toString())); + + // Execute the propoal + const executeTx = governor.connect(user.signer).execute(proposal2Id); + + await expect(Promise.resolve(executeTx)).to.be.revertedWith('ONLY_QUEUED_PROPOSALS'); + await expectProposalState(proposal2Id, proposalStates.EXPIRED, testEnv); + }); + + it('should execute one proposal with no sig + calldata', async () => { + const { + governor, + users: [user], + } = testEnv; + await advanceBlock(Number(executionTime.add(gracePeriod).sub(5).toString())); + + expect(await governor.getVotingDelay()).to.be.equal(votingDelay); + // Execute the proposal: changing the delay to 300 + const executeTx = governor.connect(user.signer).execute(proposal2Id); + + await expect(Promise.resolve(executeTx)) + .to.emit(governor, 'ProposalExecuted') + .withArgs(proposal2Id, user.address); + expect(await governor.getVotingDelay()).to.be.equal(BigNumber.from('300')); + }); + + it('should execute one proposal with sig + argument', async () => { + const { + governor, + users: [user], + } = testEnv; + await advanceBlock(Number(executionTime.add(gracePeriod).sub(5).toString())); + + expect(await governor.getVotingDelay()).to.be.equal(votingDelay); + + // execute the second proposal: changing delay to 400 + const executeTx1 = governor.connect(user.signer).execute(proposal1Id); + + await expect(Promise.resolve(executeTx1)) + .to.emit(governor, 'ProposalExecuted') + .withArgs(proposal1Id, user.address); + expect(await governor.getVotingDelay()).to.be.equal(BigNumber.from('400')); + }); + + it('should change admin via proposal', async () => { + const { + governor, + users: [user], + } = testEnv; + await advanceBlock(Number(executionTime.add(gracePeriod).sub(5).toString())); + + expect(await executor.getPendingAdmin()).to.be.equal(ZERO_ADDRESS); + + // execute the second proposal: changing delay to 400 + const executeTx3 = governor.connect(user.signer).execute(proposal3Id); + + await expect(Promise.resolve(executeTx3)) + .to.emit(governor, 'ProposalExecuted') + .withArgs(proposal3Id, user.address); + expect(await executor.getPendingAdmin()).to.be.equal(user.address); + expect(await executor.getAdmin()).to.be.equal(governor.address); + + await (await executor.connect(user.signer).acceptAdmin()).wait(); + expect(await executor.getAdmin()).to.be.equal(user.address); + }); + }); + + describe('Testing cancel function on succeeded proposal', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('succeeded') || '1'); + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + snapshots.set('succeeded', await evmSnapshot()); + }); + + it('should not cancel when Threshold is higher than minimum', async () => { + const { + governor, + users: [user], + } = testEnv; + // giving threshold power + await setBalance(user, minimumCreatePower, testEnv); + // no threshold + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'PROPOSITION_CANCELLATION_INVALID' + ); + }); + + it('should cancel a succeeded proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + + it('should not cancel when proposition already canceled', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + // cancelled + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'ONLY_BEFORE_EXECUTED' + ); + }); + + it('should cancel an succeeded proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + + // active + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + }); + + describe('Testing queue function', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('succeeded') || '1'); + await expectProposalState(proposal2Id, proposalStates.SUCCEEDED, testEnv); + snapshots.set('succeeded', await evmSnapshot()); + }); + + it('Queue a proposal', async () => { + const { + governor, + users: [user], + } = testEnv; + // Queue + const queueTx = await governor.connect(user.signer).queue(proposal2Id); + const queueTxResponse = await waitForTx(queueTx); + const blockTime = await DRE.ethers.provider.getBlock(queueTxResponse.blockNumber); + + const executionTime = blockTime.timestamp + Number(executionDelay.toString()); + + await expect(Promise.resolve(queueTx)) + .to.emit(governor, 'ProposalQueued') + .withArgs(proposal2Id, executionTime, user.address); + await expectProposalState(proposal2Id, proposalStates.QUEUED, testEnv); + }); + }); + + describe('Testing voting functions', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('active') || '1'); + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + snapshots.set('active', await evmSnapshot()); + }); + + it('Vote a proposal without quorum => proposal failed', async () => { + // User 1 has 50% min power, should fail + const { + governor, + users: [user1], + dydxToken, + } = testEnv; + + // user 1 has only half of enough voting power + const balance = await dydxToken.connect(user1.signer).balanceOf(user1.address); + await expect(governor.connect(user1.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user1.address, true, balance); + + await advanceBlockTo(Number(endBlock.add('9').toString())); + expect(await executor.isQuorumValid(governor.address, proposal2Id)).to.be.equal(false); + expect(await executor.isVoteDifferentialValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposal2Id)).to.be.equal( + proposalStates.FAILED + ); + }); + + it('Vote a proposal with quorum => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2], + dydxToken, + } = testEnv; + // User 1 + User 2 power > voting po succeeded + + await advanceBlockTo(Number(endBlock.add('10').toString())); + expect(await executor.isQuorumValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposal2Id)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + + it('Vote a proposal with quorum via delegation => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, , , user5], + dydxToken, + } = testEnv; + // user 5 has delegated to user 2 + const balance2 = await dydxToken.connect(user1.signer).balanceOf(user2.address); + const balance5 = await dydxToken.connect(user2.signer).balanceOf(user5.address); + expect(await strategy.getVotingPowerAt(user2.address, startBlock)).to.be.equal( + balance2.add(balance5) + ); + await expect(governor.connect(user2.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user2.address, true, balance2.add(balance5)); + // active => succeeded + await advanceBlockTo(Number(endBlock.add('11').toString())); + expect(await executor.isQuorumValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposal2Id)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + + it('Vote a proposal with quorum but not vote dif => proposal failed', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, user3, user4], + dydxToken, + } = testEnv; + // User 2 + User 5 delegation = 20% power, voting yes + // user 2 has received delegation from user 5 + const power2 = await strategy.getVotingPowerAt(user2.address, startBlock); + await expect(governor.connect(user2.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user2.address, true, power2); + + // User 4 = 15% Power, voting no + const balance4 = await dydxToken.connect(user4.signer).balanceOf(user4.address); + await expect(governor.connect(user4.signer).submitVote(proposal2Id, false)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user4.address, false, balance4); + + await advanceBlockTo(Number(endBlock.add('12').toString())); + expect(await executor.isQuorumValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposal2Id)).to.be.equal(false); + expect(await governor.connect(user1.signer).getProposalState(proposal2Id)).to.be.equal( + proposalStates.FAILED + ); + }); + + it('Vote a proposal with quorum and vote dif => proposal succeeded', async () => { + // Vote + const { + governor, + strategy, + users: [user1, user2, user3, user4], + dydxToken, + } = testEnv; + // User 2 + User 5 delegation = 20% power, voting yes + // user 2 has received delegation from user 5 + const power2 = await strategy.getVotingPowerAt(user2.address, startBlock); + await expect(governor.connect(user2.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user2.address, true, power2); + + // User 4 = 15% Power, voting no + const balance4 = await dydxToken.connect(user4.signer).balanceOf(user4.address); + await expect(governor.connect(user4.signer).submitVote(proposal2Id, false)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user4.address, false, balance4); + + // User 3 makes the vote swing + const balance3 = await dydxToken.connect(user3.signer).balanceOf(user3.address); + await expect(governor.connect(user3.signer).submitVote(proposal2Id, true)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user3.address, true, balance3); + + await advanceBlockTo(Number(endBlock.add('13').toString())); + expect(await executor.isQuorumValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await executor.isVoteDifferentialValid(governor.address, proposal2Id)).to.be.equal(true); + expect(await governor.connect(user1.signer).getProposalState(proposal2Id)).to.be.equal( + proposalStates.SUCCEEDED + ); + }); + + it('Vote a proposal by permit', async () => { + const { + users: [, , user3], + deployer, + dydxToken, + governor, + } = testEnv; + const {chainId} = await DRE.ethers.provider.getNetwork(); + const configChainId = DRE.network.config.chainId; + // ChainID must exist in current provider to work + expect(configChainId).to.be.equal(chainId); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + + // Prepare signature + const msgParams = buildPermitParams(chainId, governor.address, proposal2Id.toString(), true); + const ownerPrivateKey = require('../../test-wallets.js').accounts[3].secretKey; // deployer, user1, user2, user3 + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + const balance = await dydxToken.connect(deployer.signer).balanceOf(user3.address); + + // Publish vote by signature using other address as relayer + const votePermitTx = await governor + .connect(user3.signer) + .submitVoteBySignature(proposal2Id, true, v, r, s); + + await expect(Promise.resolve(votePermitTx)) + .to.emit(governor, 'VoteEmitted') + .withArgs(proposal2Id, user3.address, true, balance); + }); + }); + + describe('Testing cancel function on active proposal', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('active') || '1'); + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + snapshots.set('active', await evmSnapshot()); + }); + + it('should not cancel when Threshold is higher than minimum', async () => { + const { + governor, + users: [user], + } = testEnv; + // giving threshold power + await setBalance(user, minimumCreatePower, testEnv); + // no threshold + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'PROPOSITION_CANCELLATION_INVALID' + ); + }); + + it('should cancel a active proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + + it('should not cancel when proposition already canceled', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + // cancelled + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // active + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'ONLY_BEFORE_EXECUTED' + ); + }); + + it('should cancel an active proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + + // active + await expectProposalState(proposal2Id, proposalStates.ACTIVE, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + }); + + describe('Testing cancel function pending proposal', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('pending') || '1'); + await expectProposalState(proposal2Id, proposalStates.PENDING, testEnv); + snapshots.set('pending', await evmSnapshot()); + }); + + it('should not cancel when Threshold is higher than minimum', async () => { + const { + governor, + users: [user], + } = testEnv; + // giving threshold power + await setBalance(user, minimumCreatePower, testEnv); + // no threshold + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'PROPOSITION_CANCELLATION_INVALID' + ); + }); + + it('should cancel a pending proposal when threshold lost', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // pending + await expectProposalState(proposal2Id, proposalStates.PENDING, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + }); + + it('should not cancel when proposition already canceled', async () => { + const { + governor, + users: [user], + } = testEnv; + // removing threshold power + // cancelled + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + // pending + await expectProposalState(proposal2Id, proposalStates.PENDING, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)) + .to.emit(governor, 'ProposalCanceled') + .withArgs(proposal2Id) + .to.emit(executor, 'CancelledAction'); + await expectProposalState(proposal2Id, proposalStates.CANCELED, testEnv); + await expect(governor.connect(user.signer).cancel(proposal2Id)).to.be.revertedWith( + 'ONLY_BEFORE_EXECUTED' + ); + }); + }); + + describe('Testing create function', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('start') || '1'); + snapshots.set('start', await evmSnapshot()); + const {governor} = testEnv; + let currentCount = await governor.getProposalsCount(); + proposal2Id = currentCount.eq('0') ? currentCount : currentCount.sub('1'); + }); + + it('should not create a proposal when proposer has not enough power', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give not enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower.sub('1'), testEnv); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'PROPOSITION_CREATION_INVALID' + ); + }); + + it('should create proposal when enough power', async () => { + const { + governor, + users: [user], + strategy, + } = testEnv; + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // give enough power + await setBalance(user, minimumCreatePower, testEnv); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + const tx = await governor.connect(user.signer).create(...params); + // Check ProposalCreated event + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params; + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + await expectProposalState(count, proposalStates.PENDING, testEnv); + }); + + it('should create proposal when enough power via delegation', async () => { + const { + governor, + users: [user, user2], + dydxToken, + strategy, + } = testEnv; + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // give enough power + await setBalance(user, minimumCreatePower.div('2').add('1'), testEnv); + await setBalance(user2, minimumCreatePower.div('2').add('1'), testEnv); + await waitForTx(await dydxToken.connect(user2.signer).delegate(user.address)); + + // Params for proposal + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + const tx = await governor.connect(user.signer).create(...params); + // Check ProposalCreated event + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params; + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + await expectProposalState(count, proposalStates.PENDING, testEnv); + }); + + it('should not create a proposal without targets', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with no target + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + }); + + it('should not create a proposal with unauthorized executor', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with not authorized user as executor + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [user.address, [ZERO_ADDRESS], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'EXECUTOR_NOT_AUTHORIZED' + ); + }); + + it('should not create a proposal with less targets than calldata', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + // Params with no target + const params: [ + string, + string[], + BigNumberish[], + string[], + BytesLike[], + boolean[], + BytesLike + ] = [executor.address, [], ['0'], [''], ['0x'], [false], ipfsBytes32Hash]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params)).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + }); + + it('should not create a proposal with inconsistent data', async () => { + const { + governor, + users: [user], + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + // Count current proposal id + const count = await governor.connect(user.signer).getProposalsCount(); + + const params: ( + targetsLength: number, + valuesLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [string, string[], BigNumberish[], string[], BytesLike[], boolean[], BytesLike] = ( + targetsLength: number, + valueLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [ + executor.address, + Array(targetsLength).fill(ZERO_ADDRESS), + Array(valueLength).fill('0'), + Array(signaturesLength).fill(''), + Array(calldataLength).fill('0x'), + Array(withDelegatesLength).fill(false), + ipfsBytes32Hash, + ]; + + // Create proposal + await expect(governor.connect(user.signer).create(...params(2, 1, 1, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 2, 1, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(0, 1, 1, 1, 1))).to.be.revertedWith( + 'INVALID_EMPTY_TARGETS' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 2, 1, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 1, 2, 1))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + await expect(governor.connect(user.signer).create(...params(1, 1, 1, 1, 2))).to.be.revertedWith( + 'INCONSISTENT_PARAMS_LENGTH' + ); + }); + + it('should create a proposals with different data lengths', async () => { + const { + governor, + users: [user], + strategy, + } = testEnv; + // Give enough DYDX for proposition tokens + await setBalance(user, minimumCreatePower, testEnv); + + const params: ( + targetsLength: number, + valuesLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [string, string[], BigNumberish[], string[], BytesLike[], boolean[], BytesLike] = ( + targetsLength: number, + valueLength: number, + signaturesLength: number, + calldataLength: number, + withDelegatesLength: number + ) => [ + executor.address, + Array(targetsLength).fill(ZERO_ADDRESS), + Array(valueLength).fill('0'), + Array(signaturesLength).fill(''), + Array(calldataLength).fill('0x'), + Array(withDelegatesLength).fill(false), + ipfsBytes32Hash, + ]; + for (let i = 1; i < 12; i++) { + const count = await governor.connect(user.signer).getProposalsCount(); + const tx = await governor.connect(user.signer).create(...params(i, i, i, i, i)); + const startBlock = BigNumber.from(tx.blockNumber).add(votingDelay); + const endBlock = startBlock.add(votingDuration); + const [ + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + ipfsHash, + ] = params(i, i, i, i, i); + + await expect(Promise.resolve(tx)) + .to.emit(governor, 'ProposalCreated') + .withArgs( + count, + user.address, + executorAddress, + targets, + values, + signatures, + calldatas, + withDelegateCalls, + startBlock, + endBlock, + strategy.address, + ipfsHash + ); + } + }); + }); +}); +makeSuite( + 'dYdX Governance tests: admin functions', + deployPhase2, + (testEnv: TestEnv) => { + describe('Testing setter functions', async function () { + it('Set governance strategy', async () => { + const {governor, deployer, dydxToken, safetyModule} = testEnv; + + const strategy = await deployGovernanceStrategy(dydxToken.address, safetyModule.address); + + // Set new strategy + await governor.connect(deployer.signer).setGovernanceStrategy(strategy.address); + const govStrategy = await governor.getGovernanceStrategy(); + + expect(govStrategy).to.equal(strategy.address); + }); + + it('Set voting delay', async () => { + const {governor, deployer} = testEnv; + + // Set voting delay + await governor.connect(deployer.signer).setVotingDelay('10'); + const govVotingDelay = await governor.getVotingDelay(); + + expect(govVotingDelay).to.equal('10'); + }); + }); + describe('Testing executor auth/unautho functions', async function () { + it('Unauthorize executor', async () => { + const {governor, deployer, shortTimelock} = testEnv; + + // Unauthorize executor + await governor.connect(deployer.signer).unauthorizeExecutors([shortTimelock.address]); + const isAuthorized = await governor + .connect(deployer.signer) + .isExecutorAuthorized(shortTimelock.address); + + expect(isAuthorized).to.equal(false); + }); + + it('Authorize executor', async () => { + const { + governor, + deployer, // is owner of gov + shortTimelock, + } = testEnv; + + // Authorize + await governor.connect(deployer.signer).authorizeExecutors([shortTimelock.address]); + const isAuthorized = await governor + .connect(deployer.signer) + .isExecutorAuthorized(shortTimelock.address); + + expect(isAuthorized).to.equal(true); + }); + }); + } +); diff --git a/test/governance/priority-executor.spec.ts b/test/governance/priority-executor.spec.ts new file mode 100644 index 0000000..7776234 --- /dev/null +++ b/test/governance/priority-executor.spec.ts @@ -0,0 +1,614 @@ +import { expect, use } from 'chai'; +import { solidity } from 'ethereum-waffle'; +import { BigNumber, Signer } from 'ethers'; +import { + keccak256, + defaultAbiCoder, +} from 'ethers/lib/utils'; +import { + makeSuite, + TestEnv, + noDeploy, +} from '../test-helpers/make-suite'; +import { + evmRevert, + evmSnapshot, + timeLatest, + increaseTimeAndMine, +} from '../../helpers/misc-utils'; +import { getEthersSigners } from '../../helpers/contracts-helpers'; +import { + deployPriorityExecutor, + deployMintableErc20, +} from '../../helpers/contracts-deployments'; +import { PriorityExecutor } from '../../types/PriorityExecutor'; +import { MintableErc20 } from '../../types/MintableErc20'; +import rawBRE from 'hardhat'; + +type ExecutorTx = { + target: string; + value: string; + signature: string; + data: string; + executionTime: number; + withDelegatecall: boolean; +} + +const snapshots = new Map(); +const afterDeploy = 'AfterDeploy'; +const afterQueueTx = 'AfterQueueTx'; + +makeSuite('dYdX priority executor tests', noDeploy, (_: TestEnv) => { + let priorityExecutorWithAdmin: PriorityExecutor; + let priorityExecutorWithController: PriorityExecutor; + let adminSigner: Signer; + let priorityControllerSigner: Signer; + let testSigner: Signer; + const delay: BigNumber = BigNumber.from(60); // 1 minute + const gracePeriod: BigNumber = BigNumber.from(60 * 2); // 2 minutes + const minDelay: BigNumber = BigNumber.from(30); // 30 seconds + const maxDelay: BigNumber = BigNumber.from(60); // 2 minutes + const priorityPeriod: BigNumber = BigNumber.from(30); // 30 seconds + + before(async () => { + [adminSigner, priorityControllerSigner, testSigner] = await getEthersSigners(); + + const fillerParam: string = BigNumber.from(1).toString() + + priorityExecutorWithAdmin = await deployPriorityExecutor( + await adminSigner.getAddress(), + delay.toString(), + gracePeriod.toString(), + minDelay.toString(), + maxDelay.toString(), + priorityPeriod.toString(), + // Following 4 are not relevant to these tests but necessary for + // creating a priority executor + fillerParam, + fillerParam, + fillerParam, + fillerParam, + await priorityControllerSigner.getAddress(), + ); + + priorityExecutorWithController = priorityExecutorWithAdmin.connect(priorityControllerSigner); + + await saveSnapshot(afterDeploy); + }); + + describe('when an action is unlocked for priority execution', () => { + let testTx: ExecutorTx; + let actionHash: string; + + before(async () => { + const testToken: MintableErc20 = await deployMintableErc20( + 'test', + 'TEST', + 18, + ); + + // Make a test transaction and get the action hash. + const adminAddress = await adminSigner.getAddress(); + const callData = testToken.interface.encodeFunctionData('mint', [adminAddress, BigNumber.from(7)]); + const now = await timeLatest(); + testTx = { + target: testToken.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + // Set the priority status to unlocked for execution during the priority window. + await expect( + priorityExecutorWithController.setTransactionPriorityStatus(actionHash, true)) + .to + .emit(priorityExecutorWithController, 'UpdatedActionPriorityStatus') + .withArgs(actionHash, true); + + expect(await priorityExecutorWithAdmin.hasPriorityStatus(actionHash)).to.be.true; + + await saveSnapshot(afterQueueTx); + }) + + afterEach(async () => { + await loadSnapshot(afterQueueTx); + }); + + after(async () => { + await loadSnapshot(afterDeploy); + }); + + it('Allows admin to execute the transaction in priority period', async () => { + const priorityPeriodStart = BigNumber.from(testTx.executionTime).sub(priorityPeriod); + await advanceTimeTo(priorityPeriodStart); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ); + }); + + it('Admin cannot execute the transaction before priority window', async () => { + const priorityPeriodStart = BigNumber.from(testTx.executionTime).sub(priorityPeriod); + + // Advance to just before the start of the priority window. + await advanceTimeTo(priorityPeriodStart.sub(100)); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to.be.revertedWith('NOT_IN_PRIORITY_WINDOW'); + }); + + it('Authorized executor cannot set priority status on a transaction that was not queued', async () => { + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setPriorityPeriod', [delay]); + const now = await timeLatest(); + + // Make a new transaction and action hash. + const tx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + const actionHash = keccak256( + defaultAbiCoder.encode( + ['address', 'uint256', 'string', 'bytes', 'uint256', 'bool'], + [tx.target, tx.value, tx.signature, tx.data, tx.executionTime, tx.withDelegatecall], + ) + ); + + await expect( + priorityExecutorWithController.setTransactionPriorityStatus(actionHash, true)) + .to.be.revertedWith('ACTION_NOT_QUEUED'); + expect(await priorityExecutorWithAdmin.hasPriorityStatus(actionHash)).to.be.false; + }); + + it('Admin cannot execute the transaction in priority period if priority status is revoked', async () => { + const priorityPeriodStart = BigNumber.from(testTx.executionTime).sub(priorityPeriod); + await advanceTimeTo(priorityPeriodStart); + + await expect( + priorityExecutorWithController.setTransactionPriorityStatus(actionHash, false)) + .to + .emit(priorityExecutorWithController, 'UpdatedActionPriorityStatus') + .withArgs(actionHash, false); + expect(await priorityExecutorWithAdmin.hasPriorityStatus(actionHash)).to.be.false; + + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to.be.revertedWith('TIMELOCK_NOT_FINISHED'); + }); + }); + + describe('setPriorityPeriod', () => { + afterEach(async () => { + await loadSnapshot(afterDeploy); + }); + + it('The timelock can update its own priority period and set it equal to the delay', async () => { + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setPriorityPeriod', [delay]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'NewPriorityPeriod') + .withArgs(delay); + + const newPriorityPeriod: BigNumber = await priorityExecutorWithAdmin.getPriorityPeriod(); + expect(newPriorityPeriod).to.equal(delay); + }); + + it('The timelock can update its own priority period and set it equal to 0', async () => { + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setPriorityPeriod', [BigNumber.from(0)]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'NewPriorityPeriod') + .withArgs(0); + + const newPriorityPeriod: BigNumber = await priorityExecutorWithAdmin.getPriorityPeriod(); + expect(newPriorityPeriod).to.equal(0); + }); + + it('Cannot set a priority period greater than delay', async () => { + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setPriorityPeriod', [delay.add(1)]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to.be.revertedWith('FAILED_ACTION_EXECUTION'); + }); + }); + + describe('updatePriorityController', () => { + afterEach(async () => { + await loadSnapshot(afterDeploy); + }); + + it('Priority timelock can add a priority executor', async () => { + const testSignerAddress = await testSigner.getAddress(); + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('updatePriorityController', [testSignerAddress, true]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'PriorityControllerUpdated') + .withArgs(testSignerAddress, true); + + expect(await priorityExecutorWithAdmin.isPriorityController(testSignerAddress)).to.be.true; + }); + + it('Priority timelock can remove a priority executor', async () => { + const priorityExecutorSignerAddress = await priorityControllerSigner.getAddress(); + + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('updatePriorityController', [await priorityControllerSigner.getAddress(), false]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'PriorityControllerUpdated') + .withArgs(priorityExecutorSignerAddress, false); + + expect(await priorityExecutorWithAdmin.isPriorityController(priorityExecutorSignerAddress)).to.be.false; + }); + }); + + describe('setDelay', () => { + afterEach(async () => { + await loadSnapshot(afterDeploy); + }); + + it('The timelock can update the delay to a value greater than the priority period', async () => { + const newDelay: BigNumber = delay.sub(1); + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setDelay', [newDelay]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'NewDelay') + .withArgs(newDelay); + }); + + it('The timelock can update the delay to a value equal to the priority period', async () => { + const newDelay: BigNumber = priorityPeriod; + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setDelay', [newDelay]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + const actionHash = await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to + .emit(priorityExecutorWithAdmin, 'ExecutedAction') + .withArgs( + actionHash, + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + defaultAbiCoder.encode([], []), + ) + .emit(priorityExecutorWithAdmin, 'NewDelay') + .withArgs(newDelay); + }); + + it('Cannot set a delay less than the priority period', async () => { + const badDelay: BigNumber = priorityPeriod.sub(1); + const callData = priorityExecutorWithAdmin.interface.encodeFunctionData('setDelay', [badDelay]); + + const now = await timeLatest(); + + const testTx = { + target: priorityExecutorWithAdmin.address, + value: '0', + signature: '', + data: callData, + executionTime: now.plus(delay.toString()).plus(10).toNumber(), // 70 seconds in future + withDelegatecall: false, + }; + + await queueTransaction(priorityExecutorWithAdmin, testTx); + + await advanceTimeTo(BigNumber.from(testTx.executionTime.toString())); + await expect( + priorityExecutorWithAdmin.executeTransaction( + testTx.target, + testTx.value, + testTx.signature, + testTx.data, + testTx.executionTime, + testTx.withDelegatecall, + )) + .to.be.revertedWith('FAILED_ACTION_EXECUTION'); + }); + }); +}); + +async function advanceTimeTo(timestamp: BigNumber): Promise { + const latestBlockTimestamp = await timeLatest(); + const diff = timestamp.sub(latestBlockTimestamp.toString()).toNumber(); + await increaseTimeAndMine(diff); +} + +async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); +} + +async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); +} + +// queues a transaction and returns an action hash +async function queueTransaction( + priorityExecutor: PriorityExecutor, + tx: ExecutorTx, +): Promise { + const actionHash = keccak256( + defaultAbiCoder.encode( + ['address', 'uint256', 'string', 'bytes', 'uint256', 'bool'], + [tx.target, tx.value, tx.signature, tx.data, tx.executionTime, tx.withDelegatecall], + ) + ); + await expect( + priorityExecutor.queueTransaction( + tx.target, + tx.value, + tx.signature, + tx.data, + tx.executionTime, + tx.withDelegatecall, + )) + .to + .emit(priorityExecutor, 'QueuedAction') + .withArgs( + actionHash, + tx.target, + tx.value, + tx.signature, + tx.data, + tx.executionTime, + tx.withDelegatecall, + ); + + return actionHash; +} diff --git a/test/governance/token.spec.ts b/test/governance/token.spec.ts new file mode 100644 index 0000000..f4097b2 --- /dev/null +++ b/test/governance/token.spec.ts @@ -0,0 +1,631 @@ +import { BigNumber, BigNumberish, Event } from 'ethers'; +import { expect, use } from 'chai'; +import { solidity } from 'ethereum-waffle'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + ZERO_ADDRESS, + ONE_DAY as ONE_DAY_BN, + ONE_YEAR as ONE_YEAR_BN, +} from '../../helpers/constants'; +import { getDydxTokenWithSigner } from '../../helpers/contracts-getters'; +import { + evmRevert, + evmSnapshot, + increaseTimeAndMine, + timeLatest, + waitForTx, +} from '../../helpers/misc-utils'; +import { DEPLOY_CONFIG } from '../../tasks/helpers/deploy-config'; +import { toWad } from '../../helpers/misc-utils'; +import { tEthereumAddress } from '../../helpers/types'; +import { DydxToken } from '../../types/DydxToken'; +import { GovernanceStrategy } from '../../types/GovernanceStrategy'; + +const INITIAL_SUPPLY = BigNumber.from(10).pow(27); +const ONE_YEAR = ONE_YEAR_BN.toNumber(); // The minting interval. + +const snapshots = new Map(); + +const afterTokenDeploy: string = 'afterTokenDeploy'; + +makeSuite( + 'dYdX Token Tests', + deployPhase2, + (testEnv: TestEnv) => { + let distributor: SignerWithAddress; + let executor: SignerWithAddress; + let dydxToken: DydxToken; + let dydxTokenWithDistributorSigner: DydxToken; + let dydxTokenWithGovernanceSigner: DydxToken; + let dydxTokenWithNonOwnerSigner: DydxToken; + let strategy: GovernanceStrategy; + let transfersRestrictedBefore: BigNumber; + let transferRestrictionLiftedNoLaterThan: BigNumber; + let mintingRestrictedBefore: BigNumber; + let tokenDeployedBlockNumber: number; + + before(async () => { + distributor = testEnv.deployer; + executor = testEnv.users[0]; + dydxToken = testEnv.dydxToken; + dydxTokenWithDistributorSigner = await getDydxTokenWithSigner(distributor.signer); + dydxTokenWithGovernanceSigner = await getDydxTokenWithSigner(distributor.signer); + dydxTokenWithNonOwnerSigner = dydxToken.connect(testEnv.users[1].signer); + strategy = testEnv.strategy; + transfersRestrictedBefore = await dydxToken._transfersRestrictedBefore(); + transferRestrictionLiftedNoLaterThan = await dydxToken.TRANSFER_RESTRICTION_LIFTED_NO_LATER_THAN(); + mintingRestrictedBefore = await dydxToken._mintingRestrictedBefore(); + + tokenDeployedBlockNumber = await getLatestMintBlockNumber(dydxToken); + + snapshots.set(afterTokenDeploy, await evmSnapshot()) + }); + + describe('Constants', () => { + it("Has given name", async () => { + expect(await dydxToken.name()).to.equal('dYdX'); + }) + + it("Has given symbol", async () => { + expect(await dydxToken.symbol()).to.equal('DYDX'); + }); + + it("Has 18 decimals", async () => { + expect(await dydxToken.decimals()).to.equal(18); + }); + + it("Has initial supply of one billion", async () => { + expect(await dydxToken.INITIAL_SUPPLY()).to.equal(INITIAL_SUPPLY); + }); + + it("Has mint min interval of one year", async () => { + expect(await dydxToken.MINT_MIN_INTERVAL()).to.equal(ONE_YEAR); + }); + + it("Has mint max percent of 2", async () => { + expect(await dydxToken.MINT_MAX_PERCENT()).to.equal(2); + }); + + it("Owner is set to token distributor", async () => { + expect(await dydxToken.owner()).to.equal(distributor.address); + }); + + it("Distributor and treasuries are in transfer allowlist", async () => { + expect(await dydxToken._tokenTransferAllowlist(distributor.address)).to.be.true; + expect(await dydxToken._tokenTransferAllowlist(testEnv.rewardsTreasury.address)).to.be.true; + expect(await dydxToken._tokenTransferAllowlist(testEnv.communityTreasury.address)).to.be.true; + }); + + it("Has the EIP 712 domain hash", async () => { + expect(await dydxToken.EIP712_DOMAIN()).to.equal( + '0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f', + ); + }); + + it("Has the permit typehash", async () => { + expect(await dydxToken.PERMIT_TYPEHASH()).to.equal( + '0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9', + ); + }); + + it("Has initial total supply snapshot and voting + proposition supply", async () => { + await expectTotalSupplySnapshotsCount(1); + await expectTotalSupplySnapshot(0, tokenDeployedBlockNumber, INITIAL_SUPPLY); + + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber, INITIAL_SUPPLY); + }); + + it("voting + proposition supply is 0 before deployment block number", async () => { + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber - 1, 0); + }); + + it("voting + proposition supply is initial supply after deployment block number", async () => { + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber + 1, INITIAL_SUPPLY); + }); + }); + + describe('balanceOf', () => { + it("Grants 100% of supply - rewards treasury frontloaded funds to distributor", async () => { + const distributorBalance: BigNumber = await dydxToken.balanceOf(testEnv.deployer.address); + expect(distributorBalance).to.equal( + BigNumber.from(toWad(1_000_000_000)).sub(DEPLOY_CONFIG.REWARDS_TREASURY.FRONTLOADED_FUNDS)); + + const totalSupply: BigNumber = await dydxToken.totalSupply(); + expect(totalSupply).to.equal(distributorBalance.add(DEPLOY_CONFIG.REWARDS_TREASURY.FRONTLOADED_FUNDS)); + }) + }); + + describe('mint', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Non-minter addresses cannot mint", async () => { + await expect(dydxTokenWithNonOwnerSigner.mint(executor.address, 1)) + .to.be.revertedWith('Ownable: caller is not the owner'); + }) + + it("Reverts if minter mints before _mintingRestrictedBefore", async () => { + await advanceTimeTo(transfersRestrictedBefore); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)).to.be.revertedWith( + 'MINT_TOO_EARLY', + ); + }) + + it("Reverts if mint recipient is the zero address", async () => { + await advanceTimeTo(mintingRestrictedBefore); + await expect(dydxTokenWithGovernanceSigner.mint(ZERO_ADDRESS, 1)).to.be.revertedWith( + 'ERC20: mint to the zero address', + ); + }) + + it("Minter can mint at time _mintingRestrictedBefore", async () => { + const mintAmount = 1; + + await advanceTimeTo(mintingRestrictedBefore); + const balanceBefore = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, mintAmount)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer') + .withArgs(ZERO_ADDRESS, executor.address, mintAmount); + const balanceAfter = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + expect(balanceAfter.sub(balanceBefore)).to.equal(mintAmount); + const mintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterMint: BigNumber = INITIAL_SUPPLY.add(mintAmount); + + // Check total supply. + expect(await dydxToken.totalSupply()).to.equal(supplyAfterMint); + + // Check total supply snapshots. + await expectTotalSupplySnapshotsCount(2); + await expectTotalSupplySnapshot(0, tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalSupplySnapshot(1, mintBlockNumber, supplyAfterMint); + + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(mintBlockNumber, supplyAfterMint); + + // blocks directly after mints should have the same supply + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber + 1, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(mintBlockNumber + 1, supplyAfterMint); + }) + + it("Minter can mint after _mintingRestrictedBefore", async () => { + const mintAmount = 1; + + await advanceTimeTo(mintingRestrictedBefore.add(1000)); + const balanceBefore = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, mintAmount)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer') + .withArgs(ZERO_ADDRESS, executor.address, mintAmount); + const balanceAfter = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + expect(balanceAfter.sub(balanceBefore)).to.equal(mintAmount); + const mintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterMint: BigNumber = INITIAL_SUPPLY.add(mintAmount); + + // Check total supply. + expect(await dydxToken.totalSupply()).to.equal(supplyAfterMint); + + // Check total supply snapshots. + await expectTotalSupplySnapshotsCount(2); + await expectTotalSupplySnapshot(0, tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalSupplySnapshot(1, mintBlockNumber, supplyAfterMint); + + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(mintBlockNumber, supplyAfterMint); + + // blocks directly after mints should have the same supply + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber + 1, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(mintBlockNumber + 1, supplyAfterMint); + }) + + it("Minter can mint 2% of the total supply", async () => { + await advanceTimeTo(mintingRestrictedBefore.add(1000)); + const balanceBefore = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + const mintAmount = INITIAL_SUPPLY.div(50); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, mintAmount)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer') + .withArgs(ZERO_ADDRESS, executor.address, mintAmount); + const balanceAfter = await dydxTokenWithGovernanceSigner.balanceOf(executor.address); + expect(balanceAfter.sub(balanceBefore)).to.equal(mintAmount); + const firstMintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterFirstMint = INITIAL_SUPPLY.add(mintAmount); + + // Mint second year. + await advanceTimeTo(mintingRestrictedBefore.add(1100).add(ONE_YEAR).add(100)); + const secondMintAmount = mintAmount.mul(102).div(100); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, secondMintAmount)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer') + .withArgs(ZERO_ADDRESS, executor.address, secondMintAmount); + const secondMintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterSecondMint = INITIAL_SUPPLY.add(mintAmount).add(secondMintAmount); + + // Check total suppply. + expect(await dydxToken.totalSupply()).to.equal(INITIAL_SUPPLY.add(mintAmount).add(secondMintAmount)); + + // Check total supply snapshots. + await expectTotalSupplySnapshotsCount(3); + await expectTotalSupplySnapshot(0, tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalSupplySnapshot(1, firstMintBlockNumber, INITIAL_SUPPLY.add(mintAmount)); + await expectTotalSupplySnapshot(2, secondMintBlockNumber, INITIAL_SUPPLY.add(mintAmount).add(secondMintAmount)); + + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(firstMintBlockNumber, supplyAfterFirstMint); + await expectTotalVotingAndPropositionSupply(secondMintBlockNumber, supplyAfterSecondMint); + + // blocks directly after mints should have the same supply + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber + 1, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(firstMintBlockNumber + 1, supplyAfterFirstMint); + await expectTotalVotingAndPropositionSupply(secondMintBlockNumber + 1, supplyAfterSecondMint); + }) + + it("Minter cannot mint more than 2% of the total supply", async () => { + await advanceTimeTo(mintingRestrictedBefore.add(1000)); + const mintAmount = INITIAL_SUPPLY.div(50).add(1); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, mintAmount)).to.be.revertedWith( + 'MAX_MINT_EXCEEDED', + ); + }) + + it("Reverts if minting again before the min interval has elapsed", async() => { + await advanceTimeTo(mintingRestrictedBefore.add(1000)); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer'); + await advanceTimeTo(mintingRestrictedBefore.add(1000).add(ONE_YEAR - 1)); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)).to.be.revertedWith( + 'MINT_TOO_EARLY', + ); + }) + + it("Minter can mint multiple times, if at least the min interval has elapsed", async () => { + // Advance time to after mint restriction + await advanceTimeTo(mintingRestrictedBefore.add(1000)); + + // Do the first and second mints. + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer'); + const firstMintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterFirstMint: BigNumber = INITIAL_SUPPLY.add(1); + await advanceTimeTo(mintingRestrictedBefore.add(1000).add(ONE_YEAR + 25)); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer'); + const secondMintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterSecondMint: BigNumber = INITIAL_SUPPLY.add(1).add(1); + + // Mine some extra blocks, for the sake of testing the snapshot logic. + for (let i = 0; i < 10; i++) { + await dydxTokenWithDistributorSigner.transfer(executor.address, 0); + } + + // Mint a third time. + await advanceTimeTo(mintingRestrictedBefore.add(1000).add(ONE_YEAR * 2 + 50)); + await expect(dydxTokenWithGovernanceSigner.mint(executor.address, 1)) + .to.emit(dydxTokenWithGovernanceSigner, 'Transfer'); + const thirdMintBlockNumber = await getLatestMintBlockNumber(dydxToken); + const supplyAfterThirdMint: BigNumber = INITIAL_SUPPLY.add(1).add(1).add(1); + + // Check total suppply. + expect(await dydxToken.totalSupply()).to.equal(INITIAL_SUPPLY.add(3)); + + // Check total supply snapshots. + await expectTotalSupplySnapshotsCount(4); + await expectTotalSupplySnapshot(0, tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalSupplySnapshot(1, firstMintBlockNumber, INITIAL_SUPPLY.add(1)); + await expectTotalSupplySnapshot(2, secondMintBlockNumber, INITIAL_SUPPLY.add(2)); + await expectTotalSupplySnapshot(3, thirdMintBlockNumber, INITIAL_SUPPLY.add(3)); + + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(firstMintBlockNumber, supplyAfterFirstMint); + await expectTotalVotingAndPropositionSupply(secondMintBlockNumber, supplyAfterSecondMint); + await expectTotalVotingAndPropositionSupply(thirdMintBlockNumber, supplyAfterThirdMint); + + // blocks directly after mints should have the same supply + await expectTotalVotingAndPropositionSupply(tokenDeployedBlockNumber + 1, INITIAL_SUPPLY); + await expectTotalVotingAndPropositionSupply(firstMintBlockNumber + 1, supplyAfterFirstMint); + await expectTotalVotingAndPropositionSupply(secondMintBlockNumber + 1, supplyAfterSecondMint); + await expectTotalVotingAndPropositionSupply(thirdMintBlockNumber + 1, supplyAfterThirdMint); + }) + }); + + describe('addToTokenTransferAllowlist', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Non-owner addresses cannot add to transfer allowlist", async () => { + await expect(dydxTokenWithNonOwnerSigner.addToTokenTransferAllowlist([testEnv.users[1].address])).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }) + + it("Governance can add to transfer allowlist", async () => { + const userAddress: tEthereumAddress = testEnv.users[1].address; + await expect(dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress])) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress, true); + }) + + it("Governance can add multiple addresses to transfer allowlist", async () => { + const userAddress1: tEthereumAddress = testEnv.users[1].address; + const userAddress2: tEthereumAddress = testEnv.users[2].address; + await expect(dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress1, userAddress2])) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress1, true) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress2, true); + }) + + it("Adding addresses that already exist in transfer allowlist fails", async () => { + const userAddress: tEthereumAddress = testEnv.users[1].address; + await dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress]); + + await expect(dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress])).to.be.revertedWith( + 'ADDRESS_EXISTS_IN_TRANSFER_ALLOWLIST', + ); + }) + }); + + describe('removeFromTokenTransferAllowlist', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Non-owner addresses cannot remove from transfer allowlist", async () => { + await expect(dydxTokenWithNonOwnerSigner.removeFromTokenTransferAllowlist([testEnv.users[1].address])).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }) + + it("Governance can remove from transfer allowlist", async () => { + const userAddress: tEthereumAddress = testEnv.users[1].address; + await dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress]); + + await expect(dydxTokenWithGovernanceSigner.removeFromTokenTransferAllowlist([userAddress])) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress, false); + }) + + it("Governance can remove multiple addresses to transfer allowlist", async () => { + const userAddress1: tEthereumAddress = testEnv.users[1].address; + const userAddress2: tEthereumAddress = testEnv.users[2].address; + await dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([userAddress1, userAddress2]); + + await expect(dydxTokenWithGovernanceSigner.removeFromTokenTransferAllowlist([userAddress1, userAddress2])) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress1, false) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransferAllowlistUpdated') + .withArgs(userAddress2, false); + }) + + it("Removing addresses that don't exist in transfer allowlist fails", async () => { + await expect(dydxTokenWithGovernanceSigner.removeFromTokenTransferAllowlist([testEnv.users[1].address])).to.be.revertedWith( + 'ADDRESS_DOES_NOT_EXIST_IN_TRANSFER_ALLOWLIST', + ); + }) + }); + + describe('updateTransfersRestrictedBefore', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Non-owner addresses cannot update transfersRestrictedBefore", async () => { + await expect(dydxTokenWithNonOwnerSigner.updateTransfersRestrictedBefore(0)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ); + }) + + it("Owner can update transfersRestrictedBefore", async () => { + const newTransfersRestrictedBefore = transfersRestrictedBefore.add(ONE_DAY_BN.toString()); + + await expect(dydxTokenWithGovernanceSigner.updateTransfersRestrictedBefore(newTransfersRestrictedBefore)) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransfersRestrictedBeforeUpdated') + .withArgs(newTransfersRestrictedBefore); + }) + + it("Owner can update transfersRestrictedBefore to value of `TRANSFER_RESTRICTION_LIFTED_NO_LATER_THAN`", async () => { + await expect(dydxTokenWithGovernanceSigner.updateTransfersRestrictedBefore(transferRestrictionLiftedNoLaterThan)) + .to + .emit(dydxTokenWithGovernanceSigner, 'TransfersRestrictedBeforeUpdated') + .withArgs(transferRestrictionLiftedNoLaterThan); + }) + + it("Owner cannot update transfersRestrictedBefore to after `TRANSFER_RESTRICTION_LIFTED_NO_LATER_THAN`", async () => { + const afterMaxTransferRestriction: BigNumber = transferRestrictionLiftedNoLaterThan.add(1); + await expect(dydxTokenWithGovernanceSigner.updateTransfersRestrictedBefore(afterMaxTransferRestriction)).to.be.revertedWith( + 'AFTER_MAX_TRANSFER_RESTRICTION', + ); + }) + + it("Owner cannot update transfersRestrictedBefore to an earlier value", async () => { + const earlierTransfersRestrictedBefore: BigNumber = transfersRestrictedBefore.sub(1); + await expect(dydxTokenWithGovernanceSigner.updateTransfersRestrictedBefore(earlierTransfersRestrictedBefore)).to.be.revertedWith( + 'NEW_TRANSFER_RESTRICTION_TOO_EARLY', + ); + }) + + it("Owner cannot update transfersRestrictedBefore after transfer restriction has ended", async () => { + await advanceTimeTo(transfersRestrictedBefore); + await expect(dydxTokenWithGovernanceSigner.updateTransfersRestrictedBefore(transferRestrictionLiftedNoLaterThan)).to.be.revertedWith( + 'TRANSFER_RESTRICTION_ENDED', + ); + }) + }); + + describe('transfer', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Transfer isn't allowed if transfers aren't enabled and address isn't in transfer allowlist", async () => { + await expect(dydxTokenWithNonOwnerSigner.transfer(testEnv.users[2].address, toWad(1))).to.be.revertedWith( + 'NON_ALLOWLIST_TRANSFERS_DISABLED', + ); + }) + + it("Transfer is allowed if transfers are enabled, even if address isn't in transfer allowlist", async () => { + await advanceTimeTo(transfersRestrictedBefore); + + const amount: number = 5000; + await expect(() => dydxTokenWithDistributorSigner.transfer(executor.address, amount)) + .to + .changeTokenBalances(dydxTokenWithDistributorSigner, [distributor.signer, executor.signer], [-amount, amount]); + }) + + it("Transfer is allowed if transfers aren't enabled but receipient is in transfer allowlist", async () => { + const user: SignerWithAddress = testEnv.users[1]; + await dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([user.address]); + + const amount: number = 1; + await expect(() => dydxTokenWithDistributorSigner.transfer(user.address, amount)) + .to + .changeTokenBalances(dydxTokenWithDistributorSigner, [distributor.signer, user.signer], [-amount, amount]); + }) + + it("Transfer is allowed if transfers aren't enabled but sender is in transfer allowlist", async () => { + const amount: number = 777; + await expect(() => dydxTokenWithDistributorSigner.transfer(testEnv.users[1].address, amount)) + .to + .changeTokenBalances(dydxTokenWithDistributorSigner, [distributor.signer, testEnv.users[1].signer], [-amount, amount]); + }) + }); + + describe('transferFrom', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("TransferFrom isn't allowed if transfers aren't enabled and address isn't in transfer allowlist", async () => { + const amount: number = 999999999999; + const txSender: SignerWithAddress = testEnv.users[1]; + const fundsSender: SignerWithAddress = testEnv.users[2]; + const recipient: SignerWithAddress = testEnv.users[3]; + + await waitForTx(await dydxToken.transfer(fundsSender.address, amount)); + await approveForSender(fundsSender, txSender, recipient, amount); + + const dydxTokenWithSenderSigner = await getDydxTokenWithSigner(txSender.signer); + + await expect(dydxTokenWithSenderSigner.transferFrom(fundsSender.address, recipient.address, amount)).to.be.revertedWith( + 'NON_ALLOWLIST_TRANSFERS_DISABLED', + ); + }) + + it("TransferFrom is allowed if transfers are enabled, even if address isn't in transfer allowlist", async () => { + await advanceTimeTo(transfersRestrictedBefore); + + const amount: number = 889999999999; + + const txSender: SignerWithAddress = testEnv.users[1]; + const fundsSender: SignerWithAddress = testEnv.users[2]; + const recipient: SignerWithAddress = testEnv.users[3]; + await waitForTx(await dydxToken.transfer(fundsSender.address, amount)); + await approveForSender(fundsSender, txSender, recipient, amount); + + const dydxTokenWithSenderSigner = await getDydxTokenWithSigner(txSender.signer); + + await expect(() => dydxTokenWithSenderSigner.transferFrom(fundsSender.address, recipient.address, amount)) + .to + .changeTokenBalances(dydxTokenWithSenderSigner, [fundsSender.signer, recipient.signer], [-amount, amount]); + }) + + it("TransferFrom is allowed if transfers aren't enabled but recipient is in transfer allowlist", async () => { + const amount: number = 889999999969; + const txSender: SignerWithAddress = testEnv.users[1]; + const fundsSender: SignerWithAddress = testEnv.users[2]; + const recipient: SignerWithAddress = testEnv.users[3]; + + await waitForTx(await dydxToken.transfer(fundsSender.address, amount)); + await approveForSender(fundsSender, txSender, recipient, amount); + + await dydxTokenWithGovernanceSigner.addToTokenTransferAllowlist([recipient.address]); + + const dydxTokenWithSenderSigner = await getDydxTokenWithSigner(txSender.signer); + + await expect(() => dydxTokenWithSenderSigner.transferFrom(fundsSender.address, recipient.address, amount)) + .to + .changeTokenBalances(dydxTokenWithSenderSigner, [fundsSender.signer, recipient.signer], [-amount, amount]); + }) + + it("TransferFrom is allowed if transfers aren't enabled but sender is in transfer allowlist", async () => { + const amount: number = 889999999969; + const sender: SignerWithAddress = testEnv.users[1]; + const recipient: SignerWithAddress = testEnv.users[2]; + await approveForSender(distributor, sender, recipient, amount); + + const dydxTokenWithSenderSigner = await getDydxTokenWithSigner(sender.signer); + + await expect(() => dydxTokenWithSenderSigner.transferFrom(distributor.address, recipient.address, amount)) + .to + .changeTokenBalances(dydxTokenWithSenderSigner, [distributor.signer, recipient.signer], [-amount, amount]); + }) + }); + + async function expectTotalSupplySnapshotsCount( + expectedCount: number, + ): Promise { + expect(await dydxToken._totalSupplySnapshotsCount()).to.equal(expectedCount); + await expectTotalSupplySnapshot(expectedCount, 0, 0); + } + + async function expectTotalSupplySnapshot( + snapshotIndex: number, + expectedBlockNumber: BigNumberish, + expectedValue: BigNumberish + ): Promise { + const snapshot = await dydxToken._totalSupplySnapshots(snapshotIndex); + expect(snapshot.blockNumber).to.equal(expectedBlockNumber); + expect(snapshot.value).to.equal(expectedValue); + } + + async function expectTotalVotingAndPropositionSupply( + expectedBlockNumber: BigNumberish, + expectedSupply: BigNumberish + ): Promise { + const [ + propositionSupplyAt, + votingSupplyAt + ]: [ + BigNumber, + BigNumber, + ] = await Promise.all([ + strategy.getTotalPropositionSupplyAt(expectedBlockNumber), + strategy.getTotalVotingSupplyAt(expectedBlockNumber), + ]); + + expect(propositionSupplyAt).to.equal(expectedSupply); + expect(votingSupplyAt).to.equal(expectedSupply); + } +}) + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterTokenDeploy) || '1'); + snapshots.set(afterTokenDeploy, await evmSnapshot()) +} + +async function approveForSender(userWithTokens: SignerWithAddress, txSender: SignerWithAddress, recipient: SignerWithAddress, amount: number): Promise { + const dydxTokenWithDistributorSigner = await getDydxTokenWithSigner(userWithTokens.signer); + await dydxTokenWithDistributorSigner.approve(txSender.address, amount); +} + +async function advanceTimeTo(timestamp: BigNumber): Promise { + const latestBlockTimestamp = await timeLatest(); + const diff = timestamp.sub(latestBlockTimestamp.toString()).toNumber(); + await increaseTimeAndMine(diff); +} + +async function getLatestMintBlockNumber(dydxToken: DydxToken): Promise { + const tokenMintFilter = dydxToken.filters.Transfer(ZERO_ADDRESS, null, null); + const mintEvents: Event[] = await dydxToken.queryFilter(tokenMintFilter); + return mintEvents[mintEvents.length - 1].blockNumber; +} diff --git a/test/governance/treasury-vester.spec.ts b/test/governance/treasury-vester.spec.ts new file mode 100644 index 0000000..841b002 --- /dev/null +++ b/test/governance/treasury-vester.spec.ts @@ -0,0 +1,108 @@ +import BNJS from 'bignumber.js'; +import { BigNumber } from 'ethers'; +import { expect } from 'chai'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmRevert, + evmSnapshot, + increaseTime, + timeLatest, + toWad, + waitForTx, +} from '../../helpers/misc-utils'; +import { TreasuryVester } from '../../types/TreasuryVester'; +import { DydxToken } from '../../types/DydxToken'; + +const snapshots = new Map(); + +makeSuite( + 'dYdX Treasury Vester tests', + deployPhase2, + (testEnv: TestEnv) => { + + let dydxToken: DydxToken; + let treasuryVester: TreasuryVester; + let treasuryVesterRecipient: SignerWithAddress; + let vestingAmount: BNJS; + let vestingBegin: BNJS; + let vestingCliff: BNJS; + let vestingEnd: BNJS; + let preBalance: BigNumber; + + before(async () => { + treasuryVester = testEnv.rewardsTreasuryVester; + treasuryVesterRecipient = testEnv.rewardsTreasury; + dydxToken = testEnv.dydxToken; + + vestingAmount = new BNJS((await testEnv.rewardsTreasuryVester.vestingAmount()).toString()); + vestingBegin = new BNJS((await testEnv.rewardsTreasuryVester.vestingBegin()).toString()); + vestingCliff = new BNJS((await testEnv.rewardsTreasuryVester.vestingCliff()).toString()); + vestingEnd = new BNJS((await testEnv.rewardsTreasuryVester.vestingEnd()).toString()); + await waitForTx(await dydxToken.transfer(treasuryVester.address, vestingAmount.toFixed())); + preBalance = await dydxToken.balanceOf(treasuryVesterRecipient.address); + + snapshots.set('fullTreasurySnapshot', await evmSnapshot()) + }); + + describe('TreasuryVester tests', async function () { + beforeEach(async () => { + await evmRevert(snapshots.get('fullTreasurySnapshot') || '1'); + }); + + it('setRecipient from non-recipient fails', async () => { + const nonTreasuryRecipient: SignerWithAddress = testEnv.users[1]; + + await expect(treasuryVester.setRecipient(nonTreasuryRecipient.address)).to.be.revertedWith( + 'SET_RECIPIENT_UNAUTHORIZED', + ); + }); + + it('Claim fails when before vestingCliff', async () => { + await expect(treasuryVester.claim()).to.be.revertedWith( + 'CLAIM_TOO_EARLY', + ); + const latestBlockTimestamp = await timeLatest(); + const secondsUntilVestingCliff: number = vestingCliff.minus(latestBlockTimestamp).toNumber(); + // before vestingCliff + await increaseTime(secondsUntilVestingCliff - 1); + await expect(treasuryVester.claim()).to.be.revertedWith( + 'CLAIM_TOO_EARLY', + ); + }); + + it('Claim succeeds when after vesting cliff but before vesting end', async () => { + const halfwayPoint: number = Math.floor(vestingEnd.minus(vestingBegin).div('2').toNumber()); + const latestBlockTimestamp = await timeLatest(); + const secondsUntilVestingBegin: number = vestingBegin.minus(latestBlockTimestamp).toNumber(); + await increaseTime(secondsUntilVestingBegin + halfwayPoint) + + await treasuryVester.claim(); + + const postBalance: BigNumber = await dydxToken.balanceOf(treasuryVesterRecipient.address); + + // expect to be vested roughly half the tokens + const vestedTokens: BNJS = new BNJS(postBalance.sub(preBalance).toString()); + const expectedVestedTokens: BNJS = vestingAmount.div(2); + expect(vestedTokens.minus(expectedVestedTokens).lte(toWad(10))).to.be.true; + }); + + it('Claim after vesting end sends all tokens to recipient', async () => { + const latestBlockTimestamp = await timeLatest(); + const secondsUntilVestingEnd: number = vestingEnd.minus(latestBlockTimestamp).toNumber(); + await increaseTime(secondsUntilVestingEnd) + + await treasuryVester.claim(); + + // rewards treasury should have received all tokens + const postBalance: BigNumber = await dydxToken.balanceOf(treasuryVesterRecipient.address); + const vestedTokens: BNJS = new BNJS(postBalance.sub(preBalance).toString()); + expect(vestedTokens.toString()).to.be.eq(vestingAmount.toString()); + }); + }); + } +); diff --git a/test/governance/treasury.spec.ts b/test/governance/treasury.spec.ts new file mode 100644 index 0000000..df41054 --- /dev/null +++ b/test/governance/treasury.spec.ts @@ -0,0 +1,134 @@ +import BNJS from 'bignumber.js'; +import { BigNumber } from 'ethers'; +import { expect } from 'chai'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmRevert, + evmSnapshot, +} from '../../helpers/misc-utils'; +import { Treasury } from '../../types/Treasury'; +import { DydxToken } from '../../types/DydxToken'; +import { tEthereumAddress } from '../../helpers/types'; + +const snapshots = new Map(); +const afterDeploy: string = 'afterDeploy'; + +makeSuite( + 'dYdX Treasury tests', + deployPhase2, + (testEnv: TestEnv) => { + + let treasury: Treasury; + let token: DydxToken; + let owner: SignerWithAddress; + let nonOwner: SignerWithAddress; + + before(async () => { + treasury = testEnv.rewardsTreasury; + owner = testEnv.deployer; + nonOwner = testEnv.users[0]; + token = testEnv.dydxToken; + + snapshots.set(afterDeploy, await evmSnapshot()) + }); + + describe('approve', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Can not call approve with non owner", async () => { + await expect(treasury.connect(nonOwner.signer).approve(token.address, nonOwner.address, 1)) + .to.be.revertedWith('Ownable: caller is not the owner'); + }) + + it("Owner can approve tokens to address", async () => { + const amount: number = 777; + + const nonOwnerAllowanceBefore: BigNumber = await token.allowance(treasury.address, nonOwner.address); + + await expect(treasury.approve(token.address, nonOwner.address, amount)) + .to.emit(token, 'Approval') + .withArgs(treasury.address, nonOwner.address, amount); + + + const nonOwnerAllowanceAfter: BigNumber = await token.allowance(treasury.address, nonOwner.address); + expect(nonOwnerAllowanceAfter.sub(nonOwnerAllowanceBefore)).to.equal(amount); + }) + }); + + describe('transfer', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Can not call transfer with non owner", async () => { + await expect(treasury.connect(nonOwner.signer).transfer(token.address, nonOwner.address, 1)) + .to.be.revertedWith('Ownable: caller is not the owner'); + }) + + it("Owner can transfer tokens to address", async () => { + const amount: number = 777; + + const [ + treasuryBalanceBefore, + nonOwnerBalanceBefore, + ]: [ + BigNumber, + BigNumber, + ] = await Promise.all([ + token.balanceOf(treasury.address), + token.balanceOf(nonOwner.address), + ]); + + await expect(treasury.transfer(token.address, nonOwner.address, amount)) + .to.emit(token, 'Transfer') + .withArgs(treasury.address, nonOwner.address, amount); + + const [ + treasuryBalanceAfter, + nonOwnerBalanceAfter, + ]: [ + BigNumber, + BigNumber, + ] = await Promise.all([ + token.balanceOf(treasury.address), + token.balanceOf(nonOwner.address), + ]); + + expect(treasuryBalanceBefore.sub(treasuryBalanceAfter)).to.equal(amount); + expect(nonOwnerBalanceAfter.sub(nonOwnerBalanceBefore)).to.equal(amount); + }) + }); + + describe('transferOwnership', () => { + beforeEach(async () => { + await revertTestChanges(); + }) + + it("Can not call transferOwnership with non owner", async () => { + await expect(treasury.connect(nonOwner.signer).transferOwnership(nonOwner.address)) + .to.be.revertedWith('Ownable: caller is not the owner'); + }) + + it("Owner can transfer ownership to address", async () => { + await expect(treasury.transferOwnership(nonOwner.address)) + .to.emit(treasury, 'OwnershipTransferred') + .withArgs(owner.address, nonOwner.address); + + const newOwner: tEthereumAddress = await treasury.owner(); + expect(newOwner).to.equal(nonOwner.address); + }) + }); + } +); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterDeploy) || '1'); + snapshots.set(afterDeploy, await evmSnapshot()) +} diff --git a/test/merkle-distributor/md1-chainlink-adapter.spec.ts b/test/merkle-distributor/md1-chainlink-adapter.spec.ts new file mode 100644 index 0000000..76d9aaf --- /dev/null +++ b/test/merkle-distributor/md1-chainlink-adapter.spec.ts @@ -0,0 +1,303 @@ +import { BigNumber } from 'ethers'; +import { expect } from 'chai'; + +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { + evmRevert, + evmSnapshot, + incrementTimeToTimestamp, + timeLatest, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { DydxToken } from '../../types/DydxToken'; +import { before } from 'mocha'; +import { Treasury } from '../../types/Treasury'; +import { Md1ChainlinkAdapter } from '../../types/Md1ChainlinkAdapter'; +import { MockChainlinkToken } from '../../types/MockChainlinkToken'; +import { DEPLOY_CONFIG, HARDHAT_MOCK_CHAINLINK_ORACLE_ADDRESS } from '../../tasks/helpers/deploy-config'; + +const FEE_AMOUNT = BigNumber.from(1e17.toFixed()); +const CHAINLINK_JOB_ID = DEPLOY_CONFIG.CHAINLINK_ADAPTER.JOB_ID; +const MOCK_IPFS_CID = `0x${'0123'.repeat(16)}`; + +const snapshots = new Map(); + +const beforeSettingMerkleRoot: string = 'beforeSettingMerkleRoot'; + +makeSuite('Merkle Distributor Chainlink Adapter', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: Treasury; + let dydxToken: DydxToken; + let mockChainlinkToken: MockChainlinkToken; + let chainlinkAdapter: Md1ChainlinkAdapter; + let merkleDistributor: MerkleDistributorV1; + let oracleExternalAdapter: SignerWithAddress; + let user: SignerWithAddress; + let otherUser: SignerWithAddress; + let tokenSupply: BigNumber; + let simpleTree: BalanceTree; + let simpleTreeRoot: string; + let waitingPeriod: number; + + before(async () => { + ({ + deployer, + chainlinkAdapter, + rewardsTreasury, + dydxToken, + mockChainlinkToken, + merkleDistributor, + } = testEnv); + + ([oracleExternalAdapter, user, otherUser] = testEnv.users); + + // Send all tokens to the rewards treasury, and set allowance on the Merkle distributor contract. + const distributorBalance: BigNumber = await dydxToken.balanceOf(rewardsTreasury.address); + await dydxToken.connect(deployer.signer).transfer(rewardsTreasury.address, distributorBalance); + tokenSupply = await dydxToken.balanceOf(rewardsTreasury.address); + + // Mint some LINK to the user so they can pay a fee when making a request. + await mockChainlinkToken.mint(user.address, FEE_AMOUNT); + await mockChainlinkToken.connect(user.signer).approve(chainlinkAdapter.address, FEE_AMOUNT.mul(100)); + + // Simple tree example that gives all tokens to a single address. + simpleTree = new BalanceTree({ + [user.address]: tokenSupply, + }); + simpleTreeRoot = simpleTree.getHexRoot(); + + // Get the waiting period. + waitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + // Advance to the start of epoch zero. + const epochParameters = await merkleDistributor.getEpochParameters(); + await incrementTimeToTimestamp(epochParameters.offset); + snapshots.set(beforeSettingMerkleRoot, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(); + }); + + describe('initialization', () => { + + it('Has Chainlink token', async () => { + expect(await chainlinkAdapter.CHAINLINK_TOKEN()).to.equal(mockChainlinkToken.address); + }); + + it('Has Merkle Distributor', async () => { + expect(await chainlinkAdapter.MERKLE_DISTRIBUTOR()).to.equal(merkleDistributor.address); + }); + + it('Has Chainlink oracle contract address', async () => { + expect(await chainlinkAdapter.ORACLE_CONTRACT()).to.equal(HARDHAT_MOCK_CHAINLINK_ORACLE_ADDRESS); + }); + + it('Has oracle external (off-chain) adapter address', async () => { + expect(await chainlinkAdapter.ORACLE_EXTERNAL_ADAPTER()).to.equal(oracleExternalAdapter.address); + }); + + it('Has Chainlink job ID', async () => { + expect(await chainlinkAdapter.JOB_ID()).to.equal( + `0x` + Buffer.from(CHAINLINK_JOB_ID).toString('hex'), + ); + }); + }); + + describe('Basic operation', () => { + + it('Transfers fee and makes a request', async () => { + // Make the request. + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + expect(await mockChainlinkToken.balanceOf(user.address)).to.equal(0); + + // Expect the request to have been made via the LINK token transferAndCall() function. + expect(await mockChainlinkToken._CALLED_WITH_TO_()).to.equal(HARDHAT_MOCK_CHAINLINK_ORACLE_ADDRESS); + expect(await mockChainlinkToken._CALLED_WITH_VALUE_()).to.equal(FEE_AMOUNT) + }); + }); + + describe('End-to-end Merkle root updates', () => { + + it('Cannot propose root if oracle has not provided a root yet', async () => { + await expect(merkleDistributor.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle root is zero (unset)', + ); + }); + + it('Updates the root in epoch zero', async () => { + expect( + BigNumber.from((await merkleDistributor.getProposedRoot()).merkleRoot) + ).to.equal(0); + + // Request oracle data. + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + + // The external (off-chain) adapter is expected to call the callback. + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + + // It should now be possible to update the proposed root to the oracle value. + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + const proposedRoot = await merkleDistributor.getProposedRoot(); + expect(proposedRoot.merkleRoot).to.equal(simpleTreeRoot); + expect(proposedRoot.epoch).to.equal(0); + expect(proposedRoot.ipfsCid).to.equal(MOCK_IPFS_CID); + + // Allow the waiting period to elapse. + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Update the active root. + await expect(merkleDistributor.updateRoot()).to.emit(merkleDistributor, 'RootUpdated'); + const activeRoot = await merkleDistributor.getActiveRoot(); + expect(activeRoot.merkleRoot).to.equal(simpleTreeRoot); + expect(activeRoot.epoch).to.equal(0); + expect(activeRoot.ipfsCid).to.equal(MOCK_IPFS_CID); + + // Make a claim based on the active root. + const proof = simpleTree.getProof(user.address, tokenSupply); + await expect(merkleDistributor.connect(user.signer).claimRewards(tokenSupply, proof)) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(user.address, tokenSupply); + + // Check balances after. + expect(await dydxToken.balanceOf(user.address)).to.equal(tokenSupply); + expect(await dydxToken.balanceOf(rewardsTreasury.address)).to.equal(0); + }); + + it('Proposes the root multiple times in epoch zero', async () => { + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + // Should throw if oracle root has not changed. + await expect(merkleDistributor.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle root was already proposed', + ); + + // Update the oracle and proposed root with a different IPFS CID. + const newIpfsCid = `0x${'0101'.repeat(16)}`; + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + simpleTreeRoot, + 0, + newIpfsCid, + ); + await merkleDistributor.proposeRoot(); + expect((await merkleDistributor.getProposedRoot()).ipfsCid).to.equal(newIpfsCid); + + // Should throw when proposing root if oracle epoch number is not correct. + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + simpleTreeRoot, + 1, + MOCK_IPFS_CID, + ); + await expect(merkleDistributor.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle epoch is not next root epoch', + ); + + // Promote the proposed root to active root. + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + const activeRoot = await merkleDistributor.getActiveRoot(); + expect(activeRoot.merkleRoot).to.equal(simpleTreeRoot); + expect(activeRoot.epoch).to.equal(0); + expect(activeRoot.ipfsCid).to.equal(newIpfsCid); + }); + + it('Proposes the root for multiple epochs in sequence', async () => { + // Tree 1: Give some tokens to two users. + const tree_1 = new BalanceTree({ + [user.address]: 150, + [otherUser.address]: 250, + }); + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + tree_1.getHexRoot(), + 0, + MOCK_IPFS_CID, + ); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Make a claim for user 1 from tree 1. + const proof_1_1 = tree_1.getProof(user.address, 150); + await expect(merkleDistributor.connect(user.signer).claimRewards(150, proof_1_1)) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(user.address, 150); + expect(await dydxToken.balanceOf(user.address)).to.equal(150); + + // Tree 2: Give more tokens to the first user. + const tree_2 = new BalanceTree({ + [user.address]: 350, + [otherUser.address]: 250, + }); + await mockChainlinkToken.mint(user.address, FEE_AMOUNT); + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + tree_2.getHexRoot(), + 1, + MOCK_IPFS_CID, + ); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Make a claim for user 1 from tree 2. + const proof_2_1 = tree_2.getProof(user.address, 350); + await expect(merkleDistributor.connect(user.signer).claimRewards(350, proof_2_1)) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(user.address, 200); + expect(await dydxToken.balanceOf(user.address)).to.equal(350); + + // Tree 3: Give more tokens to both users. + const tree_3 = new BalanceTree({ + [user.address]: 500, + [otherUser.address]: 1000, + }); + await mockChainlinkToken.mint(user.address, FEE_AMOUNT); + await chainlinkAdapter.connect(user.signer).transferAndRequestOracleData(FEE_AMOUNT); + await chainlinkAdapter.connect(oracleExternalAdapter.signer).writeOracleData( + tree_3.getHexRoot(), + 2, + MOCK_IPFS_CID, + ); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Make a claim for user 2 from tree 3. + const proof_3_2 = tree_3.getProof(otherUser.address, 1000); + await expect(merkleDistributor.connect(otherUser.signer).claimRewards(1000, proof_3_2)) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(otherUser.address, 1000); + expect(await dydxToken.balanceOf(otherUser.address)).to.equal(1000); + + // Make a claim for user 1 from tree 3. + const proof_3_1 = tree_3.getProof(user.address, 500); + await expect(merkleDistributor.connect(user.signer).claimRewards(500, proof_3_1)) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(user.address, 150); + expect(await dydxToken.balanceOf(user.address)).to.equal(500); + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(beforeSettingMerkleRoot)!); + // retake snapshot since each snapshot can only be used once + snapshots.set(beforeSettingMerkleRoot, await evmSnapshot()); +} diff --git a/test/merkle-distributor/merkle-distributor.spec.ts b/test/merkle-distributor/merkle-distributor.spec.ts new file mode 100644 index 0000000..04a740e --- /dev/null +++ b/test/merkle-distributor/merkle-distributor.spec.ts @@ -0,0 +1,565 @@ +import { BigNumber, utils } from 'ethers'; +import { expect } from 'chai'; + +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { + evmRevert, + evmSnapshot, + incrementTimeToTimestamp, + timeLatest, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { DydxToken } from '../../types/DydxToken'; +import { before } from 'mocha'; +import { EPOCH_LENGTH, ONE_DAY } from '../../helpers/constants'; +import { Treasury } from '../../types/Treasury'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; +import { deployMockRewardsOracle } from '../../helpers/contracts-deployments'; +import { sendAllTokensToTreasury } from '../test-helpers/treasury-utils'; + +const MOCK_IPFS_CID = Buffer.from('0'.repeat(64), 'hex'); + +const snapshots = new Map(); + +const beforeSettingMerkleRoot: string = 'beforeSettingMerkleRoot'; + +makeSuite('dYdX Merkle Distributor', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: Treasury; + let dydxToken: DydxToken; + let MD1: MerkleDistributorV1; + let mockRewardsOracle: MockRewardsOracle; + let users: SignerWithAddress[]; + let addrs: string[]; + let treasurySupply: BigNumber; + let simpleTree: BalanceTree; + let simpleTreeRoot: string; + let waitingPeriod: number; + + before(async () => { + ({ + deployer, + deployer, + rewardsTreasury, + dydxToken, + merkleDistributor: MD1, + users, + } = testEnv); + + addrs = users.map(u => u.address); + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await MD1.connect(deployer.signer).setRewardsOracle(mockRewardsOracle.address); + + // Send all tokens to the rewards treasury. + await sendAllTokensToTreasury(testEnv); + treasurySupply = await dydxToken.balanceOf(rewardsTreasury.address) + + // Simple tree example that gives all tokens to a single address. + simpleTree = new BalanceTree({ + [addrs[1]]: treasurySupply, + }); + simpleTreeRoot = simpleTree.getHexRoot(); + + // Get the waiting period. + waitingPeriod = (await MD1.WAITING_PERIOD()).toNumber(); + + // Advance to the start of epoch zero. + const epochParameters = await MD1.getEpochParameters(); + await incrementTimeToTimestamp(epochParameters.offset); + snapshots.set(beforeSettingMerkleRoot, await evmSnapshot()); + }); + + describe('initialization', () => { + + it('Has DYDX as the rewards token', async () => { + expect(await MD1.REWARDS_TOKEN()).to.equal(dydxToken.address); + }); + + it('Has epoch of zero, after advancing to the offset timestamp', async () => { + expect(await MD1.getCurrentEpoch()).to.equal(0); + }); + + it('Has waiting period of seven days', async () => { + expect(await MD1.WAITING_PERIOD()).to.equal(ONE_DAY.times(7).toString()); + }); + + it('Deployer has roles, and owner is admin of all roles', async () => { + const roles: string[] = await Promise.all([ + MD1.OWNER_ROLE(), + MD1.PAUSER_ROLE(), + MD1.UNPAUSER_ROLE(), + ]); + + // Deployer should have owner role + expect(await MD1.hasRole(await MD1.OWNER_ROLE(), deployer.address)).to.be.true; + + // OWNER_ROLE should be admin for all other roles. + const ownerRole: string = roles[0]; + for (const role of roles) { + expect(await MD1.getRoleAdmin(role)).to.equal(ownerRole); + } + }); + }); + + describe('proposeRoot', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('succeeds for the epoch zero root', async () => { + // Check getters. + expect(await MD1.hasPendingRoot()).to.be.false; + expect(await MD1.canUpdateRoot()).to.be.false; + expect(await MD1.getNextRootEpoch()).to.equal(0); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await expect(MD1.proposeRoot()).to.emit(MD1, 'RootProposed'); + + // Check getters. + expect(await MD1.hasPendingRoot()).to.be.true; + expect(await MD1.canUpdateRoot()).to.be.false; + expect(await MD1.getNextRootEpoch()).to.equal(0); + + // Do it again with a different IPFS CID. + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + Buffer.from('1'.repeat(64), 'hex'), + ); + await expect(MD1.proposeRoot()).to.emit(MD1, 'RootProposed'); + + // Check getters. + expect(await MD1.hasPendingRoot()).to.be.true; + expect(await MD1.canUpdateRoot()).to.be.false; + expect(await MD1.getNextRootEpoch()).to.equal(0); + }); + + it('reverts if the same params were already proposed', async () => { + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await MD1.proposeRoot(); + await expect(MD1.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle root was already proposed', + ); + }); + + it('reverts if the oracle root is zero', async () => { + await expect(MD1.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle root is zero' + ); + }); + + it('reverts, for the first proposed root, if the epoch number is not zero', async () => { + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 1, + MOCK_IPFS_CID, + ); + await expect(MD1.proposeRoot()).to.be.revertedWith( + 'MD1RootUpdates: Oracle epoch is not next root epoch', + ); + }); + }); + + describe('updateRoot', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('can update the root after the waiting period has elapsed', async () => { + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Check getters. + expect(await MD1.hasPendingRoot()).to.be.true; + expect(await MD1.canUpdateRoot()).to.be.true; + expect(await MD1.getNextRootEpoch()).to.equal(0); + + // Update the root. + await expect(MD1.updateRoot()).to.emit(MD1, 'RootUpdated'); + + // Check getters. + expect(await MD1.hasPendingRoot()).to.be.false; + expect(await MD1.canUpdateRoot()).to.be.false; + expect(await MD1.getNextRootEpoch()).to.equal(1); + }); + + it('reverts if no root was proposed', async () => { + await expect(MD1.updateRoot()).to.be.revertedWith( + 'MD1RootUpdates: Proposed root is zero', + ); + }); + + it('reverts if the waiting period has not elapsed', async () => { + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await MD1.proposeRoot(); + await expect(MD1.updateRoot()).to.be.revertedWith( + 'MD1RootUpdates: Waiting period has not elapsed', + ); + + // Check that it also fails after waiting a while, less than the waiting period. + const waitDuration = waitingPeriod - 300; + await incrementTimeToTimestamp((await timeLatest()).plus(waitDuration).toNumber()); + await expect(MD1.updateRoot()).to.be.revertedWith( + 'MD1RootUpdates: Waiting period has not elapsed', + ); + }); + + it('reverts if the root was already updated for that epoch', async () => { + // Should succeed first, after the waiting period. + await mockRewardsOracle.setMockValue(simpleTreeRoot, 0, MOCK_IPFS_CID); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await MD1.updateRoot(); + + // Second call should fail. + await expect(MD1.updateRoot()).to.be.revertedWith( + 'MD1RootUpdates: Proposed epoch is not next root epoch', + ); + }); + }); + + describe('setAlwaysAllowClaimsFor', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('Allows user to set their status on the allowlist', async () => { + await expect( + MD1.connect(users[1].signer).setAlwaysAllowClaimsFor(true) + ).to.emit(MD1, 'AlwaysAllowClaimForUpdated').withArgs(addrs[1], true); + expect(await MD1.getAlwaysAllowClaimsFor(addrs[1])).to.be.true; + await expect( + MD1.connect(users[1].signer).setAlwaysAllowClaimsFor(false) + ).to.emit(MD1, 'AlwaysAllowClaimForUpdated').withArgs(addrs[1], false); + expect(await MD1.getAlwaysAllowClaimsFor(addrs[1])).to.be.false; + }); + }); + + describe('setEpochParameters', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('Allows CONFIG_UPDATER_ROLE to set the epoch parameters', async () => { + const configUpdaterRoleHash: string = utils.keccak256(utils.toUtf8Bytes('CONFIG_UPDATER_ROLE')); + const configUpdater: SignerWithAddress = users[0]; + await MD1.grantRole(configUpdaterRoleHash, configUpdater.address); + + const currentEpoch = await MD1.getCurrentEpoch(); + const currentTimestamp = await timeLatest(); + expect(currentEpoch).to.equal(0); + const newOffset = currentTimestamp.minus(EPOCH_LENGTH.toNumber() * 3).toNumber(); + await expect(MD1.connect(configUpdater.signer).setEpochParameters( + EPOCH_LENGTH, + newOffset, + )).to.emit(MD1, 'EpochScheduleUpdated').withArgs([ EPOCH_LENGTH, newOffset ]); + const newEpoch = await MD1.getCurrentEpoch(); + expect(newEpoch).to.equal(3); + }); + }); + + describe('setRewardsOracle', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('Allow owner to set the rewards oracle address', async () => { + await expect(MD1.connect(deployer.signer).setRewardsOracle( + users[1].address, + )).to.emit(MD1, 'RewardsOracleChanged').withArgs(users[1].address); + const newOracle = await MD1.getRewardsOracle(); + expect(newOracle).to.equal(users[1].address); + await expect(MD1.proposeRoot()).to.be.revertedWith('function call to a non-contract account'); + }); + }); + + describe('while root updates are paused', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + beforeEach(async () => { + // Set a proposed root. + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await expect(MD1.proposeRoot()).to.emit(MD1, 'RootProposed'); + + // Advance past the waiting period. + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Then, pause root updates. + await MD1.grantRole(await MD1.PAUSER_ROLE(), deployer.address); + await MD1.pauseRootUpdates(); + }); + + it('can update the proposed root', async () => { + // Set a new IPFS CID. + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + Buffer.from('1'.repeat(64), 'hex'), + ); + await expect(MD1.proposeRoot()).to.emit(MD1, 'RootProposed'); + }); + + it('cannot update the active root', async () => { + await expect(MD1.updateRoot()).to.be.revertedWith('MD1Pausable: Updates paused'); + }); + + it('returns false from canUpdateRoot()', async () => { + expect(await MD1.canUpdateRoot()).to.be.false; + }); + + it('can update the active root immediately after unpausing', async () => { + await MD1.grantRole(await MD1.UNPAUSER_ROLE(), deployer.address); + await MD1.unpauseRootUpdates(); + await expect(MD1.updateRoot()).to.emit(MD1, 'RootUpdated'); + }); + }); + + describe('claimRewards', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('Fails when rewards treasury does not have enough tokens to send user', async () => { + // Set up Merkle tree. + const amount = treasurySupply.add(1); + const tree = new BalanceTree({ + [addrs[1]]: amount, + }); + await mockRewardsOracle.setMockValue(tree.getHexRoot(), 0, MOCK_IPFS_CID); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await MD1.updateRoot(); + + // Try to claim rewards. + const proof = tree.getProof(addrs[1], amount); + await expect(MD1.connect(users[1].signer).claimRewards(amount, proof)) + .to.be.revertedWith('SafeERC20: low-level call failed') + }); + + describe('Default empty merkle tree', () => { + + it('cannot claim rewards', async () => { + await expect(MD1.connect(users[1].signer).claimRewards(0, [])).to.be.revertedWith( + 'MD1Claims: Invalid Merkle proof', + ); + }); + }); + + describe('One-account merkle tree', () => { + + let proof: Buffer[] + + beforeEach(async () => { + // Set the root on the contract. + await mockRewardsOracle.setMockValue(simpleTreeRoot, 0, MOCK_IPFS_CID); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await MD1.updateRoot(); + proof = simpleTree.getProof(addrs[1], treasurySupply); + }); + + afterEach(async () => { + await revertTestChanges(); + }); + + it('Succeeds when merkle tree sends all tokens to user', async () => { + await expect(MD1.connect(users[1].signer).claimRewards(treasurySupply, proof)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[1], treasurySupply); + + // Check balances after. + expect(await dydxToken.balanceOf(addrs[1])).to.equal(treasurySupply); + expect(await dydxToken.balanceOf(rewardsTreasury.address)).to.equal(0); + }); + + it('Cannot normally claim on behalf of another user', async () => { + await expect( + MD1.connect(users[2].signer).claimRewardsFor(addrs[1], treasurySupply, proof), + ).to.be.revertedWith( + 'MD1Claims: Do not have permission to claim for this user', + ); + }); + + it('Can claim on behalf of a user on the allowlist', async () => { + await MD1.connect(users[1].signer).setAlwaysAllowClaimsFor(true); + await MD1.connect(users[2].signer).claimRewardsFor(addrs[1], treasurySupply, proof); + expect(await dydxToken.balanceOf(addrs[1])).to.equal(treasurySupply); + }); + + it('Cannot claim on behalf of a user removed from the allowlist', async () => { + await MD1.connect(users[1].signer).setAlwaysAllowClaimsFor(true); + await MD1.connect(users[1].signer).setAlwaysAllowClaimsFor(false); + await expect( + MD1.connect(users[2].signer).claimRewardsFor(addrs[1], treasurySupply, proof), + ).to.be.revertedWith( + 'MD1Claims: Do not have permission to claim for this user', + ); + }); + }); + + describe('Four-account merkle tree', () => { + let tree: BalanceTree; + let merkleRoot: string; + + before(() => { + tree = new BalanceTree({ + [addrs[1]]: BigNumber.from(100), + [addrs[2]]: BigNumber.from(101), + [addrs[3]]: BigNumber.from(102), + [addrs[4]]: BigNumber.from(103), + }) + merkleRoot = tree.getHexRoot(); + }); + + beforeEach(async () => { + // Set the root on the contract. + await mockRewardsOracle.setMockValue(tree.getHexRoot(), 0, MOCK_IPFS_CID); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await MD1.updateRoot(); + }); + + it('successful claim for multiple users', async () => { + const proof0 = tree.getProof(addrs[1], BigNumber.from(100)); + await expect(MD1.connect(users[1].signer).claimRewards(100, proof0)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[1], 100); + const proof1 = tree.getProof(addrs[2], BigNumber.from(101)); + await expect(MD1.connect(users[2].signer).claimRewards(101, proof1)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[2], 101); + + expect(await dydxToken.balanceOf(addrs[1])).to.equal(100); + expect(await dydxToken.balanceOf(addrs[2])).to.equal(101); + expect(await dydxToken.balanceOf(addrs[3])).to.equal(0); + expect(await dydxToken.balanceOf(addrs[4])).to.equal(0); + expect(await dydxToken.balanceOf(rewardsTreasury.address)).to.equal(treasurySupply.sub(201)); + }) + + it('successful claim for multiple users after setting new root', async () => { + // claim addrs[1] and addrs[3] tokens + const proof0 = tree.getProof(addrs[1], BigNumber.from(100)); + await expect(MD1.connect(users[1].signer).claimRewards(100, proof0)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[1], 100); + + const proof2 = tree.getProof(addrs[3], BigNumber.from(102)); + await expect(MD1.connect(users[3].signer).claimRewards(102, proof2)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[3], 102); + + // create new merkle tree and verify the following scenarios: + // addrs[1] can claim and receives more tokens in epoch 2 after claiming tokens in epoch 1 + // addrs[2] can claim after not claiming tokens in epoch 1 + // addrs[3] has nothing to claim after claiming tokens in epoch 1 + // addrs[4] can claim and receives more tokens in epoch 2 after not claiming tokens in epoch 1 + // addrs[5] can claim tokens and receives tokens in epoch 2 after not receiving tokens in epoch 1 + const newTree = new BalanceTree({ + // addrs[1] got 100 in epoch 1 + 100 in epoch 2 + [addrs[1]]: BigNumber.from(200), + // addrs[2] got 101 in epoch 1 + 0 in epoch 2 + [addrs[2]]: BigNumber.from(101), + // addrs[3] got 102 in epoch 1 + 0 in epoch 2 + [addrs[3]]: BigNumber.from(102), + // addrs[4] got 103 in epoch 1 + 100 in epoch 2 + [addrs[4]]: BigNumber.from(203), + // addrs[4] got 0 in epoch 1 + 104 in epoch 2 + [addrs[5]]: BigNumber.from(104), + }) + const merkleRoot = newTree.getHexRoot(); + + // Set new root. + await mockRewardsOracle.setMockValue(merkleRoot, 1, MOCK_IPFS_CID); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await MD1.updateRoot(); + + const newProof0 = newTree.getProof(addrs[1], BigNumber.from(200)); + await expect(MD1.connect(users[1].signer).claimRewards(200, newProof0)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[1], 100); + const newProof1 = newTree.getProof(addrs[2], BigNumber.from(101)); + await expect(MD1.connect(users[2].signer).claimRewards(101, newProof1)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[2], 101); + const newProof2 = newTree.getProof(addrs[3], BigNumber.from(102)); + await expect(MD1.connect(users[3].signer).claimRewards(102, newProof2)) + .not.to.emit(MD1, 'RewardsClaimed') + const newProof3 = newTree.getProof(addrs[4], BigNumber.from(203)); + await expect(MD1.connect(users[4].signer).claimRewards(203, newProof3)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[4], 203); + const newProof4 = newTree.getProof(addrs[5], BigNumber.from(104)); + await expect(MD1.connect(users[5].signer).claimRewards(104, newProof4)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(addrs[5], 104); + + expect(await dydxToken.balanceOf(addrs[1])).to.equal(200); + expect(await dydxToken.balanceOf(addrs[2])).to.equal(101); + expect(await dydxToken.balanceOf(addrs[3])).to.equal(102); + expect(await dydxToken.balanceOf(addrs[4])).to.equal(203); + expect(await dydxToken.balanceOf(addrs[5])).to.equal(104); + expect(await dydxToken.balanceOf(rewardsTreasury.address)) + .to.equal(treasurySupply.sub(200 + 101 + 102 + 203 + 104)); + }) + + it('user cannot claim with invalid cumulativeAmount', async () => { + const proof0 = tree.getProof(addrs[1], BigNumber.from(100)); + + await expect(MD1.connect(users[1].signer).claimRewards(101, proof0)) + .to.be.revertedWith('MD1Claims: Invalid Merkle proof'); + }) + + it('user cannot claim with invalid merkle proof', async () => { + const proof1 = tree.getProof(addrs[2], BigNumber.from(101)); + + await expect(MD1.connect(users[1].signer).claimRewards(101, proof1)) + .to.be.revertedWith('MD1Claims: Invalid Merkle proof'); + }) + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(beforeSettingMerkleRoot)!); + // retake snapshot since each snapshot can only be used once + snapshots.set(beforeSettingMerkleRoot, await evmSnapshot()); +} diff --git a/test/misc/claims-proxy.spec.ts b/test/misc/claims-proxy.spec.ts new file mode 100644 index 0000000..7fd3931 --- /dev/null +++ b/test/misc/claims-proxy.spec.ts @@ -0,0 +1,304 @@ +import { expect, use } from 'chai'; +import { BigNumber, BigNumberish } from 'ethers'; +import { solidity } from 'ethereum-waffle'; + +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { + evmRevert, + evmSnapshot, + increaseTimeAndMine, + timeLatest, + toWad, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { ClaimsProxy } from '../../types/ClaimsProxy'; +import { DydxToken } from '../../types/DydxToken'; +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { MAX_UINT_AMOUNT } from '../../helpers/constants'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; +import { MintableErc20 } from '../../types/MintableErc20'; +import { deployMockRewardsOracle } from '../../helpers/contracts-deployments'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; + +const MOCK_IPFS_CID = Buffer.from('0'.repeat(64), 'hex'); + +const snapshots = new Map(); +const afterSetup: string = 'afterSetup'; + +const SAFETY_STAKE: number = 400_000; +const LIQUIDITY_STAKE: number = 1_000_000; +const MERKLE_DISTRIBUTOR_BALANCE: number = 1_600_000; + +makeSuite('Claims Proxy', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let mockRewardsOracle: MockRewardsOracle; + let claimsProxy: ClaimsProxy; + let dydxToken: DydxToken; + let users: SignerWithAddress[]; + let addrs: string[]; + + // Safety module. + let safetyModule: SafetyModuleV1; + let safetyDistributionStart: string; + + // Liquidity staking. + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + + // Merkle distributor. + let merkleDistributor: MerkleDistributorV1; + let merkleSimpleTree: BalanceTree; + let merkleWaitingPeriod: number; + + before(async () => { + ({ + deployer, + rewardsTreasury, + claimsProxy, + dydxToken, + safetyModule, + mockStakedToken, + merkleDistributor, + liquidityStaking, + users, + } = testEnv); + + addrs = users.map(u => u.address); + + // ============ Safety Module ============ + + expect(await safetyModule.STAKED_TOKEN()).to.be.equal(dydxToken.address); + expect(await safetyModule.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + safetyDistributionStart = (await safetyModule.DISTRIBUTION_START()).toString(); + + // Mint token to the user and set their allowance on the contract. + await dydxToken.connect(deployer.signer).transfer(addrs[1], SAFETY_STAKE); + await dydxToken.connect(users[1].signer).approve(safetyModule.address, SAFETY_STAKE); + + // Set allowance for safety module to pull from the rewards vault. + await dydxToken.connect(rewardsTreasury.signer).approve(safetyModule.address, MAX_UINT_AMOUNT) + + // Set rewards emission rate. + await safetyModule.setRewardsPerSecond(15); + + await safetyModule.grantRole(await safetyModule.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // ============ Liquidity Staking ============ + + expect(await liquidityStaking.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + // Mint token to the user and set their allowance on the contract. + await mockStakedToken.mint(addrs[1], LIQUIDITY_STAKE); + await mockStakedToken.connect(users[1].signer).approve(liquidityStaking.address, LIQUIDITY_STAKE); + + // Set rewards emission rate. + await liquidityStaking.setRewardsPerSecond(25); + + await liquidityStaking.grantRole(await liquidityStaking.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // ============ Merkle Distributor ============ + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await merkleDistributor.connect(deployer.signer).setRewardsOracle(mockRewardsOracle.address); + + expect(await merkleDistributor.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + // Simple tree example that gives all tokens to a single address. + merkleSimpleTree = new BalanceTree({ + [addrs[1]]: BigNumber.from(MERKLE_DISTRIBUTOR_BALANCE), + }); + + // Get the waiting period. + merkleWaitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + await merkleDistributor.grantRole(await merkleDistributor.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // Send tokens to rewards treasury vester + await dydxToken.transfer(testEnv.rewardsTreasuryVester.address, toWad(100_000_000)); + + // Advance to the start of safety module rewards (safety module rewards start after liquidity module rewards). + await incrementTimeToTimestamp(safetyDistributionStart); + + snapshots.set(afterSetup, await evmSnapshot()); + }); + + describe('claimRewards', () => { + afterEach(async () => { + await revertTestChanges(); + }); + + it('claims from safety module only', async () => { + await safetyModule.connect(users[1].signer).stake(SAFETY_STAKE) + await increaseTimeAndMine(1000); + + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + true, + false, + 0, + [], + false, + )) + .to.emit(safetyModule, 'ClaimedRewards'); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + expect(diff).not.to.equal(0); + }); + + it('claims from liquidity module only', async () => { + await liquidityStaking.connect(users[1].signer).stake(LIQUIDITY_STAKE); + await increaseTimeAndMine(1000); + + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + false, + true, + 0, + [], + false, + )) + .to.emit(liquidityStaking, 'ClaimedRewards'); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + const error = diff.sub(25000).abs().toNumber(); + expect(error).to.be.lte(50); + }); + + it('claims from Merkle distributor only', async () => { + // Set up Merkle tree. + await mockRewardsOracle.setMockValue(merkleSimpleTree.getHexRoot(), 0, MOCK_IPFS_CID); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(merkleWaitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const proof = merkleSimpleTree.getProof(addrs[1], BigNumber.from(MERKLE_DISTRIBUTOR_BALANCE)); + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + false, + false, + MERKLE_DISTRIBUTOR_BALANCE, + proof, + false, + )) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(addrs[1], MERKLE_DISTRIBUTOR_BALANCE); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + expect(diff).to.equal(MERKLE_DISTRIBUTOR_BALANCE); + }); + + it('claims from all contracts', async () => { + // Set up Merkle tree. + await mockRewardsOracle.setMockValue(merkleSimpleTree.getHexRoot(), 0, MOCK_IPFS_CID); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(merkleWaitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Stake in both staking contracts and elapse time. + await safetyModule.connect(users[1].signer).stake(SAFETY_STAKE) + await liquidityStaking.connect(users[1].signer).stake(LIQUIDITY_STAKE); + await increaseTimeAndMine(1000); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const proof = merkleSimpleTree.getProof(addrs[1], BigNumber.from(MERKLE_DISTRIBUTOR_BALANCE)); + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + true, + true, + MERKLE_DISTRIBUTOR_BALANCE, + proof, + false, + )) + .to.emit(safetyModule, 'ClaimedRewards') + .to.emit(liquidityStaking, 'ClaimedRewards') + .to.emit(merkleDistributor, 'RewardsClaimed'); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + const error = diff.sub(MERKLE_DISTRIBUTOR_BALANCE + 15000 + 25000).abs().toNumber(); + expect(error).to.be.lte(100); + }); + + it('Vests from rewards treasury vester and claims from all contracts', async () => { + // Set up Merkle tree so that 100% of rewards treasury balance is sent to addrs[1]. + const rewardsTreasuryBalance: BigNumber = await dydxToken.balanceOf(rewardsTreasury.address); + merkleSimpleTree = new BalanceTree({ + [addrs[1]]: rewardsTreasuryBalance, + }); + await mockRewardsOracle.setMockValue(merkleSimpleTree.getHexRoot(), 0, MOCK_IPFS_CID); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(merkleWaitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + + // Stake in both staking contracts and elapse time. + await safetyModule.connect(users[1].signer).stake(SAFETY_STAKE) + await liquidityStaking.connect(users[1].signer).stake(LIQUIDITY_STAKE); + await increaseTimeAndMine(10); + + // Claim rewards. + const proof = merkleSimpleTree.getProof(addrs[1], rewardsTreasuryBalance); + + // expect TX to fail because rewards treasury underfunded + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + true, + true, + rewardsTreasuryBalance, + proof, + false, + )).to.be.revertedWith( + 'SafeERC20: low-level call failed' + ); + + // expect TX to succeed because we vest additional funds from rewards treasury vester + await expect( + claimsProxy.connect(users[1].signer).claimRewards( + true, + true, + rewardsTreasuryBalance, + proof, + true, + )) + .to.emit(safetyModule, 'ClaimedRewards') + .to.emit(liquidityStaking, 'ClaimedRewards') + .to.emit(merkleDistributor, 'RewardsClaimed') + .to.emit(dydxToken, 'Transfer'); + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterSetup)!); + // retake snapshot since each snapshot can be used once + snapshots.set(afterSetup, await evmSnapshot()); +} + +async function incrementTimeToTimestamp(timestampString: BigNumberish): Promise { + const latestBlockTimestamp = (await timeLatest()).toNumber(); + const timestamp = BigNumber.from(timestampString); + // we can increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp).to.be.at.most(timestamp.toNumber()); + const timestampDiff = timestamp.sub(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/misc/stark-ex-helper-governor.spec.ts b/test/misc/stark-ex-helper-governor.spec.ts new file mode 100644 index 0000000..5e86d60 --- /dev/null +++ b/test/misc/stark-ex-helper-governor.spec.ts @@ -0,0 +1,122 @@ +import { expect, use } from 'chai'; +import { solidity } from 'ethereum-waffle'; + +import { + evmRevert, + evmSnapshot, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { ZERO_ADDRESS } from '../../helpers/constants'; +import { MockStarkPerpetual } from '../../types/MockStarkPerpetual'; +import { deployMockStarkPerpetual, deployStarkExHelperGovernor, deployStarkExRemoverGovernor } from '../../helpers/contracts-deployments'; +import { StarkExHelperGovernor } from '../../types/StarkExHelperGovernor'; + +const snapshots = new Map(); +const afterSetup: string = 'afterSetup'; +const zeroBytes32 = padHex256('0x0'); +const exampleHash = padHex256('0x000123'); + +makeSuite('StarkEx helper governor', deployPhase2, (testEnv: TestEnv) => { + let starkEx: MockStarkPerpetual; + let helper: StarkExHelperGovernor; + let users: SignerWithAddress[]; + let notOwner: SignerWithAddress; + let addrs: string[]; + + before(async () => { + users = testEnv.users; + notOwner = users[0]; + addrs = users.map(u => u.address); + + // Deploy contracts. + starkEx = testEnv.mockStarkPerpetual; + helper = testEnv.starkExHelperGovernor; + + // Check owner is not `notOwner`. + expect(await helper.owner()).not.to.equal(notOwner.address); + + snapshots.set(afterSetup, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(); + }); + + it('accepts governance', async () => { + expect(await starkEx._MAIN_GOVERNORS_(helper.address)).to.be.false; + expect(await starkEx._PROXY_GOVERNORS_(helper.address)).to.be.false; + + await helper.mainAcceptGovernance(); + + expect(await starkEx._MAIN_GOVERNORS_(helper.address)).to.be.true; + expect(await starkEx._PROXY_GOVERNORS_(helper.address)).to.be.false; // Still false. + }); + + describe('executeAssetConfigurationChanges', () => { + + beforeEach(async () => { + await helper.mainAcceptGovernance(); + }); + + it('registers and applies zero asset config changes', async () => { + await helper.executeAssetConfigurationChanges([], []); + expect(await starkEx._ASSET_CONFIGS_(5)).to.equal(zeroBytes32); + }); + + it('registers and applies one asset config change', async () => { + await helper.executeAssetConfigurationChanges([5], [exampleHash]); + expect(await starkEx._ASSET_CONFIGS_(5)).to.equal(exampleHash); + }); + + it('registers and applies several asset config changes', async () => { + const configHashes = ['0x05', '0x04', '0x03', '0x02', '0x01'].map(padHex256); + await helper.executeAssetConfigurationChanges( + [5, 4, 3, 2, 1], + configHashes, + ); + expect(await starkEx._ASSET_CONFIGS_(5)).to.equal(configHashes[0]); + expect(await starkEx._ASSET_CONFIGS_(4)).to.equal(configHashes[1]); + expect(await starkEx._ASSET_CONFIGS_(3)).to.equal(configHashes[2]); + expect(await starkEx._ASSET_CONFIGS_(2)).to.equal(configHashes[3]); + expect(await starkEx._ASSET_CONFIGS_(1)).to.equal(configHashes[4]); + }); + + it('reverts if called by non-owner', async () => { + await expect(helper.connect(notOwner.signer).executeAssetConfigurationChanges([5], [exampleHash])) + .to.be.revertedWith('Ownable: caller is not the owner'); + }); + }); + + describe('executeGlobalConfigurationChange', () => { + + beforeEach(async () => { + await helper.mainAcceptGovernance(); + }); + + it('registers and applies a global config change', async () => { + expect(await starkEx._GLOBAL_CONFIG_()).to.equal(zeroBytes32); + await helper.executeGlobalConfigurationChange(exampleHash); + expect(await starkEx._GLOBAL_CONFIG_()).to.equal(exampleHash); + }); + + it('reverts if called by non-owner', async () => { + await expect(helper.connect(notOwner.signer).executeGlobalConfigurationChange(exampleHash)) + .to.be.revertedWith('Ownable: caller is not the owner'); + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterSetup)!); + // retake snapshot since each snapshot can be used once + snapshots.set(afterSetup, await evmSnapshot()); +} + +function padHex256(hex: string): string{ + return `0x${hex.slice(2).padStart(64, '0')}`; +} diff --git a/test/misc/stark-ex-remover-governor.spec.ts b/test/misc/stark-ex-remover-governor.spec.ts new file mode 100644 index 0000000..3ca653c --- /dev/null +++ b/test/misc/stark-ex-remover-governor.spec.ts @@ -0,0 +1,74 @@ +import { expect, use } from 'chai'; +import { solidity } from 'ethereum-waffle'; + +import { + evmRevert, + evmSnapshot, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MockStarkPerpetual } from '../../types/MockStarkPerpetual'; +import { deployMockStarkPerpetual, deployStarkExRemoverGovernor } from '../../helpers/contracts-deployments'; +import { StarkExRemoverGovernor } from '../../types/StarkExRemoverGovernor'; +import { tEthereumAddress } from '../../src/tx-builder/types/index'; + +const snapshots = new Map(); +const afterSetup: string = 'afterSetup'; + +makeSuite('StarkEx remover governor', deployPhase2, (testEnv: TestEnv) => { + let starkEx: MockStarkPerpetual; + let remover: StarkExRemoverGovernor; + let initialGovernor: SignerWithAddress; + + before(async () => { + starkEx = testEnv.mockStarkPerpetual; + remover = testEnv.starkExRemoverGovernor; + + initialGovernor = testEnv.mockGovernorToRemove; + + // Set initial governor. + await starkEx.connect(initialGovernor.signer).mainAcceptGovernance(); + await starkEx.connect(initialGovernor.signer).proxyAcceptGovernance(); + + snapshots.set(afterSetup, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(); + }); + + it('accepts governance', async () => { + expect(await starkEx._MAIN_GOVERNORS_(remover.address)).to.be.false; + expect(await starkEx._PROXY_GOVERNORS_(remover.address)).to.be.false; + + await remover.mainAcceptGovernance(); + await remover.proxyAcceptGovernance(); + + expect(await starkEx._MAIN_GOVERNORS_(remover.address)).to.be.true; + expect(await starkEx._PROXY_GOVERNORS_(remover.address)).to.be.true; + }); + + it('removes the target governor', async () => { + expect(await starkEx._MAIN_GOVERNORS_(initialGovernor.address)).to.be.true; + expect(await starkEx._PROXY_GOVERNORS_(initialGovernor.address)).to.be.true; + + await remover.mainAcceptGovernance(); + await remover.mainRemoveGovernor(); + + await remover.proxyAcceptGovernance(); + await remover.proxyRemoveGovernor(); + + expect(await starkEx._MAIN_GOVERNORS_(initialGovernor.address)).to.be.false; + expect(await starkEx._PROXY_GOVERNORS_(initialGovernor.address)).to.be.false; + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterSetup)!); + // retake snapshot since each snapshot can be used once + snapshots.set(afterSetup, await evmSnapshot()); +} diff --git a/test/misc/treasury-merkle-claim-proxy.spec.ts b/test/misc/treasury-merkle-claim-proxy.spec.ts new file mode 100644 index 0000000..07a6cf2 --- /dev/null +++ b/test/misc/treasury-merkle-claim-proxy.spec.ts @@ -0,0 +1,144 @@ +import { BigNumber } from 'ethers'; +import { expect } from 'chai'; + +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { + DRE, + evmRevert, + evmSnapshot, + incrementTimeToTimestamp, + timeLatest, + toWad, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { DydxToken } from '../../types/DydxToken'; +import { before } from 'mocha'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; +import { deployMockRewardsOracle } from '../../helpers/contracts-deployments'; +import { TreasuryMerkleClaimProxy } from '../../types/TreasuryMerkleClaimProxy'; +import { getTreasuryMerkleClaimProxy } from '../../helpers/contracts-getters'; +import { Treasury } from '../../types/Treasury'; + +const MOCK_IPFS_CID = Buffer.from('0'.repeat(64), 'hex'); + +const snapshots = new Map(); + +const afterUpdatingMerkleRoot: string = 'afterUpdatingMerkleRoot'; + +makeSuite('dYdX Treasury Merkle Claim Proxy', deployPhase2, (testEnv: TestEnv) => { + const treasuryMerkleClaimProxyRewards = toWad(1_000); + const deployerRewards = toWad(777); + + let deployer: SignerWithAddress; + let treasuryMerkleClaimProxy: TreasuryMerkleClaimProxy; + let dydxToken: DydxToken; + let MD1: MerkleDistributorV1; + let mockRewardsOracle: MockRewardsOracle; + let simpleTree: BalanceTree; + let simpleTreeRoot: string; + let waitingPeriod: number; + let rewardsTreasury: Treasury; + let communityTreasury: Treasury; + + before(async () => { + ({ + deployer, + dydxToken, + merkleDistributor: MD1, + rewardsTreasury, + communityTreasury, + } = testEnv); + + const result = await DRE.run('deploy-treasury-merkle-claim-proxy', { + skipWalletGeneration: true, + }); + + treasuryMerkleClaimProxy = await getTreasuryMerkleClaimProxy({ address: result.treasuryMerkleClaimProxyAddress }); + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await MD1.connect(deployer.signer).setRewardsOracle(mockRewardsOracle.address); + + // Simple tree example that gives tokens to deployer and treasury merkle claim proxy. + simpleTree = new BalanceTree({ + [deployer.address]: deployerRewards, + [treasuryMerkleClaimProxy.address]: treasuryMerkleClaimProxyRewards, + }); + simpleTreeRoot = simpleTree.getHexRoot(); + + // Get the waiting period. + waitingPeriod = (await MD1.WAITING_PERIOD()).toNumber(); + + // Advance to the start of epoch zero. + const epochParameters = await MD1.getEpochParameters(); + await incrementTimeToTimestamp(epochParameters.offset); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + MOCK_IPFS_CID, + ); + await MD1.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Update the root. + await expect(MD1.updateRoot()).to.emit(MD1, 'RootUpdated'); + + snapshots.set(afterUpdatingMerkleRoot, await evmSnapshot()); + }); + + describe('claimRewards', () => { + + afterEach(async () => { + await revertTestChanges(); + }); + + it('can claim rewards when the merkle root is active', async () => { + const treasuryMerkleClaimProxyBalanceBefore: BigNumber = await dydxToken.balanceOf(treasuryMerkleClaimProxy.address); + const communityTreasuryBalanceBefore: BigNumber = await dydxToken.balanceOf(communityTreasury.address); + const rewardsTreasuryBalanceBefore: BigNumber = await dydxToken.balanceOf(rewardsTreasury.address); + + const proof = simpleTree.getProof(treasuryMerkleClaimProxy.address, treasuryMerkleClaimProxyRewards); + await expect(treasuryMerkleClaimProxy.claimRewards(treasuryMerkleClaimProxyRewards, proof)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(treasuryMerkleClaimProxy.address, treasuryMerkleClaimProxyRewards); + + const treasuryMerkleClaimProxyBalanceAfter: BigNumber = await dydxToken.balanceOf(treasuryMerkleClaimProxy.address); + const communityTreasuryBalanceAfter: BigNumber = await dydxToken.balanceOf(communityTreasury.address); + const rewardsTreasuryBalanceAfter: BigNumber = await dydxToken.balanceOf(rewardsTreasury.address); + + expect(communityTreasuryBalanceAfter.sub(communityTreasuryBalanceBefore)).to.equal(treasuryMerkleClaimProxyRewards); + expect(rewardsTreasuryBalanceBefore.sub(rewardsTreasuryBalanceAfter)).to.equal(treasuryMerkleClaimProxyRewards); + expect(treasuryMerkleClaimProxyBalanceBefore).to.equal(0); + expect(treasuryMerkleClaimProxyBalanceAfter).to.equal(0); + }); + + it('can not claim rewards twice', async () => { + const proof = simpleTree.getProof(treasuryMerkleClaimProxy.address, treasuryMerkleClaimProxyRewards); + await expect(treasuryMerkleClaimProxy.claimRewards(treasuryMerkleClaimProxyRewards, proof)) + .to.emit(MD1, 'RewardsClaimed') + .withArgs(treasuryMerkleClaimProxy.address, treasuryMerkleClaimProxyRewards); + + const secondClaimAmount = await treasuryMerkleClaimProxy.callStatic.claimRewards(treasuryMerkleClaimProxyRewards, proof); + expect(secondClaimAmount).to.equal(0); + }); + + it('can not claim rewards with invalid merkle proof', async () => { + const invalidProof = simpleTree.getProof(deployer.address, deployerRewards); + await expect(treasuryMerkleClaimProxy.claimRewards(treasuryMerkleClaimProxyRewards, invalidProof)) + .to.be.revertedWith('MD1Claims: Invalid Merkle proof'); + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterUpdatingMerkleRoot)!); + // retake snapshot since each snapshot can only be used once + snapshots.set(afterUpdatingMerkleRoot, await evmSnapshot()); +} diff --git a/test/staking/LiquidityStakingV1/liquidityStakingV1.spec.ts b/test/staking/LiquidityStakingV1/liquidityStakingV1.spec.ts new file mode 100644 index 0000000..06b905d --- /dev/null +++ b/test/staking/LiquidityStakingV1/liquidityStakingV1.spec.ts @@ -0,0 +1,139 @@ +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { timeLatest } from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH } from '../../../helpers/constants'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { DydxToken } from '../../../types/DydxToken'; +import { expect } from 'chai'; +import { DEPLOY_CONFIG } from '../../../tasks/helpers/deploy-config'; + +makeSuite('LiquidityStakingV1', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + // Users. + [staker1] = testEnv.users.slice(1); + }); + + describe('Initial parameters', () => { + it('Staked token is set during initialization', async () => { + expect(await liquidityStakingV1.STAKED_TOKEN()).to.be.equal(mockStakedToken.address); + }); + + it('Rewards token is set during initialization', async () => { + expect(await liquidityStakingV1.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + }); + + it('Deployer has proper roles set during initialization and is admin of all roles', async () => { + const roles: string[] = await Promise.all([ + liquidityStakingV1.OWNER_ROLE(), + liquidityStakingV1.EPOCH_PARAMETERS_ROLE(), + liquidityStakingV1.REWARDS_RATE_ROLE(), + liquidityStakingV1.BORROWER_ADMIN_ROLE(), + ]); + + // deployer should have all roles except claimOperator, stakeOperator, and debtOperator. + for (const role of roles) { + expect(await liquidityStakingV1.hasRole(role, deployer.address)).to.be.true; + } + + const stakeOperatorRole: string = await liquidityStakingV1.STAKE_OPERATOR_ROLE(); + expect(await liquidityStakingV1.hasRole(stakeOperatorRole, deployer.address)).to.be.false; + + const debtOperatorRole: string = await liquidityStakingV1.DEBT_OPERATOR_ROLE(); + expect(await liquidityStakingV1.hasRole(debtOperatorRole, deployer.address)).to.be.false; + + const claimOperatorRole: string = await liquidityStakingV1.CLAIM_OPERATOR_ROLE(); + expect(await liquidityStakingV1.hasRole(debtOperatorRole, deployer.address)).to.be.false; + + const ownerRole: string = roles[0]; + const allRoles: string[] = roles.concat(stakeOperatorRole); + for (const role of allRoles) { + expect(await liquidityStakingV1.getRoleAdmin(role)).to.equal(ownerRole); + } + }); + + it('Rewards vault is set during initialization', async () => { + expect(await liquidityStakingV1.REWARDS_TREASURY()).to.be.equal(rewardsTreasury.address); + }); + + it('Blackout window is set during initialization', async () => { + expect(await liquidityStakingV1.getBlackoutWindow()).to.be.equal(BLACKOUT_WINDOW.toString()); + }); + + it('Emissions per second is set to deploy config value', async () => { + expect(await liquidityStakingV1.getRewardsPerSecond()).to.be.equal(DEPLOY_CONFIG.LIQUIDITY_STAKING.REWARDS_PER_SECOND); + }); + + it('Total borrowed balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalBorrowedBalance()).to.be.equal(0); + }); + + it('Total borrower debt balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalBorrowerDebtBalance()).to.be.equal(0); + }); + + it('Epoch parameters are set during initialization', async () => { + const timeLatestString: string = (await timeLatest()).toString(); + + const epochParameters = await liquidityStakingV1.getEpochParameters(); + expect(epochParameters.interval).to.be.equal(EPOCH_LENGTH.toString()); + // expect offset to be at least later than now + expect(epochParameters.offset).to.be.at.least(timeLatestString); + }); + + it('Total active current balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalActiveBalanceCurrentEpoch()).to.equal(0); + }); + + it('Total active next balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalActiveBalanceNextEpoch()).to.equal(0); + }); + + it('Total inactive current balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalInactiveBalanceCurrentEpoch()).to.equal(0); + }); + + it('Total inactive next balance is initially zero', async () => { + expect(await liquidityStakingV1.getTotalInactiveBalanceNextEpoch()).to.equal(0); + }); + + it('User active current balance is initially zero', async () => { + expect(await liquidityStakingV1.getActiveBalanceCurrentEpoch(staker1.address)).to.equal(0); + }); + + it('User active next balance is initially zero', async () => { + expect(await liquidityStakingV1.getActiveBalanceNextEpoch(staker1.address)).to.equal(0); + }); + + it('User inactive current balance is initially zero', async () => { + expect(await liquidityStakingV1.getInactiveBalanceCurrentEpoch(staker1.address)).to.equal(0); + }); + + it('User inactive next balance is initially zero', async () => { + expect(await liquidityStakingV1.getInactiveBalanceNextEpoch(staker1.address)).to.equal(0); + }); + + it('Staker debt balance is initially zero', async () => { + expect(await liquidityStakingV1.getStakerDebtBalance(staker1.address)).to.equal(0); + }); + }); +}); diff --git a/test/staking/LiquidityStakingV1/ls1Admin.spec.ts b/test/staking/LiquidityStakingV1/ls1Admin.spec.ts new file mode 100644 index 0000000..ca78af7 --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1Admin.spec.ts @@ -0,0 +1,410 @@ +import { expect } from 'chai'; +import { BigNumber, BigNumberish } from 'ethers'; + +import { timeLatest, evmSnapshot, evmRevert, increaseTimeAndMine } from '../../../helpers/misc-utils'; +import { + BLACKOUT_WINDOW, + EPOCH_LENGTH, + MAX_BLACKOUT_LENGTH, + MAX_EPOCH_LENGTH, + MIN_BLACKOUT_LENGTH, + MIN_EPOCH_LENGTH, + ZERO_ADDRESS, + ONE_DAY, +} from '../../../helpers/constants'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; + +const snapshots = new Map(); + +// Snapshots +const afterMockTokenMint = 'AfterMockTokenMint'; +const afterEpochZero = 'AfterEpochZero'; +const inBlackoutWindow = 'InBlackoutWindow'; +const borrowerAllocationsSet = 'BorrowerAllocationsSet'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1Admin', deployPhase2, (testEnv: TestEnv) => { + let liquidityStakingV1: LiquidityStakingV1; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: SignerWithAddress[]; + let otherUser: SignerWithAddress; + + let distributionStart: BigNumber; + let distributionEnd: BigNumber; + let initialOffset: BigNumber; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.users.slice(3, 5); // 2 borrowers + otherUser = testEnv.users[5]; + + distributionStart = await liquidityStakingV1.DISTRIBUTION_START(); + distributionEnd = await liquidityStakingV1.DISTRIBUTION_END(); + + const epochParams: { + interval: BigNumber, + offset: BigNumber, + } = await liquidityStakingV1.getEpochParameters(); + + initialOffset = epochParams.offset; + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + testEnv.mockStakedToken, + testEnv.rewardsTreasury, + testEnv.deployer, + testEnv.deployer, + stakers.concat(borrowers), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all(borrowers.map((b) => contract.approveContract(b, stakerInitialBalance))); + + saveSnapshot(afterMockTokenMint); + }); + + describe('Before epoch zero has started', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it("Can set valid epoch & blackout parameters which don't jump past the start of epoch zero", async () => { + const currentTime = await timeLatest(); + // Double epoch length. + // We would now be in the blackout window, except that it doesn't apply before epoch zero. + await contract.setEpochParameters(EPOCH_LENGTH.mul(2), initialOffset); + // Increase epoch length to max. + await contract.setEpochParameters(MAX_EPOCH_LENGTH, initialOffset); + await expect( + contract.setEpochParameters(MAX_EPOCH_LENGTH.add(1), initialOffset) + ).to.be.revertedWith('LS1EpochSchedule: Epoch length too small'); + // Increase blackout window to max. + await contract.setBlackoutWindow(MAX_BLACKOUT_LENGTH); + await expect(contract.setBlackoutWindow(MAX_BLACKOUT_LENGTH.add(1))).to.be.revertedWith( + 'LS1EpochSchedule: Blackout window can be at most half the epoch length' + ); + // Decrease blackout window to min. + await contract.setBlackoutWindow(MIN_BLACKOUT_LENGTH); + await expect(contract.setBlackoutWindow(MIN_BLACKOUT_LENGTH.sub(1))).to.be.revertedWith( + 'LS1EpochSchedule: Blackout window too large' + ); + // Decrease epoch length to min. + await contract.setEpochParameters(MIN_EPOCH_LENGTH, initialOffset); + await expect( + contract.setEpochParameters(MIN_EPOCH_LENGTH.sub(1), initialOffset) + ).to.be.revertedWith( + 'LS1EpochSchedule: Blackout window can be at most half the epoch length' + ); + // Set different offsets. Any offset should be valid as long as it's in the future. + await contract.setEpochParameters(MIN_EPOCH_LENGTH, currentTime.plus(30).toString()); + await contract.setEpochParameters(MIN_EPOCH_LENGTH, currentTime.plus(100000).toString()); + }); + + it('Cannot set epoch parameters which jump past the start of epoch zero', async () => { + const currentTime = (await timeLatest()).toNumber(); + await expect(contract.setEpochParameters(EPOCH_LENGTH, currentTime)).to.be.revertedWith( + 'LS1Admin: Started epoch zero' + ); + await expect( + contract.setEpochParameters(EPOCH_LENGTH, currentTime - 10000) + ).to.be.revertedWith('LS1Admin: Started epoch zero'); + }); + + it('Can set the emission rate', async () => { + await contract.setRewardsPerSecond(123); + }); + + it('Can set borrower allocations', async () => { + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + }); + + it('Can set partial allocation by assigning units to address(0)', async () => { + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.3, + [ZERO_ADDRESS]: 0.2, + }); + }); + + it('Cannot set allocations which sum to greater than one', async () => { + await expect( + contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.5001, + }) + ).to.be.revertedWith('LS1BorrowerAllocations: Invalid'); + }); + + it('Cannot set allocations which sum to less than one', async () => { + await expect( + contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.4999, + }) + ).to.be.revertedWith('LS1BorrowerAllocations: Invalid'); + }); + + it('Cannot call with invalid params', async () => { + await expect(liquidityStakingV1.setBorrowerAllocations([], [1])).to.be.revertedWith( + 'LS1Admin: Params length mismatch' + ); + }); + }); + + describe('After epoch zero has started', () => { + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Move us roughly midway into epoch 2 (right before blackout window). + const newTimestamp = initialOffset.toNumber() + EPOCH_LENGTH.toNumber() * 2.49; + await incrementTimeToTimestamp(newTimestamp); + expect(await liquidityStakingV1.getCurrentEpoch()).to.equal(2); + + await saveSnapshot(afterEpochZero); + }); + + beforeEach(async () => { + await loadSnapshot(afterEpochZero); + }); + + it('Can set epoch parameters which maintain current epoch and blackout status', async () => { + // v now + // Initial schedule: + // | | | | + // 0 1 2 3 + // + // New schedule: + // | | | | + // 0 1 2 3 + await contract.setBlackoutWindow(BLACKOUT_WINDOW.div(4)); // Can be at most half epoch length. + await contract.setEpochParameters( + EPOCH_LENGTH.div(3), + initialOffset.add(EPOCH_LENGTH.mul(5).div(3)) + ); + // + // New schedule: + // | | | + // 1 2 3 + await contract.setEpochParameters( + EPOCH_LENGTH.mul(2), + initialOffset.sub(EPOCH_LENGTH.mul(2)) + ); + // Another schedule, with epoch zero further in the past. + await contract.setEpochParameters( + EPOCH_LENGTH.mul(3), + initialOffset.sub(EPOCH_LENGTH.mul(6)) + ); + }); + + it('Cannot set epoch parameters which move us into the blackout window', async () => { + // Change the offset to move us into the blackout window. + await expect( + contract.setEpochParameters( + EPOCH_LENGTH, + initialOffset.sub(EPOCH_LENGTH.div(2).sub(1000)) // Move us 1000 seconds before epoch end. + ) + ).to.be.revertedWith('LS1Admin: End in blackout window'); + }); + + it('Cannot set blackout parameters which move us into the blackout window', async () => { + const newEpochLength: BigNumber = EPOCH_LENGTH.add(ONE_DAY.multipliedBy(2).toString()); + await contract.setEpochParameters( + newEpochLength, + initialOffset, + ); + + const midwayThroughEpoch2: BigNumber = initialOffset.add(newEpochLength.mul(5).div(2)); + await incrementTimeToTimestamp(midwayThroughEpoch2); + + // Expand the blackout window to include the current timestamp. + await expect(contract.setBlackoutWindow(newEpochLength.div(2))).to.be.revertedWith( + 'LS1Admin: End in blackout window' + ); + }); + + it('Cannot set epoch parameters which decrease the current epoch number', async () => { + // Change the offset by one epoch. + await expect( + contract.setEpochParameters(EPOCH_LENGTH, initialOffset.add(EPOCH_LENGTH)) + ).to.be.revertedWith('LS1Admin: Changed epochs'); + }); + + it('Cannot set epoch parameters which increase the current epoch number', async () => { + // Change the offset by one epoch. + await expect( + contract.setEpochParameters(EPOCH_LENGTH, initialOffset.sub(EPOCH_LENGTH)) + ).to.be.revertedWith('LS1Admin: Changed epochs'); + }); + + it('Cannot set epoch parameters which put us before epoch zero', async () => { + // Change the offset by one epoch. + await expect( + contract.setEpochParameters(EPOCH_LENGTH, initialOffset.add(EPOCH_LENGTH.mul(10))) + ).to.be.revertedWith('LS1EpochSchedule: Epoch zero has not started'); + }); + + it('Can set the emission rate', async () => { + await contract.setRewardsPerSecond(123); + }); + + it('Can set borrower allocations', async () => { + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + }); + + it('Can set partial allocation by assigning units to address(0)', async () => { + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.3, + [ZERO_ADDRESS]: 0.2, + }); + }); + + it('Cannot set allocations which sum to greater than one', async () => { + await expect( + contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.5001, + }) + ).to.be.revertedWith('LS1BorrowerAllocations: Invalid'); + }); + }); + + describe('While in the blackout window', () => { + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Move us to the middle of the blackout window of epoch 2. + const newTimestamp = + initialOffset.toNumber() + EPOCH_LENGTH.toNumber() * 3 - BLACKOUT_WINDOW.toNumber() / 2; + await incrementTimeToTimestamp(newTimestamp); + expect(await liquidityStakingV1.getCurrentEpoch()).to.equal(2); + expect(await liquidityStakingV1.inBlackoutWindow()).to.be.true; + + await saveSnapshot(inBlackoutWindow); + }); + + beforeEach(async () => { + await loadSnapshot(inBlackoutWindow); + }); + + it('Cannot set epoch parameters', async () => { + await expect(contract.setEpochParameters(EPOCH_LENGTH, initialOffset)).to.be.revertedWith( + 'LS1Admin: Blackout window' + ); + }); + + it('Cannot set blackout window', async () => { + await expect(contract.setBlackoutWindow(BLACKOUT_WINDOW)).to.be.revertedWith( + 'LS1Admin: Blackout window' + ); + }); + + it('Cannot update borrower allocations', async () => { + await expect(contract.setBorrowerAllocations({})).to.be.revertedWith( + 'LS1Admin: Blackout window' + ); + }); + + it('Can set the emission rate', async () => { + await contract.setRewardsPerSecond(123); + }); + }); + + describe('after borrower allocations are set', async () => { + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Set allocations and move to the start of epoch zero. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + await incrementTimeToTimestamp(initialOffset); + + await saveSnapshot(borrowerAllocationsSet); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerAllocationsSet); + }); + + it('can set and remove borrowing restrictions', async () => { + // Initial stake and borrow. + await contract.stake(stakers[0], stakerInitialBalance); + await contract.borrow(borrowers[0], stakerInitialBalance * 0.4); + + // Restrict both borrowers. + await contract.setBorrowingRestriction(borrowers[0], true); + await contract.setBorrowingRestriction(borrowers[1], true); + + // Expect borrower 1 cannot borrow. + await expect(contract.borrow(borrowers[1], 1)).to.be.revertedWith( + 'LS1Borrowing: Restricted' + ); + expect(await liquidityStakingV1.getBorrowableAmount(borrowers[1].address)).to.equal(0); + + // Expect borrower 0 can repay, but then cannot borrow. + await contract.repayBorrow(borrowers[0], borrowers[0], stakerInitialBalance * 0.2); + await expect(contract.borrow(borrowers[0], 1)).to.be.revertedWith( + 'LS1Borrowing: Restricted' + ); + + // Release restriction on borrower 0. + await contract.setBorrowingRestriction(borrowers[0], false); + + // Only borrower 0 can borrow. + await contract.borrow(borrowers[0], stakerInitialBalance * 0.2); + await expect(contract.borrow(borrowers[1], 1)).to.be.revertedWith( + 'LS1Borrowing: Restricted' + ); + }); + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: BigNumberish): Promise { + const latestBlockTimestamp = (await timeLatest()).toNumber(); + const timestamp = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp).to.be.at.most(timestamp.toNumber()); + const timestampDiff = timestamp.sub(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1Borrowing.spec.ts b/test/staking/LiquidityStakingV1/ls1Borrowing.spec.ts new file mode 100644 index 0000000..6d584d0 --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1Borrowing.spec.ts @@ -0,0 +1,373 @@ +import BigNumber from 'bignumber.js'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, +} from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { StarkProxyV1 } from '../../../types/StarkProxyV1'; +import { DydxToken } from '../../../types/DydxToken'; + +// Snapshots +const snapshots = new Map(); +const afterMintSnapshot = 'AfterMockTokenMint'; +const distributionStartSnapshot = 'DistributionStart'; +const fundsStakedSnapshot = 'FundsStaked'; +const borrowerAllocationsSnapshot = 'BorrowerAllocationsSet'; +const borrowerAllocationsSettledSnapshot = 'BorrowerAllocationsSettled'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1Borrowing', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: StarkProxyV1[]; + + let distributionStart: string; + let expectedAllocations: number[]; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.starkProxyV1Borrowers; + + // Grant roles. + const borrowerRole = await borrowers[0].BORROWER_ROLE(); + await Promise.all(borrowers.map(async b => { + await b.grantRole(borrowerRole, deployer.address); + })); + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all(borrowers.map((b) => contract.approveContract(b, stakerInitialBalance))); + + // Stake funds. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], stakerInitialBalance / 4); + await contract.stake(stakers[1], (stakerInitialBalance / 4) * 3); + + saveSnapshot(fundsStakedSnapshot); + }); + + describe('if no borrower allocations have been set', () => { + beforeEach(async () => { + await loadSnapshot(fundsStakedSnapshot); + }); + + it('Borrower cannot borrow if they have no allocation', async () => { + await expect(contract.borrowViaProxy(borrowers[0], stakerInitialBalance)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + }); + }); + + describe('before borrower allocations have taken effect', () => { + before(async () => { + await loadSnapshot(fundsStakedSnapshot); + // Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + expectedAllocations = [stakerInitialBalance * 0.4, stakerInitialBalance * 0.6]; + saveSnapshot(borrowerAllocationsSnapshot); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerAllocationsSnapshot); + }); + + it('Borrower cannot borrow if the next allocation has not taken effect', async () => { + await expect(contract.borrowViaProxy(borrowers[0], stakerInitialBalance)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + }); + + it('Can query current and next borrower allocations', async () => { + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[0].address) + ).to.equal(0); + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[1].address) + ).to.equal(0); + + expect(await liquidityStakingV1.getAllocatedBalanceNextEpoch(borrowers[0].address)).to.equal( + expectedAllocations[0] + ); + expect(await liquidityStakingV1.getAllocatedBalanceNextEpoch(borrowers[1].address)).to.equal( + expectedAllocations[1] + ); + }); + }); + + describe('after funds have been staked and allocations have taken effect', () => { + before(async () => { + await loadSnapshot(fundsStakedSnapshot); + // Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + expectedAllocations = [stakerInitialBalance * 0.4, stakerInitialBalance * 0.6]; + await elapseEpoch(); // Increase time to next epoch, so borrower can use new allocation + saveSnapshot(borrowerAllocationsSettledSnapshot); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerAllocationsSettledSnapshot); + }); + + it('Can query current borrower allocation', async () => { + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[0].address) + ).to.equal(expectedAllocations[0]); + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[1].address) + ).to.equal(expectedAllocations[1]); + }); + + it('Borrower cannot borrow more than their allocation', async () => { + await expect(contract.borrowViaProxy(borrowers[0], stakerInitialBalance)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + const expectedAllocation = stakerInitialBalance * 0.4; + await expect( + contract.borrowViaProxy(borrowers[0], expectedAllocation + 1) + ).to.be.revertedWith('LS1Borrowing: Amount > allocated'); + }); + + it('Borrower can borrow and repay the full allocated balance', async () => { + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0]); + await contract.fullBorrowViaProxy(borrowers[1], expectedAllocations[1]); + + await contract.repayBorrowViaProxy(borrowers[0], expectedAllocations[0]); + await contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1]); + }); + + it('Borrower can make partial borrows and repayments', async () => { + await contract.borrowViaProxy(borrowers[0], expectedAllocations[0] / 2); + await contract.borrowViaProxy(borrowers[1], expectedAllocations[1] / 2); + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0] / 2); + await contract.fullBorrowViaProxy(borrowers[1], expectedAllocations[1] / 2); + + // Equal repayments and re-borrows. + await contract.repayBorrowViaProxy(borrowers[0], Math.floor(expectedAllocations[0] / 3)); + await contract.borrowViaProxy(borrowers[0], Math.floor(expectedAllocations[0] / 3)); + await contract.repayBorrowViaProxy(borrowers[0], expectedAllocations[0] / 2); + await contract.borrowViaProxy(borrowers[0], expectedAllocations[0] / 2); + await contract.repayBorrowViaProxy(borrowers[0], expectedAllocations[0]); + + // Mixed amounts. + await contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1] / 2); + await contract.borrowViaProxy(borrowers[1], expectedAllocations[1] / 4); + await contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1] / 2); + await contract.borrowViaProxy(borrowers[1], expectedAllocations[1] / 4); + await contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1] / 2); + + // Expect exactly the full amount to be available. + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0]); + await contract.fullBorrowViaProxy(borrowers[1], expectedAllocations[1]); + }); + + it("Borrower can repay another borrower's owed amount", async () => { + await contract.borrowViaProxy(borrowers[0], expectedAllocations[0]); + await contract.borrowViaProxy(borrowers[1], expectedAllocations[1]); + await mockStakedToken.mint(borrowers[0].address, stakerInitialBalance * 0.2); + await contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1]); + await contract.repayBorrowViaProxy(borrowers[0], expectedAllocations[0]); + }); + + it('Borrower cannot repay more than is owed', async () => { + await contract.borrowViaProxy(borrowers[0], expectedAllocations[0]); + await contract.borrowViaProxy(borrowers[1], expectedAllocations[1]); + + await expect( + contract.repayBorrowViaProxy(borrowers[0], expectedAllocations[0] + 1) + ).to.be.revertedWith('LS1Borrowing: Repay > borrowed'); + await expect( + contract.repayBorrowViaProxy(borrowers[1], expectedAllocations[1] + 1) + ).to.be.revertedWith('LS1Borrowing: Repay > borrowed'); + }); + + it('Can immediately borrow more after users stake more, even in the blackout window', async () => { + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0]); + + const additionalStake = stakerInitialBalance / 4; + + await contract.stake(stakers[0], additionalStake); + await contract.fullBorrowViaProxy(borrowers[0], additionalStake * 0.4); + await advanceToBlackoutWindow(); + await contract.stake(stakers[0], additionalStake); + await contract.fullBorrowViaProxy(borrowers[0], additionalStake * 0.4); + }); + + it('Outside blackout window, not affected if next active balance is lower than current', async () => { + // Request withdrawal, decreasing next active balance. + await contract.requestWithdrawal(stakers[0], stakerInitialBalance / 4); + await contract.requestWithdrawal(stakers[1], (stakerInitialBalance / 4) * 3); + + // Can still borrow full amount. + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0]); + }); + + it('In blackout window, can borrow less if next active balance will be less', async () => { + // Request withdrawal, decreasing next active balance. + await contract.requestWithdrawal(stakers[0], stakerInitialBalance / 8); + + // Expect borrowable amount to be less. + await advanceToBlackoutWindow(); + const expectedAllocationNext = expectedAllocations[0] - (stakerInitialBalance / 8) * 0.4; + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocationNext); + }); + + it('Can borrow less if admin decreases allocation (even while it is the next epoch allocation)', async () => { + // Give all of borrower 0's allocation to borrower 2. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0, + [borrowers[2].address]: 0.4, + }); + + // Check that neither borrower can borrow. + await expect(contract.borrowViaProxy(borrowers[0], 1)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + await expect(contract.borrowViaProxy(borrowers[2], 1)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + + // Give some of the allocation back. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.2, + [borrowers[2].address]: 0.2, + }); + + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0] / 2); + }); + + it('Cannot immediately borrow more if admin increase allocation', async () => { + await contract.fullBorrowViaProxy(borrowers[0], expectedAllocations[0]); + + // Give all of borrower 1's allocation to borrower 0. + await contract.setBorrowerAllocations({ + [borrowers[1].address]: 0, + [borrowers[0].address]: 1, + }); + + // Still unable to borrow more. + await expect(contract.borrowViaProxy(borrowers[0], 1)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + }); + + it('Staker cannot borrow', async () => { + await contract.fullBorrow(stakers[0], 0); + await expect(contract.borrow(stakers[0], 1)).to.be.revertedWith( + 'LS1Borrowing: Amount > allocated' + ); + }); + + it('New borrower can borrow in next epoch', async () => { + // Give all of borrower 0's allocation to borrower 2. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0, + [borrowers[2].address]: 0.4, + }); + + // Wait for the allocation to become active. + await elapseEpoch(); + + await contract.fullBorrowViaProxy(borrowers[2], expectedAllocations[0]); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStakingV1.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + /** + * Progress to the blackout window of the current epoch. + */ + async function advanceToBlackoutWindow(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStakingV1.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + const timeUntilBlackoutWindow = remaining - BLACKOUT_WINDOW.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(timeUntilBlackoutWindow); + } else { + await increaseTime(timeUntilBlackoutWindow); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = new BigNumber(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most(timestamp.toNumber()); + const timestampDiff: number = timestamp.minus(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1DebtAccounting.spec.ts b/test/staking/LiquidityStakingV1/ls1DebtAccounting.spec.ts new file mode 100644 index 0000000..eb18eeb --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1DebtAccounting.spec.ts @@ -0,0 +1,459 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; + +import { timeLatest, evmSnapshot, evmRevert, increaseTime } from '../../../helpers/misc-utils'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { SHORTFALL_INDEX_BASE, ZERO_ADDRESS } from '../../../helpers/constants'; +import { DydxToken } from '../../../types/DydxToken'; + +const snapshots = new Map(); + +// Snapshots +const afterMockTokenMint = 'AfterMockTokenMint'; +const twoShortfalls = 'TwoShortfalls'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1DebtAccounting', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: SignerWithAddress[]; + let otherUser: SignerWithAddress; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.users.slice(3, 5); // 2 borrowers + otherUser = testEnv.users[5]; + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await liquidityStakingV1.DISTRIBUTION_END()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers).concat([otherUser]), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all( + borrowers.map((b) => contract.approveContract(b, BigNumber.from('0x10000000000000000'))) + ); + + await saveSnapshot(afterMockTokenMint); + }); + + describe('Simple shortfall scenarios', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Allows restricting a borrower even if there is no shortfall', async () => { + // Before epoch 0: Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + + // Epoch 0: Stake and borrow. + await incrementTimeToTimestamp(distributionStart); + await contract.mintAndApprove(stakers[0], 100e10); + await contract.stake(stakers[0], 100e10); + await contract.borrow(borrowers[0], 40e10); + await contract.borrow(borrowers[1], 60e10); + // Revoke allocation. + await contract.setBorrowerAllocations({ + [ZERO_ADDRESS]: 1, + [borrowers[0].address]: 0, + [borrowers[1].address]: 0, + }); + // Borrower 0 makes a repayment. + await contract.repayBorrow(borrowers[0], borrowers[0], 40e10); + + // Epoch 1: No shortfall since there were no withdrawal requests. + await contract.elapseEpoch(); + await contract.expectNoShortfall(); + + // Borrower who repaid cannot be restricted. + await expect(liquidityStakingV1.restrictBorrower(borrowers[0].address)).to.be.revertedWith( + 'LS1DebtAccounting: Borrower not overdue' + ); + + // Borrower who did not make a repayment can be restricted. + await expect(liquidityStakingV1.restrictBorrower(borrowers[1].address)) + .to.emit(liquidityStakingV1, 'BorrowingRestrictionChanged') + .withArgs(borrowers[1].address, true); + }); + + it('Cannot mark debt if provided borrowers do not cover the shortfall', async () => { + // Before epoch 0: Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.999, + [borrowers[1].address]: 0.001, + }); + + // Epoch 0: Stake, borrow, and request withdrawal. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], 1000); + await contract.borrow(borrowers[0], 999); + await contract.borrow(borrowers[1], 1); + await contract.requestWithdrawal(stakers[0], 1000); + + // Epoch 1 + await contract.elapseEpoch(); + + // Fails if both borrowers are not specified. + await expect( + contract.markDebt({ [borrowers[0].address]: 999 }, [borrowers[0].address], 0) + ).to.be.revertedWith('LS1DebtAccounting: Borrowers do not cover the shortfall'); + await expect( + contract.markDebt({ [borrowers[1].address]: 1 }, [borrowers[1].address], 0) + ).to.be.revertedWith('LS1DebtAccounting: Borrowers do not cover the shortfall'); + + // Succeeds if both borrowers are specified. + await contract.markDebt( + { + [borrowers[0].address]: 999, + [borrowers[1].address]: 1, + }, + borrowers, + 0 + ); + }); + + it('Can mark debt with a single borrower if they are short for the whole shortfall amount', async () => { + // Before epoch 0: Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.5, + [borrowers[1].address]: 0.5, + [otherUser.address]: 0, + }); + + // Epoch 0: Stake, borrow, and request withdrawal. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], 1000); + await contract.borrow(borrowers[0], 500); + await contract.borrow(borrowers[1], 500); + await contract.requestWithdrawal(stakers[0], 500); + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0, + [borrowers[1].address]: 0, + [otherUser.address]: 1, + }); + + // Epoch 1 + // + // There is a shortfall of 500. + // Each borrower is individually short 500 versus their allocation. + await contract.elapseEpoch(); + + const singleBorrowerShortfall = 'SingleBorrowerShortfall'; + await saveSnapshot(singleBorrowerShortfall); + + // Can mark debt with borrower 0. + await contract.markDebt({ [borrowers[0].address]: 500 }, [borrowers[0]], 0); + + // Can mark debt with borrower 1. + await loadSnapshot(singleBorrowerShortfall); + await contract.markDebt({ [borrowers[1].address]: 500 }, [borrowers[1]], 0); + + // Can mark debt with all borrowers. + await loadSnapshot(singleBorrowerShortfall); + await contract.markDebt( + { + [otherUser.address]: 0, // Will skip borrowers who have no debt. + [borrowers[0].address]: 500, // First borrower with debt will be allocated the shortfall. + [borrowers[1].address]: 0, + }, + [borrowers[0]], // Only the first one will be restricted, since they cover the whole debt. + 0 + ); + }); + }); + + describe('Debt accounting, after two shortfalls', () => { + before(async () => { + loadSnapshot(afterMockTokenMint); + + // Before epoch 0: Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + + // TOOD: This should be handled by state.madeInitialAllocation, but it's not working. + [ZERO_ADDRESS]: 0, + }); + + // Epoch 0: Deposit, borrow, request withdrawal. Partial repayment. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], 300); + await contract.stake(stakers[1], 700); + await contract.borrow(borrowers[0], 300); + await contract.borrow(borrowers[1], 600); + await contract.requestWithdrawal(stakers[0], 100); + await contract.requestWithdrawal(stakers[1], 500); + await contract.repayBorrow(borrowers[1], borrowers[1], 200); + + // Epoch 1: First partial shortfall: debt of 300 against inactive balance of 600. + await contract.elapseEpoch(); + await contract.markDebt( + { + [borrowers[0].address]: 140, + [borrowers[1].address]: 160, + }, + borrowers, // Expect both borrowers to be restricted. + 0.5 // Expected new index. + ); + await contract.requestWithdrawal(stakers[0], 200); + + // Epoch 2: Second partial shortfall: debt of 200 against inactive balance of 500. + await contract.elapseEpoch(); + await contract.markDebt( + { + [borrowers[0].address]: 80, + [borrowers[1].address]: 120, + }, + [], // Expect no new restrictions. + 0.6 // Expected new index. + ); + + // Expect state. + await contract.expectStakerDebt({ + [stakers[0].address]: 150, + [stakers[1].address]: 350, + }); + await contract.expectBorrowerDebt({ + [borrowers[0].address]: 220, + [borrowers[1].address]: 280, + }); + + await saveSnapshot(twoShortfalls); + }); + + beforeEach(async () => { + await loadSnapshot(twoShortfalls); + }); + + it('getters return shortfall info', async () => { + expect(await liquidityStakingV1.getShortfallCount()).to.equal(2); + expect((await liquidityStakingV1.getShortfall(0)).epoch).to.equal(1); + expect((await liquidityStakingV1.getShortfall(1)).epoch).to.equal(2); + expect((await liquidityStakingV1.getShortfall(0)).index).to.equal( + SHORTFALL_INDEX_BASE.mul(5).div(10) + ); + expect((await liquidityStakingV1.getShortfall(1)).index).to.equal( + SHORTFALL_INDEX_BASE.mul(6).div(10) + ); + }); + + it('newly deposited funds are not affected by past shortfalls', async () => { + await contract.mintAndApprove(otherUser, 150e6); + await contract.stake(otherUser, 150e6); + await contract.elapseEpoch(); + await contract.requestWithdrawal(otherUser, 100e6); + await contract.elapseEpoch(); + await contract.withdrawStake(otherUser, otherUser, 50e6); + await contract.elapseEpoch(); + await contract.requestWithdrawal(otherUser, 50e6); + await contract.elapseEpoch(); + await contract.withdrawStake(otherUser, otherUser, 100e6); + expect(await liquidityStakingV1.getActiveBalanceCurrentEpoch(otherUser.address)).to.equal(0); + expect(await liquidityStakingV1.getInactiveBalanceNextEpoch(otherUser.address)).to.equal(0); + }); + + it('stakers cannot withdraw debt if there were no repayments', async () => { + await contract.fullWithdrawDebt(stakers[0], 0); + await contract.fullWithdrawDebt(stakers[1], 0); + }); + + it('stakers can withdraw repaid debt on a first-come first-serve basis', async () => { + await contract.fullRepayDebt(borrowers[0], 220); + await contract.fullWithdrawDebt(stakers[0], 150); + await contract.fullWithdrawDebt(stakers[1], 70); + await contract.fullRepayDebt(borrowers[1], 280); + await contract.fullWithdrawDebt(stakers[1], 280); + }); + + it('stakers can withdraw max debt', async () => { + await contract.fullRepayDebt(borrowers[0], 220); + await contract.fullRepayDebt(borrowers[1], 280); + await contract.withdrawMaxDebt(stakers[0], stakers[0]); + await contract.withdrawMaxDebt(stakers[1], stakers[1]); + expect(await liquidityStakingV1.getTotalDebtAvailableToWithdraw()).to.equal(0); + + // Can still call withdrawMaxDebt() even if there are no funds to withdraw. + await contract.withdrawMaxDebt(stakers[0], stakers[0]); + await contract.withdrawMaxDebt(stakers[1], stakers[1]); + }); + + it('borrower can make partial repayments', async () => { + await contract.repayDebt(borrowers[0], borrowers[0], 1); + await contract.repayDebt(borrowers[0], borrowers[0], 10); + await contract.repayDebt(borrowers[0], borrowers[0], 100); + await contract.fullWithdrawDebt(stakers[0], 111); + await contract.fullRepayDebt(borrowers[0], 109); + await contract.fullWithdrawDebt(stakers[1], 109); + }); + + it('anyone can repay debt on behalf of a borrower', async () => { + await contract.repayDebt(stakers[0], borrowers[0], 110); + await contract.fullWithdrawDebt(stakers[1], 110); + + // Borrower repays the rest of their own debt. + await contract.fullRepayDebt(borrowers[0], 110); + }); + + it('repaid debt cannot be borrowed', async () => { + // Check initial state. + // + // Currently, the contract is perfectly balanced, with: + // Staker Active = Borrower Borrowed = 200 + // Staker Inactive = Available in Contract = 300 + // Staker Debt = Borrower Debt = 500 + expect(await liquidityStakingV1.getTotalBorrowedBalance()).to.equal(200); + expect(await liquidityStakingV1.getTotalActiveBalanceCurrentEpoch()).to.equal(200); + expect(await liquidityStakingV1.getTotalInactiveBalanceCurrentEpoch()).to.equal(300); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(300); + + // Add more stake. + await contract.stake(stakers[0], 500); + + // Release borrower restrictions. + await contract.setBorrowingRestriction(borrowers[0], false); + await contract.setBorrowingRestriction(borrowers[1], false); + + // Verify borrowable amounts. + expect(await liquidityStakingV1.getBorrowableAmount(borrowers[0].address)).to.be.equal(200); + expect(await liquidityStakingV1.getBorrowableAmount(borrowers[1].address)).to.be.equal(300); + + // Use another user to withdraw all borrowable funds. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0, + [borrowers[1].address]: 0, + [otherUser.address]: 1, + }); + await contract.elapseEpoch(); + // Active balance 700, contract balance 800, inactive balance 300 => can withdraw 500 + await contract.fullBorrow(otherUser, 500); + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + [otherUser.address]: 0, + }); + await contract.elapseEpoch(); + + // New state: + // Staker Active = Borrower Borrowed = 700 + // Staker Inactive = Available in Contract = 300 + // Staker Debt = Borrower Debt = 500 + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(300); + expect(await liquidityStakingV1.getContractBalanceAvailableToWithdraw()).to.equal(300); + + // Repay full debt balances, so that there are more funds in the contract. + await contract.fullRepayDebt(borrowers[0], 220); + await contract.fullRepayDebt(borrowers[1], 280); + // + // Now the contract balance should be: 300 unborrowed + 500 repaid debt + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(800); + + // Verify that no additional borrows can be made. + await contract.fullBorrow(borrowers[0], 0); + await contract.fullBorrow(borrowers[1], 0); + + // Furthermore, the borrowable amounts should reflect this. + expect(await liquidityStakingV1.getBorrowableAmount(borrowers[0].address)).to.be.equal(0); + expect(await liquidityStakingV1.getBorrowableAmount(borrowers[1].address)).to.be.equal(0); + + // Verify that the borrowers still have allocated balances though. + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[0].address) + ).to.be.equal(280); + expect( + await liquidityStakingV1.getAllocatedBalanceCurrentEpoch(borrowers[1].address) + ).to.be.equal(420); + }); + + it('repaid debt cannot be withdrawn as stake withdrawal', async () => { + // Withdraw the full withdrawable stake amount. + await contract.fullWithdrawStake(stakers[0], 150); + await contract.fullWithdrawStake(stakers[1], 150); + + // Verify that there are no tokens left in the contract. + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(0); + + // Request to withdraw remaining funds. + await contract.requestWithdrawal(stakers[1], 200, { roundingTolerance: 1 }); + + // Elapse epoch so that the inactive balance becomes current. + await contract.elapseEpoch(); + + // Verify that the staker has a current inactive balance. + expect( + await liquidityStakingV1.getInactiveBalanceCurrentEpoch(stakers[1].address) + ).to.be.equal(200); + + // Repay full debt balances. + await contract.fullRepayDebt(borrowers[0], 220, { roundingTolerance: 1 }); + await contract.fullRepayDebt(borrowers[1], 280, { roundingTolerance: 1 }); + + // Verify that no stake can be withdrawn. + await contract.fullWithdrawStake(stakers[0], 0, { roundingTolerance: 1 }); + await contract.fullWithdrawStake(stakers[1], 0, { roundingTolerance: 1 }); + }); + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most(timestamp.toNumber()); + const timestampDiff: number = timestamp.sub(latestBlockTimestamp.toString()).toNumber(); + await increaseTime(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1DebtAccountingScenarios.spec.ts b/test/staking/LiquidityStakingV1/ls1DebtAccountingScenarios.spec.ts new file mode 100644 index 0000000..a41215d --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1DebtAccountingScenarios.spec.ts @@ -0,0 +1,315 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; + +import { timeLatest, evmSnapshot, increaseTime } from '../../../helpers/misc-utils'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { DydxToken } from '../../../types/DydxToken'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; + +const snapshots = new Map(); + +// Snapshots +const afterMockTokenMint = 'AfterMockTokenMint'; +const twoShortfalls = 'TwoShortfalls'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1DebtAccounting (Scenarios)', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: SignerWithAddress[]; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.users.slice(3, 5); // 2 borrowers + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await liquidityStakingV1.DISTRIBUTION_END()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all(borrowers.map((b) => contract.approveContract(b, stakerInitialBalance))); + + saveSnapshot(afterMockTokenMint); + }); + + describe('Shortfalls and debt accounting', () => { + it('Initial scenario with two shortfalls', async () => { + await incrementTimeToTimestamp(distributionStart); + + // Epoch 0 + // + // Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + + // Epoch (step) 1 2 (1) 2 (2) 3 (1) 3 (2) 4 + // Active 1000 400 400 300 300 200 + // Inactive 0 600 300 400 300 400 + // Debt 0 0 300 300 400 400 + // Total 1000 1000 1000 1000 1000 1000 + // Borrowed 900 700 400 400 300 300 + // Available 100 300 300 300 300 300 + // + // Debt + Borrowed + Available is always 1000 + // Active + Inactive Value + Debt is normally 1000 + + // + // Epoch 1 + // + await contract.elapseEpoch(); + // + // Deposit, borrow, request withdrawal. + await contract.stake(stakers[0], 300); + await contract.stake(stakers[1], 700); + await contract.borrow(borrowers[0], 300); + await contract.borrow(borrowers[1], 600); + await contract.requestWithdrawal(stakers[0], 100); + await contract.requestWithdrawal(stakers[1], 500); + // + // Make a partial repayment. + // + // Borrower 0 1 + // ---------------------------------------- + // Initial allocated 400 600 + // Initial borrowed 300 600 + // New allocated 160 240 + // New borrowed 300 400 + // Epoch 2 shortfall 140 160 + await contract.repayBorrow(borrowers[1], borrowers[1], 200); + + // + // Epoch 2 + // + // (Step 1) + // + await contract.elapseEpochWithExpectedBalanceUpdates( + { + // Active. + [stakers[0].address]: [300, 200], + [stakers[1].address]: [700, 200], + }, + { + // Inactive. + [stakers[0].address]: [0, 100], + [stakers[1].address]: [0, 500], + } + ); + // + // (Step 2) + // + // Initially, expect no debt. + expect(await liquidityStakingV1.getBorrowerDebtBalance(borrowers[0].address)).to.equal(0); + expect(await liquidityStakingV1.getBorrowerDebtBalance(borrowers[1].address)).to.equal(0); + expect(await liquidityStakingV1.getStakerDebtBalance(stakers[0].address)).to.equal(0); + expect(await liquidityStakingV1.getStakerDebtBalance(stakers[1].address)).to.equal(0); + // + // Mark debt. + // + // Expect loss of 300 against a total inactive balance of 600. + await contract.markDebt( + { + [borrowers[0].address]: 140, + [borrowers[1].address]: 160, + }, + borrowers, // Expect both borrowers to be restricted. + 0.5 // Expected new index. + ); + // + // Expect staker debt balances. + // + // Staker 1: Requested to withdraw 100, cut by 50% => is owed 50. + // Staker 1: Requested to withdraw 500, cut by 50% => is owed 250. + await contract.expectStakerDebt({ + [stakers[0].address]: 50, + [stakers[1].address]: 250, + }); + // + // Request to withdraw to trigger a second shortfall. + await contract.requestWithdrawal(stakers[0], 100); + + // + // Epoch 3 + // + // (Step 1) + // + await contract.elapseEpochWithExpectedBalanceUpdates( + { + // Active. + [stakers[0].address]: [200, 100], + [stakers[1].address]: [200, 200], + }, + { + // Inactive. + [stakers[0].address]: [50, 150], + [stakers[1].address]: [250, 250], + } + ); + // + // Request to withdraw again, before debt has been marked. + // This inactive balance should NOT be included in the shortfall. + await contract.requestWithdrawal(stakers[0], 100); + expect(await liquidityStakingV1.getInactiveBalanceCurrentEpoch(stakers[0].address)).to.equal( + 150 + ); + expect(await liquidityStakingV1.getInactiveBalanceNextEpoch(stakers[0].address)).to.equal( + 250 + ); + // Check the total balance. + expect(await getTotalStakerBalance()).to.equal(1000); + // + // (Step 2) + // + // Now mark debt. + // + // Borrower 0 1 + // ---------------------------------------- + // Epoch 2 allocated 160 240 + // Epoch 2 borrowed 300 400 + // Epoch 2 shortfall 140 160 + // Current allocated 120 180 + // Current borrowed 160 240 + // New shortfall 40 60 + // + // Expect loss of 100 against a total inactive balance (next epoch) of 400. + await contract.markDebt( + { + [borrowers[0].address]: 40, + [borrowers[1].address]: 60, + }, + [], // Expect no new restrictions. + 0.75, // Expected new index. + { roundingTolerance: 1 } + ); + // Expect staker debt balances. + // + // Staker 1: Has 150 inactive, cut by 25% => is owed 37.5 in new debt. + // Staker 1: Has 250 inactive, cut by 25% => is owed 62.5 in new debt. + await contract.expectStakerDebt({ + [stakers[0].address]: 50 + 38, // Rounds up. + [stakers[1].address]: 250 + 63, // Rounds up. + }); + // Check the total balance. + expect(await getTotalStakerBalance()).to.equal(1000); + + // + // Epoch 4 + // + await contract.elapseEpochWithExpectedBalanceUpdates( + { + // Active. + [stakers[0].address]: [100, 0], + [stakers[1].address]: [200, 200], + }, + { + // Inactive. + [stakers[0].address]: [112, 212], // Inactive balance was pulled forward early. + [stakers[1].address]: [187, 187], + }, + { roundingTolerance: 1 } + ); + // Check total balances. + expect(await getTotalStakerBalance()).to.equal(1000); + expect(await liquidityStakingV1.getTotalActiveBalanceCurrentEpoch()).to.equal(200); + expect(await liquidityStakingV1.getTotalInactiveBalanceCurrentEpoch()).to.equal(400); + expect(await liquidityStakingV1.getTotalBorrowerDebtBalance()).to.equal(400); + expect(await liquidityStakingV1.getTotalBorrowedBalance()).to.equal(300); + // + // Finally, check invariants again. + await contract.checkInvariants({ roundingTolerance: 1 }); + + // Test failsafe functions. + const signer0 = liquidityStakingV1.connect(stakers[0].signer); + const signer1 = liquidityStakingV1.connect(stakers[1].signer); + await signer0.failsafeDeleteUserInactiveBalance(); + expect(await liquidityStakingV1.getInactiveBalanceCurrentEpoch(stakers[0].address)).to.equal( + 0 + ); + expect(await liquidityStakingV1.getInactiveBalanceNextEpoch(stakers[0].address)).to.equal(0); + await signer0.failsafeDeleteUserInactiveBalance(); + await signer1.failsafeSettleUserInactiveBalanceToEpoch(2); + await signer1.failsafeSettleUserInactiveBalanceToEpoch(3); + await signer1.failsafeSettleUserInactiveBalanceToEpoch(4); + await signer1.failsafeSettleUserInactiveBalanceToEpoch(4); + await expect(signer1.failsafeSettleUserInactiveBalanceToEpoch(5)).to.be.revertedWith( + 'LS1StakedBalances: maxEpoch' + ); + + // Test that normal operation still works afterwards. + const amount = 12345; + const options = { skipInvariantChecks: true }; + await contract.stake(stakers[0], amount, options); + await contract.stake(stakers[1], amount, options); + await contract.requestWithdrawal(stakers[0], amount, options); + await contract.requestWithdrawal(stakers[1], amount, options); + await contract.elapseEpoch(); + await contract.withdrawStake(stakers[0], stakers[0], amount, options); + await contract.withdrawStake(stakers[1], stakers[1], amount, options); + }); + }); + + /** + * Add up all funds owned by stakers: active + inactive (rounded) + debt. + */ + async function getTotalStakerBalance(): Promise { + let sum = await liquidityStakingV1.getTotalActiveBalanceCurrentEpoch(); + for (const staker of stakers) { + const address = staker.address; + sum = sum.add(await liquidityStakingV1.getInactiveBalanceCurrentEpoch(address)); + sum = sum.add(await liquidityStakingV1.getStakerDebtBalance(address)); + } + return sum; + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most(timestamp.toNumber()); + const timestampDiff: number = timestamp.sub(latestBlockTimestamp.toString()).toNumber(); + await increaseTime(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1ERC20.spec.ts b/test/staking/LiquidityStakingV1/ls1ERC20.spec.ts new file mode 100644 index 0000000..86c3e07 --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1ERC20.spec.ts @@ -0,0 +1,353 @@ +import BigNumber from 'bignumber.js'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, +} from '../../../helpers/misc-utils'; +import { EPOCH_LENGTH } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { DydxToken } from '../../../types/DydxToken'; + +const snapshots = new Map(); + +const afterMockTokenMint = 'AfterMockTokenMint'; + +const stakerInitialBalance: number = 1_000_000; +const stakerInitialBalance2: number = 4_000_000; + +makeSuite('LS1ERC20', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + // Users calling the liquidity staking contract. + let stakerSigner1: LiquidityStakingV1; + let stakerSigner2: LiquidityStakingV1; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Users calling the liquidity staking contract. + stakerSigner1 = liquidityStakingV1.connect(staker1.signer); + stakerSigner2 = liquidityStakingV1.connect(staker2.signer); + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await liquidityStakingV1.DISTRIBUTION_END()).toString(); + + await mockStakedToken.mint(staker1.address, stakerInitialBalance); + await mockStakedToken + .connect(staker1.signer) + .approve(liquidityStakingV1.address, stakerInitialBalance); + + await mockStakedToken.mint(staker2.address, stakerInitialBalance2); + await mockStakedToken + .connect(staker2.signer) + .approve(liquidityStakingV1.address, stakerInitialBalance2); + + snapshots.set(afterMockTokenMint, await evmSnapshot()); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + [staker1, staker2], + false, + ); + }); + + describe('name', () => { + it('Has correct name', async () => { + expect(await liquidityStakingV1.name()).to.equal('dYdX Staked USDC'); + }); + }); + + describe('symbol', () => { + it('Has correct symbol', async () => { + expect(await liquidityStakingV1.symbol()).to.equal('stkUSDC'); + }); + }); + + describe('decimals', () => { + it('Has correct decimals', async () => { + expect(await liquidityStakingV1.decimals()).to.equal(6); + }); + }); + + describe('totalSupply', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('totalSupply is zero with no staked funds', async () => { + expect(await liquidityStakingV1.totalSupply()).to.equal(0); + }); + + it('totalSupply increases when users stake funds and does not decrease when funds are inactive', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + expect(await liquidityStakingV1.totalSupply()).to.equal(stakerInitialBalance); + + await contract.requestWithdrawal(staker1, stakerInitialBalance); + + // funds aren't inactive yet + expect(await liquidityStakingV1.totalSupply()).to.equal(stakerInitialBalance); + + // another user stakes funds + await contract.stake(staker2, stakerInitialBalance2); + const totalFunds = stakerInitialBalance + stakerInitialBalance2; + expect(await liquidityStakingV1.totalSupply()).to.equal(totalFunds); + + // funds are inactive in next epoch + await elapseEpoch(); + expect(await liquidityStakingV1.totalSupply()).to.equal(totalFunds); + + await contract.withdrawMaxStake(staker1, staker1); + // should be stakerInitialBalance2 after staker1 withdraws funds + expect(await liquidityStakingV1.totalSupply()).to.equal(stakerInitialBalance2); + }); + }); + + describe('balanceOf', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('balanceOf is zero if user has no staked funds', async () => { + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + }); + + it('User balance increases when user stakes funds and does not decrease when funds are inactive', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + + await contract.requestWithdrawal(staker1, stakerInitialBalance); + + // funds aren't inactive yet + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + + // funds are inactive in next epoch + await elapseEpoch(); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + + await contract.withdrawMaxStake(staker1, staker1); + // should be 0 after user withdraws funds + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + }); + }); + + describe('allowance', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('allowance is initially zero for user', async () => { + expect(await liquidityStakingV1.allowance(staker1.address, staker2.address)).to.equal(0); + }); + + it('allowance can be set to non-zero with approve', async () => { + expect(await stakerSigner1.approve(staker2.address, stakerInitialBalance)) + .to.emit(stakerSigner1, 'Approval') + .withArgs(staker1.address, staker2.address, stakerInitialBalance); + + expect(await liquidityStakingV1.allowance(staker1.address, staker2.address)).to.equal( + stakerInitialBalance + ); + }); + }); + + describe('increaseAllowance', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('allowance can be increased', async () => { + expect(await stakerSigner1.increaseAllowance(staker2.address, stakerInitialBalance)) + .to.emit(stakerSigner1, 'Approval') + .withArgs(staker1.address, staker2.address, stakerInitialBalance); + + expect(await liquidityStakingV1.allowance(staker1.address, staker2.address)).to.equal( + stakerInitialBalance + ); + }); + }); + + describe('decreaseAllowance', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('allowance can be decreased', async () => { + expect(await stakerSigner1.increaseAllowance(staker2.address, stakerInitialBalance)) + .to.emit(stakerSigner1, 'Approval') + .withArgs(staker1.address, staker2.address, stakerInitialBalance); + + expect(await liquidityStakingV1.allowance(staker1.address, staker2.address)).to.equal( + stakerInitialBalance + ); + + expect(await stakerSigner1.decreaseAllowance(staker2.address, stakerInitialBalance)) + .to.emit(stakerSigner1, 'Approval') + .withArgs(staker1.address, staker2.address, 0); + + // allowance should now be 0 + expect(await liquidityStakingV1.allowance(staker1.address, staker2.address)).to.equal(0); + }); + }); + + describe('transfer', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('User cannot transfer funds if they have not staked', async () => { + expect(await liquidityStakingV1.getTransferableBalance(staker1.address)).to.equal(0); + await expect( + stakerSigner1.transfer(staker2.address, stakerInitialBalance) + ).to.be.revertedWith('LS1ERC20: Transfer exceeds next epoch active balance'); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + expect(await liquidityStakingV1.getTransferableBalance(staker1.address)).to.equal( + stakerInitialBalance + ); + await contract.transfer(staker1, staker2, stakerInitialBalance); + + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + + it('User with staked balance for one epoch can transfer to another user and claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + await contract.stake(staker1, stakerInitialBalance); + const stakeTimestamp: BigNumber = await timeLatest(); + + // increase time to next epoch, so user can earn rewards + await elapseEpoch(); + + expect(await liquidityStakingV1.getTransferableBalance(staker1.address)).to.equal( + stakerInitialBalance + ); + await contract.transfer(staker1, staker2, stakerInitialBalance); + + const balanceBeforeClaiming = await dydxToken.balanceOf(staker1.address); + const now: BigNumber = await timeLatest(); + const numTokens = now.minus(stakeTimestamp).times(emissionRate).toString(); + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(liquidityStakingV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, numTokens); + expect(await dydxToken.balanceOf(staker1.address)).to.equal( + balanceBeforeClaiming.add(numTokens).toString() + ); + }); + + it('User cannot transfer funds that are going to be inactive in the next epoch', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.requestWithdrawal(staker1, stakerInitialBalance); + + // funds will be inactive next epoch, cannot transfer them + expect(await liquidityStakingV1.getTransferableBalance(staker1.address)).to.equal(0); + await expect( + stakerSigner1.transfer(staker2.address, stakerInitialBalance) + ).to.be.revertedWith('LS1ERC20: Transfer exceeds next epoch active balance'); + }); + }); + + describe('transferFrom', () => { + beforeEach(async () => { + await revertTestChanges(); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.approve(staker1, staker2, stakerInitialBalance); + await contract.transferFrom(staker2, staker1, staker2, stakerInitialBalance); + + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStakingV1.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterMockTokenMint) || '1'); + snapshots.set(afterMockTokenMint, await evmSnapshot()); + contract.reset(); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = new BigNumber(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most(timestamp.toNumber()); + const timestampDiff: number = timestamp.minus(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1Operators.spec.ts b/test/staking/LiquidityStakingV1/ls1Operators.spec.ts new file mode 100644 index 0000000..d999c78 --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1Operators.spec.ts @@ -0,0 +1,284 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; + +import { timeLatest, evmSnapshot, evmRevert, increaseTime } from '../../../helpers/misc-utils'; +import { + CLAIM_OPERATOR_ROLE_KEY, + DEBT_OPERATOR_ROLE_KEY, + EPOCH_LENGTH, + STAKE_OPERATOR_ROLE_KEY, +} from '../../../helpers/constants'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { DydxToken } from '../../../types/DydxToken'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; + +const snapshots = new Map(); + +// Snapshots +const afterMockTokenMint = 'AfterMockTokenMint'; +const afterShortfall = 'AfterShortfall'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1Operators', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: SignerWithAddress[]; + let operator: SignerWithAddress; + + // Smart contract callers. + let operatorSigner: LiquidityStakingV1; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.users.slice(3, 5); // 2 borrowers + operator = testEnv.users[5]; + + operatorSigner = liquidityStakingV1.connect(operator.signer); + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await liquidityStakingV1.DISTRIBUTION_END()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers).concat([deployer, operator]), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all(borrowers.map((b) => contract.approveContract(b, stakerInitialBalance))); + + await saveSnapshot(afterMockTokenMint); + }); + + describe('Stake and claim operators', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + await incrementTimeToTimestamp(distributionStart); + }); + + it('The stake operator can withdraw stake on behalf of a user', async () => { + const largeBalance = stakerInitialBalance * 100; + + await contract.addOperator(operator, STAKE_OPERATOR_ROLE_KEY); + await contract.mintAndApprove(operator, largeBalance); + + // Stake using the operator's funds, and withdraw back to the operator. + await operatorSigner.stakeFor(stakers[0].address, largeBalance); + await operatorSigner.requestWithdrawalFor(stakers[0].address, largeBalance); + await contract.elapseEpoch(); + await operatorSigner.withdrawStakeFor(stakers[0].address, operator.address, largeBalance); + + expect(await mockStakedToken.balanceOf(operator.address)).to.equal(largeBalance); + }); + + it('Another user can stake on behalf of a user, but not withdraw', async () => { + const stakerSigner = liquidityStakingV1.connect(stakers[1].signer); + + await stakerSigner.stakeFor(stakers[0].address, stakerInitialBalance); + await expect(stakerSigner.requestWithdrawalFor(stakers[0].address, 1)).to.be.revertedWith( + 'revert AccessControl: account' + ); + }); + + it('The claim operator can claim rewards on behalf of a user', async () => { + await contract.addOperator(operator, CLAIM_OPERATOR_ROLE_KEY); + + const initialBalance = await dydxToken.balanceOf(operator.address); + + const rewardsRate = 16; + await contract.setRewardsPerSecond(rewardsRate); + await contract.stake(stakers[0], stakerInitialBalance); + await contract.elapseEpoch(); + await operatorSigner.claimRewardsFor(stakers[0].address, operator.address); + + const finalBalance = await dydxToken.balanceOf(operator.address); + const amountReceived = finalBalance.sub(initialBalance).toNumber(); + const expectedReceived = EPOCH_LENGTH.mul(rewardsRate).toNumber(); + expect(amountReceived).to.be.closeTo(expectedReceived, 100); + }); + }); + + describe('Debt operator', () => { + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Before epoch 0: Set borrower allocations. + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + + // Epoch 0: Deposit, borrow, request withdrawal. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], 500); + await contract.stake(stakers[1], 500); + await contract.borrow(borrowers[0], 400); + await contract.borrow(borrowers[1], 600); + await contract.requestWithdrawal(stakers[0], 200); + await contract.requestWithdrawal(stakers[1], 300); + + // Epoch 1: Full shortfall. + await contract.elapseEpoch(); + await contract.markDebt( + { + [borrowers[0].address]: 200, + [borrowers[1].address]: 300, + }, + borrowers, // Expect both borrowers to be restricted. + 0 // Expected new index. + ); + // Expect staker debt balances. + await contract.expectStakerDebt({ + [stakers[0].address]: 200, + [stakers[1].address]: 300, + }); + await saveSnapshot(afterShortfall); + }); + + beforeEach(async () => { + await loadSnapshot(afterShortfall); + }); + + it('The admin can add and remove debt operators', async () => { + await contract.addOperator(operator, DEBT_OPERATOR_ROLE_KEY); + await contract.addOperator(borrowers[0], DEBT_OPERATOR_ROLE_KEY); + await contract.removeOperator(borrowers[0], DEBT_OPERATOR_ROLE_KEY); + await contract.removeOperator(operator, DEBT_OPERATOR_ROLE_KEY); + }); + + it('The debt operator can reduce staker debt balances', async () => { + // Repay debt so that there are debt funds available. + await contract.repayDebt(borrowers[1], borrowers[1], 100); + + // Add debt operator and decrease staker debt. + await contract.addOperator(operator, DEBT_OPERATOR_ROLE_KEY); + await contract.decreaseStakerDebt(operator, stakers[0], 200, { + skipStakerVsBorrowerDebtComparison: true, + }); + + // Check that the debt cannot be further decreased by the manager. + await expect(contract.decreaseStakerDebt(operator, stakers[0], 1)).to.be.revertedWith( + 'SafeMath: subtraction overflow' + ); + + // Check that the staker cannot withdraw debt anymore. + await expect(contract.withdrawDebt(stakers[0], stakers[0], 1)).to.be.revertedWith( + 'LS1Staking: Withdraw debt exceeds debt owed' + ); + + // Re-balance by reducing borrower debt an equal amount. + await contract.decreaseBorrowerDebt(operator, borrowers[0], 200); + + // The other staker can still withdraw debt. Can withdraw 100 since that's what was repaid. + await contract.fullWithdrawDebt(stakers[1], 100); + }); + + it('The debt operator can reduce borrower debt balances', async () => { + await contract.addOperator(operator, DEBT_OPERATOR_ROLE_KEY); + await contract.decreaseBorrowerDebt(operator, borrowers[0], 200, { + skipStakerVsBorrowerDebtComparison: true, + }); + + // Check that the debt cannot be further decreased by the manager. + await expect(contract.decreaseBorrowerDebt(operator, borrowers[0], 1)).to.be.revertedWith( + 'SafeMath: subtraction overflow' + ); + + // Check that the debt cannot be further decreased by the borrower. + await expect(contract.repayDebt(borrowers[0], borrowers[0], 1)).to.be.revertedWith( + 'LS1Borrowing: Repay > debt' + ); + + // Re-balance by reducing staker debt an equal amount. + await contract.decreaseStakerDebt(operator, stakers[0], 200); + }); + + it('A non-debt operator cannot reduce debt balances', async () => { + // Try with admin. + const debtOperatorRole: string = (await contract.getRoles())[DEBT_OPERATOR_ROLE_KEY]; + const nonDebtOperatorAddress: string = stakers[0].address; + await expect( + contract.decreaseStakerDebt(nonDebtOperatorAddress, stakers[0], 0) + ).to.be.revertedWith( + `AccessControl: account ${nonDebtOperatorAddress.toLowerCase()} is missing role ${debtOperatorRole}` + ); + await expect(contract.decreaseBorrowerDebt(stakers[0], borrowers[0], 0)).to.be.revertedWith( + `AccessControl: account ${nonDebtOperatorAddress.toLowerCase()} is missing role ${debtOperatorRole}` + ); + + const newDebtOperator: string = stakers[1].address; + await contract.addOperator(newDebtOperator, DEBT_OPERATOR_ROLE_KEY); + await contract.removeOperator(newDebtOperator, DEBT_OPERATOR_ROLE_KEY); + + // Try with debt operator who was removed. + await expect(contract.decreaseStakerDebt(newDebtOperator, stakers[0], 0)).to.be.revertedWith( + `AccessControl: account ${newDebtOperator.toLowerCase()} is missing role ${debtOperatorRole}` + ); + + await expect( + contract.decreaseBorrowerDebt(newDebtOperator, borrowers[0], 0) + ).to.be.revertedWith( + `AccessControl: account ${newDebtOperator.toLowerCase()} is missing role ${debtOperatorRole}` + ); + }); + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most( + timestamp.toNumber(), + 'incrementTimeToTimestamp: going backwards in time' + ); + const timestampDiff: number = timestamp.sub(latestBlockTimestamp.toString()).toNumber(); + await increaseTime(timestampDiff); +} diff --git a/test/staking/LiquidityStakingV1/ls1Staking.spec.ts b/test/staking/LiquidityStakingV1/ls1Staking.spec.ts new file mode 100644 index 0000000..e073391 --- /dev/null +++ b/test/staking/LiquidityStakingV1/ls1Staking.spec.ts @@ -0,0 +1,511 @@ +import BigNumber from 'bignumber.js'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, +} from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { DydxToken } from '../../../types/DydxToken'; + +const snapshots = new Map(); + +const afterMockTokenMint = 'AfterMockTokenMint'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('LS1Staking', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStakingV1: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + // Users calling the liquidity staking contract. + let stakerSigner1: LiquidityStakingV1; + let stakerSigner2: LiquidityStakingV1; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + liquidityStakingV1 = testEnv.liquidityStaking; + mockStakedToken = testEnv.mockStakedToken; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Users calling the liquidity staking contract. + stakerSigner1 = liquidityStakingV1.connect(staker1.signer); + stakerSigner2 = liquidityStakingV1.connect(staker2.signer); + + distributionStart = (await liquidityStakingV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await liquidityStakingV1.DISTRIBUTION_END()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStakingV1, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + [staker1, staker2], + false, + ); + + await contract.mintAndApprove(staker1, stakerInitialBalance); + + saveSnapshot(afterMockTokenMint); + }); + + describe('stake', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with mock tokens cannot successfully stake if epoch zero has not started', async () => { + await expect(stakerSigner1.stake(stakerInitialBalance)).to.be.revertedWith( + 'LS1EpochSchedule: Epoch zero has not started' + ); + }); + + it('User with mock tokens can successfully stake if epoch zero has started', async () => { + await incrementTimeToTimestamp(distributionStart); + await contract.stake(staker1, stakerInitialBalance); + + // `mockStakedToken` should be transferred to LiquidityStakingV1 contract, and user should be given an + // equivalent amount of `LS1ERC20` tokens + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal( + stakerInitialBalance + ); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + }); + + describe('requestWithdrawal', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with nonzero staked balance can request a withdrawal after epoch zero has started', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + + // `mockStakedToken` should still be owned by LiquidityStakingV1 contract, and user should still own an + // equivalent amount of `LS1ERC20` tokens + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal( + stakerInitialBalance + ); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + + it('User with nonzero staked balance cannot request a withdrawal during blackout window', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + const withinBlackoutWindow: string = EPOCH_LENGTH.add(distributionStart) + .sub(BLACKOUT_WINDOW) + .toString(); + await incrementTimeToTimestamp(withinBlackoutWindow); + + await expect(contract.requestWithdrawal(staker1, stakerInitialBalance)).to.be.revertedWith( + 'LS1Staking: Withdraw requests restricted in the blackout window' + ); + + // `mockStakedToken` should still be owned by LiquidityStakingV1 contract, and user should still have an + // equivalent amount of `LS1ERC20` tokens + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal( + stakerInitialBalance + ); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + + it('User with zero staked balance cannot request a withdrawal', async () => { + // increment time to past epoch zero + await incrementTimeToTimestamp(distributionStart); + + await expect(contract.requestWithdrawal(staker1, 1)).to.be.revertedWith( + 'LS1Staking: Withdraw request exceeds next active balance' + ); + }); + }); + + describe('withdrawStake', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Staker can request and withdraw full balance', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, stakerInitialBalance); + + // `mockStakedToken` should be sent to fundsRecipient, LiquidityStakingV1 contract should own nothing + // and user should have 0 staked token balance + expect(await mockStakedToken.balanceOf(fundsRecipient.address)).to.equal( + stakerInitialBalance + ); + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + }); + + it('Staker can request full balance and make multiple partial withdrawals', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + const withdrawAmount = 1; + await contract.withdrawStake(staker1, fundsRecipient, withdrawAmount); + + // `mockStakedToken` should be sent to fundsRecipient, LiquidityStakingV1 contract should own remainder + expect(await mockStakedToken.balanceOf(fundsRecipient.address)).to.equal(withdrawAmount); + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal( + stakerInitialBalance - withdrawAmount + ); + + // Additional withdrawal + await contract.withdrawStake(staker1, fundsRecipient, 10); + await contract.withdrawStake(staker1, fundsRecipient, 100); + await contract.withdrawStake(staker1, fundsRecipient, stakerInitialBalance - 111); + }); + + it('Staker can make multiple partial requests and then a full withdrawal', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, 100); + await contract.requestWithdrawal(staker1, 10); + await contract.requestWithdrawal(staker1, 1); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, 111); + }); + + it('Staker can make multiple partial requests and then multiple partial withdrawals', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, 100); + await contract.requestWithdrawal(staker1, 10); + await contract.requestWithdrawal(staker1, 1); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, 50); + await contract.withdrawStake(staker1, fundsRecipient, 60); + await contract.withdrawStake(staker1, fundsRecipient, 1); + }); + + it('Staker cannot withdraw funds if none are staked', async () => { + await incrementTimeToTimestamp(distributionStart); + + await expect( + stakerSigner1.withdrawStake(staker1.address, stakerInitialBalance) + ).to.be.revertedWith('LS1Staking: Withdraw exceeds amount available in the contract'); + }); + }); + + describe('withdrawMaxStake', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Staker can request and withdraw full balance', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawMaxStake(staker1.address, fundsRecipient.address); + + // `mockStakedToken` should be sent to fundsRecipient, LiquidityStakingV1 contract should own nothing + // and user should have 0 staked token balance + expect(await mockStakedToken.balanceOf(fundsRecipient.address)).to.equal( + stakerInitialBalance + ); + expect(await mockStakedToken.balanceOf(staker1.address)).to.equal(0); + expect(await mockStakedToken.balanceOf(liquidityStakingV1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + }); + + it('Staker can try to withdraw max stake even if there is none', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.withdrawMaxStake(staker1, staker1); + }); + }); + + describe('claimRewards', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + const initialRewardsRate = 0; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(initialRewardsRate)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(initialRewardsRate); + + // Repeat with different rewards rates. + await contract.stake(staker1, stakerInitialBalance); + let lastTimestamp = (await timeLatest()).toNumber(); + for (const rewardsRate of [1, 100, 100001]) { + await contract.setRewardsPerSecond(rewardsRate); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + } + }); + + it('User with nonzero staked balance for one epoch but emission rate was zero cannot claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + const emissionRate1 = 0; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(emissionRate1)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate1); + + await contract.stake(staker1, stakerInitialBalance); + + // increase time to next epoch, so user can earn rewards + await elapseEpoch(); + + // change EMISSION_RATE to be greater than 0 + const emissionRate2 = 1; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(emissionRate2)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate2); + + const fundsRecipient: SignerWithAddress = testEnv.users[2]; + + expect(await stakerSigner1.callStatic.claimRewards(fundsRecipient.address)).to.equal(0); + }); + + it('Multiple users can stake, requestWithdrawal, withdrawStake, and claimRewards', async () => { + // mint tokens to staker2 + const stakerInitialBalance2 = 4_000_000; + await contract.mintAndApprove(staker2, stakerInitialBalance2); + + await incrementTimeToTimestamp(distributionStart); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await contract.setRewardsPerSecond(emissionRate); + + await contract.stake(staker1, stakerInitialBalance); + const stakeTimestamp1 = (await timeLatest()).toNumber(); + await contract.stake(staker2, stakerInitialBalance2); + const stakeTimestamp2 = (await timeLatest()).toNumber(); + + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker2, stakerInitialBalance2); + + await elapseEpoch(); + + const totalBalance = stakerInitialBalance + stakerInitialBalance2; + + const beforeClaim1: BigNumber = await timeLatest(); + const numTokens1: number = beforeClaim1 + .minus(stakeTimestamp1) + .times(emissionRate) + .times(stakerInitialBalance) + .div(totalBalance) + .toNumber(); + + expect( + (await stakerSigner1.callStatic.claimRewards(staker1.address)).toNumber() + ).to.be.closeTo(numTokens1, 2); + + const beforeClaim2: BigNumber = await timeLatest(); + const numTokens2: number = beforeClaim2 + .minus(stakeTimestamp2) + .times(emissionRate) + .times(stakerInitialBalance2) + .div(totalBalance) + .toNumber(); + expect( + (await stakerSigner2.callStatic.claimRewards(staker2.address)).toNumber() + ).to.be.closeTo(numTokens2, 2); + }); + + it('User with nonzero staked balance does not earn rewards after distributionEnd', async () => { + await incrementTimeToTimestamp( + new BigNumber(distributionEnd).minus(EPOCH_LENGTH.toNumber()).toString() + ); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + // expect the `stake` call to succeed, else we can't test `claimRewards` + await contract.stake(staker1.address, stakerInitialBalance); + const stakedTimestamp: BigNumber = await timeLatest(); + + // move multiple epochs forward so we're after DISTRIBUTION_END + // (user should only earn rewards for last epoch) + for (let i = 0; i < 5; i++) { + await elapseEpoch(); + } + + const numTokens = new BigNumber(distributionEnd) + .minus(stakedTimestamp) + .times(emissionRate) + .toString(); + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(liquidityStakingV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, numTokens); + + // verify user can withdraw and doesn't earn additional rewards + await contract.requestWithdrawal(staker1.address, stakerInitialBalance); + + await elapseEpoch(); + await contract.withdrawStake(staker1.address, staker1.address, stakerInitialBalance); + + // user shouldn't have any additional rewards since it's after DISTRIBUTION_END + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(liquidityStakingV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, 0); + }); + }); + + describe('transfer', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.transfer(staker1, staker2, stakerInitialBalance); + + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + + it('User with staked balance for one epoch can transfer to another user and claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(liquidityStakingV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(liquidityStakingV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + await contract.stake(staker1, stakerInitialBalance); + const stakeTimestamp: BigNumber = await timeLatest(); + + // increase time to next epoch, so user can earn rewards + await elapseEpoch(); + + await contract.transfer(staker1, staker2, stakerInitialBalance); + + const balanceBeforeClaiming = await dydxToken.balanceOf(staker1.address); + const now: BigNumber = await timeLatest(); + const numTokens = now.minus(stakeTimestamp).times(emissionRate).toString(); + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(liquidityStakingV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, numTokens); + expect(await dydxToken.balanceOf(staker1.address)).to.equal( + balanceBeforeClaiming.add(numTokens).toString() + ); + }); + }); + + describe('transferFrom', () => { + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.approve(staker1, staker2, stakerInitialBalance); + await contract.transferFrom(staker2, staker1, staker2, stakerInitialBalance); + + expect(await liquidityStakingV1.balanceOf(staker1.address)).to.equal(0); + expect(await liquidityStakingV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStakingV1.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BigNumber = new BigNumber(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp.toNumber()).to.be.at.most(timestamp.toNumber()); + const timestampDiff: number = timestamp.minus(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/SafetyModuleV1/delegation.spec.ts b/test/staking/SafetyModuleV1/delegation.spec.ts new file mode 100644 index 0000000..5061a94 --- /dev/null +++ b/test/staking/SafetyModuleV1/delegation.spec.ts @@ -0,0 +1,840 @@ +import { expect } from 'chai'; +import { fail } from 'assert'; +import { BigNumber, ethers } from 'ethers'; + +import { deployPhase2, makeSuite, SignerWithAddress, TestEnv } from '../../test-helpers/make-suite'; +import { DRE, advanceBlock, timeLatest, waitForTx, increaseTime, incrementTimeToTimestamp, latestBlock, evmSnapshot, evmRevert } from '../../../helpers/misc-utils'; +import { deployDoubleTransferHelper } from '../../../helpers/contracts-deployments'; +import { + buildDelegateParams, + buildDelegateByTypeParams, + getSignatureFromTypedData, +} from '../../../helpers/contracts-helpers'; +import { parseEther } from 'ethers/lib/utils'; +import { MAX_UINT_AMOUNT, ZERO_ADDRESS } from '../../../helpers/constants'; +import { SafetyModuleV1 } from '../../../types/SafetyModuleV1'; +import { DydxToken } from '../../../types/DydxToken'; + +const testWallets = require('../../../test-wallets'); + +const SAFETY_MODULE_EIP_712_DOMAIN_NAME = 'dYdX Safety Module'; + +const snapshots = new Map(); +const snapshotName = 'init'; + +makeSuite('Safety Module Staked DYDX - Power Delegations', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let dydxToken: DydxToken; + let safetyModule: SafetyModuleV1; + let users: SignerWithAddress[]; + let userKeys: string[]; + + before(async () => { + ({ + deployer, + dydxToken, + safetyModule, + users, + } = testEnv); + + userKeys = testWallets.accounts.map((a: { secretKey: string }) => a.secretKey); + + // Give some tokens to stakers and set allowance. + const amount = ethers.utils.parseEther('100').toString(); + for (const user of users.slice(1, 6)) { + await dydxToken.connect(deployer.signer).transfer(user.address, amount); + await dydxToken.connect(user.signer).approve(safetyModule.address, amount); + } + + // Advance to when transfers are enabled. Note that this is after the distribution start. + await incrementTimeToTimestamp(await dydxToken._transfersRestrictedBefore()); + + snapshots.set(snapshotName, await evmSnapshot()); + }); + + afterEach(async () => { + await evmRevert(snapshots.get(snapshotName)!); + snapshots.set(snapshotName, await evmSnapshot()); + }); + + it('User 1 tries to delegate voting power to user 2', async () => { + await safetyModule.connect(users[1].signer).delegateByType(users[2].address, '0'); + + const delegatee = await safetyModule.getDelegateeByType(users[1].address, '0'); + + expect(delegatee.toString()).to.be.equal(users[2].address); + }); + + it('User 1 tries to delegate proposition power to user 3', async () => { + await safetyModule.connect(users[1].signer).delegateByType(users[3].address, '1'); + + const delegatee = await safetyModule.getDelegateeByType(users[1].address, '1'); + + expect(delegatee.toString()).to.be.equal(users[3].address); + }); + + it('User 1 tries to delegate voting power to ZERO_ADDRESS but delegator should remain', async () => { + const tokenBalance = parseEther('1'); + + // Stake + await safetyModule.connect(users[1].signer).stake(tokenBalance); + + // Track current power + const priorPowerUser = await safetyModule.getPowerCurrent(users[1].address, '0'); + const priorPowerUserZeroAddress = await safetyModule.getPowerCurrent(ZERO_ADDRESS, '0'); + + expect(priorPowerUser).to.be.equal(tokenBalance, 'user power should equal balance'); + expect(priorPowerUserZeroAddress).to.be.equal('0', 'zero address should have zero power'); + + await expect( + safetyModule.connect(users[1].signer).delegateByType(ZERO_ADDRESS, '0') + ).to.be.revertedWith('INVALID_DELEGATEE'); + }); + + it('User 1 stakes 2 DYDX; checks voting and proposition power of user 2 and 3', async () => { + // Setup: user 1 has delegated to users 2 and 3... + await safetyModule.connect(users[1].signer).delegateByType(users[2].address, '0'); + await safetyModule.connect(users[1].signer).delegateByType(users[3].address, '1'); + + const tokenBalance = parseEther('2'); + const expectedStaked = parseEther('2'); + + // Stake + await safetyModule.connect(users[1].signer).stakeFor(users[1].address, tokenBalance); + + const stakedTokenBalanceAfterMigration = await safetyModule.balanceOf(users[1].address); + + const user1PropPower = await safetyModule.getPowerCurrent(users[1].address, '0'); + const user1VotingPower = await safetyModule.getPowerCurrent(users[1].address, '1'); + + const user2VotingPower = await safetyModule.getPowerCurrent(users[2].address, '0'); + const user2PropPower = await safetyModule.getPowerCurrent(users[2].address, '1'); + + const user3VotingPower = await safetyModule.getPowerCurrent(users[3].address, '0'); + const user3PropPower = await safetyModule.getPowerCurrent(users[3].address, '1'); + + expect(user1PropPower).to.be.equal('0', 'Incorrect prop power for user 1'); + expect(user1VotingPower).to.be.equal('0', 'Incorrect voting power for user 1'); + + expect(user2PropPower).to.be.equal('0', 'Incorrect prop power for user 2'); + expect(user2VotingPower).to.be.equal( + stakedTokenBalanceAfterMigration, + 'Incorrect voting power for user 2' + ); + + expect(user3PropPower).to.be.equal( + stakedTokenBalanceAfterMigration, + 'Incorrect prop power for user 3' + ); + expect(user3VotingPower).to.be.equal('0', 'Incorrect voting power for user 3'); + + expect(expectedStaked).to.be.equal(stakedTokenBalanceAfterMigration); + }); + + describe('after user 1 delegates to users 2 and 3, and stakes 2 DYDX', () => { + + beforeEach(async () => { + // Setup: user 1 has delegated to users 2 and 3 and stakes 2 DYDX... + await safetyModule.connect(users[1].signer).delegateByType(users[2].address, '0'); + await safetyModule.connect(users[1].signer).delegateByType(users[3].address, '1'); + await safetyModule.connect(users[1].signer).stakeFor(users[1].address, parseEther('2')); + }); + + it('User 2 stakes 2 DYDX; checks voting and proposition power of user 2', async () => { + const tokenBalance = parseEther('2'); + const expectedStakedTokenBalanceAfterStake = parseEther('2'); + + // Stake + await safetyModule.connect(users[2].signer).stakeFor(users[2].address, tokenBalance); + + const user2VotingPower = await safetyModule.getPowerCurrent(users[2].address, '0'); + const user2PropPower = await safetyModule.getPowerCurrent(users[2].address, '1'); + + expect(user2PropPower).to.be.equal( + expectedStakedTokenBalanceAfterStake, + 'Incorrect prop power for user 2' + ); + expect(user2VotingPower).to.be.equal( + expectedStakedTokenBalanceAfterStake.mul('2'), + 'Incorrect voting power for user 2' + ); + }); + + it('User 3 stakes 2 DYDX; checks voting and proposition power of user 3', async () => { + // Stake + await safetyModule.connect(users[3].signer).stakeFor(users[3].address, parseEther('2')); + + const user3VotingPower = await safetyModule.getPowerCurrent(users[3].address, '0'); + const user3PropPower = await safetyModule.getPowerCurrent(users[3].address, '1'); + + expect(user3PropPower.toString()).to.be.equal( + parseEther('4'), + 'Incorrect prop power for user 3' + ); + expect(user3VotingPower.toString()).to.be.equal( + parseEther('2'), + 'Incorrect voting power for user 3' + ); + }); + + it('User 2 also delegates powers to user 3', async () => { + // Stake + await safetyModule.connect(users[2].signer).stakeFor(users[2].address, parseEther('2')); + await safetyModule.connect(users[3].signer).stakeFor(users[3].address, parseEther('2')); + + // Delegate + await safetyModule.connect(users[2].signer).delegate(users[3].address); + + expect(await safetyModule.getPowerCurrent(users[3].address, '0')).to.be.equal( + parseEther('4'), + 'Incorrect voting power for user 3', + ); + expect(await safetyModule.getPowerCurrent(users[3].address, '1')).to.be.equal( + parseEther('6'), + 'Incorrect prop power for user 3', + ); + }); + }); + + it('Checks the delegation at a past block', async () => { + // Setup: user 1 has delegated to users 2 and 3... + await safetyModule.connect(users[1].signer).delegateByType(users[2].address, '0'); + await safetyModule.connect(users[1].signer).delegateByType(users[3].address, '1'); + + // Stake + await safetyModule.connect(users[1].signer).stakeFor(users[1].address, parseEther('2')); + const blockNumber = await latestBlock(); + + const user1 = users[1]; + const user2 = users[2]; + const user3 = users[3]; + + const user1VotingPower = await safetyModule.getPowerAtBlock( + user1.address, + blockNumber, + '0' + ); + const user1PropPower = await safetyModule.getPowerAtBlock( + user1.address, + blockNumber, + '1' + ); + + const user2VotingPower = await safetyModule.getPowerAtBlock( + user2.address, + blockNumber, + '0' + ); + const user2PropPower = await safetyModule.getPowerAtBlock( + user2.address, + blockNumber, + '1' + ); + + const user3VotingPower = await safetyModule.getPowerAtBlock( + user3.address, + blockNumber, + '0' + ); + const user3PropPower = await safetyModule.getPowerAtBlock( + user3.address, + blockNumber, + '1' + ); + + const expectedUser1DelegatedVotingPower = '0'; + const expectedUser1DelegatedPropPower = '0'; + + const expectedUser2DelegatedVotingPower = parseEther('2'); + const expectedUser2DelegatedPropPower = '0'; + + const expectedUser3DelegatedVotingPower = '0'; + const expectedUser3DelegatedPropPower = parseEther('2'); + + expect(user1VotingPower.toString()).to.be.equal( + expectedUser1DelegatedPropPower, + 'Incorrect voting power for user 1' + ); + expect(user1PropPower.toString()).to.be.equal( + expectedUser1DelegatedVotingPower, + 'Incorrect prop power for user 1' + ); + + expect(user2VotingPower.toString()).to.be.equal( + expectedUser2DelegatedVotingPower, + 'Incorrect voting power for user 2' + ); + expect(user2PropPower.toString()).to.be.equal( + expectedUser2DelegatedPropPower, + 'Incorrect prop power for user 2' + ); + + expect(user3VotingPower.toString()).to.be.equal( + expectedUser3DelegatedVotingPower, + 'Incorrect voting power for user 3' + ); + expect(user3PropPower.toString()).to.be.equal( + expectedUser3DelegatedPropPower, + 'Incorrect prop power for user 3' + ); + }); + + it('Ensure that getting the power at the current block is the same as using getPowerCurrent', async () => { + // Setup: user 1 has delegated to users 2 and 3... + await safetyModule.connect(users[1].signer).delegateByType(users[2].address, '0'); + await safetyModule.connect(users[1].signer).delegateByType(users[3].address, '1'); + + // Stake + await safetyModule.connect(users[1].signer).stakeFor(users[1].address, parseEther('2')); + + const currTime = await timeLatest(); + + await advanceBlock(currTime.toNumber() + 1); + + const currentBlock = await latestBlock(); + + const votingPowerAtPreviousBlock = await safetyModule.getPowerAtBlock( + users[1].address, + currentBlock - 1, + '0' + ); + const votingPowerCurrent = await safetyModule.getPowerCurrent(users[1].address, '0'); + + const propPowerAtPreviousBlock = await safetyModule.getPowerAtBlock( + users[1].address, + currentBlock - 1, + '1' + ); + const propPowerCurrent = await safetyModule.getPowerCurrent(users[1].address, '1'); + + expect(votingPowerAtPreviousBlock.toString()).to.be.equal( + votingPowerCurrent.toString(), + 'Incorrect voting power for user 1' + ); + expect(propPowerAtPreviousBlock.toString()).to.be.equal( + propPowerCurrent.toString(), + 'Incorrect voting power for user 1' + ); + }); + + it("Checks you can't fetch power at a block in the future", async () => { + const currentBlock = await latestBlock(); + + await expect( + safetyModule.getPowerAtBlock(users[1].address, currentBlock + 1, '0') + ).to.be.revertedWith('INVALID_BLOCK_NUMBER'); + await expect( + safetyModule.getPowerAtBlock(users[1].address, currentBlock + 1, '1') + ).to.be.revertedWith('INVALID_BLOCK_NUMBER'); + }); + + it('User 1 transfers value to himself. Ensures nothing changes in the delegated power', async () => { + const user1VotingPowerBefore = await safetyModule.getPowerCurrent(users[1].address, '0'); + const user1PropPowerBefore = await safetyModule.getPowerCurrent(users[1].address, '1'); + + const balance = await safetyModule.balanceOf(users[1].address); + + await safetyModule.connect(users[1].signer).transfer(users[1].address, balance); + + const user1VotingPowerAfter = await safetyModule.getPowerCurrent(users[1].address, '0'); + const user1PropPowerAfter = await safetyModule.getPowerCurrent(users[1].address, '1'); + + expect(user1VotingPowerBefore.toString()).to.be.equal( + user1VotingPowerAfter, + 'Incorrect voting power for user 1' + ); + expect(user1PropPowerBefore.toString()).to.be.equal( + user1PropPowerAfter, + 'Incorrect prop power for user 1' + ); + }); + + it('User 1 delegates voting power to User 2 via signature', async () => { + const [, user1, user2] = users; + + // Calculate expected voting power + const user2VotPower = await safetyModule.getPowerCurrent(user2.address, '1'); + const expectedVotingPower = (await safetyModule.getPowerCurrent(user1.address, '1')).add( + user2VotPower + ); + + // Check prior delegatee is still user1 + const priorDelegatee = await safetyModule.getDelegateeByType(user1.address, '0'); + expect(priorDelegatee.toString()).to.be.equal(user1.address); + + // Prepare params to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user1.address)).toString(); + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateByTypeParams( + chainId, + safetyModule.address, + user2.address, + '0', + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[2]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit message via delegateByTypeBySig + const tx = await safetyModule + .connect(user1.signer) + .delegateByTypeBySig(user2.address, '0', nonce, expiration, v, r, s); + + // Check tx success and DelegateChanged + const receipt = await(tx.wait(1)); + await expect(tx) + .to.emit(safetyModule, 'DelegateChanged') + .withArgs(user1.address, user2.address, 0); + + // Check DelegatedPowerChanged event: users[1] power should drop to zero + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user1.address, 0, 0); + + // Check DelegatedPowerChanged event: users[2] power should increase to expectedVotingPower + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user2.address, expectedVotingPower, 0); + + // Check internal state + const delegatee = await safetyModule.getDelegateeByType(user1.address, '0'); + expect(delegatee.toString()).to.be.equal(user2.address, 'Delegatee should be user 2'); + + const user2VotingPower = await safetyModule.getPowerCurrent(user2.address, '0'); + expect(user2VotingPower).to.be.equal( + expectedVotingPower, + 'Delegatee should have voting power from user 1' + ); + }); + + it('User 1 delegates proposition to User 3 via signature', async () => { + const { + users: [, user1, , user3], + safetyModule, + } = testEnv; + + // Calculate expected proposition power + const user3PropPower = await safetyModule.getPowerCurrent(user3.address, '1'); + const expectedPropPower = (await safetyModule.getPowerCurrent(user1.address, '1')).add( + user3PropPower + ); + + // Check prior proposition delegatee is still user1 + const priorDelegatee = await safetyModule.getDelegateeByType(user1.address, '1'); + expect(priorDelegatee.toString()).to.be.equal( + user1.address, + 'expected proposition delegatee to be user1' + ); + + // Prepare parameters to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user1.address)).toString(); + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateByTypeParams( + chainId, + safetyModule.address, + user3.address, + '1', + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[2]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit tx via delegateByTypeBySig + const tx = await safetyModule + .connect(user1.signer) + .delegateByTypeBySig(user3.address, '1', nonce, expiration, v, r, s); + + // Check tx success and DelegateChanged + await expect(tx) + .to.emit(safetyModule, 'DelegateChanged') + .withArgs(user1.address, user3.address, 1); + + // Check DelegatedPowerChanged event: users[1] power should drop to zero + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user1.address, 0, 1); + + // Check DelegatedPowerChanged event: users[2] power should increase to expectedVotingPower + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user3.address, expectedPropPower, 1); + + // Check internal state matches events + const delegatee = await safetyModule.getDelegateeByType(user1.address, '1'); + expect(delegatee.toString()).to.be.equal(user3.address, 'Delegatee should be user 3'); + + const user3PropositionPower = await safetyModule.getPowerCurrent(user3.address, '1'); + expect(user3PropositionPower).to.be.equal( + expectedPropPower, + 'Delegatee should have propostion power from user 1' + ); + }); + + it('User 2 delegates all to User 4 via signature', async () => { + const [, user1, user2, , user4] = users + + await safetyModule.connect(user2.signer).delegate(user2.address); + + // Calculate expected powers + const user4PropPower = await safetyModule.getPowerCurrent(user4.address, '1'); + const expectedPropPower = (await safetyModule.getPowerCurrent(user2.address, '1')).add( + user4PropPower + ); + + const user1VotingPower = await safetyModule.balanceOf(user1.address); + const user4VotPower = await safetyModule.getPowerCurrent(user4.address, '0'); + const user2ExpectedVotPower = user1VotingPower; + const user4ExpectedVotPower = (await safetyModule.getPowerCurrent(user2.address, '0')) + .add(user4VotPower) + .sub(user1VotingPower); // Delegation does not delegate votes others from other delegations + + // Check prior proposition delegatee is still user1 + const priorPropDelegatee = await safetyModule.getDelegateeByType(user2.address, '1'); + expect(priorPropDelegatee.toString()).to.be.equal( + user2.address, + 'expected proposition delegatee to be user1' + ); + + const priorVotDelegatee = await safetyModule.getDelegateeByType(user2.address, '0'); + expect(priorVotDelegatee.toString()).to.be.equal( + user2.address, + 'expected proposition delegatee to be user1' + ); + + // Prepare parameters to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user2.address)).toString(); + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateParams( + chainId, + safetyModule.address, + user4.address, + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[3]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit tx via delegateByTypeBySig + const tx = await safetyModule + .connect(user2.signer) + .delegateBySig(user4.address, nonce, expiration, v, r, s); + + // Check tx success and DelegateChanged for voting + await expect(tx) + .to.emit(safetyModule, 'DelegateChanged') + .withArgs(user2.address, user4.address, 1); + // Check tx success and DelegateChanged for proposition + await expect(tx) + .to.emit(safetyModule, 'DelegateChanged') + .withArgs(user2.address, user4.address, 0); + + // Check DelegatedPowerChanged event: users[2] power should drop to zero + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user2.address, 0, 1); + + // Check DelegatedPowerChanged event: users[4] power should increase to expectedVotingPower + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user4.address, expectedPropPower, 1); + + // Check DelegatedPowerChanged event: users[2] power should drop to zero + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user2.address, user2ExpectedVotPower, 0); + + // Check DelegatedPowerChanged event: users[4] power should increase to expectedVotingPower + await expect(tx) + .to.emit(safetyModule, 'DelegatedPowerChanged') + .withArgs(user4.address, user4ExpectedVotPower, 0); + + // Check internal state matches events + const propDelegatee = await safetyModule.getDelegateeByType(user2.address, '1'); + expect(propDelegatee.toString()).to.be.equal( + user4.address, + 'Proposition delegatee should be user 4' + ); + + const votDelegatee = await safetyModule.getDelegateeByType(user2.address, '0'); + expect(votDelegatee.toString()).to.be.equal(user4.address, 'Voting delegatee should be user 4'); + + const user4PropositionPower = await safetyModule.getPowerCurrent(user4.address, '1'); + expect(user4PropositionPower).to.be.equal( + expectedPropPower, + 'Delegatee should have propostion power from user 2' + ); + const user4VotingPower = await safetyModule.getPowerCurrent(user4.address, '0'); + expect(user4VotingPower).to.be.equal( + user4ExpectedVotPower, + 'Delegatee should have votinh power from user 2' + ); + + const user2PropositionPower = await safetyModule.getPowerCurrent(user2.address, '1'); + expect(user2PropositionPower).to.be.equal('0', 'User 2 should have zero prop power'); + const user2VotingPower = await safetyModule.getPowerCurrent(user2.address, '0'); + expect(user2VotingPower).to.be.equal( + user2ExpectedVotPower, + 'User 2 should still have voting power from user 1 delegation' + ); + }); + + it('User 1 should not be able to delegate with bad signature', async () => { + const [, user1, user2] = users; + + // Prepare params to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user1.address)).toString(); + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateByTypeParams( + chainId, + safetyModule.address, + user2.address, + '0', + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[0]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit message via delegateByTypeBySig + await expect( + safetyModule + .connect(user1.signer) + .delegateByTypeBySig(user2.address, '0', nonce, expiration, 0, r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('User 1 should not be able to delegate with bad nonce', async () => { + const [, user1, user2] = users; + + // Prepare params to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateByTypeParams( + chainId, + safetyModule.address, + user2.address, + '0', + MAX_UINT_AMOUNT, // bad nonce + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[0]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit message via delegateByTypeBySig + await expect( + safetyModule + .connect(user1.signer) + .delegateByTypeBySig(user2.address, '0', MAX_UINT_AMOUNT, expiration, v, r, s) + ).to.be.revertedWith('INVALID_NONCE'); + }); + + it('User 1 should not be able to delegate if signature expired', async () => { + const [, user1, user2] = users; + + // Prepare params to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user1.address)).toString(); + const expiration = '0'; + const msgParams = buildDelegateByTypeParams( + chainId, + safetyModule.address, + user2.address, + '0', + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[2]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit message via delegateByTypeBySig + await expect( + safetyModule + .connect(user1.signer) + .delegateByTypeBySig(user2.address, '0', nonce, expiration, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + }); + + it('User 2 should not be able to delegate all with bad signature', async () => { + const [, , user2, , user4] = users + + // Prepare parameters to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user2.address)).toString(); + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateParams( + chainId, + safetyModule.address, + user4.address, + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[3]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit tx via delegateBySig + await expect( + safetyModule.connect(user2.signer).delegateBySig(user4.address, nonce, expiration, '0', r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('User 2 should not be able to delegate all with bad nonce', async () => { + const [, , user2, , user4] = users + + // Prepare parameters to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = MAX_UINT_AMOUNT; + const expiration = MAX_UINT_AMOUNT; + const msgParams = buildDelegateParams( + chainId, + safetyModule.address, + user4.address, + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[3]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit tx via delegateByTypeBySig + await expect( + safetyModule.connect(user2.signer).delegateBySig(user4.address, nonce, expiration, v, r, s) + ).to.be.revertedWith('INVALID_NONCE'); + }); + + it('User 2 should not be able to delegate all if signature expired', async () => { + const [, , user2, , user4] = users + + // Prepare parameters to sign message + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const nonce = (await safetyModule.nonces(user2.address)).toString(); + const expiration = '0'; + const msgParams = buildDelegateParams( + chainId, + safetyModule.address, + user4.address, + nonce, + expiration, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + const ownerPrivateKey = userKeys[3]; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + // Transmit tx via delegateByTypeBySig + await expect( + safetyModule.connect(user2.signer).delegateBySig(user4.address, nonce, expiration, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + }); + + it('Correct proposal and voting snapshotting on double action in the same block', async () => { + const { + users: [, sender, receiver], + safetyModule, + } = testEnv; + + // Stake + const initialBalance = parseEther('1'); + await safetyModule.connect(users[1].signer).stake(initialBalance); + + const receiverPriorPower = await safetyModule.getPowerCurrent(receiver.address, '0'); + const senderPriorPower = await safetyModule.getPowerCurrent(sender.address, '0'); + + // Deploy double transfer helper + const doubleTransferHelper = await deployDoubleTransferHelper(safetyModule.address); + + await waitForTx( + await safetyModule + .connect(sender.signer) + .transfer(doubleTransferHelper.address, initialBalance) + ); + + // Do double transfer + await waitForTx( + await doubleTransferHelper + .connect(sender.signer) + .doubleSend(receiver.address, parseEther('0.7'), initialBalance.sub(parseEther('0.7'))) + ); + + const receiverCurrentPower = await safetyModule.getPowerCurrent(receiver.address, '0'); + const senderCurrentPower = await safetyModule.getPowerCurrent(sender.address, '0'); + + expect(receiverCurrentPower).to.be.equal( + senderPriorPower.add(receiverPriorPower), + 'Receiver should have added the sender power after double transfer' + ); + expect(senderCurrentPower).to.be.equal( + '0', + 'Sender power should be zero due transfered all the funds' + ); + }); +}); diff --git a/test/staking/SafetyModuleV1/permit.spec.ts b/test/staking/SafetyModuleV1/permit.spec.ts new file mode 100644 index 0000000..717da80 --- /dev/null +++ b/test/staking/SafetyModuleV1/permit.spec.ts @@ -0,0 +1,336 @@ +import { expect, use } from 'chai'; +import { fail } from 'assert'; +import { parseEther } from 'ethers/lib/utils'; + +import { deployPhase2, makeSuite, TestEnv } from '../../test-helpers/make-suite'; +import { DRE, evmRevert, evmSnapshot, waitForTx } from '../../../helpers/misc-utils'; +import { buildPermitParams, getSignatureFromTypedData } from '../../../helpers/contracts-helpers'; +import { MAX_UINT_AMOUNT, ZERO_ADDRESS } from '../../../helpers/constants'; + +const SAFETY_MODULE_EIP_712_DOMAIN_NAME = 'dYdX Safety Module'; + +const snapshots = new Map(); +const snapshotName = 'init'; + +makeSuite('Safety Module Staked DYDX - Permit', deployPhase2, (testEnv: TestEnv) => { + + before(async () => { + snapshots.set(snapshotName, await evmSnapshot()); + }); + + afterEach(async () => { + await evmRevert(snapshots.get(snapshotName)!); + snapshots.set(snapshotName, await evmSnapshot()); + }); + + it('sanity check chain ID', async () => { + const { chainId } = await DRE.ethers.provider.getNetwork(); + if (!chainId) { + fail("Current network doesn't have CHAIN ID"); + } + const configChainId = DRE.network.config.chainId; + expect(configChainId).to.be.equal(chainId); + }) + + it('Reverts submitting a permit with 0 expiration', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const expiration = 0; + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = parseEther('2').toString(); + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + permitAmount, + expiration.toFixed(), + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + safetyModule + .connect(users[1].signer) + .permit(owner, spender, permitAmount, expiration, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_AFTER_PERMIT' + ); + }); + + it('Submits a permit with maximum expiration length', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const configChainId = DRE.network.config.chainId; + + expect(configChainId).to.be.equal(chainId); + const deadline = MAX_UINT_AMOUNT; + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = parseEther('2').toString(); + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + deadline, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await waitForTx( + await safetyModule + .connect(users[1].signer) + .permit(owner, spender, permitAmount, deadline, v, r, s) + ); + + expect((await safetyModule.nonces(owner)).toNumber()).to.be.equal(1); + }); + + it('Cancels the previous permit', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const deadline = MAX_UINT_AMOUNT; + + { + const permitAmount = parseEther('2').toString(); + + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + deadline, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await safetyModule + .connect(users[1].signer) + .permit(owner, spender, permitAmount, deadline, v, r, s) + } + + { + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + deadline, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + parseEther('2'), + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + await waitForTx( + await safetyModule + .connect(users[1].signer) + .permit(owner, spender, permitAmount, deadline, v, r, s) + ); + expect((await safetyModule.allowance(owner, spender)).toString()).to.be.equal( + permitAmount, + 'INVALID_ALLOWANCE_AFTER_PERMIT' + ); + + expect((await safetyModule.nonces(owner)).toNumber()).to.be.equal(2); + } + }); + + it('Tries to submit a permit with invalid nonce', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const deadline = MAX_UINT_AMOUNT; + const nonce = 1000; + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + deadline, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + safetyModule.connect(users[1].signer).permit(owner, spender, permitAmount, deadline, v, r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('Tries to submit a permit with invalid expiration (previous to the current block)', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const expiration = '1'; + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + expiration, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + safetyModule + .connect(users[1].signer) + .permit(owner, spender, expiration, permitAmount, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + }); + + it('Tries to submit a permit with invalid signature', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const deadline = MAX_UINT_AMOUNT; + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + deadline, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + safetyModule + .connect(users[1].signer) + .permit(owner, ZERO_ADDRESS, permitAmount, deadline, v, r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('Tries to submit a permit with invalid owner', async () => { + const { deployer, users, safetyModule } = testEnv; + const owner = deployer.address; + const spender = users[1].address; + + const { chainId } = await DRE.ethers.provider.getNetwork(); + const expiration = MAX_UINT_AMOUNT; + const nonce = (await safetyModule.nonces(owner)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + safetyModule.address, + owner, + spender, + nonce, + expiration, + permitAmount, + SAFETY_MODULE_EIP_712_DOMAIN_NAME, + ); + + const ownerPrivateKey = require('../../../test-wallets').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const { v, r, s } = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + safetyModule + .connect(users[1].signer) + .permit(ZERO_ADDRESS, spender, expiration, permitAmount, v, r, s) + ).to.be.revertedWith('INVALID_OWNER'); + }); +}); diff --git a/test/staking/SafetyModuleV1/safetyModuleV1.spec.ts b/test/staking/SafetyModuleV1/safetyModuleV1.spec.ts new file mode 100644 index 0000000..262d3c4 --- /dev/null +++ b/test/staking/SafetyModuleV1/safetyModuleV1.spec.ts @@ -0,0 +1,133 @@ +import { deployPhase2, makeSuite, SignerWithAddress, TestEnv } from '../../test-helpers/make-suite'; +import { evmRevert, evmSnapshot, timeLatest } from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH } from '../../../helpers/constants'; +import { SafetyModuleV1 } from '../../../types/SafetyModuleV1'; +import { expect } from 'chai'; +import { DydxToken } from '../../../types/DydxToken'; +import { DEPLOY_CONFIG } from '../../../tasks/helpers/deploy-config'; + +const snapshots = new Map(); +const snapshotName = 'init'; + +makeSuite('SafetyModuleV1', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let safetyModule: SafetyModuleV1; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + + before(async () => { + ({ + safetyModule, + dydxToken, + rewardsTreasury, + deployer, + } = testEnv); + + // Users. + [staker1] = testEnv.users.slice(1); + + snapshots.set(snapshotName, await evmSnapshot()); + }); + + afterEach(async () => { + await evmRevert(snapshots.get(snapshotName)!); + snapshots.set(snapshotName, await evmSnapshot()); + }); + + describe('Initial parameters', () => { + + it('Staked token is set during initialization', async () => { + expect(await safetyModule.STAKED_TOKEN()).to.be.equal(dydxToken.address); + }); + + it('Rewards token is set during initialization', async () => { + expect(await safetyModule.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + }); + + it('Deployer has proper roles set during initialization and is admin of all roles', async () => { + const roles: string[] = await Promise.all([ + safetyModule.OWNER_ROLE(), + safetyModule.EPOCH_PARAMETERS_ROLE(), + safetyModule.REWARDS_RATE_ROLE(), + ]); + + // deployer should have all roles except claimOperator, stakeOperator, and debtOperator. + for (const role of roles) { + expect(await safetyModule.hasRole(role, deployer.address)).to.be.true; + } + + const stakeOperatorRole: string = await safetyModule.STAKE_OPERATOR_ROLE(); + expect(await safetyModule.hasRole(stakeOperatorRole, deployer.address)).to.be.false; + + const ownerRole: string = roles[0]; + const allRoles: string[] = roles.concat(stakeOperatorRole); + for (const role of allRoles) { + expect(await safetyModule.getRoleAdmin(role)).to.equal(ownerRole); + } + }); + + it('Rewards vault is set during initialization', async () => { + expect(await safetyModule.REWARDS_TREASURY()).to.be.equal(rewardsTreasury.address); + }); + + it('Blackout window is set during initialization', async () => { + expect(await safetyModule.getBlackoutWindow()).to.be.equal(BLACKOUT_WINDOW.toString()); + }); + + it('Emissions per second is initially zero', async () => { + expect(await safetyModule.getRewardsPerSecond()).to.be.equal(DEPLOY_CONFIG.SAFETY_MODULE.REWARDS_PER_SECOND); + }); + + it('Epoch parameters are set during initialization', async () => { + const timeLatestString: string = (await timeLatest()).toString(); + + const epochParameters = await safetyModule.getEpochParameters(); + expect(epochParameters.interval).to.be.equal(EPOCH_LENGTH.toString()); + // expect offset to be at least later than now (was initialized to 60 seconds after current blocktime) + expect(epochParameters.offset).to.be.at.least(timeLatestString); + }); + + it('Initializes the exchange rate to a value of one', async () => { + const exchangeRateBase = await safetyModule.EXCHANGE_RATE_BASE(); + const exchangeRate = await safetyModule.getExchangeRate(); + expect(exchangeRate).to.equal(exchangeRateBase); + expect(exchangeRate).not.to.equal(0); + }); + + it('Total active current balance is initially zero', async () => { + expect(await safetyModule.getTotalActiveBalanceCurrentEpoch()).to.equal(0); + }); + + it('Total active next balance is initially zero', async () => { + expect(await safetyModule.getTotalActiveBalanceNextEpoch()).to.equal(0); + }); + + it('Total inactive current balance is initially zero', async () => { + expect(await safetyModule.getTotalInactiveBalanceCurrentEpoch()).to.equal(0); + }); + + it('Total inactive next balance is initially zero', async () => { + expect(await safetyModule.getTotalInactiveBalanceNextEpoch()).to.equal(0); + }); + + it('User active current balance is initially zero', async () => { + expect(await safetyModule.getActiveBalanceCurrentEpoch(staker1.address)).to.equal(0); + }); + + it('User active next balance is initially zero', async () => { + expect(await safetyModule.getActiveBalanceNextEpoch(staker1.address)).to.equal(0); + }); + + it('User inactive current balance is initially zero', async () => { + expect(await safetyModule.getInactiveBalanceCurrentEpoch(staker1.address)).to.equal(0); + }); + + it('User inactive next balance is initially zero', async () => { + expect(await safetyModule.getInactiveBalanceNextEpoch(staker1.address)).to.equal(0); + }); + }); +}); diff --git a/test/staking/SafetyModuleV1/sm1Slashing.spec.ts b/test/staking/SafetyModuleV1/sm1Slashing.spec.ts new file mode 100644 index 0000000..2366e59 --- /dev/null +++ b/test/staking/SafetyModuleV1/sm1Slashing.spec.ts @@ -0,0 +1,488 @@ +import BNJS from 'bignumber.js'; +import { BigNumber } from 'ethers'; + +import { deployPhase2, makeSuite, SignerWithAddress, TestEnv } from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, + advanceBlock, + incrementTimeToTimestamp, +} from '../../../helpers/misc-utils'; +import { EPOCH_LENGTH } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { SafetyModuleV1 } from '../../../types/SafetyModuleV1'; +import { expect } from 'chai'; +import { DydxToken } from '../../../types/DydxToken'; +import { deployMintableErc20, deploySafetyModuleV1 } from '../../../helpers/contracts-deployments'; +import { DEPLOY_CONFIG } from '../../../tasks/helpers/deploy-config'; + +const snapshots = new Map(); + +const afterDistributionStarted = 'AfterDistributionStarted'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('SM1Slashing', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let safetyModule: SafetyModuleV1; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + // Users calling the liquidity staking contract. + let stakerSigner1: SafetyModuleV1; + let stakerSigner2: SafetyModuleV1; + + let distributionStart: string; + + let contract: StakingHelper; + + before(async () => { + ({ + safetyModule, + dydxToken, + rewardsTreasury, + deployer, + } = testEnv); + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Users calling the liquidity staking contract. + stakerSigner1 = safetyModule.connect(staker1.signer); + stakerSigner2 = safetyModule.connect(staker2.signer); + + distributionStart = (await safetyModule.DISTRIBUTION_START()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + safetyModule, + dydxToken, + rewardsTreasury, + deployer, + deployer, + [staker1, staker2], + true, + ); + + // Empty user balances. + await dydxToken.connect(staker1.signer).transfer(deployer.address, await dydxToken.balanceOf(staker1.address)); + await dydxToken.connect(staker2.signer).transfer(deployer.address, await dydxToken.balanceOf(staker2.address)); + await dydxToken.connect(fundsRecipient.signer).transfer(deployer.address, await dydxToken.balanceOf(fundsRecipient.address)); + + // Mint to stakers 1 and 2. + await contract.mintAndApprove(staker1, stakerInitialBalance); + await contract.mintAndApprove(staker2, stakerInitialBalance); + + // Set initial rewards rate to zero. + await contract.setRewardsPerSecond(0); + + // Start rewards. + await incrementTimeToTimestamp(distributionStart); + + saveSnapshot(afterDistributionStarted); + }); + + afterEach(async () => { + await loadSnapshot(afterDistributionStarted); + }); + + describe('slash', () => { + + it('slashing when there are no balances does nothing', async () => { + await safetyModule.connect(deployer.signer).slash(0, fundsRecipient.address); + await safetyModule.connect(deployer.signer).slash(1, fundsRecipient.address); + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address); + expect(await safetyModule.getExchangeRateSnapshotCount()).to.equal(0); + }); + + it('slashing when there is only one token does nothing', async () => { + await contract.stake(staker1, 1); + await safetyModule.connect(deployer.signer).slash(0, fundsRecipient.address); + await safetyModule.connect(deployer.signer).slash(1, fundsRecipient.address); + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address); + expect(await safetyModule.getExchangeRateSnapshotCount()).to.equal(0); + }); + + it('slashing zero does nothing', async () => { + await contract.stake(staker1, stakerInitialBalance); + await safetyModule.connect(deployer.signer).slash(0, fundsRecipient.address); + + // Check underlying token balances. + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(stakerInitialBalance); + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(0); + + // Check stake balances. + expect(await safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + expect(await safetyModule.totalSupply()).to.equal(stakerInitialBalance); + + // Check slash snapshots. + expect(await safetyModule.getExchangeRateSnapshotCount()).to.equal(0); + }); + + it('slashes one token', async () => { + // Stake 2 tokens. + await contract.stake(staker1, 2); + + // Request to slash 3 tokens, should be limited to 1. + await safetyModule.connect(deployer.signer).slash(3, fundsRecipient.address); + + // Check underlying token balances. + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(1); + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(1); + + // Check stake balances. + expect(await safetyModule.balanceOf(staker1.address)).to.equal(2); + expect(await safetyModule.totalSupply()).to.equal(2); + + // Check slash snapshots. + expect(await safetyModule.getExchangeRateSnapshotCount()).to.equal(1); + }); + + it('does not affect stake balance, but affects gov power and tokens received on withdrawal', async () => { + const slashAmount = stakerInitialBalance / 2; + const remainingAmount = stakerInitialBalance - slashAmount; + await contract.stake(staker1, stakerInitialBalance); + await safetyModule.connect(deployer.signer).slash(slashAmount, fundsRecipient.address); + + // Check underlying token balances. + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(remainingAmount); + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(slashAmount); + + // Check stake balances. + expect(await safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + expect(await safetyModule.balanceOf(safetyModule.address)).to.equal(0); + expect(await safetyModule.balanceOf(fundsRecipient.address)).to.equal(0); + expect(await safetyModule.totalSupply()).to.equal(stakerInitialBalance); + + // Check slash snapshots. + await advanceBlock(); + await advanceBlock(); + const slashBlock = await getLatestSlashBlockNumber(safetyModule); + expect(await safetyModule.getExchangeRateSnapshotCount()).to.equal(1); + expect((await safetyModule.getExchangeRateSnapshot(0))[0]).to.equal(slashBlock); + expect((await safetyModule.getExchangeRateSnapshot(0))[1]).to.equal('2000000000000000000'); // 2e18 + + // Check gov power. + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock - 1, 0)).to.equal(stakerInitialBalance); + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock - 1, 1)).to.equal(stakerInitialBalance); + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock, 0)).to.equal(remainingAmount); + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock, 1)).to.equal(remainingAmount); + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock + 1, 0)).to.equal(remainingAmount); + expect(await safetyModule.getPowerAtBlock(staker1.address, slashBlock + 1, 1)).to.equal(remainingAmount); + expect(await safetyModule.getPowerCurrent(staker1.address, 0)).to.equal(remainingAmount); + expect(await safetyModule.getPowerCurrent(staker1.address, 1)).to.equal(remainingAmount); + + // Withdraw funds. + await contract.requestWithdrawal(staker1, stakerInitialBalance); // In stake units + await elapseEpoch(); + const withdrawalAmount = await contract.withdrawMaxStake(staker1, staker1); + expect(withdrawalAmount).to.equal(stakerInitialBalance); // In stake units + + // Check underlying token balances. + expect(await dydxToken.balanceOf(staker1.address)).to.equal(remainingAmount); + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(0); + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(slashAmount); + + // Check total supply. + expect(await safetyModule.totalSupply()).to.equal(0); + }); + + it('does not affect rewards earned', async () => { + await contract.stake(staker1, stakerInitialBalance); + + // Earn some rewards before the slash. + let lastTimestamp = (await timeLatest()).toNumber(); + await contract.setRewardsPerSecond(150); + await elapseEpoch(); + + // Slash. + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance / 2, fundsRecipient.address); + + // Earn some rewards after the slash. + await elapseEpoch(); + + // Slash again. + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance / 4, fundsRecipient.address); + + // Earn some rewards after the second slash. + await elapseEpoch(); + + // Claim rewards (with assertions within the helper function). + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + }); + + it('affects rewards earned relative to a new depositor', async () => { + await contract.stake(staker1, stakerInitialBalance); + + // Slash by 50%, three times. + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance / 2, fundsRecipient.address) + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance / 4, fundsRecipient.address) + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance / 8, fundsRecipient.address) + + // Second staker after first three slashes. + await contract.stake(staker2, stakerInitialBalance); + + // Slash again by 50%. + await safetyModule.connect(deployer.signer).slash((await dydxToken.balanceOf(safetyModule.address)).div(2), fundsRecipient.address) + + // Earn some rewards after the slash. + const lastTimestamp = (await timeLatest()).toNumber(); + await contract.setRewardsPerSecond(150); + await elapseEpoch(); + + // Expect staker 2 to earn an 8x share vs. staker 1. + const staker1Rewards = await contract.claimRewards(staker1, fundsRecipient, lastTimestamp, null, 1 / 9); + const staker2Rewards = await contract.claimRewards(staker2, fundsRecipient, lastTimestamp, null, 8 / 9); + const error = staker1Rewards.mul(8).sub(staker2Rewards).abs().toNumber() + expect(error).to.be.lte(300); + }); + }); + + describe('max slash', () => { + + it('slashes 95%', async () => { + await contract.stake(staker1, stakerInitialBalance); + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address); + + // Check underlying token balances. + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(stakerInitialBalance / 20); + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(stakerInitialBalance / 20 * 19); + + // Check stake balances. + expect(await safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + expect(await safetyModule.balanceOf(safetyModule.address)).to.equal(0); + expect(await safetyModule.balanceOf(fundsRecipient.address)).to.equal(0); + expect(await safetyModule.totalSupply()).to.equal(stakerInitialBalance); + + // Withdraw. + await safetyModule.connect(staker1.signer).requestWithdrawal(stakerInitialBalance); + await elapseEpoch(); + await safetyModule.connect(staker1.signer).withdrawMaxStake(staker1.address); + expect(await dydxToken.balanceOf(staker1.address)).to.equal(stakerInitialBalance / 20); + }); + + it('staker continues earning rewards', async () => { + await contract.stake(staker1, stakerInitialBalance); + + // Earn some rewards before the slash. + let lastTimestamp = (await timeLatest()).toNumber(); + await contract.setRewardsPerSecond(150); + await elapseEpoch(); + + // Slash. + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address); + + // Earn some rewards after the slash. + await elapseEpoch(); + + // Slash again. + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address); + + // Earn some rewards after the second slash. + await elapseEpoch(); + + // Claim rewards (with assertions within the helper function). + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + }); + + it('staker receive funds via transfer after being slashed', async () => { + await contract.stake(staker1, stakerInitialBalance); + await safetyModule.connect(deployer.signer).slash(stakerInitialBalance, fundsRecipient.address) + + // New deposit multiplied by exchange rate of 20... + await contract.stake(staker2, stakerInitialBalance); + await contract.transfer(staker2, staker1, stakerInitialBalance * 10); // Transfer half. + + // Check balances. + expect(await safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance * 11); + expect(await safetyModule.balanceOf(staker2.address)).to.equal(stakerInitialBalance * 10); + expect(await safetyModule.totalSupply()).to.equal(stakerInitialBalance * 21); + + // Check underlying balance of the contract. + expect(await dydxToken.balanceOf(safetyModule.address)).to.equal(stakerInitialBalance + stakerInitialBalance / 20); + + // Can earn rewards. + const startTimestamp = (await timeLatest()).toNumber(); + await contract.setRewardsPerSecond(150); + await elapseEpoch(); + const rewards1 = await contract.claimRewards(staker1, fundsRecipient, startTimestamp, null, 11 / 21); + const rewards2 = await contract.claimRewards(staker2, fundsRecipient, startTimestamp, null, 10 / 21); + const error = rewards1.sub(rewards2.mul(11).div(10)).abs().toNumber() + expect(error).to.be.lte(300); + }); + }); + + describe('exchange rate max', () => { + + it('supports max slash up to 34 times', async () => { + // This test case requires that we mint far beyond the initial supply of DYDX. + const mockDydxToken = await deployMintableErc20('Mock dYdX', 'DYDX', 18); + const distributionStart_2 = (await timeLatest()).plus(100).toString(); + const safetyModule_2: SafetyModuleV1 = await deploySafetyModuleV1( + mockDydxToken.address, + dydxToken.address, + rewardsTreasury.address, + DEPLOY_CONFIG.SAFETY_MODULE.DISTRIBUTION_START.toString(), + DEPLOY_CONFIG.SAFETY_MODULE.DISTRIBUTION_END.toString(), + distributionStart_2, // Must be in the future. + DEPLOY_CONFIG.SAFETY_MODULE.EPOCH_LENGTH.toString(), + DEPLOY_CONFIG.SAFETY_MODULE.BLACKOUT_WINDOW_LENGTH.toString(), + deployer.signer, + true, // use gas price overrides + ); + contract = new StakingHelper( + safetyModule_2, + mockDydxToken, + rewardsTreasury, + deployer, + deployer, + [staker1, staker2], + true, + ); + await incrementTimeToTimestamp(distributionStart_2); + await mockDydxToken.mint(deployer.address, BigNumber.from(10).pow(30)); + + // Give stakers a larger balance. + const amountToStake = BigNumber.from(10).pow(24); + await contract.mintAndApprove(staker1, amountToStake); + await contract.mintAndApprove(staker2, amountToStake); + + // Stake. + await contract.stake(staker1, amountToStake); // 1e24 in base units + + // Do a max slash 34 times, to get an exchange rate of around 1.7e44 (raw value 1.7e62). + let slashCount = 0; + while (slashCount < 34) { + await safetyModule_2.connect(deployer.signer).slash(amountToStake, fundsRecipient.address); + // Every 10 slashes, add more funds, to ensure we don't run out of funds to slash. + if (slashCount % 10 === 9) { + await contract.mintAndApprove(staker1, amountToStake); + await safetyModule_2.connect(staker1.signer).stake(amountToStake); + } + slashCount++ + } + + // Cannot do another max slash. + await expect( + safetyModule_2.connect(deployer.signer).slash(amountToStake, fundsRecipient.address), + ).to.be.revertedWith('SM1ExchangeRate: Max exchange rate exceeded'); + + // Staked balances should never overflow, under the assumption that the total underlying + // token balance is not more than 10^28. + const veryLargeAmountToStake = BigNumber.from(10).pow(28); + await contract.mintAndApprove(staker2, veryLargeAmountToStake); + await safetyModule_2.connect(staker2.signer).stake(veryLargeAmountToStake); // Receive ~1.7e72 in staked units + + // Cannot deposit much more without overflowing uint240. + const additionalAmount = BigNumber.from(10).pow(27); + await contract.mintAndApprove(staker2, additionalAmount); + await expect( + safetyModule_2.connect(staker2.signer).stake(additionalAmount), + ).to.be.revertedWith('SafeCast: toUint240 overflow'); + + // All regular logic should continue to work as expected... + + // Check staked balance + const stakedBalance = await safetyModule_2.balanceOf(staker2.address); + expect(stakedBalance.gt(BigNumber.from(10).pow(72))).to.be.true; + + // Check power. + expect(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(veryLargeAmountToStake); + + // Request full withdrawal. + await safetyModule_2.connect(staker2.signer).requestWithdrawal(stakedBalance); + + // Elapse epoch. + let remaining = (await safetyModule_2.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + await increaseTimeAndMine(remaining); + + // Execute full withdrawal. + let balanceBefore = await mockDydxToken.balanceOf(staker2.address); + await safetyModule_2.connect(staker2.signer).withdrawMaxStake(staker2.address); + let balanceAfter = await mockDydxToken.balanceOf(staker2.address); + let receivedAmount = balanceAfter.sub(balanceBefore); + expect(receivedAmount).to.equal(veryLargeAmountToStake); + + // Check power. + expect(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(0); + + // Stake again. + await mockDydxToken.connect(staker2.signer).approve(safetyModule_2.address, veryLargeAmountToStake); + await safetyModule_2.connect(staker2.signer).stake(veryLargeAmountToStake); + + // Check power. + expect(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(veryLargeAmountToStake); + + // Request withdrawal of most of the staked balance. + const requestAmount = BigNumber.from(10).pow(72); // In staked units. + await safetyModule_2.connect(staker2.signer).requestWithdrawal(requestAmount); + + // Elapse epoch. + remaining = (await safetyModule_2.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + await increaseTimeAndMine(remaining); + + // Execute withdrawal. + balanceBefore = await mockDydxToken.balanceOf(staker2.address); + await safetyModule_2.connect(staker2.signer).withdrawMaxStake(staker2.address); + balanceAfter = await mockDydxToken.balanceOf(staker2.address); + receivedAmount = balanceAfter.sub(balanceBefore); + const expectedReceivedAmount = veryLargeAmountToStake.mul(10).div(17); + const error = new BNJS(expectedReceivedAmount.toString()).minus(receivedAmount.toString()).div(receivedAmount.toString()); + expect(error.abs().toNumber()).to.be.lessThan(0.02); + + // Check governance power. + const power = await safetyModule_2.getPowerCurrent(staker2.address, 0); + const expectedPower = veryLargeAmountToStake.sub(receivedAmount); + const powerError = expectedPower.sub(power); + expect(powerError.toNumber()).to.be.lte(1); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await safetyModule.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function getLatestSlashBlockNumber(safetyModule: SafetyModuleV1): Promise { + const filter = safetyModule.filters.Slashed(null, null, null); + const events = await safetyModule.queryFilter(filter); + return events[events.length - 1].blockNumber; +} diff --git a/test/staking/SafetyModuleV1/sm1Staking.spec.ts b/test/staking/SafetyModuleV1/sm1Staking.spec.ts new file mode 100644 index 0000000..2729a36 --- /dev/null +++ b/test/staking/SafetyModuleV1/sm1Staking.spec.ts @@ -0,0 +1,531 @@ +import BigNumber from 'bignumber.js'; +import { deployPhase2, makeSuite, SignerWithAddress, TestEnv } from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, + incrementTimeToTimestamp, + waitForTx, +} from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH, ZERO_ADDRESS } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { SafetyModuleV1 } from '../../../types/SafetyModuleV1'; +import { expect } from 'chai'; +import { DydxToken } from '../../../types/DydxToken'; + +const snapshots = new Map(); + +const afterMockTokenMint = 'AfterMockTokenMint'; + +const stakerInitialBalance = 1_000_000; +const stakerInitialBalance2 = 4_000_000; + +makeSuite('SM1Staking', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let safetyModuleV1: SafetyModuleV1; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + // Users calling the liquidity staking contract. + let stakerSigner1: SafetyModuleV1; + let stakerSigner2: SafetyModuleV1; + + let distributionStart: string; + let distributionEnd: string; + + let contract: StakingHelper; + + before(async () => { + safetyModuleV1 = testEnv.safetyModule; + dydxToken = testEnv.dydxToken; + rewardsTreasury = testEnv.rewardsTreasury; + deployer = testEnv.deployer; + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Users calling the liquidity staking contract. + stakerSigner1 = safetyModuleV1.connect(staker1.signer); + stakerSigner2 = safetyModuleV1.connect(staker2.signer); + + distributionStart = (await safetyModuleV1.DISTRIBUTION_START()).toString(); + distributionEnd = (await safetyModuleV1.DISTRIBUTION_END()).toString(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + safetyModuleV1, + dydxToken, + rewardsTreasury, + deployer, + deployer, + [staker1, staker2], + true, + ); + + // Empty user balances. + await dydxToken.connect(staker1.signer).transfer(deployer.address, await dydxToken.balanceOf(staker1.address)); + await dydxToken.connect(staker2.signer).transfer(deployer.address, await dydxToken.balanceOf(staker2.address)); + await dydxToken.connect(fundsRecipient.signer).transfer(deployer.address, await dydxToken.balanceOf(fundsRecipient.address)); + + // Mint to stakers. + await contract.mintAndApprove(staker1, stakerInitialBalance); + await contract.mintAndApprove(staker2, stakerInitialBalance2); + + // Send tokens to the rewards treasury, and set allowance on the safety module. + const deployerBalance = await testEnv.dydxToken.balanceOf(deployer.address); + await dydxToken.connect(deployer.signer).transfer(testEnv.rewardsTreasury.address, deployerBalance); + + // Set initial rewards rate to zero. + await contract.setRewardsPerSecond(0); + + saveSnapshot(afterMockTokenMint); + }); + + describe('stake', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with mock tokens cannot successfully stake if epoch zero has not started', async () => { + await expect(stakerSigner1.stake(stakerInitialBalance)).to.be.revertedWith( + 'SM1EpochSchedule: Epoch zero has not started' + ); + }); + + it('User with mock tokens can successfully stake if epoch zero has started', async () => { + await incrementTimeToTimestamp(distributionStart); + await contract.stake(staker1, stakerInitialBalance); + + // `dydxToken` should be transferred to SafetyModuleV1 contract, and user should be given an + // equivalent amount of `SM1ERC20` tokens + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal( + stakerInitialBalance + ); + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + }); + + describe('requestWithdrawal', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with nonzero staked balance can request a withdrawal after epoch zero has started', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + + // `dydxToken` should still be owned by SafetyModuleV1 contract, and user should still own an + // equivalent amount of `SM1ERC20` tokens + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal( + stakerInitialBalance + ); + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + + it('User with nonzero staked balance cannot request a withdrawal during blackout window', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + const withinBlackoutWindow: string = EPOCH_LENGTH.add(distributionStart) + .sub(BLACKOUT_WINDOW) + .toString(); + await incrementTimeToTimestamp(withinBlackoutWindow); + + await expect(contract.requestWithdrawal(staker1, stakerInitialBalance)).to.be.revertedWith( + 'SM1Staking: Withdraw requests restricted in the blackout window' + ); + + // `dydxToken` should still be owned by SafetyModuleV1 contract, and user should still have an + // equivalent amount of `SM1ERC20` tokens + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal( + stakerInitialBalance + ); + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(stakerInitialBalance); + }); + + it('User with zero staked balance cannot request a withdrawal', async () => { + // increment time to past epoch zero + await incrementTimeToTimestamp(distributionStart); + + await expect(contract.requestWithdrawal(staker1, 1)).to.be.revertedWith( + 'SM1Staking: Withdraw request exceeds next active balance' + ); + }); + }); + + describe('withdrawStake', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Staker can request and withdraw full balance', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, stakerInitialBalance); + + // `dydxToken` should be sent to fundsRecipient, SafetyModuleV1 contract should own nothing + // and user should have 0 staked token balance + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal( + stakerInitialBalance + ); + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal(0); + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(0); + }); + + it('Staker can request full balance and make multiple partial withdrawals', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + const withdrawAmount = 1; + await contract.withdrawStake(staker1, fundsRecipient, withdrawAmount); + + // `dydxToken` should be sent to fundsRecipient, SafetyModuleV1 contract should own remainder + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal(withdrawAmount); + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal( + stakerInitialBalance - withdrawAmount + ); + + // Additional withdrawal + await contract.withdrawStake(staker1, fundsRecipient, 10); + await contract.withdrawStake(staker1, fundsRecipient, 100); + await contract.withdrawStake(staker1, fundsRecipient, stakerInitialBalance - 111); + }); + + it('Staker can make multiple partial requests and then a full withdrawal', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, 100); + await contract.requestWithdrawal(staker1, 10); + await contract.requestWithdrawal(staker1, 1); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, 111); + }); + + it('Staker can make multiple partial requests and then multiple partial withdrawals', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, 100); + await contract.requestWithdrawal(staker1, 10); + await contract.requestWithdrawal(staker1, 1); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawStake(staker1, fundsRecipient, 50); + await contract.withdrawStake(staker1, fundsRecipient, 60); + await contract.withdrawStake(staker1, fundsRecipient, 1); + }); + + it('Staker cannot withdraw funds if none are staked', async () => { + await incrementTimeToTimestamp(distributionStart); + + await expect( + stakerSigner1.withdrawStake(staker1.address, stakerInitialBalance) + ).to.be.revertedWith('SM1Staking: Withdraw amount exceeds staker inactive balance'); + }); + }); + + describe('withdrawMaxStake', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Staker can request and withdraw full balance', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await contract.withdrawMaxStake(staker1.address, fundsRecipient.address); + + // `dydxToken` should be sent to fundsRecipient, SafetyModuleV1 contract should own nothing + // and user should have 0 staked token balance + expect(await dydxToken.balanceOf(fundsRecipient.address)).to.equal( + stakerInitialBalance + ); + expect(await dydxToken.balanceOf(staker1.address)).to.equal(0); + expect(await dydxToken.balanceOf(safetyModuleV1.address)).to.equal(0); + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(0); + }); + + it('Staker can try to withdraw max stake even if there is none', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.withdrawMaxStake(staker1, staker1); + }); + }); + + describe('Transfer events', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Emits transfer events as expected', async () => { + await incrementTimeToTimestamp(distributionStart); + + await expect(safetyModuleV1.connect(staker1.signer).stake(stakerInitialBalance)) + .to.emit(safetyModuleV1, 'Transfer') + .withArgs(ZERO_ADDRESS, staker1.address, stakerInitialBalance); + + await expect(safetyModuleV1.connect(staker1.signer).transfer(staker2.address, stakerInitialBalance)) + .to.emit(safetyModuleV1, 'Transfer') + .withArgs(staker1.address, staker2.address, stakerInitialBalance); + + await expect(safetyModuleV1.connect(staker2.signer).requestWithdrawal(stakerInitialBalance)) + .not.to.emit(safetyModuleV1, 'Transfer'); + + await elapseEpoch(); // increase time to next epoch, so user can withdraw funds + await expect(safetyModuleV1.connect(staker2.signer).withdrawStake(fundsRecipient.address, stakerInitialBalance)) + .to.emit(safetyModuleV1, 'Transfer') + .withArgs(staker2.address, ZERO_ADDRESS, stakerInitialBalance); + }); + }); + + describe('claimRewards', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + // Repeat with different rewards rates. + await contract.stake(staker1, stakerInitialBalance); + let lastTimestamp = (await timeLatest()).toNumber(); + for (const rewardsRate of [1]) { + await contract.setRewardsPerSecond(rewardsRate); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + await elapseEpoch(); // Earn one epoch of rewards. + await contract.claimRewards(staker1, fundsRecipient, lastTimestamp); + lastTimestamp = (await timeLatest()).toNumber(); + } + }); + + it('User with nonzero staked balance for one epoch but emission rate was zero cannot claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + // increase time to next epoch, so user can earn rewards + await elapseEpoch(); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(safetyModuleV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(safetyModuleV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + const fundsRecipient: SignerWithAddress = testEnv.users[2]; + + expect(await stakerSigner1.callStatic.claimRewards(fundsRecipient.address)).to.equal(0); + }); + + it('Multiple users can stake, requestWithdrawal, withdrawStake, and claimRewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await contract.setRewardsPerSecond(emissionRate); + + await contract.stake(staker1, stakerInitialBalance); + const stakeTimestamp1 = (await timeLatest()).toNumber(); + await contract.stake(staker2, stakerInitialBalance2); + const stakeTimestamp2 = (await timeLatest()).toNumber(); + + await contract.requestWithdrawal(staker1, stakerInitialBalance); + await contract.requestWithdrawal(staker2, stakerInitialBalance2); + + await elapseEpoch(); + + const totalBalance = stakerInitialBalance + stakerInitialBalance2; + + const beforeClaim1: BigNumber = await timeLatest(); + const numTokens1: number = beforeClaim1 + .minus(stakeTimestamp1) + .times(emissionRate) + .times(stakerInitialBalance) + .div(totalBalance) + .toNumber(); + + expect( + (await stakerSigner1.callStatic.claimRewards(staker1.address)).toNumber() + ).to.be.closeTo(numTokens1, 2); + + const beforeClaim2: BigNumber = await timeLatest(); + const numTokens2: number = beforeClaim2 + .minus(stakeTimestamp2) + .times(emissionRate) + .times(stakerInitialBalance2) + .div(totalBalance) + .toNumber(); + expect( + (await stakerSigner2.callStatic.claimRewards(staker2.address)).toNumber() + ).to.be.closeTo(numTokens2, 2); + }); + + it('User with nonzero staked balance does not earn rewards after distributionEnd', async () => { + await incrementTimeToTimestamp( + new BigNumber(distributionEnd).minus(EPOCH_LENGTH.toNumber()).toString() + ); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(safetyModuleV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(safetyModuleV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + // expect the `stake` call to succeed, else we can't test `claimRewards` + await contract.stake(staker1.address, stakerInitialBalance); + const stakedTimestamp: BigNumber = await timeLatest(); + + // move multiple epochs forward so we're after DISTRIBUTION_END + // (user should only earn rewards for last epoch) + for (let i = 0; i < 5; i++) { + await elapseEpoch(); + } + + const numTokens = new BigNumber(distributionEnd) + .minus(stakedTimestamp) + .times(emissionRate) + .toString(); + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(safetyModuleV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, numTokens); + + // verify user can withdraw and doesn't earn additional rewards + await contract.requestWithdrawal(staker1.address, stakerInitialBalance); + + await elapseEpoch(); + await contract.withdrawStake(staker1.address, staker1.address, stakerInitialBalance); + + // user shouldn't have any additional rewards since it's after DISTRIBUTION_END + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(safetyModuleV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, 0); + }); + }); + + describe('transfer', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.transfer(staker1, staker2, stakerInitialBalance); + + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(0); + expect(await safetyModuleV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + + it('User with staked balance for one epoch can transfer to another user and claim rewards', async () => { + await incrementTimeToTimestamp(distributionStart); + + // change EMISSION_RATE to be greater than 0 + const emissionRate = 1; + await expect(safetyModuleV1.connect(deployer.signer).setRewardsPerSecond(emissionRate)) + .to.emit(safetyModuleV1, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + await contract.stake(staker1, stakerInitialBalance); + const stakeTimestamp: BigNumber = await timeLatest(); + + // increase time to next epoch, so user can earn rewards + await elapseEpoch(); + + await contract.transfer(staker1, staker2, stakerInitialBalance); + + const balanceBeforeClaiming = await dydxToken.balanceOf(staker1.address); + const now: BigNumber = await timeLatest(); + const numTokens = now.minus(stakeTimestamp).times(emissionRate).toString(); + await expect(stakerSigner1.claimRewards(staker1.address)) + .to.emit(safetyModuleV1, 'ClaimedRewards') + .withArgs(staker1.address, staker1.address, numTokens); + expect(await dydxToken.balanceOf(staker1.address)).to.equal( + balanceBeforeClaiming.add(numTokens).toString() + ); + }); + }); + + describe('transferFrom', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('User with staked balance can transfer to another user', async () => { + await incrementTimeToTimestamp(distributionStart); + + await contract.stake(staker1, stakerInitialBalance); + + await contract.approve(staker1, staker2, stakerInitialBalance); + await contract.transferFrom(staker2, staker1, staker2, stakerInitialBalance); + + expect(await safetyModuleV1.balanceOf(staker1.address)).to.equal(0); + expect(await safetyModuleV1.balanceOf(staker2.address)).to.equal(stakerInitialBalance); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await safetyModuleV1.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); diff --git a/test/staking/StarkProxyV1/sp1Borrowing.spec.ts b/test/staking/StarkProxyV1/sp1Borrowing.spec.ts new file mode 100644 index 0000000..edbf622 --- /dev/null +++ b/test/staking/StarkProxyV1/sp1Borrowing.spec.ts @@ -0,0 +1,371 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, +} from '../../../helpers/misc-utils'; +import { BLACKOUT_WINDOW, EPOCH_LENGTH } from '../../../helpers/constants'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { StarkProxyV1 } from '../../../types/StarkProxyV1'; +import { MockStarkPerpetual } from '../../../types/MockStarkPerpetual'; + +// Snapshots +const snapshots = new Map(); +const fundsStakedSnapshot = 'FundsStaked'; +const borrowerAllocationsSettledSnapshot = 'BorrowerAllocationsSettled'; +const borrowerAmountDue = 'BorrowerAmountDue'; +const borrowerRestrictedSnapshot = 'BorrowerRestrictedSnapshot'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('SP1Borrowing', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let rewardsTreasury: SignerWithAddress; + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let mockStarkPerpetual: MockStarkPerpetual; + + // Users. + let deployer: SignerWithAddress; + let stakers: SignerWithAddress[]; + let borrowers: StarkProxyV1[]; + + let distributionStart: number; + let expectedAllocations: number[]; + + let contract: StakingHelper; + + before(async () => { + ({ + rewardsTreasury, + liquidityStaking, + mockStakedToken, + mockStarkPerpetual, + deployer, + } = testEnv); + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.starkProxyV1Borrowers; + + // Grant roles. + const exchangeOperatorRole = await borrowers[0].EXCHANGE_OPERATOR_ROLE(); + const borrowerRole = await borrowers[0].BORROWER_ROLE(); + await Promise.all(borrowers.map(async b => { + await b.grantRole(exchangeOperatorRole, deployer.address); + await b.grantRole(borrowerRole, deployer.address); + })); + + distributionStart = (await liquidityStaking.DISTRIBUTION_START()).toNumber(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStaking, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + await Promise.all(borrowers.map((b) => contract.approveContract(b, stakerInitialBalance))); + + // Initial stake of 1M. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], stakerInitialBalance / 4); + await contract.stake(stakers[1], (stakerInitialBalance / 4) * 3); + saveSnapshot(fundsStakedSnapshot); + }); + + describe('After stake is deposited and allocations are set', () => { + before(async () => { + await loadSnapshot(fundsStakedSnapshot); + + // Allocations: [40%, 60%] + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + expectedAllocations = [stakerInitialBalance * 0.4, stakerInitialBalance * 0.6]; + + // Enter the blackout window. + await contract.elapseEpoch(); + await advanceToBlackoutWindow(); + + await saveSnapshot(borrowerAllocationsSettledSnapshot); + }); + + describe('Before borrowing', () => { + beforeEach(async () => { + await loadSnapshot(borrowerAllocationsSettledSnapshot); + }); + + it('Auto-pay cannot be called outside the blackout window', async () => { + await contract.elapseEpoch(); + await expect(borrowers[0].autoPayOrBorrow()).to.be.revertedWith( + 'SP1Borrowing: Auto-pay may only be used during the blackout window' + ); + }); + + it('Auto-pay will borrow full borrowable amount', async () => { + const results = await contract.autoPay(borrowers[0]); + expectEqs(results, [expectedAllocations[0], 0, 0]); + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + }); + + describe('When borrower has an amount due', async () => { + before(async () => { + await loadSnapshot(borrowerAllocationsSettledSnapshot); + + // Borrow full amount of 0.4M + await contract.fullBorrowViaProxy(borrowers[0], stakerInitialBalance * 0.4); + + // Staker request withdrawal of 0.5M (half the funds in the contract). + await contract.elapseEpoch(); + await contract.requestWithdrawal(stakers[1], stakerInitialBalance / 2); // 0.5M + + // Enter the blackout window. + await advanceToBlackoutWindow(); + + await saveSnapshot(borrowerAmountDue); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerAmountDue); + }); + + it('Auto-pay will repay the borrow amount due', async () => { + // Expect repay 0.2M + let results = await contract.autoPay(borrowers[0]); + expectEqs(results, [0, expectedAllocations[0] / 2, 0]); + + // While still in the blackout period, staker stakes more, and borrower auto-borrows. + // Stake another 1M. + await contract.mintAndApprove(stakers[0], stakerInitialBalance); + await contract.stake(stakers[0], stakerInitialBalance); + // Just a quick overview of the current state. + expect(await liquidityStaking.getTotalActiveBalanceCurrentEpoch()).to.equal(2000000); + expect(await liquidityStaking.getTotalActiveBalanceNextEpoch()).to.equal(1500000); + expect(await liquidityStaking.getTotalInactiveBalanceCurrentEpoch()).to.equal(0); + expect(await liquidityStaking.getTotalInactiveBalanceNextEpoch()).to.equal(500000); + expect( + await liquidityStaking.getAllocatedBalanceCurrentEpoch(borrowers[0].address) + ).to.equal(800000); + expect( + await liquidityStaking.getAllocatedBalanceNextEpoch(borrowers[0].address) + ).to.equal(600000); + expect(await liquidityStaking.getContractBalanceAvailableToWithdraw()).to.equal(1800000); + expect(await liquidityStaking.getBorrowedBalance(borrowers[0].address)).to.equal(200000); + expect(await liquidityStaking.getBorrowableAmount(borrowers[0].address)).to.equal(400000); + // Current active is 2M and next active is 1.5M. + // We have an outstanding borrow of 0.2M. + // Expect borrow to be limited by next active, so should be another 0.4M. + results = await contract.autoPay(borrowers[0]); + expectEqs(results, [stakerInitialBalance * 0.4, 0, 0]); + + // Next epoch: request withdraw everything. + await contract.elapseEpoch(); + await contract.requestWithdrawal(stakers[0], (stakerInitialBalance * 5) / 4); // 0.25M + await contract.requestWithdrawal(stakers[1], (stakerInitialBalance * 1) / 4); // 1.25M + await advanceToBlackoutWindow(); + results = await contract.autoPay(borrowers[0]); + expectEqs(results, [0, stakerInitialBalance * 0.6, 0]); // Repay 0.6M + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + + it('Auto-pay will revert if borrower does not have funds to pay amount due by the next epoch', async () => { + // Need repayment of 0.2M. Borrower currently has 0.4M. + + // Deposit 0.2M + 1 to the exchange. + const starkKey = 123; + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + await borrowers[0].allowStarkKey(starkKey); + await borrowers[0].depositToExchange(starkKey, 456, 789, stakerInitialBalance * 0.2 + 1); + + // Auto-pay will revert to indicate there are not enough funds to avoid a shortfall. + await expect(contract.autoPay(borrowers[0])).to.be.revertedWith( + 'SP1Borrowing: Insufficient funds to avoid falling short on repayment', + ); + + // Withdraw (all) from the exchange, then deposit 0.2M again. + await borrowers[0].withdrawFromExchange(starkKey, 456); + await borrowers[0].depositToExchange(starkKey, 456, 789, stakerInitialBalance * 0.2); + + // Expect repay 0.2M + const results = await contract.autoPay(borrowers[0]); + expectEqs(results, [0, expectedAllocations[0] / 2, 0]); + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + }); + + describe('When borrower has a debt due', async () => { + before(async () => { + await loadSnapshot(borrowerAllocationsSettledSnapshot); + + // Borrow full amount of 0.4M + await contract.fullBorrowViaProxy(borrowers[0], stakerInitialBalance * 0.4); + + // Staker request withdrawal of 0.75M (3/4 the funds in the contract). + await contract.elapseEpoch(); + await contract.requestWithdrawal(stakers[1], (stakerInitialBalance * 3) / 4); + + // Advance to the next epoch and mark debt. + // Overdue amount is 0.15M against a requested amount of 0.75M. + await contract.elapseEpoch(); + await contract.markDebt( + { + [borrowers[0].address]: stakerInitialBalance * 0.15, + }, + [borrowers[0]], + 0.8 // (0.75 - 0.15) / 0.75 + ); + + await saveSnapshot(borrowerAmountDue); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerAmountDue); + }); + + it('Auto-pay will pay outstanding debt if there are available funds to do so', async () => { + // Borrowed, 0.4M and have not repaid any, but 0.15M was converted to debt. + // The total active staked funds are 0.25M, and borrower's allocation is 40%, or 0.1M. + // + // So expect to owe 0.15M in debt and another 0.15M in borrow due by the next epoch. + // + // Make a partial repayment directly, first, and then call auto-pay. + const earlyRepaymentAmount = 129787; + await contract.repayBorrowViaProxy(borrowers[0], earlyRepaymentAmount); + await advanceToBlackoutWindow(); + const results = await contract.autoPay(borrowers[0]); + expectEqs(results, [ + 0, + stakerInitialBalance * 0.15 - earlyRepaymentAmount, + stakerInitialBalance * 0.15, + ]); + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + }); + + describe('After restricted by guardian', () => { + before(async () => { + await loadSnapshot(borrowerAllocationsSettledSnapshot); + await expect( + borrowers[0].connect(testEnv.deployer.signer).guardianSetBorrowingRestriction(true) + ) + .to.emit(borrowers[0], 'BorrowingRestrictionChanged') + .withArgs(true); + await saveSnapshot(borrowerRestrictedSnapshot); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerRestrictedSnapshot); + }); + + it('Auto-pay will not borrow (and will not revert) if bororwer is restricted by guardian', async () => { + // Expect borrowable amount according to staking contract to be unchanged. + expect(await liquidityStaking.getBorrowableAmount(borrowers[0].address)).to.equal( + stakerInitialBalance * 0.4 + ); + + // Expect borrowable amount according to proxy contract to be zero. + expect(await borrowers[0].getBorrowableAmount()).to.equal(0); + + // Auto-pay results in zero borrow. + const results = await contract.autoPay(borrowers[0]); + expectEqs(results, [0, 0, 0]); + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + + it('Borrower can borrow again if restriction is released', async () => { + await expect( + borrowers[0].connect(testEnv.deployer.signer).guardianSetBorrowingRestriction(false) + ) + .to.emit(borrowers[0], 'BorrowingRestrictionChanged') + .withArgs(false); + + // Borrow full amount of 0.4M. + const results = await contract.autoPay(borrowers[0]); + expectEqs(results, [expectedAllocations[0], 0, 0]); + + // After a non-reverting call to autoPay(), should never be overdue in the next epoch. + await contract.elapseEpoch(); + expect(await liquidityStaking.isBorrowerOverdue(borrowers[0].address)).to.be.false; + }); + }); + }); + + /** + * Progress to the blackout window of the current epoch. + */ + async function advanceToBlackoutWindow(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStaking.getTimeRemainingInCurrentEpoch()).toNumber() || EPOCH_LENGTH.toNumber(); + const timeUntilBlackoutWindow = remaining - BLACKOUT_WINDOW.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(timeUntilBlackoutWindow); + } else { + await increaseTime(timeUntilBlackoutWindow); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +function expectEqs(actual: BigNumberish[], expected: BigNumberish[]): void { + expect(actual).to.have.length(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(actual[i], `expectEqs[${i}]: ${actual}`).to.be.equal(expected[i]); + } +} + +async function incrementTimeToTimestamp(timestampString: BigNumberish): Promise { + const latestBlockTimestamp = (await timeLatest()).toNumber(); + const timestamp = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp).to.be.at.most(timestamp.toNumber()); + const timestampDiff = timestamp.sub(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/StarkProxyV1/sp1Exchange.spec.ts b/test/staking/StarkProxyV1/sp1Exchange.spec.ts new file mode 100644 index 0000000..6341660 --- /dev/null +++ b/test/staking/StarkProxyV1/sp1Exchange.spec.ts @@ -0,0 +1,237 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + timeLatest, + evmSnapshot, + evmRevert, + increaseTimeAndMine, +} from '../../../helpers/misc-utils'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { StarkProxyV1 } from '../../../types/StarkProxyV1'; +import { MockStarkPerpetual } from '../../../types/MockStarkPerpetual'; + +// Snapshots +const snapshots = new Map(); +const fundsStakedSnapshot = 'FundsStaked'; +const borrowerHasBorrowed = 'BorrowerHasBorrowed'; +const borrowerAmountDue = 'BorrowerAmountDue'; +const borrowerRestrictedSnapshot = 'BorrowerRestrictedSnapshot'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('SP1Exchange', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let mockStarkPerpetual: MockStarkPerpetual; + + // Users. + let stakers: SignerWithAddress[]; + let borrowers: StarkProxyV1[]; + + let distributionStart: number; + let expectedAllocations: number[]; + + let contract: StakingHelper; + + before(async () => { + ({ + rewardsTreasury, + liquidityStaking, + mockStakedToken, + mockStarkPerpetual, + deployer, + } = testEnv); + + // Users. + stakers = testEnv.users.slice(1, 3); // 2 stakers + borrowers = testEnv.starkProxyV1Borrowers; + + // Grant roles. + const exchangeOperatorRole = await borrowers[0].EXCHANGE_OPERATOR_ROLE(); + const borrowerRole = await borrowers[0].BORROWER_ROLE(); + await Promise.all(borrowers.map(async b => { + await b.grantRole(exchangeOperatorRole, deployer.address); + await b.grantRole(borrowerRole, deployer.address); + })); + + distributionStart = (await liquidityStaking.DISTRIBUTION_START()).toNumber(); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStaking, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + stakers.concat(borrowers), + false, + ); + + // Mint staked tokens and set allowances. + await Promise.all(stakers.map((s) => contract.mintAndApprove(s, stakerInitialBalance))); + + // Initial stake of 1M. + await incrementTimeToTimestamp(distributionStart); + await contract.stake(stakers[0], stakerInitialBalance / 4); + await contract.stake(stakers[1], (stakerInitialBalance / 4) * 3); + saveSnapshot(fundsStakedSnapshot); + }); + + describe('Borrower, after borrowing funds', () => { + before(async () => { + await loadSnapshot(fundsStakedSnapshot); + + // Allocations: [40%, 60%] + await contract.setBorrowerAllocations({ + [borrowers[0].address]: 0.4, + [borrowers[1].address]: 0.6, + }); + expectedAllocations = [stakerInitialBalance * 0.4, stakerInitialBalance * 0.6]; + + // Borrow full amount. + await contract.elapseEpoch(); + await contract.fullBorrowViaProxy(borrowers[0], stakerInitialBalance * 0.4); + expect(await mockStakedToken.balanceOf(borrowers[0].address)).to.equal( + stakerInitialBalance * 0.4 + ); + + await saveSnapshot(borrowerHasBorrowed); + }); + + describe('interacting with the exchange', () => { + const starkKey = 123; + + beforeEach(async () => { + await loadSnapshot(borrowerHasBorrowed); + }); + + it('can add a STARK key that is registered to the StarkProxy contract', async () => { + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + await borrowers[0].allowStarkKey(starkKey); + }); + + it('cannot add a STARK key that is not registered', async () => { + await expect(borrowers[0].allowStarkKey(starkKey)).to.be.revertedWith( + 'USER_UNREGISTERED', + ); + }); + + it('cannot add a STARK key that is registered to another address', async () => { + await mockStarkPerpetual.registerUser(borrowers[1].address, starkKey, []); + await expect(borrowers[0].allowStarkKey(starkKey)).to.be.revertedWith( + 'SP1Owner: STARK key not registered to this contract', + ); + }); + + it('cannot deposit to a STARK key that has not been allowed', async () => { + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + await expect(borrowers[0].depositToExchange(starkKey, 0, 0, 0)).to.be.revertedWith( + 'SP1Storage: STARK key is not on the allowlist' + ); + }); + + it('cannot deposit to a STARK key that has been disallowed', async () => { + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + await borrowers[0].allowStarkKey(starkKey); + await borrowers[0].disallowStarkKey(starkKey); + await expect(borrowers[0].depositToExchange(starkKey, 0, 0, 0)).to.be.revertedWith( + 'SP1Storage: STARK key is not on the allowlist' + ); + }); + + it('can deposit and withdraw, and then repay', async () => { + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + await borrowers[0].allowStarkKey(starkKey); + await borrowers[0].depositToExchange(starkKey, 456, 789, stakerInitialBalance * 0.4); + await borrowers[0].withdrawFromExchange(starkKey, 456); + await contract.repayBorrowViaProxy(borrowers[0], stakerInitialBalance * 0.4); + }); + }); + + describe('after restricted by guardian', () => { + const starkKey = 123; + + before(async () => { + await loadSnapshot(borrowerHasBorrowed); + + // Register with the exchange. + await mockStarkPerpetual.registerUser(borrowers[0].address, starkKey, []); + + // Restrict borrowing. + await expect( + borrowers[0].connect(testEnv.deployer.signer).guardianSetBorrowingRestriction(true) + ) + .to.emit(borrowers[0], 'BorrowingRestrictionChanged') + .withArgs(true); + + await saveSnapshot(borrowerRestrictedSnapshot); + }); + + beforeEach(async () => { + await loadSnapshot(borrowerRestrictedSnapshot); + }); + + it('cannot deposit borrowed funds to the exchange', async () => { + await borrowers[0].allowStarkKey(starkKey); + await expect( + borrowers[0].depositToExchange(starkKey, 456, 789, stakerInitialBalance * 0.4) + ).to.be.revertedWith( + 'SP1Borrowing: Cannot deposit borrowed funds to the exchange while Restricted' + ); + }); + + it('can still deposit own funds to the exchange', async () => { + await borrowers[0].allowStarkKey(starkKey); + + // Transfer some funds directly to the StarkProxy contract. + const ownFundsAmount = 12340; + await mockStakedToken + .connect(stakers[0].signer) + .transfer(borrowers[0].address, ownFundsAmount); + + // Deposit own funds to the exchange. + await borrowers[0].depositToExchange(starkKey, 456, 789, ownFundsAmount); + + // Cannot deposit any more. + await expect(borrowers[0].depositToExchange(starkKey, 456, 789, 1)).to.be.revertedWith( + 'SP1Borrowing: Cannot deposit borrowed funds to the exchange while Restricted' + ); + }); + }); + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); + +async function incrementTimeToTimestamp(timestampString: BigNumberish): Promise { + const latestBlockTimestamp = (await timeLatest()).toNumber(); + const timestamp = BigNumber.from(timestampString); + // we can only increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp).to.be.at.most(timestamp.toNumber()); + const timestampDiff = timestamp.sub(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/staking/StarkProxyV1/sp1Guardian.spec.ts b/test/staking/StarkProxyV1/sp1Guardian.spec.ts new file mode 100644 index 0000000..73dc3e1 --- /dev/null +++ b/test/staking/StarkProxyV1/sp1Guardian.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import _ from 'lodash' + +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { StarkProxyV1 } from '../../../types/StarkProxyV1'; +import { incrementTimeToTimestamp, timeLatest, waitForTx } from '../../../helpers/misc-utils'; +import { MockStarkPerpetual } from '../../../types/MockStarkPerpetual'; + +makeSuite('SP1Guardian', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let guardian: SignerWithAddress; + let exchangeOperator: SignerWithAddress; + + // Each borrower is represented by a stark proxy contract. + let liquidityStaking: LiquidityStakingV1; + let mockStarkPerpetual: MockStarkPerpetual; + let borrowers: StarkProxyV1[]; + let borrower: StarkProxyV1; + + before(async () => { + deployer = testEnv.deployer; + guardian = testEnv.users[0]; + exchangeOperator = testEnv.users[1]; + + liquidityStaking = testEnv.liquidityStaking; + mockStarkPerpetual = testEnv.mockStarkPerpetual; + borrowers = testEnv.starkProxyV1Borrowers; + borrower = borrowers[0] + + // Grant exchange operator role. + borrower.grantRole(await borrower.EXCHANGE_OPERATOR_ROLE(), exchangeOperator.address); + + // Grant guardian role. + borrower.grantRole(await borrower.GUARDIAN_ROLE(), guardian.address); + borrower.grantRole(await borrower.VETO_GUARDIAN_ROLE(), guardian.address); + borrower.renounceRole(await borrower.GUARDIAN_ROLE(), deployer.address); + }); + + describe('isContractInDefault', () => { + + it('No borrowers are initially in default', async () => { + for (const borrower of borrowers) { + expect(await liquidityStaking.isBorrowerOverdue(borrower.address)).to.be.false; + } + }); + }); + + describe('guardianVetoForcedTradeRequests', () => { + + it('Can veto a forced trade request', async () => { + // Register STARK key and add it to the allowlist. + const mockStarkKey = 0; + await mockStarkPerpetual.registerUser(borrower.address, mockStarkKey, []); + await borrower.allowStarkKey(mockStarkKey); + + // Owner enqueues a forced trade request. + const args = _.range(12); + const signature = Buffer.from('mock-signature'); + const tx = await borrower.queueForcedTradeRequest(args); + const receipt = await waitForTx(tx); + const { argsHash } = borrower.interface.parseLog(receipt.logs[0]).args; + + // Expect to fail during waiting period. + await expect(borrower.forcedTradeRequest(args, signature)).to.be.revertedWith( + 'SP1Owner: Waiting period has not elapsed for forced trade' + ); + + // Allow waiting period to elapse. + let timestamp = await timeLatest(); + await incrementTimeToTimestamp(timestamp.plus((await borrower.FORCED_TRADE_WAITING_PERIOD()).toString()).toString()); + + // Note: 'function selector was not recognized' means it has attempted to call through to the + // mock StarkPerpetual contract, and did not revert on StarkProxy. + await expect(borrower.forcedTradeRequest(args, signature)).to.be.revertedWith( + 'function selector was not recognized' + ); + + // Expect to fail after waiting period, if vetoed. + await borrower.connect(guardian.signer).guardianVetoForcedTradeRequests([argsHash]); + await expect(borrower.forcedTradeRequest(args, signature)).to.be.revertedWith( + 'SP1Owner: Forced trade not queued or was vetoed' + ); + + // Queue it again and expect it to fail if outside of the grace period. + await borrower.queueForcedTradeRequest(args); + timestamp = await timeLatest(); + await incrementTimeToTimestamp( + timestamp + .plus((await borrower.FORCED_TRADE_WAITING_PERIOD()).toString()) + .plus((await borrower.FORCED_TRADE_GRACE_PERIOD()).toString()) + .plus(1) + .toString() + ); + await expect(borrower.forcedTradeRequest(args, signature)).to.be.revertedWith( + 'SP1Owner: Grace period has elapsed for forced trade', + ); + }); + }); +}); diff --git a/test/staking/StarkProxyV1/sp1Withdrawals.ts b/test/staking/StarkProxyV1/sp1Withdrawals.ts new file mode 100644 index 0000000..8963d61 --- /dev/null +++ b/test/staking/StarkProxyV1/sp1Withdrawals.ts @@ -0,0 +1,226 @@ +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../../test-helpers/make-suite'; +import { + evmSnapshot, + evmRevert, + incrementTimeToTimestamp, + timeLatest, +} from '../../../helpers/misc-utils'; +import { StakingHelper } from '../../test-helpers/staking-helper'; +import { LiquidityStakingV1 } from '../../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../../types/MintableErc20'; +import { expect } from 'chai'; +import { StarkProxyV1 } from '../../../types/StarkProxyV1'; +import { ZERO_ADDRESS } from '../../../helpers/constants'; +import { MockStarkPerpetual } from '../../../types/MockStarkPerpetual'; +import { deployMockRewardsOracle } from '../../../helpers/contracts-deployments'; +import { sendAllTokensToTreasury } from '../../test-helpers/treasury-utils'; +import BalanceTree from '../../../src/merkle-tree-helpers/balance-tree'; +import { MerkleDistributorV1 } from '../../../types/MerkleDistributorV1'; +import { Treasury } from '../../../types/Treasury'; +import { DydxToken } from '../../../types/DydxToken'; +import { MockRewardsOracle } from '../../../types/MockRewardsOracle'; +import { BigNumber } from 'ethers'; + +// Snapshots +const snapshots = new Map(); +const fundsStakedSnapshot = 'FundsStaked'; +const borrowerHasBorrowed = 'BorrowerHasBorrowed'; + +const stakerInitialBalance: number = 1_000_000; + +makeSuite('SP1Withdrawals', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let mockStarkPerpetual: MockStarkPerpetual; + + // Users. + let staker: SignerWithAddress; + let exchangeOperator: SignerWithAddress; + let withdrawalOperator: SignerWithAddress; + let borrower: StarkProxyV1; + let asExchangeOperator: StarkProxyV1; + let asWithdrawalOperator: StarkProxyV1; + + let contract: StakingHelper; + + before(async () => { + ({ + liquidityStaking, + mockStakedToken, + mockStarkPerpetual, + rewardsTreasury, + deployer, + } = testEnv); + + // Users. + [staker, exchangeOperator, withdrawalOperator] = testEnv.users; + [borrower] = testEnv.starkProxyV1Borrowers; + + // Grant roles. + await borrower.grantRole(await borrower.BORROWER_ROLE(), deployer.address); + await borrower.grantRole(await borrower.EXCHANGE_OPERATOR_ROLE(), deployer.address); + await borrower.grantRole(await borrower.EXCHANGE_OPERATOR_ROLE(), exchangeOperator.address); + await borrower.grantRole(await borrower.WITHDRAWAL_OPERATOR_ROLE(), withdrawalOperator.address); + asExchangeOperator = borrower.connect(exchangeOperator.signer); + asWithdrawalOperator = borrower.connect(withdrawalOperator.signer); + + // Use helper class to automatically check contract invariants after every update. + contract = new StakingHelper( + liquidityStaking, + mockStakedToken, + rewardsTreasury, + deployer, + deployer, + [staker, borrower, exchangeOperator], + false, + ); + + // Mint staked tokens and set allowances. + await contract.mintAndApprove(staker, stakerInitialBalance); + + // Initial stake of 1M. + const distributionStart = (await liquidityStaking.DISTRIBUTION_START()).toNumber(); + await incrementTimeToTimestamp(distributionStart); + await contract.stake(staker, stakerInitialBalance); + saveSnapshot(fundsStakedSnapshot); + }); + + describe('Borrower, after borrowing funds', () => { + + before(async () => { + await loadSnapshot(fundsStakedSnapshot); + + // Allocations: [40%, 60%] + await contract.setBorrowerAllocations({ + [borrower.address]: 0.4, + [ZERO_ADDRESS]: 0.6, + }); + + // Borrow full amount. + await contract.elapseEpoch(); + await contract.fullBorrowViaProxy(borrower, stakerInitialBalance * 0.4); + expect(await mockStakedToken.balanceOf(borrower.address)).to.equal( + stakerInitialBalance * 0.4 + ); + + await saveSnapshot(borrowerHasBorrowed); + }); + + it('Can use non-borrowed funds on the exchagne, and withdrawal operator can withdraw those funds', async () => { + const borrowedAmount = stakerInitialBalance * 0.4; + const [starkKey, assetType, vaultId] = [123, 456, 789]; + await mockStarkPerpetual.registerUser(borrower.address, starkKey, []); + await borrower.allowStarkKey(starkKey); + + // Deposit borrowed funds to the exchange. + await asExchangeOperator.depositToExchange(starkKey, assetType, vaultId, borrowedAmount); + + // Deposit own, un-borrowed funds to the exchange. + const ownFundsAmount = 1_200_000; + await contract.mintAndApprove(borrower, ownFundsAmount); + await asExchangeOperator.depositToExchange(starkKey, assetType, vaultId, ownFundsAmount); + + // Expect contract to have no funds + expect(await mockStakedToken.balanceOf(borrower.address)).to.equal(0); + + // Withdraw from the exchange. + await asExchangeOperator.withdrawFromExchange(starkKey, assetType); + + // Non withdrawal operator cannot withdraw from the proxy. + await expect(asExchangeOperator.externalWithdrawToken(exchangeOperator.address, 1)) + .to.be.revertedWith('AccessControl'); + + // Cannot withdraw if recipient not on the allowlist. + await expect(asWithdrawalOperator.externalWithdrawToken(withdrawalOperator.address, ownFundsAmount)) + .to.be.revertedWith('SP1Storage: Recipient is not on the allowlist'); + + // Cannot withdraw if contract would end up without enough funds to cover the borrowed balance. + await borrower.allowExternalRecipient(withdrawalOperator.address); + await expect(asWithdrawalOperator.externalWithdrawToken(withdrawalOperator.address, ownFundsAmount + 1)) + .to.be.revertedWith('SP1Withdrawals: Amount exceeds withdrawable balance'); + + // Can withdraw if enough funds are left to cover the borrowed balance. + await asWithdrawalOperator.externalWithdrawToken(withdrawalOperator.address, ownFundsAmount); + + // Expected borrower to have all their funds back. + expect(await mockStakedToken.balanceOf(withdrawalOperator.address)).to.equal(ownFundsAmount); + + // Return borrowed funds. + await contract.repayBorrowViaProxy(borrower, borrowedAmount); + expect(await borrower.getBorrowedBalance()).to.equal(0); + }); + }); + + describe('claimRewardsFromMerkleDistributor', () => { + let rewardsTreasury: Treasury; + let dydxToken: DydxToken; + let merkleDistributor: MerkleDistributorV1; + let mockRewardsOracle: MockRewardsOracle; + let treasurySupply: BigNumber; + let simpleTree: BalanceTree; + let waitingPeriod: number; + + before(async () => { + ({ + dydxToken, + merkleDistributor, + rewardsTreasury, + } = testEnv); + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await merkleDistributor.connect(deployer.signer).setRewardsOracle(mockRewardsOracle.address); + + // Send all tokens to the rewards treasury. + await sendAllTokensToTreasury(testEnv); + treasurySupply = await dydxToken.balanceOf(rewardsTreasury.address) + + // Simple tree example that gives all tokens to a single address. + simpleTree = new BalanceTree({ + [borrower.address]: treasurySupply, + }); + + // Get the waiting period. + waitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + const mockIpfsCid = Buffer.from('0'.repeat(64), 'hex'); + await mockRewardsOracle.setMockValue(simpleTree.getHexRoot(), 0, mockIpfsCid); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + }); + + it('can claim rewards', async () => { + const proof = simpleTree.getProof(borrower.address, treasurySupply); + await expect(asWithdrawalOperator.claimRewardsFromMerkleDistributor( + treasurySupply, + proof, + )) + .to.emit(merkleDistributor, 'RewardsClaimed') + .withArgs(borrower.address, treasurySupply); + }); + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + contract.saveSnapshot(label); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + contract.loadSnapshot(label); + } +}); diff --git a/test/test-helpers/comparator-engine.ts b/test/test-helpers/comparator-engine.ts new file mode 100644 index 0000000..a62e79e --- /dev/null +++ b/test/test-helpers/comparator-engine.ts @@ -0,0 +1,87 @@ +import { BigNumber, Event } from 'ethers'; + +const { expect } = require('chai'); + +interface CustomFieldLogic { + fieldName: keyof State; + logic: ( + stateUpdate: Update, + stateBefore: State, + stateAfter: State, + txTimestamp: number + ) => Promise | string | BigNumber; +} + +export interface CompareRules { + fieldsEqualToInput?: (keyof State)[]; + fieldsEqualToAnother?: { fieldName: keyof State; equalTo: keyof Update }[]; + fieldsWithCustomLogic?: CustomFieldLogic[]; +} + +export async function comparatorEngine( + fieldsToTrack: (keyof State)[], + updateInput: Input, + stateBefore: State, + stateAfter: State, + actionBlockTimestamp: number, + { + fieldsEqualToInput = [], + fieldsEqualToAnother = [], + fieldsWithCustomLogic = [], + }: CompareRules +) { + const unchangedFields = fieldsToTrack.filter( + (fieldName) => + !fieldsEqualToInput.includes(fieldName) && + !fieldsEqualToAnother.find((eq) => eq.fieldName === fieldName) && + !fieldsWithCustomLogic.find((eq) => eq.fieldName === fieldName) + ); + + for (const fieldName of unchangedFields) { + // @ts-ignore + expect(stateAfter[fieldName].toString()).to.be.equal( + // @ts-ignore + stateBefore[fieldName].toString(), + `${fieldName} should not change` + ); + } + + for (const fieldName of fieldsEqualToInput) { + // @ts-ignore + expect(stateAfter[fieldName].toString()).to.be.equal( + // @ts-ignore + updateInput[fieldName].toString(), + `${fieldName} are not updated` + ); + } + + for (const { fieldName, equalTo } of fieldsEqualToAnother) { + // @ts-ignore + expect(stateAfter[fieldName].toString()).to.be.equal( + // @ts-ignore + updateInput[equalTo].toString(), + `${fieldName} are not updated` + ); + } + + for (const { fieldName, logic } of fieldsWithCustomLogic) { + const logicOutput = logic(updateInput, stateBefore, stateAfter, actionBlockTimestamp); + const equalTo = logicOutput instanceof Promise ? await logicOutput : logicOutput; + // @ts-ignore + expect(stateAfter[fieldName].toString()).to.be.equal( + equalTo.toString(), + `${fieldName} are not correctly updated` + ); + } +} + +export function eventChecker(event: Event, name: string, args: any[] = []): void { + expect(event.event).to.be.equal(name, `Incorrect event emitted`); + expect(event.args?.length || 0 / 2).to.be.equal(args.length, `${name} signature are wrong`); + args.forEach((arg, index) => { + expect(event.args && event.args[index].toString()).to.be.equal( + arg.toString(), + `${name} has incorrect value on position ${index}` + ); + }); +} diff --git a/test/test-helpers/gov-utils.ts b/test/test-helpers/gov-utils.ts new file mode 100644 index 0000000..f13625a --- /dev/null +++ b/test/test-helpers/gov-utils.ts @@ -0,0 +1,83 @@ +import { BigNumber, utils } from 'ethers'; +import { SignerWithAddress, TestEnv } from './make-suite'; +import { latestBlock, DRE, waitForTx } from '../../helpers/misc-utils'; +import {expect} from 'chai'; +import { Executor } from '../../types/Executor'; +import { deployExecutor } from '../../helpers/contracts-deployments'; +import { ONE_DAY } from '../../helpers/constants'; +import { eContractId } from '../../helpers/types'; + +export const emptyBalances = async (users: SignerWithAddress[], testEnv: TestEnv) => { + for (let i = 0; i < users.length; i++) { + const balanceBefore = await testEnv.dydxToken.connect(users[i].signer).balanceOf(users[i].address); + await ( + await testEnv.dydxToken.connect(users[i].signer).transfer(testEnv.deployer.address, balanceBefore) + ).wait(); + } +}; + +export const setBalance = async (user: SignerWithAddress, amount:BigNumber, testEnv: TestEnv) => { + // emptying + const balanceBefore = await testEnv.dydxToken.connect(user.signer).balanceOf(user.address); + await ( + await testEnv.dydxToken.connect(user.signer).transfer(testEnv.deployer.address, balanceBefore) + ).wait(); + // filling + await testEnv.dydxToken.connect(testEnv.deployer.signer).transfer(user.address, amount); +}; + +export const getInitContractData = async (testEnv: TestEnv, executor: Executor) => ({ + votingDelay: await testEnv.governor.getVotingDelay(), + votingDuration: await executor.VOTING_DURATION(), + executionDelay: await executor.getDelay(), + minimumPower: await executor.getMinimumVotingPowerNeeded( + await testEnv.strategy.getTotalVotingSupplyAt(await latestBlock()) + ), + minimumCreatePower: await executor.getMinimumPropositionPowerNeeded( + testEnv.governor.address, + await latestBlock() + ), + gracePeriod: await executor.GRACE_PERIOD(), +}); + +export const expectProposalState = async ( + proposalId: BigNumber, + state: number, + testEnv: TestEnv +) => { + expect(await testEnv.governor.connect(testEnv.deployer.signer).getProposalState(proposalId)).to.be.equal( + state + ); +}; + +export const getLastProposalId = async (testEnv: TestEnv) => { + const currentCount = await testEnv.governor.getProposalsCount(); + return currentCount.eq('0') ? currentCount : currentCount.sub('1'); +}; + +export const encodeSetDelay = async (newDelay: string, testEnv: TestEnv) => + testEnv.governor.interface.encodeFunctionData('setVotingDelay', [BigNumber.from(newDelay)]); + +export const deployTestExecutor = async (testEnv: TestEnv): Promise => { + const executor = await deployExecutor( + testEnv.governor.address, + '60', // 60 second delay + ONE_DAY.multipliedBy(14).toString(), // 14 day grace period + '1', // 1 second min delay + ONE_DAY.multipliedBy(30).toString(), // 30 day max delay + '100', // 1% proposition threshold + '6', // 6 block vote duration + '500', // 5% vote differential + '2000', // 20% min quorum + eContractId.Executor, + ); + + // authorize executor to pass proposals + await waitForTx(await testEnv.governor.authorizeExecutors([executor.address])); + + // grant executor OWNER_ROLE on governor + const ownerRole = utils.keccak256(utils.toUtf8Bytes('OWNER_ROLE')); + await waitForTx(await testEnv.governor.grantRole(ownerRole, executor.address)); + + return executor; +}; diff --git a/test/test-helpers/make-suite.ts b/test/test-helpers/make-suite.ts new file mode 100644 index 0000000..c26dd06 --- /dev/null +++ b/test/test-helpers/make-suite.ts @@ -0,0 +1,208 @@ +import { + evmRevert, + evmSnapshot, + DRE, +} from '../../helpers/misc-utils'; +import { Signer } from 'ethers'; +import rawBRE from 'hardhat'; +import chai from 'chai'; +// @ts-ignore +import { solidity } from 'ethereum-waffle'; + +import { getEthersSigners } from '../../helpers/contracts-helpers'; +import { DEPLOY_CONFIG } from '../../tasks/helpers/deploy-config'; +import { + getClaimsProxy, + getDydxGovernor, + getExecutor, + getGovernanceStrategy, + getLiquidityStakingV1, + getSafetyModuleV1, + getMockStarkPerpetual, + getChainlinkAdapter, +} from '../../helpers/contracts-getters'; +import { + getRewardsTreasuryVester, + getCommunityTreasuryVester, + getRewardsTreasury, + getDydxCommunityTreasury, + getStarkwarePriorityTimelock, + getStarkExHelperGovernor, + getStarkExRemoverGovernor, + getMintableErc20, + getMockChainlinkToken, + getBorrowerStarkProxy, +} from '../../helpers/contracts-deployments'; +import { tEthereumAddress, eContractId } from '../../helpers/types'; +import { DydxGovernor } from '../../types/DydxGovernor'; +import { DydxToken } from '../../types/DydxToken'; +import { Executor } from '../../types/Executor'; +import { GovernanceStrategy } from '../../types/GovernanceStrategy'; +import { TreasuryVester } from '../../types/TreasuryVester'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; +import { StarkProxyV1 } from '../../types/StarkProxyV1'; +import { ClaimsProxy } from '../../types/ClaimsProxy'; +import { MintableErc20 } from '../../types/MintableErc20'; +import { PriorityExecutor } from '../../types/PriorityExecutor'; +import { getMerkleDistributor, getDydxToken } from '../../helpers/contracts-getters'; +import { Treasury } from '../../types/Treasury'; +import { StarkExHelperGovernor } from '../../types/StarkExHelperGovernor'; +import { MockStarkPerpetual } from '../../types/MockStarkPerpetual'; +import { StarkExRemoverGovernor } from '../../types/StarkExRemoverGovernor'; +import { Md1ChainlinkAdapter } from '../../types/Md1ChainlinkAdapter'; +import { MockChainlinkToken } from '../../types/MockChainlinkToken'; + +chai.use(solidity); + +export interface SignerWithAddress { + signer: Signer; + address: tEthereumAddress; +} + +export interface TestEnv { + deployer: SignerWithAddress; + users: SignerWithAddress[]; + dydxToken: DydxToken; + governor: DydxGovernor; + strategy: GovernanceStrategy; + shortTimelock: Executor; + longTimelock: Executor; + merkleTimelock: Executor; + starkwarePriorityTimelock: PriorityExecutor; + rewardsTreasury: Treasury; + rewardsTreasuryVester: TreasuryVester; + communityTreasury: Treasury; + communityTreasuryVester: TreasuryVester; + safetyModule: SafetyModuleV1; + merkleDistributor: MerkleDistributorV1; + chainlinkAdapter: Md1ChainlinkAdapter; + liquidityStaking: LiquidityStakingV1; + starkProxyV1Borrowers: StarkProxyV1[]; + claimsProxy: ClaimsProxy; + starkExHelperGovernor: StarkExHelperGovernor; + starkExRemoverGovernor: StarkExRemoverGovernor; + mockStarkPerpetual: MockStarkPerpetual; + mockStakedToken: MintableErc20; + mockChainlinkToken: MockChainlinkToken; + mockGovernorToRemove: SignerWithAddress; +} + +let buidlerevmSnapshotId: string = '0x1'; +const setBuidlerevmSnapshotId = (id: string) => { + if (DRE.network.name === 'hardhat') { + buidlerevmSnapshotId = id; + } +}; + +const testEnv: TestEnv = { + deployer: {} as SignerWithAddress, + users: [] as SignerWithAddress[], + dydxToken: {} as DydxToken, + governor: {} as DydxGovernor, + strategy: {} as GovernanceStrategy, + shortTimelock: {} as Executor, + longTimelock: {} as Executor, + merkleTimelock: {} as Executor, + starkwarePriorityTimelock: {} as PriorityExecutor, + rewardsTreasury: {} as Treasury, + rewardsTreasuryVester: {} as TreasuryVester, + communityTreasury: {} as Treasury, + communityTreasuryVester: {} as TreasuryVester, + safetyModule: {} as SafetyModuleV1, + merkleDistributor: {} as MerkleDistributorV1, + chainlinkAdapter: {} as Md1ChainlinkAdapter, + liquidityStaking: {} as LiquidityStakingV1, + starkProxyV1Borrowers: [] as StarkProxyV1[], + claimsProxy: {} as ClaimsProxy, + starkExHelperGovernor: {} as StarkExHelperGovernor, + starkExRemoverGovernor: {} as StarkExRemoverGovernor, + mockStarkPerpetual: {} as MockStarkPerpetual, + mockStakedToken: {} as MintableErc20, + mockChainlinkToken: {} as MockChainlinkToken, + mockGovernorToRemove: {} as SignerWithAddress, +} as TestEnv; + +let INITIALIZED_PHASE_2: Boolean = false; + +export async function initializeMakeSuite() { + const [_deployer, ...restSigners] = await getEthersSigners(); + const deployer: SignerWithAddress = { + address: await _deployer.getAddress(), + signer: _deployer, + }; + + testEnv.users = await Promise.all( + restSigners.map(async (signer) => ({ + signer, + address: await signer.getAddress(), + })) + ); + + testEnv.deployer = deployer; + testEnv.dydxToken = await getDydxToken(); + testEnv.governor = await getDydxGovernor(); + testEnv.strategy = await getGovernanceStrategy(); + testEnv.shortTimelock = await getExecutor(eContractId.ShortTimelock); + testEnv.longTimelock = await getExecutor(eContractId.ShortTimelock); + testEnv.merkleTimelock = await getExecutor(eContractId.ShortTimelock); + testEnv.starkwarePriorityTimelock = await getStarkwarePriorityTimelock(); + testEnv.rewardsTreasury = await getRewardsTreasury(); + testEnv.rewardsTreasuryVester = await getRewardsTreasuryVester(); + testEnv.communityTreasury = await getDydxCommunityTreasury(); + testEnv.communityTreasuryVester = await getCommunityTreasuryVester(); + testEnv.merkleDistributor = await getMerkleDistributor(); + testEnv.chainlinkAdapter = await getChainlinkAdapter(); + testEnv.liquidityStaking = await getLiquidityStakingV1(); + testEnv.safetyModule = await getSafetyModuleV1(); + testEnv.claimsProxy = await getClaimsProxy(); + testEnv.starkExHelperGovernor = await getStarkExHelperGovernor(); + testEnv.starkExRemoverGovernor = await getStarkExRemoverGovernor(); + testEnv.mockStarkPerpetual = await getMockStarkPerpetual(); + testEnv.mockStakedToken = await getMintableErc20('USDC'); + testEnv.mockChainlinkToken = await getMockChainlinkToken(); + testEnv.mockGovernorToRemove = testEnv.users[0]; + + const borrowers: StarkProxyV1[] = []; + for (var i = 0; i < DEPLOY_CONFIG.STARK_PROXY.BORROWER_CONFIGS.length; i++) { + const borrowContract: StarkProxyV1 = await getBorrowerStarkProxy(i); + borrowers.push(borrowContract); + } + testEnv.starkProxyV1Borrowers = borrowers; +} + +export async function deployPhase2() { + if (!INITIALIZED_PHASE_2) { + console.log('-> Deploying phase 2 test environment...'); + await rawBRE.run('migrate:mainnet', { runPhase3: false }); + await initializeMakeSuite(); + INITIALIZED_PHASE_2 = true; + } else { + console.log("Phase 2 already deployed.") + } + console.log('\n***************'); + console.log('Snapshot finished'); + console.log('***************\n'); +} + +export async function noDeploy() { + console.log('-> Skipping deploy...'); + console.log('\n***************'); + console.log('Snapshot finished'); + console.log('***************\n'); +} + +export async function makeSuite(name: string, deployment: () => Promise, tests: (testEnv: TestEnv) => void) { + describe(name, async () => { + before(async () => { + await rawBRE.run('set-DRE'); + await deployment(); + setBuidlerevmSnapshotId(await evmSnapshot()); + }); + tests(testEnv); + after(async () => { + await evmRevert(buidlerevmSnapshotId); + }) + }); +} diff --git a/test/test-helpers/permit.ts b/test/test-helpers/permit.ts new file mode 100644 index 0000000..06e230b --- /dev/null +++ b/test/test-helpers/permit.ts @@ -0,0 +1,75 @@ +import { tEthereumAddress } from '../../helpers/types'; +import {fromRpcSig, ECDSASignature} from 'ethereumjs-util'; +import { signTypedData_v4 } from 'eth-sig-util'; + +export const buildPermitParams = ( + chainId: number, + governance: tEthereumAddress, + id: string, + support: boolean +) => ({ + types: { + EIP712Domain: [ + {name: 'name', type: 'string'}, + {name: 'chainId', type: 'uint256'}, + {name: 'verifyingContract', type: 'address'}, + ], + VoteEmitted: [ + {name: 'id', type: 'uint256'}, + {name: 'support', type: 'bool'}, + ], + }, + primaryType: 'VoteEmitted' as const, + domain: { + name: 'dYdX Governance', + version: '1', + chainId: chainId, + verifyingContract: governance, + }, + message: { + id, + support, + }, +}); + +// Test case for ecrevocer bug +export const buildFakePermitParams = ( + chainId: number, + governance: tEthereumAddress, + id: string, + support: boolean +) => ({ + types: { + EIP712Domain: [ + {name: 'name', type: 'string'}, + {name: 'chainId', type: 'uint256'}, + {name: 'verifyingContract', type: 'address'}, + {name: 'version', type: 'uint256'}, // Missing EIP712Domain parameter at gov + ], + VoteEmitted: [ + {name: 'id', type: 'uint256'}, + {name: 'support', type: 'bool'}, + ], + }, + primaryType: 'VoteEmitted' as const, + domain: { + name: 'dYdX Governance', + version: '1', + chainId: chainId, + verifyingContract: governance, + }, + message: { + id, + support, + }, +}); + +export const getSignatureFromTypedData = ( + privateKey: string, + typedData: any // TODO: should be TypedData, from eth-sig-utils, but TS doesn't accept it +): ECDSASignature => { + const signature = signTypedData_v4(Buffer.from(privateKey.substring(2, 66), 'hex'), { + data: typedData, + }); + return fromRpcSig(signature); +}; diff --git a/test/test-helpers/ray-math/bignumber.ts b/test/test-helpers/ray-math/bignumber.ts new file mode 100644 index 0000000..3bdad8a --- /dev/null +++ b/test/test-helpers/ray-math/bignumber.ts @@ -0,0 +1,16 @@ +import BigNumber from 'bignumber.js'; +import { BigNumber as BigNumberEthers, BigNumberish } from 'ethers'; + +export type BigNumberValue = string | number | BigNumber | BigNumberEthers | BigNumberish; + +export const BigNumberZD = BigNumber.clone({ + DECIMAL_PLACES: 0, + ROUNDING_MODE: BigNumber.ROUND_DOWN, +}); + +export function valueToBigNumber(amount: BigNumberValue): BigNumber { + return new BigNumber(amount.toString()); +} +export function valueToZDBigNumber(amount: BigNumberValue): BigNumber { + return new BigNumberZD(amount.toString()); +} diff --git a/test/test-helpers/ray-math/index.ts b/test/test-helpers/ray-math/index.ts new file mode 100644 index 0000000..91cff63 --- /dev/null +++ b/test/test-helpers/ray-math/index.ts @@ -0,0 +1,40 @@ +import BigNumber from 'bignumber.js'; +import { BigNumberValue, valueToZDBigNumber } from './bignumber'; + +export function getLinearCumulatedRewards( + emissionPerSecond: BigNumberValue, + lastUpdateTimestamp: BigNumberValue, + currentTimestamp: BigNumberValue +): BigNumber { + const timeDelta = valueToZDBigNumber(currentTimestamp).minus(lastUpdateTimestamp.toString()); + return timeDelta.multipliedBy(emissionPerSecond.toString()); +} + +export function getNormalizedDistribution( + balance: BigNumberValue, + oldIndex: BigNumberValue, + emissionPerSecond: BigNumberValue, + lastUpdateTimestamp: BigNumberValue, + currentTimestamp: BigNumberValue, + emissionEndTimestamp: BigNumberValue, + precision: number = 18 +): BigNumber { + if ( + balance.toString() === '0' || + valueToZDBigNumber(lastUpdateTimestamp).gte(emissionEndTimestamp.toString()) + ) { + return valueToZDBigNumber(oldIndex); + } + const linearReward = getLinearCumulatedRewards( + emissionPerSecond, + lastUpdateTimestamp, + valueToZDBigNumber(currentTimestamp).gte(emissionEndTimestamp.toString()) + ? emissionEndTimestamp + : currentTimestamp + ); + + return linearReward + .multipliedBy(valueToZDBigNumber(10).exponentiatedBy(precision)) + .div(balance.toString()) + .plus(oldIndex.toString()); +} diff --git a/test/test-helpers/staking-helper.ts b/test/test-helpers/staking-helper.ts new file mode 100644 index 0000000..7b281b9 --- /dev/null +++ b/test/test-helpers/staking-helper.ts @@ -0,0 +1,1671 @@ +import BNJS from 'bignumber.js'; +import { LogDescription } from '@ethersproject/abi'; +import { expect } from 'chai'; +import { BigNumber, BigNumberish, ContractTransaction, Signer } from 'ethers'; +import _ from 'lodash'; + +import { + EPOCH_LENGTH, + ZERO_ADDRESS, + OWNER_ROLE_KEY, + EPOCH_PARAMETERS_ROLE_KEY, + REWARDS_RATE_ROLE_KEY, + BORROWER_ADMIN_ROLE_KEY, + CLAIM_OPERATOR_ROLE_KEY, + STAKE_OPERATOR_ROLE_KEY, + DEBT_OPERATOR_ROLE_KEY, +} from '../../helpers/constants'; +import { increaseTimeAndMine, timeLatest } from '../../helpers/misc-utils'; +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; +import { SignerWithAddress } from './make-suite'; +import { StoredBalance } from './stored-balance'; +import { StarkProxyV1 } from '../../types/StarkProxyV1'; +import { Erc20 } from '../../types/Erc20'; + +type GenericStakingModule = LiquidityStakingV1 | SafetyModuleV1; + +// When iterating on tests or debugging, this can be disabled to run the tests faster. +const CHECK_INVARIANTS = false; + +const BORROWING_TOTAL_ALLOCATION = 10_000; +const SHORTFALL_INDEX_BASE = new BNJS(1e36); + +const SNAPSHOTS: Record = {}; + +export interface InvariantCheckOptions { + roundingTolerance?: BigNumberish; + skipStakerVsBorrowerDebtComparison?: boolean; + skipInvariantChecks?: boolean; +} + +export interface StakingState { + lastCurrentEpoch: BigNumber; + netDeposits: BigNumber; + netDebtDeposits: BigNumber; + netBorrowed: BigNumber; + netBorrowedByBorrower: Record; + debtBalanceByBorrower: Record; + debtBalanceByStaker: Record; + madeInitialAllocation: boolean; + activeBalanceByStaker: Record; + inactiveBalanceByStaker: Record; +} + +function defaultAddrMapping( + defaultMaker: (name: string) => T, + baseObj: Record = {} +): Record { + const handler = { + get: function (target: Record, name: string) { + // Only create default when accessing an Ethereum address key. + if (typeof name === 'string' && name.slice(0, 2) == '0x') { + if (!target.hasOwnProperty(name)) { + target[name] = defaultMaker(name); + } + } + return target[name]; + }, + }; + // Allow the proxied object to clone itself by creating a new proxy around a shallow clone of + // itself. + (baseObj as any).clone = () => { + return defaultAddrMapping( + defaultMaker, + // Use the custom cloner for the values, but not for the object iself, because that would + // cause the clone() function to call itself circularly. + _.mapValues(baseObj, (val) => _.cloneWith(val, customCloner)) + ); + }; + return new Proxy(baseObj, handler); +} + +function customCloner(value: any) { + if (typeof value.clone === 'function') { + return value.clone(); + } +} + +export class StakingHelper { + private contract: GenericStakingModule; + private token: Erc20; + private vault: SignerWithAddress; + private tokenSource: SignerWithAddress; + private admin: GenericStakingModule; + private users: Record; + private signers: Record; + private roles: Record; + private isSafetyModule: boolean; + + // Contract state. + state: StakingState; + + constructor( + contract: GenericStakingModule, + token: Erc20, + vault: SignerWithAddress, + tokenSource: SignerWithAddress, + admin: SignerWithAddress, + users: SignerWithAddress[], + isSafetyModule: boolean, + ) { + this.contract = contract; + this.token = token; + this.vault = vault; + this.tokenSource = tokenSource; + this.admin = contract.connect(admin.signer); + this.users = {}; + this.signers = {}; + this.roles = {}; + for (const signer of users) { + this.users[signer.address] = contract.connect(signer.signer); + this.signers[signer.address] = signer.signer; + } + this.isSafetyModule = isSafetyModule; + this.state = this.makeInitialState(); + } + + // ============ Snapshots ============ + + saveSnapshot(label: string): void { + SNAPSHOTS[label] = _.cloneDeepWith(this.state, customCloner); + } + + loadSnapshot(label: string): void { + this.state = _.cloneDeepWith(SNAPSHOTS[label], customCloner); + } + + // ============ Staked Token ============ + + async mintAndApprove(account: string | SignerWithAddress, amount: BigNumberish): Promise { + const address = asAddress(account); + const signer = this.signers[address]; + await this.token.connect(this.tokenSource.signer).transfer(address, amount); + await this.token.connect(signer).approve(this.contract.address, amount); + } + + async approveContract(account: string | SignerWithAddress, amount: BigNumberish): Promise { + const address = asAddress(account); + const signer = this.signers[address]; + await this.token.connect(signer).approve(this.contract.address, amount); + } + + // ============ LS1Admin ============ + + async setEpochParameters(interval: BigNumberish, offset: BigNumberish): Promise { + const shouldVerifyEpoch = await this.contract.hasEpochZeroStarted(); + + // Get current epoch. + let currentEpoch = -1; + if (shouldVerifyEpoch) { + currentEpoch = (await this.getCurrentEpoch()).toNumber(); + } + + await expect(this.admin.setEpochParameters(interval, offset)) + .to.emit(this.contract, 'EpochParametersChanged') + .withArgs([interval, offset]); + + // Verify current epoch unchanged. + if (shouldVerifyEpoch) { + const newEpoch = (await this.getCurrentEpoch()).toNumber(); + expectEq(currentEpoch, newEpoch, 'setEpochParameters: epoch number changed'); + } + + // Check getters. + const epochParameters = await this.contract.getEpochParameters(); + expectEq(epochParameters.interval, interval, 'setEpochParameters: interval'); + expectEq(epochParameters.offset, offset, 'setEpochParameters: offset'); + } + + async setBlackoutWindow(blackoutWindow: BigNumberish): Promise { + await expect(this.admin.setBlackoutWindow(blackoutWindow)) + .to.emit(this.contract, 'BlackoutWindowChanged') + .withArgs(blackoutWindow); + + // Check getters. + expectEq(await this.contract.getBlackoutWindow(), blackoutWindow, 'setBlackoutWindow'); + } + + async setRewardsPerSecond(emissionRate: BigNumberish): Promise { + await expect(this.admin.setRewardsPerSecond(emissionRate)) + .to.emit(this.contract, 'RewardsPerSecondUpdated') + .withArgs(emissionRate); + + // Check getters. + expectEq(await this.contract.getRewardsPerSecond(), emissionRate, 'setRewardsPerSecond'); + } + + async setBorrowerAllocations(allocations: Record): Promise { + const addresses = Object.keys(allocations); + const points = addresses.map((a) => { + const pointAllocation = allocations[a] * BORROWING_TOTAL_ALLOCATION; + if (pointAllocation !== Math.floor(pointAllocation)) { + throw new Error('Borrower allocation can have at most 4 decimals of precision'); + } + if (pointAllocation > BORROWING_TOTAL_ALLOCATION) { + throw new Error('setBorrowerAllocations should be called with allocations as fractions'); + } + return pointAllocation; + }); + + // Automatically set address(0) allocation to zero, if this is the first time setting allocations. + if (!this.state.madeInitialAllocation && !addresses.includes(ZERO_ADDRESS)) { + addresses.push(ZERO_ADDRESS); + points.push(0); + } + + await this.admin.setBorrowerAllocations(addresses, points); + + // Update state. + this.state.madeInitialAllocation = true; + + // Verify borrower next allocations. + for (let i = 0; i < addresses.length; i++) { + const allocationNext = await this.contract.getAllocationFractionNextEpoch(addresses[i]); + expectEq(allocationNext, points[i], `setBorrowerAllocations: allocationNext[${i}]`); + } + + // If before epoch zero, verify borrower current allocations. + if (!(await this.contract.hasEpochZeroStarted())) { + for (let i = 0; i < addresses.length; i++) { + const allocationCurrent = await this.contract.getAllocationFractionCurrentEpoch( + addresses[i] + ); + expectEq(allocationCurrent, points[i], `setBorrowerAllocations: allocationCurrent[${i}]`); + } + } + + // Verify total current and next allocations. + const curZero = await this.contract.getAllocationFractionCurrentEpoch(ZERO_ADDRESS); + const curSum = curZero.add( + await this.sumByAddr((addr) => this.contract.getAllocationFractionCurrentEpoch(addr)) + ); + expectEq(curSum, BORROWING_TOTAL_ALLOCATION, 'setBorrowerAllocations: curSum'); + const nextZero = await this.contract.getAllocationFractionNextEpoch(ZERO_ADDRESS); + const nextSum = nextZero.add( + await this.sumByAddr((addr) => this.contract.getAllocationFractionNextEpoch(addr)) + ); + expectEq(nextSum, BORROWING_TOTAL_ALLOCATION, 'setBorrowerAllocations: nextSum'); + } + + async setBorrowingRestriction( + borrower: string | SignerWithAddress, + isRestricted: boolean + ): Promise { + const borrowerAddress = asAddress(borrower); + const tx = await this.contract.setBorrowingRestriction(borrowerAddress, isRestricted); + + // Get previous status. + const wasRestricted = await this.contract.isBorrowingRestrictedForBorrower(borrowerAddress); + + // If status changed, expect event. + if (wasRestricted !== isRestricted) { + await expect(tx) + .to.emit(this.contract, 'BorrowingRestrictionChanged') + .withArgs(borrowerAddress, isRestricted); + } + + // Check new status. + expect(await this.contract.isBorrowingRestrictedForBorrower(borrowerAddress)).to.be.equal( + isRestricted, + 'setBorrowingRestriction' + ); + } + + async addOperator(operator: string | SignerWithAddress, roleKey: string): Promise { + const address = asAddress(operator); + const allRoles: Record = await this.getRoles(); + const role: string = allRoles[roleKey]; + await expect(this.contract.grantRole(role, address)) + .to.emit(this.contract, 'RoleGranted') + .withArgs(role, address, await this.contract.signer.getAddress()); + + expect(await this.contract.hasRole(role, address)).to.be.true; + } + + async removeOperator(operator: string | SignerWithAddress, roleKey: string): Promise { + const address = asAddress(operator); + const allRoles: Record = await this.getRoles(); + const role: string = allRoles[roleKey]; + await expect(this.contract.revokeRole(role, address)) + .to.emit(this.contract, 'RoleRevoked') + .withArgs(role, address, await this.contract.signer.getAddress()); + + expect(await this.contract.hasRole(role, address)).to.be.false; + } + + // ============ LS1Staking ============ + + async stake( + account: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const signer = this.users[address]; + + // Get new active balance. + // const newActiveBalance = this.state.activeBalanceByStaker[address].current.add(amount); + + // Query ERC20 balance before. + const stakerBalanceBefore = await this.token.balanceOf(address); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + let stakeAmount = BigNumber.from(amount); + + if (this.isSafetyModule) { + // Get amount after converting by exchange rate (if this is the safety module). + const exchangeRate = await this.contract.getExchangeRate(); + const exchangeRateBase = await this.contract.EXCHANGE_RATE_BASE(); + + stakeAmount = stakeAmount.mul(exchangeRate).div(exchangeRateBase); + + await expect(signer.stake(amount)) + .to.emit(this.contract, 'Staked') + .withArgs(address, address, amount, stakeAmount); + } else { + await expect(signer.stake(amount)) + .to.emit(this.contract, 'Staked') + .withArgs(address, address, amount); + } + + // Update state. + this.state.netDeposits = this.state.netDeposits.add(amount); + await this.state.activeBalanceByStaker[address].increaseCurrentAndNext(stakeAmount); + + // Expect token transfer. + const borrowerBalanceAfter = await this.token.balanceOf(address); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceAfter.sub(contractBalanceBefore), + amount, + 'stake: Increase contract underlying staked token balance' + ); + expectEq( + stakerBalanceBefore.sub(borrowerBalanceAfter), + amount, + 'stake: Decrease staker underlying staked token balance' + ); + + // Check getters. + if (!options.skipInvariantChecks) { + expectEq( + await this.contract.getActiveBalanceCurrentEpoch(address), + await this.state.activeBalanceByStaker[address].getCurrent(), + `stake: current active balance ${address}` + ); + } + + // Check invariants. + await this.checkInvariants(options); + } + + async requestWithdrawal( + account: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const signer = this.users[address]; + + // Get new balances. + // const newActiveBalance = this.state.activeBalanceByStaker[address].sub(amount); + // const newInactiveBalance = this.state.inactiveBalanceByStaker[address].add(amount); + + await expect(signer.requestWithdrawal(amount)) + .to.emit(this.contract, 'WithdrawalRequested') + .withArgs(address, amount); + + // Update state. + await this.state.activeBalanceByStaker[address].decreaseNext(amount); + await this.state.inactiveBalanceByStaker[address].increaseNext(amount); + + // Check getters. + if (!options.skipInvariantChecks) { + expectEq( + await this.contract.getActiveBalanceNextEpoch(address), + await this.state.activeBalanceByStaker[address].getNext(), + `requestWithdrawal: next active balance ${address}` + ); + expectEqualExceptRounding( + await this.contract.getInactiveBalanceNextEpoch(address), + await this.state.inactiveBalanceByStaker[address].getNext(), + options, + `requestWithdrawal: next inactive balance ${address}` + ); + } + + // Update state and check invariants. + await this.checkInvariants(options); + } + + async withdrawStake( + account: string | SignerWithAddress, + recipient: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + // Get new balances. + // const newInactiveBalance = this.state.inactiveBalanceByStaker[address].add(amount); + + // Query ERC20 balance before. + const recipientBalanceBefore = await this.token.balanceOf(recipientAddress); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + if (this.isSafetyModule) { + await expect(signer.withdrawStake(recipientAddress, amount)) + .to.emit(this.contract, 'WithdrewStake') + .withArgs(address, recipientAddress, amount, amount); + } else { + await expect(signer.withdrawStake(recipientAddress, amount)) + .to.emit(this.contract, 'WithdrewStake') + .withArgs(address, recipientAddress, amount); + } + + // Update state. + this.state.netDeposits = this.state.netDeposits.sub(amount); + await this.state.inactiveBalanceByStaker[address].decreaseCurrentAndNext(amount); + + // Expect token transfer. + const recipientBalanceAfter = await this.token.balanceOf(recipientAddress); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceBefore.sub(contractBalanceAfter), + amount, + 'withdrawStake: Decrease contract underlying staked token balance' + ); + expectEq( + recipientBalanceAfter.sub(recipientBalanceBefore), + amount, + 'withdrawStake: Increase recipient underlying staked token balance' + ); + + // Check getters. + if (!options.skipInvariantChecks) { + expectEqualExceptRounding( + await this.contract.getInactiveBalanceCurrentEpoch(address), + await this.state.inactiveBalanceByStaker[address].getCurrent(), + options, + `withdrawStake: current inactive balance ${address}` + ); + } + + // Check invariants. + await this.checkInvariants(options); + } + + async withdrawMaxStake( + account: string | SignerWithAddress, + recipient: string | SignerWithAddress, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + // Query ERC20 balance before. + const recipientBalanceBefore = await this.token.balanceOf(recipientAddress); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + // Get current stake user has available to withdraw. + const amount = await this.contract.getStakeAvailableToWithdraw(address); + let underlyingAmount = amount; + + if (this.isSafetyModule) { + // Get underlyingAmount after converting by exchange rate (if this is the safety module). + const exchangeRate = await this.contract.getExchangeRate(); + const exchangeRateBase = await this.contract.EXCHANGE_RATE_BASE(); + underlyingAmount = underlyingAmount.mul(exchangeRateBase).div(exchangeRate); + + await expect(signer.withdrawMaxStake(recipientAddress)) + .to.emit(this.contract, 'WithdrewStake') + .withArgs(address, recipientAddress, underlyingAmount, amount); + } else { + await expect(signer.withdrawMaxStake(recipientAddress)) + .to.emit(this.contract, 'WithdrewStake') + .withArgs(address, recipientAddress, underlyingAmount); + } + + // Update state. + this.state.netDeposits = this.state.netDeposits.sub(underlyingAmount); + await this.state.inactiveBalanceByStaker[address].decreaseCurrentAndNext(amount); + + // Expect token transfer. + const recipientBalanceAfter = await this.token.balanceOf(recipientAddress); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceBefore.sub(contractBalanceAfter), + underlyingAmount, + 'withdrawStake: Decrease contract underlying staked token balance' + ); + expectEq( + recipientBalanceAfter.sub(recipientBalanceBefore), + underlyingAmount, + 'withdrawStake: Increase recipient underlying staked token balance' + ); + + // Check getters. + expectEq( + await this.contract.getInactiveBalanceCurrentEpoch(address), + await this.state.inactiveBalanceByStaker[address].getCurrent(), + `withdrawStake: current inactive balance ${address}` + ); + + // Check invariants. + await this.checkInvariants(options); + + return amount; + } + + async withdrawDebt( + account: string | SignerWithAddress, + recipient: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + // Get new balance. + const newDebtBalance = this.state.debtBalanceByStaker[address].sub(amount); + + // Query ERC20 balance before. + const recipientBalanceBefore = await this.token.balanceOf(recipientAddress); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + await expect(signer.withdrawDebt(recipientAddress, amount)) + .to.emit(this.contract, 'WithdrewDebt') + .withArgs(address, recipientAddress, amount, newDebtBalance); + + // Update state. + this.state.netDebtDeposits = this.state.netDebtDeposits.sub(amount); + this.state.debtBalanceByStaker[address] = newDebtBalance; + + // Expect token transfer. + const recipientBalanceAfter = await this.token.balanceOf(recipientAddress); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceBefore.sub(contractBalanceAfter), + amount, + 'withdrawStake: Decrease contract underlying staked token balance' + ); + expectEq( + recipientBalanceAfter.sub(recipientBalanceBefore), + amount, + 'withdrawStake: Increase recipient underlying staked token balance' + ); + + // Check getters. + expectEq( + await this.contract.getStakerDebtBalance(address), + this.state.debtBalanceByStaker[address], + `withdrawStake: debt balance ${address}` + ); + + // Update state and check invariants. + await this.checkInvariants(options); + } + + async withdrawMaxDebt( + account: string | SignerWithAddress, + recipient: string | SignerWithAddress, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + // Get current debt staker has available to withdraw. + const amount = await this.contract.getDebtAvailableToWithdraw(address); + + // Get new balance. + const newDebtBalance = this.state.debtBalanceByStaker[address].sub(amount); + + // Query ERC20 balance before. + const recipientBalanceBefore = await this.token.balanceOf(recipientAddress); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + await expect(signer.withdrawMaxDebt(recipientAddress)) + .to.emit(this.contract, 'WithdrewDebt') + .withArgs(address, recipientAddress, amount, newDebtBalance); + + // Update state. + this.state.netDebtDeposits = this.state.netDebtDeposits.sub(amount); + this.state.debtBalanceByStaker[address] = newDebtBalance; + + // Expect token transfer. + const recipientBalanceAfter = await this.token.balanceOf(recipientAddress); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceBefore.sub(contractBalanceAfter), + amount, + 'withdrawStake: Decrease contract underlying staked token balance' + ); + expectEq( + recipientBalanceAfter.sub(recipientBalanceBefore), + amount, + 'withdrawStake: Increase recipient underlying staked token balance' + ); + + // Check getters. + expectEq( + await this.contract.getStakerDebtBalance(address), + this.state.debtBalanceByStaker[address], + `withdrawStake: debt balance ${address}` + ); + + // Update state and check invariants. + await this.checkInvariants(options); + } + + // ============ Rewards ============ + + async claimRewards( + staker: string | SignerWithAddress, + recipient: string | SignerWithAddress, + startTimestamp: BigNumberish, + endTimestamp: BigNumberish | null = null, + stakerShare: number = 1, + options: InvariantCheckOptions = {} + ): Promise { + const stakerAddress = asAddress(staker); + const recipientAddress = asAddress(recipient); + const signer = this.users[stakerAddress]; + + // Get initial ERC20 token balances. + const vaultBalanceBefore = await this.token.balanceOf(this.vault.address); + const recipientBalanceBefore = await this.token.balanceOf(recipientAddress); + + // Calculate the expected rewards. + const rewardsRate = await this.contract.getRewardsPerSecond(); + const end = BigNumber.from(endTimestamp || (await timeLatest()).toNumber()); + const expectedRewards = new BNJS(end.sub(startTimestamp).mul(rewardsRate).toString()).times(stakerShare).toFixed(0); + + // Preview rewards. + const claimable = await signer.callStatic.claimRewards(recipientAddress); + expectEqualExceptRounding( + claimable, + expectedRewards, + { + ...options, + roundingTolerance: rewardsRate.mul(2), + }, + 'claimRewards: callStatic claimable' + ); + + // Send transaction. + const parsedLogs = await this.parseLogs(signer.claimRewards(recipientAddress)); + + // Check logs. + const logs = _.filter(parsedLogs, { name: 'ClaimedRewards' }); + expect(logs).to.have.length(1); + const log = logs[0]; + expect(log.args[0]).to.be.equal(stakerAddress); + expect(log.args[1]).to.be.equal(recipientAddress); + expectEqualExceptRounding( + log.args[2], + expectedRewards, + { + ...options, + roundingTolerance: rewardsRate.mul(2), + }, + 'claimRewards: logged rewards' + ); + + // Check changes in ERC20 token balances. + const vaultBalanceAfter = await this.token.balanceOf(this.vault.address); + const recipientBalanceAfter = await this.token.balanceOf(recipientAddress); + // expectEq(vaultBalanceBefore.sub(vaultBalanceAfter), expectedRewards); + // expectEq(recipientBalanceAfter.sub(recipientBalanceBefore), expectedRewards); + + return claimable; + } + + // ============ LS1ERC20 ============ + + async transfer( + account: string | SignerWithAddress, + recipient: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + await expect(signer.transfer(recipientAddress, amount)) + .to.emit(this.contract, 'Transfer') + .withArgs(address, recipientAddress, amount); + + // check invariants. + await this.checkInvariants(options); + } + + async transferFrom( + account: string | SignerWithAddress, + sender: string | SignerWithAddress, + recipient: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const senderAddress = asAddress(sender); + const recipientAddress = asAddress(recipient); + const signer = this.users[address]; + + await expect(signer.transferFrom(senderAddress, recipientAddress, amount)) + .to.emit(this.contract, 'Transfer') + .withArgs(senderAddress, recipientAddress, amount); + + // check invariants. + await this.checkInvariants(options); + } + + async approve( + account: string | SignerWithAddress, + spender: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const spenderAddress = asAddress(spender); + const signer = this.users[address]; + + await expect(signer.approve(spenderAddress, amount)) + .to.emit(this.contract, 'Approval') + .withArgs(address, spenderAddress, amount); + + // check invariants. + await this.checkInvariants(options); + } + + // ============ LS1Borrowing ============ + + async borrowViaProxy( + borrower: StarkProxyV1, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(borrower); + const sendTx = async (newBorrowedBalance: BigNumberish) => { + await expect(borrower.borrow(amount)) + .to.emit(this.contract, 'Borrowed') + .withArgs(address, amount, newBorrowedBalance); + }; + return this._borrow(sendTx, address, amount, options); + } + + async borrow( + account: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const signer = this.users[address]; + const sendTx = async (newBorrowedBalance: BigNumberish) => { + await expect(signer.borrow(amount)) + .to.emit(this.contract, 'Borrowed') + .withArgs(address, amount, newBorrowedBalance); + }; + return this._borrow(sendTx, address, amount, options); + } + + async _borrow( + sendTx: (newBorrowedBalance: BigNumberish) => Promise, + address: string, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + // Get new borrowed balance. + const newBorrowedBalance = this.state.netBorrowedByBorrower[address].add(amount); + + // Query ERC20 balance before. + const borrowerBalanceBefore = await this.token.balanceOf(address); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + await sendTx(newBorrowedBalance); + + // Update state. + this.state.netBorrowed = this.state.netBorrowed.add(amount); + this.state.netBorrowedByBorrower[address] = newBorrowedBalance; + + // Expect token transfer. + const borrowerBalanceAfter = await this.token.balanceOf(address); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceBefore.sub(contractBalanceAfter), + amount, + 'borrow: Decrease contract underlying staked token balance' + ); + expectEq( + borrowerBalanceAfter.sub(borrowerBalanceBefore), + amount, + 'borrow: Increase borrower underlying staked token balance' + ); + + // Check getters. + expectEq( + await this.contract.getBorrowedBalance(address), + this.state.netBorrowedByBorrower[address], + `borrow: net borrowed balance ${address}` + ); + + // Check invariants. + await this.checkInvariants(options); + } + + async repayBorrowViaProxy( + borrower: StarkProxyV1, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(borrower); + const sendTx = async (newBorrowerBalance: BigNumberish) => { + await expect(borrower.repayBorrow(amount)) + .to.emit(this.contract, 'RepaidBorrow') + .withArgs(address, address, amount, newBorrowerBalance); + }; + return this._repayBorrow(sendTx, address, borrower, amount, options); + } + + async repayBorrow( + account: string | SignerWithAddress, + borrower: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const borrowerAddress = asAddress(borrower); + const signer = this.users[address]; + const sendTx = async (newBorrowerBalance: BigNumberish) => { + await expect(signer.repayBorrow(borrowerAddress, amount)) + .to.emit(this.contract, 'RepaidBorrow') + .withArgs(borrowerAddress, address, amount, newBorrowerBalance); + }; + return this._repayBorrow(sendTx, address, borrower, amount, options); + } + + async _repayBorrow( + sendTx: (newBorrowedBalance: BigNumberish) => Promise, + address: string, + borrower: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const borrowerAddress = asAddress(borrower); + const signer = this.users[address]; + + // Get new borrowed balance. + const newBorrowedBalance = this.state.netBorrowedByBorrower[borrowerAddress].sub(amount); + + // Query balance before. + const borrowerBalanceBefore = await this.token.balanceOf(address); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + await sendTx(newBorrowedBalance); + + // Update state. + this.state.netBorrowed = this.state.netDeposits.sub(amount); + this.state.netBorrowedByBorrower[borrowerAddress] = newBorrowedBalance; + + // Expect token transfer. + const borrowerBalanceAfter = await this.token.balanceOf(address); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceAfter.sub(contractBalanceBefore), + amount, + 'repayBorrow: Increase contract balance' + ); + expectEq( + borrowerBalanceBefore.sub(borrowerBalanceAfter), + amount, + 'repayBorrow: Decrease borrower balance' + ); + + // Check invariants. + await this.checkInvariants(options); + } + + async repayDebt( + account: string | SignerWithAddress, + borrower: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const address = asAddress(account); + const borrowerAddress = asAddress(borrower); + const signer = this.users[address]; + + // Get new debt balance. + const newDebtBalance = this.state.debtBalanceByBorrower[borrowerAddress].sub(amount); + + // Query balance before. + const borrowerBalanceBefore = await this.token.balanceOf(address); + const contractBalanceBefore = await this.token.balanceOf(this.contract.address); + + await expect(signer.repayDebt(borrowerAddress, amount)) + .to.emit(this.contract, 'RepaidDebt') + .withArgs(borrowerAddress, address, amount, newDebtBalance); + + // Update state. + this.state.netDebtDeposits = this.state.netDebtDeposits.add(amount); + this.state.debtBalanceByBorrower[borrowerAddress] = newDebtBalance; + + // Expect token transfer. + const borrowerBalanceAfter = await this.token.balanceOf(address); + const contractBalanceAfter = await this.token.balanceOf(this.contract.address); + expectEq( + contractBalanceAfter.sub(contractBalanceBefore), + amount, + 'repayDebt: Increase contract balance' + ); + expectEq( + borrowerBalanceBefore.sub(borrowerBalanceAfter), + amount, + 'repayDebt: Decrease borrower balance' + ); + + // Check getters. + expectEq( + await this.contract.getTotalDebtAvailableToWithdraw(), + this.state.netDebtDeposits, + 'repayDebt: netDebtDeposits' + ); + + // Check invariants. + await this.checkInvariants(options); + } + + // ============ StarkProxy ============ + + async autoPay( + borrower: StarkProxyV1, + options: InvariantCheckOptions = {} + ): Promise<[BigNumber, BigNumber, BigNumber]> { + const [borrowed, repay, debt] = ((await borrower.callStatic.autoPayOrBorrow()) as unknown) as [ + BigNumber, + BigNumber, + BigNumber + ]; + const tx = await borrower.autoPayOrBorrow(); + const borrowedBalance = await this.contract.getBorrowedBalance(borrower.address); + const debtBalance = await this.contract.getBorrowerDebtBalance(borrower.address); + if (!borrowed.eq(0)) { + await expect(tx).to.emit(borrower, 'Borrowed').withArgs(borrowed, borrowedBalance); + } + if (!repay.eq(0)) { + await expect(tx).to.emit(borrower, 'RepaidBorrow').withArgs(repay, borrowedBalance, false); + } + if (!debt.eq(0)) { + await expect(tx).to.emit(borrower, 'RepaidDebt').withArgs(debt, debtBalance, false); + } + + // Update state. + const address = asAddress(borrower); + const borrowDelta = borrowed.sub(repay); + this.state.netBorrowed = this.state.netBorrowed.add(borrowDelta); + this.state.netBorrowedByBorrower[address] = this.state.netBorrowedByBorrower[address].add( + borrowDelta + ); + this.state.netDebtDeposits = this.state.netDebtDeposits.add(debt); + this.state.debtBalanceByBorrower[address] = this.state.debtBalanceByBorrower[address].sub(debt); + + // Expect a second call to always return zeroes. + const [ + borrowedAfter, + repayAfter, + debtAfter, + ] = ((await borrower.callStatic.autoPayOrBorrow()) as unknown) as [ + BigNumber, + BigNumber, + BigNumber + ]; + expectEq(borrowedAfter, 0, 'autoPay: borrowedAfter'); + expectEq(repayAfter, 0, 'autoPay: repayAfter'); + expectEq(debtAfter, 0, 'autoPay: debtAfter'); + + // Check invariants. + await this.checkInvariants(options); + + return [borrowed, repay, debt]; + } + + // ============ LS1DebtAccounting ============ + + async markDebt( + borrowersWithExpectedDebts: Record, + newlyRestrictedBorrowers: (string | SignerWithAddress)[], + expectedIndex: number, + options: InvariantCheckOptions = {} + ): Promise { + const borrowers = Object.keys(borrowersWithExpectedDebts); + const tx = await this.contract.markDebt(borrowers); + const parsedLogs = await this.parseLogs(tx); + + // Check debt marked logs. + const debtMarked = _.filter(parsedLogs, { name: 'DebtMarked' }); + const newDebtByBorrower = _.chain(debtMarked) + .map('args') + .mapKeys(0) // borrower + .mapValues(1) // amount + .value(); + for (let i = 0; i < borrowers.length; i++) { + const borrower = borrowers[i]; + const expectedDebt = borrowersWithExpectedDebts[borrower]; + + if (BigNumber.from(expectedDebt).isZero()) { + expect(borrower in newDebtByBorrower, 'markDebt: Expected no debt').to.be.false; + } else { + const loggedDebt = newDebtByBorrower[borrower]; + expectEq(loggedDebt, expectedDebt, `markDebt: ${borrower} loggedDebt ≠ expectedDebt`); + } + } + + // Check slashed inactive balances log. + const slashedInactive = _.filter(parsedLogs, { name: 'ConvertedInactiveBalancesToDebt' }); + expect(slashedInactive).to.have.length(1, 'markDebt: Converted inactive balances log'); + const newDebtSum = _.sumBy(_.values(newDebtByBorrower), (x) => x.toNumber()); + const slashedLogArgs: [BigNumber, BigNumber, BigNumber] = slashedInactive[0].args as [ + BigNumber, + BigNumber, + BigNumber + ]; + const slashedAmount = slashedLogArgs[0]; + expectEq(slashedAmount, newDebtSum, 'markDebt: slashedAmount ≠ newDebtSum'); + const expectedIndexInt = SHORTFALL_INDEX_BASE.times(expectedIndex).toFixed(0, BNJS.ROUND_FLOOR); + expectEq(slashedLogArgs[1], expectedIndexInt, 'markDebt: expectedIndex'); + + // Check borrower restricted logs. + const restricted = _.filter(parsedLogs, { name: 'BorrowingRestrictionChanged' }); + expect(restricted).to.have.length( + newlyRestrictedBorrowers.length, + 'markDebt: Number of restricted borrowers' + ); + const newlyRestrictedBorrowersAddresses = newlyRestrictedBorrowers.map(asAddress); + for (const restrictedLog of restricted) { + const [restrictedBorrower, isRestricted] = restrictedLog.args; + expect(newlyRestrictedBorrowersAddresses).to.contain( + restrictedBorrower, + `markDebt: Unexpected restricted borrower ${restrictedBorrower}` + ); + expect(isRestricted, 'markDebt: isRestricted').to.be.true; + + // Check getters. + expect(await this.contract.isBorrowingRestrictedForBorrower(restrictedBorrower)).to.be.true; + } + + // Update state and check invariants. + this.state.netDeposits = this.state.netDeposits.sub(newDebtSum); + await Promise.all( + _.map(borrowersWithExpectedDebts, async (newDebt, borrower) => { + this.state.debtBalanceByBorrower[borrower] = this.state.debtBalanceByBorrower[borrower].add( + newDebt + ); + this.state.netBorrowedByBorrower[borrower] = this.state.netBorrowedByBorrower[borrower].sub( + newDebt + ); + }) + ); + await Promise.all( + _.map(this.state.inactiveBalanceByStaker, async (balance, address) => { + // Hacky... + if (typeof address !== 'string' || address.slice(0, 2) != '0x') { + return; + } + + const oldBalance = await balance.getCurrent(); + const newBalance = BigNumber.from(Math.floor(oldBalance.toNumber() * expectedIndex)); + const debtAmount = oldBalance.sub(newBalance); + await balance.decreaseCurrentAndNext(debtAmount); + + if (!debtAmount.isZero()) { + this.state.debtBalanceByStaker[address] = this.state.debtBalanceByStaker[address].add( + debtAmount + ); + } + }) + ); + await this.checkInvariants(options); + + // Verify that markDebt can't be called again. + await this.expectNoShortfall(); + } + + async expectNoShortfall(): Promise { + await expect(this.contract.markDebt([])).to.be.revertedWith('LS1DebtAccounting: No shortfall'); + } + + // ============ LS1Operators ============ + + async decreaseStakerDebt( + debtOperator: string | SignerWithAddress, + staker: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const debtOperatorAddress = asAddress(debtOperator); + const signer = this.users[debtOperatorAddress]; + const stakerAddress = asAddress(staker); + + // Get new balance. + const newDebtBalance = this.state.debtBalanceByStaker[stakerAddress].sub(amount); + + await expect(signer.decreaseStakerDebt(stakerAddress, amount)) + .to.emit(this.contract, 'OperatorDecreasedStakerDebt') + .withArgs(stakerAddress, amount, newDebtBalance, debtOperatorAddress); + + // Update state. + this.state.debtBalanceByStaker[stakerAddress] = newDebtBalance; + + // Check getters. + expectEq( + await this.contract.getStakerDebtBalance(stakerAddress), + this.state.debtBalanceByStaker[stakerAddress], + `repayDebt: debtBalanceByStaker ${stakerAddress}` + ); + + // Check invariants. + await this.checkInvariants(options); + } + + async decreaseBorrowerDebt( + debtOperator: string | SignerWithAddress, + borrower: string | SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + const debtOperatorAddress = asAddress(debtOperator); + const signer = this.users[debtOperatorAddress]; + const borrowerAddress = asAddress(borrower); + + // Get new balance. + const newDebtBalance = this.state.debtBalanceByBorrower[borrowerAddress].sub(amount); + + await expect(signer.decreaseBorrowerDebt(borrowerAddress, amount)) + .to.emit(this.contract, 'OperatorDecreasedBorrowerDebt') + .withArgs(borrowerAddress, amount, newDebtBalance, debtOperatorAddress); + + // Update state. + this.state.debtBalanceByBorrower[borrowerAddress] = newDebtBalance; + + // Check getters. + expectEq( + await this.contract.getBorrowerDebtBalance(borrowerAddress), + this.state.debtBalanceByBorrower[borrowerAddress], + `repayDebt: debtBalanceByBorrower ${borrowerAddress}` + ); + + // Check invariants. + await this.checkInvariants(options); + } + + // ============ State-Changing Helpers ============ + + /** + * Borrow an amount and expect it to be the max borrowable amount. + */ + async fullBorrowViaProxy( + borrower: StarkProxyV1, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + expect(await this.contract.getBorrowableAmount(borrower.address)).to.equal(amount); + await this.borrowViaProxy(borrower, amount, options); + // Could be either of the following: + // - LS1Staking: Borrow amount exceeds stake amount available in the contract + // - LS1Borrowing: Amount > allocated + await expect(this.borrow(borrower, 1)).to.be.revertedWith('LS1'); + } + + /** + * Borrow an amount and expect it to be the max borrowable amount. + */ + async fullBorrow( + borrower: SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + expect(await this.contract.getBorrowableAmount(borrower.address)).to.equal(amount); + + if (BigNumber.from(amount).isZero()) { + await expect(this.borrow(borrower, amount, options)).to.be.revertedWith('LS1Borrowing: Cannot borrow zero'); + } else { + await this.borrow(borrower, amount, options); + } + + // Could be either of the following: + // - LS1Staking: Borrow amount exceeds stake amount available in the contract + // - LS1Borrowing: Amount > allocated + await expect(this.borrow(borrower, 1)).to.be.revertedWith('LS1'); + } + + /** + * Repay a borrower debt and expect it to be the full amount. + */ + async fullRepayDebt( + borrower: SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + expect(await this.contract.getBorrowerDebtBalance(borrower.address)).to.equal(amount); + await this.repayDebt(borrower, borrower, amount, options); + await expect(this.repayDebt(borrower, borrower, 1)).to.be.revertedWith( + 'LS1Borrowing: Repay > debt' + ); + } + + /** + * Withdraw a staker and expect it to be the full withdrawable amount. + */ + async fullWithdrawStake( + staker: SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + expectEq( + await this.contract.getStakeAvailableToWithdraw(staker.address), + amount, + 'fullWithdrawStake: actual available vs. expected available' + ); + await this.withdrawStake(staker, staker, amount, options); + // Note: Withdrawing `1` will still sometimes succeed, depending on rounding. + await expect(this.withdrawStake(staker, staker, 2)).to.be.reverted; + } + + /** + * Withdraw a staker debt and expect it to be the full withdrawable amount. + */ + async fullWithdrawDebt( + staker: SignerWithAddress, + amount: BigNumberish, + options: InvariantCheckOptions = {} + ): Promise { + // Calculate available amount ourselves. + const contractAvailable = await this.contract.getTotalDebtAvailableToWithdraw(); + const stakerAvailable = await this.contract.getStakerDebtBalance(staker.address); + const available = contractAvailable.lt(stakerAvailable) ? contractAvailable : stakerAvailable; + + // Compare with smart contract calculation. + expectEq( + await this.contract.getDebtAvailableToWithdraw(staker.address), + available, + 'fullWithdrawalDebt: actual available vs. expected available' + ); + + // Compare with amount, and expect exactly that amount to be withdrawable. + expectEq(available, amount, 'fullWithdrawDebt: available vs. amount'); + await this.withdrawDebt(staker, staker, amount, options); + await expect(this.withdrawDebt(staker, staker, 1)).to.be.revertedWith('LS1Staking'); + } + + // ============ Other ============ + + async getCurrentEpoch(): Promise { + const currentEpoch = await this.contract.getCurrentEpoch(); + expectGte(currentEpoch, this.state.lastCurrentEpoch, 'Current epoch went backwards'); + this.state.lastCurrentEpoch = currentEpoch; + return currentEpoch; + } + + async checkInvariants(options: InvariantCheckOptions = {}): Promise { + if (options.skipInvariantChecks || !CHECK_INVARIANTS) { + return; + } + + // Staked balance accounting; let X = Deposits - Stake Withdrawals - Debt Conversions; then... + // + // X = Total Borrowed + Contract Balance – Debt Available to Withdraw – Naked ERC20 Transfers In + const borrowed = await this.contract.getTotalBorrowedBalance(); + const balance = await this.token.balanceOf(this.contract.address); + const availableDebt = await this.contract.getTotalDebtAvailableToWithdraw(); + expectEq( + borrowed.add(balance).sub(availableDebt), + this.state.netDeposits, + 'Invariant: netDeposits' + ); + // + // X = Next Total Active + Next Total Inactive + const totalActiveNext = await this.contract.getTotalActiveBalanceNextEpoch(); + const totalInactiveNext = await this.contract.getTotalInactiveBalanceNextEpoch(); + const activePlusInactiveNext = totalActiveNext.add(totalInactiveNext); + expectEq(activePlusInactiveNext, this.state.netDeposits, 'Invariant: activePlusInactiveNext'); + // + // X = Current Total Active + Current Total Inactive + const totalActiveCur = await this.contract.getTotalActiveBalanceCurrentEpoch(); + const totalInactiveCur = await this.contract.getTotalInactiveBalanceCurrentEpoch(); + const activePlusInactiveCur = totalActiveCur.add(totalInactiveCur); + expectEq(activePlusInactiveCur, this.state.netDeposits, 'Invariant: activePlusInactiveCur'); + + // Active balance accounting + // + // Total Current Active = ∑ User Current Active + const userActiveCur = await this.sumByAddr((addr) => + this.contract.getActiveBalanceCurrentEpoch(addr) + ); + const userActiveLocalCur = await this.sumByAddr((addr) => { + return this.state.activeBalanceByStaker[addr].getCurrent(); + }); + expectEq(userActiveCur, totalActiveCur, 'Invariant: userActiveCur'); + expectEq(userActiveLocalCur, totalActiveCur, 'Invariant: userActiveLocalCur'); + // + // Total Next Active = ∑ User Next Active + const userActiveNext = await this.sumByAddr((addr) => + this.contract.getActiveBalanceNextEpoch(addr) + ); + const userActiveLocalNext = await this.sumByAddr((addr) => { + return this.state.activeBalanceByStaker[addr].getNext(); + }); + expectEq(userActiveNext, totalActiveNext, 'Invariant: userActiveNext'); + expectEq(userActiveLocalNext, totalActiveNext, 'Invariant: userActiveLocalNext'); + + // Inactive balance accounting + // + // Total Current Inactive ≈ ∑ User Current Inactive + // Total Current Inactive ≥ ∑ User Current Inactive + const userInactiveCur = await this.sumByAddr((addr) => { + return this.contract.getInactiveBalanceCurrentEpoch(addr); + }); + const userInactiveLocalCur = await this.sumByAddr((addr) => { + return this.state.inactiveBalanceByStaker[addr].getCurrent(); + }); + expectGte(totalInactiveCur, userInactiveCur, 'Invariant: userInactiveCur'); + expectEqualExceptRounding( + totalInactiveCur, + userInactiveCur, + options, + 'Invariant: userInactiveCur' + ); + expectEq(userInactiveLocalCur, userInactiveCur, 'Invariant: userInactiveLocalCur'); + + // Debt accounting + // + // Total Borrower Debt = ∑ Borrower Debt + // Total Borrower Debt ≈ (∑ Staker Debt) - Debt Available to Withdraw + const totalBorrowerDebt = await this.contract.getTotalBorrowerDebtBalance(); + const borrowerDebt = await this.sumByAddr((addr) => this.contract.getBorrowerDebtBalance(addr)); + expectEq(totalBorrowerDebt, borrowerDebt, 'Invariant: borrowerDebt'); + // This invariant is violated if the debt operator was used to adjust balances unequally. + if (!options.skipStakerVsBorrowerDebtComparison) { + const stakerDebt = await this.sumByAddr((addr) => { + return this.contract.getStakerDebtBalance(addr); + }); + const totalBorrowerDebtAndAvailableToWithdraw = totalBorrowerDebt.add(availableDebt); + expectEqualExceptRounding( + totalBorrowerDebtAndAvailableToWithdraw, + stakerDebt, + options, + 'Invariant: stakerDebt' + ); + } + + // Debt available to withdraw. + const availableToWithdraw = await this.contract.getTotalDebtAvailableToWithdraw(); + expectEq(this.state.netDebtDeposits, availableToWithdraw, 'Invariant: availableToWithdraw'); + } + + async elapseEpochWithExpectedBalanceUpdates( + checkActiveBalanceUpdates: Record, + checkInactiveBalanceUpdates: Record, + options: InvariantCheckOptions = {} + ): Promise { + // Get current epoch. + const currentEpoch = (await this.getCurrentEpoch()).toNumber(); + + // Check current balances before. + await Promise.all( + _.map(checkActiveBalanceUpdates, async (values, address) => { + expectEq( + await this.contract.getActiveBalanceCurrentEpoch(address), + values[0], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Current active balance before ${address}` + ); + }) + ); + await Promise.all( + _.map(checkInactiveBalanceUpdates, async (values, address) => { + expectEq( + await this.contract.getInactiveBalanceCurrentEpoch(address), + values[0], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Current inactive balance before ${address}` + ); + }) + ); + const sumCurrentActiveBefore = _.chain(checkActiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[0]).toNumber()) // Before. + .value(); + const sumCurrentInactiveBefore = _.chain(checkInactiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[0]).toNumber()) // Before. + .value(); + expectEq( + await this.contract.getTotalActiveBalanceCurrentEpoch(), + sumCurrentActiveBefore, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total current active balance before` + ); + expectEqualExceptRounding( + await this.contract.getTotalInactiveBalanceCurrentEpoch(), + sumCurrentInactiveBefore, + options, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total current inactive balance before` + ); + + // Check next balances before. + await Promise.all( + _.map(checkActiveBalanceUpdates, async (values, address) => { + expectEq( + await this.contract.getActiveBalanceNextEpoch(address), + values[1], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Next active balance before ${address}` + ); + }) + ); + await Promise.all( + _.map(checkInactiveBalanceUpdates, async (values, address) => { + expectEq( + await this.contract.getInactiveBalanceNextEpoch(address), + values[1], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Next inactive balance before ${address}` + ); + }) + ); + const sumNextActiveBefore = _.chain(checkActiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[1]).toNumber()) // Before. + .value(); + const sumNextInactiveBefore = _.chain(checkInactiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[1]).toNumber()) // Before. + .value(); + expectEq( + await this.contract.getTotalActiveBalanceNextEpoch(), + sumNextActiveBefore, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total next active balance before` + ); + expectEqualExceptRounding( + await this.contract.getTotalInactiveBalanceNextEpoch(), + sumNextInactiveBefore, + options, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total next inactive balance before` + ); + + await this.elapseEpoch(); + expectEq(await this.getCurrentEpoch(), currentEpoch + 1, 'elaspeEpoch: next epoch number'); + + // Check current and next balances after. + await Promise.all( + _.map(checkActiveBalanceUpdates, async (values, address) => { + expectEq( + await this.contract.getActiveBalanceCurrentEpoch(address), + values[1], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Current active balance after ${address}` + ); + expectEq( + await this.contract.getActiveBalanceNextEpoch(address), + values[1], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Next active balance after ${address}` + ); + }) + ); + await Promise.all( + _.map(checkInactiveBalanceUpdates, async (values, address) => { + expectEq(await this.contract.getInactiveBalanceCurrentEpoch(address), values[1]), + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Current inactive balance after ${address}`; + expectEq( + await this.contract.getInactiveBalanceNextEpoch(address), + values[1], + `elapseEpoch(${currentEpoch} -> ${ + currentEpoch + 1 + }): Next inactive balance after ${address}` + ); + }) + ); + const sumActiveAfter = _.chain(checkActiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[1]).toNumber()) // After. + .value(); + const sumInactiveAfter = _.chain(checkInactiveBalanceUpdates) + .values() + .sumBy((x) => BigNumber.from(x[1]).toNumber()) // After. + .value(); + expectEq( + await this.contract.getTotalActiveBalanceCurrentEpoch(), + sumActiveAfter, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total current inactive balance after` + ); + expectEq( + await this.contract.getTotalActiveBalanceNextEpoch(), + sumActiveAfter, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total current inactive balance after` + ); + expectEqualExceptRounding( + await this.contract.getTotalInactiveBalanceCurrentEpoch(), + sumInactiveAfter, + options, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total next inactive balance after` + ); + expectEqualExceptRounding( + await this.contract.getTotalInactiveBalanceNextEpoch(), + sumInactiveAfter, + options, + `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Total next inactive balance after` + ); + } + + async expectStakerDebt(expectedDebtByStaker: Record): Promise { + await Promise.all( + _.map(expectedDebtByStaker, async (value, address) => { + expectEq( + await this.contract.getStakerDebtBalance(address), + value, + `expectStakerDebt: ${address}` + ); + }) + ); + } + + async expectBorrowerDebt(expectedDebtByBorrower: Record): Promise { + await Promise.all( + _.map(expectedDebtByBorrower, async (value, address) => { + expectEq( + await this.contract.getBorrowerDebtBalance(address), + value, + `expectBorrowerDebt: ${address}` + ); + }) + ); + } + + async elapseEpoch(): Promise { + const remaining = await this.contract.getTimeRemainingInCurrentEpoch(); + if (remaining.eq(0)) { + await increaseTimeAndMine(EPOCH_LENGTH.toNumber()); + } else { + await increaseTimeAndMine(remaining.toNumber()); + } + } + + reset(): void { + this.state = this.makeInitialState(); + } + + // singleton for fetching all roles in the contract. Fetches roles if + // this.roles is empty, then returns this.roles. + async getRoles(): Promise> { + if (_.isEmpty(this.roles)) { + const roles: Record = {}; + roles[OWNER_ROLE_KEY] = await this.contract.OWNER_ROLE(); + roles[EPOCH_PARAMETERS_ROLE_KEY] = await this.contract.EPOCH_PARAMETERS_ROLE(); + roles[REWARDS_RATE_ROLE_KEY] = await this.contract.REWARDS_RATE_ROLE(); + roles[BORROWER_ADMIN_ROLE_KEY] = await this.contract.BORROWER_ADMIN_ROLE(); + roles[CLAIM_OPERATOR_ROLE_KEY] = await this.contract.CLAIM_OPERATOR_ROLE(); + roles[STAKE_OPERATOR_ROLE_KEY] = await this.contract.STAKE_OPERATOR_ROLE(); + roles[DEBT_OPERATOR_ROLE_KEY] = await this.contract.DEBT_OPERATOR_ROLE(); + + this.roles = roles; + } + + return this.roles; + } + + private makeInitialState(): StakingState { + return { + lastCurrentEpoch: BigNumber.from(0), + netDeposits: BigNumber.from(0), + netDebtDeposits: BigNumber.from(0), + netBorrowed: BigNumber.from(0), + netBorrowedByBorrower: defaultAddrMapping(() => BigNumber.from(0)), + debtBalanceByBorrower: defaultAddrMapping(() => BigNumber.from(0)), + debtBalanceByStaker: defaultAddrMapping(() => BigNumber.from(0)), + madeInitialAllocation: false, + activeBalanceByStaker: defaultAddrMapping((addr) => { + const label = `${addr.slice(0, 6)} active `; + return new StoredBalance(this.contract, label); + }), + inactiveBalanceByStaker: defaultAddrMapping((addr) => { + const label = `${addr.slice(0, 6)} inactive`; + return new StoredBalance(this.contract, label); + }), + }; + } + + private async parseLogs( + tx: ContractTransaction | Promise + ): Promise { + const result = await (await tx).wait(); + return result.logs + .filter((log) => log.address === this.contract.address) + .map((log) => this.contract.interface.parseLog(log)); + } + + private sumByAddr(mapFn: (addr: string) => BigNumber | Promise): Promise { + return bnSumReduce(Object.keys(this.users), mapFn); + } +} + +function asAddress(account: string | SignerWithAddress): string { + if (typeof account == 'string') { + return account; + } + return account.address; +} + +async function bnSumReduce( + values: T[], + mapFn: (arg: T) => BigNumber | Promise +): Promise { + let sum = BigNumber.from(0); + for (const value of values) { + sum = sum.add(await mapFn(value)); + } + return sum; +} + +function expectEqualExceptRounding( + a: BigNumberish, + b: BigNumberish, + options: InvariantCheckOptions, + message?: string +): void { + const tolerance = options.roundingTolerance || 0; + const error = BigNumber.from(a).sub(b).abs(); + const msg = `${message || ''}: ${a} ≠ ${b} (tolerance: ${tolerance})`; + const pass = error.lte(tolerance); + if (!pass) { + // Note: Have had some trouble getting chai to always print the message. + console.error(msg); + } + expect(pass, msg).to.be.true; +} + +function expectEq(a: BigNumberish, b: BigNumberish, message?: string): void { + const msg = `${message || ''}: ${a} ≠ ${b}`; + const pass = BigNumber.from(a).eq(b); + if (!pass) { + // Note: Have had some trouble getting chai to always print the message. + console.error(msg); + } + expect(pass, msg).to.be.true; +} + +function expectGte(a: BigNumberish, b: BigNumberish, message?: string): void { + expect(BigNumber.from(a).gte(b), `${message || ''}: ${a} < ${b}`).to.be.true; +} diff --git a/test/test-helpers/stored-balance.ts b/test/test-helpers/stored-balance.ts new file mode 100644 index 0000000..6c4ee76 --- /dev/null +++ b/test/test-helpers/stored-balance.ts @@ -0,0 +1,104 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import _ from 'lodash'; + +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; + +type GenericStakingModule = LiquidityStakingV1 | SafetyModuleV1; + +// For debugging purposes. +const LOG_BALANCE_UPDATES = false; + +export class StoredBalance { + protected contract: GenericStakingModule; + protected label: string; + protected cachedEpoch: number; + protected current: BigNumber; + protected next: BigNumber; + + constructor(contract: GenericStakingModule, label: string) { + this.contract = contract; + this.label = label; + this.cachedEpoch = 0; + this.current = BigNumber.from(0); + this.next = BigNumber.from(0); + } + + async getCurrent(): Promise { + await this.load(); + return this.current; + } + + async getNext(): Promise { + await this.load(); + return this.next; + } + + async increaseCurrentAndNext(amount: BigNumberish): Promise { + await this.load(); + this.log( + `${this.label}: (${this.current}, ${this.next}) -> (${this.current.add( + amount + )}, ${this.next.add(amount)}})` + ); + this.current = this.current.add(amount); + this.next = this.next.add(amount); + } + + async decreaseCurrentAndNext(amount: BigNumberish): Promise { + await this.load(); + this.log( + `${this.label}: (${this.current}, ${this.next}) -> (${this.current.sub( + amount + )}, ${this.next.sub(amount)}})` + ); + this.current = this.current.sub(amount); + this.next = this.next.sub(amount); + } + + async increaseNext(amount: BigNumberish): Promise { + await this.load(); + this.log( + `${this.label}: (${this.current}, ${this.next}) -> (${this.current}, ${this.next.add( + amount + )}})` + ); + this.next = this.next.add(amount); + } + + async decreaseNext(amount: BigNumberish): Promise { + await this.load(); + this.log( + `${this.label}: (${this.current}, ${this.next}) -> (${this.current}, ${this.next.sub( + amount + )}})` + ); + this.next = this.next.sub(amount); + } + + forceRollover(): void { + this.current = this.next; + } + + clone(): StoredBalance { + const balance = new StoredBalance(this.contract, this.label); + balance.cachedEpoch = this.cachedEpoch; + balance.current = this.current; + balance.next = this.next; + return balance; + } + + private log(message: string): void { + if (LOG_BALANCE_UPDATES) { + console.log(message); + } + } + + private async load(): Promise { + const epoch = await this.contract.getCurrentEpoch(); + if (!epoch.eq(this.cachedEpoch)) { + this.current = this.next; + this.cachedEpoch = epoch.toNumber(); + } + } +} diff --git a/test/test-helpers/treasury-utils.ts b/test/test-helpers/treasury-utils.ts new file mode 100644 index 0000000..43d9c12 --- /dev/null +++ b/test/test-helpers/treasury-utils.ts @@ -0,0 +1,16 @@ +import { BigNumber } from 'ethers'; + +import { TestEnv } from './make-suite'; + +export async function sendAllTokensToTreasury( + testEnv: TestEnv, +): Promise { + const { + deployer, + dydxToken, + rewardsTreasury, + } = testEnv; + const deployerBalance = await dydxToken.connect(deployer.signer).balanceOf(deployer.address); + await dydxToken.connect(deployer.signer).transfer(rewardsTreasury.address, deployerBalance); + return deployerBalance; +}; diff --git a/test/test-helpers/tx-builder.ts b/test/test-helpers/tx-builder.ts new file mode 100644 index 0000000..3373913 --- /dev/null +++ b/test/test-helpers/tx-builder.ts @@ -0,0 +1,18 @@ +import { TransactionReceipt } from '@ethersproject/abstract-provider'; + +import { EthereumTransactionTypeExtended } from '../../src'; +import { SignerWithAddress } from './make-suite'; + +export async function sendTransactions( + txs: EthereumTransactionTypeExtended[], + signer: SignerWithAddress, +): Promise { + const receipts: TransactionReceipt[] = []; + for (const tx of txs) { + const txRequest = await tx.tx(); + const txResponse = await signer.signer.sendTransaction(txRequest); + const txReceipt = await txResponse.wait(); + receipts.push(txReceipt); + } + return receipts; +}; diff --git a/test/tx-builder/claims-proxy.spec.ts b/test/tx-builder/claims-proxy.spec.ts new file mode 100644 index 0000000..28f3493 --- /dev/null +++ b/test/tx-builder/claims-proxy.spec.ts @@ -0,0 +1,299 @@ +import BNJS from 'bignumber.js'; +import { expect } from 'chai'; +import { BigNumber, BigNumberish } from 'ethers'; +import axios from 'axios'; +import sinon from 'sinon'; + +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { + DRE, + evmRevert, + evmSnapshot, + increaseTimeAndMine, + timeLatest, + toWad, +} from '../../helpers/misc-utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { ClaimsProxy } from '../../types/ClaimsProxy'; +import { DydxToken } from '../../types/DydxToken'; +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { ipfsBytesHash, MAX_UINT_AMOUNT, EPOCH_LENGTH } from '../../helpers/constants'; +import { MintableErc20 } from '../../types/MintableErc20'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; +import { DYDX_TOKEN_DECIMALS, Network, TxBuilder } from '../../src'; +import { sendTransactions } from '../test-helpers/tx-builder'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; +import { deployMockRewardsOracle } from '../../helpers/contracts-deployments'; + +const snapshots = new Map(); +const afterSetup: string = 'afterSetup'; + +const SAFETY_STAKE: number = 400_000; +const LIQUIDITY_STAKE: number = 1_000_000; +const MERKLE_DISTRIBUTOR_BALANCE: number = 1_600_000; + +makeSuite('TxBuilder.claimsProxyService', deployPhase2, (testEnv: TestEnv) => { + let deployer: SignerWithAddress; + let rewardsTreasury: SignerWithAddress; + let claimsProxy: ClaimsProxy; + let dydxToken: DydxToken; + let mockRewardsOracle: MockRewardsOracle; + let addrs: string[]; + let users: SignerWithAddress[]; + + // TX builder.. + let txBuilder: TxBuilder; + + // Safety module. + let safetyModule: SafetyModuleV1; + + // Liquidity staking. + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let liquidityDistributionStart: string; + let safetyDistributionStart: string; + + // Merkle distributor. + let merkleDistributor: MerkleDistributorV1; + let merkleSimpleTree: BalanceTree; + let merkleWaitingPeriod: number; + let axiosStub: sinon.SinonStub; + + before(async () => { + ({ + deployer, + rewardsTreasury, + claimsProxy, + dydxToken, + safetyModule, + mockStakedToken, + merkleDistributor, + liquidityStaking, + users, + } = testEnv); + + addrs = users.map(u => u.address); + + // Create TxBuilder for sending transactions. + txBuilder = new TxBuilder({ + network: Network.hardhat, + hardhatLiquidityModuleAddresses: { + LIQUIDITY_MODULE_ADDRESS: liquidityStaking.address, + }, + hardhatSafetyModuleAddresses: { + SAFETY_MODULE_ADDRESS: safetyModule.address, + }, + hardhatMerkleDistributorAddresses: { + MERKLE_DISTRIBUTOR_ADDRESS: merkleDistributor.address, + }, + hardhatClaimsProxyAddresses: { + CLAIMS_PROXY_ADDRESS: claimsProxy.address, + }, + injectedProvider: DRE.ethers.provider, + }); + + // ============ Safety Module ============ + + expect(await safetyModule.STAKED_TOKEN()).to.be.equal(dydxToken.address); + expect(await safetyModule.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + safetyDistributionStart = (await safetyModule.DISTRIBUTION_START()).toString(); + + // Mint token to the user and set their allowance on the contract. + await dydxToken.connect(deployer.signer).transfer(addrs[1], SAFETY_STAKE); + await dydxToken.connect(users[1].signer).approve(safetyModule.address, SAFETY_STAKE); + + // Set allowance for safety module to pull from the rewards vault. + await dydxToken.connect(rewardsTreasury.signer).approve(safetyModule.address, MAX_UINT_AMOUNT) + + // Set rewards emission rate. + await safetyModule.setRewardsPerSecond(15); + + await safetyModule.grantRole(await safetyModule.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // ============ Liquidity Staking ============ + + expect(await liquidityStaking.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + liquidityDistributionStart = (await liquidityStaking.DISTRIBUTION_START()).toString(); + + // Mint token to the user and set their allowance on the contract. + await mockStakedToken.mint(addrs[1], LIQUIDITY_STAKE); + await mockStakedToken.connect(users[1].signer).approve(liquidityStaking.address, LIQUIDITY_STAKE); + + // Set rewards emission rate. + await liquidityStaking.setRewardsPerSecond(25); + + await liquidityStaking.grantRole(await liquidityStaking.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // ============ Merkle Distributor ============ + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await merkleDistributor.connect(deployer.signer).setRewardsOracle(mockRewardsOracle.address); + + expect(await merkleDistributor.REWARDS_TOKEN()).to.be.equal(dydxToken.address); + + // Simple tree example that gives all tokens to a single address. + merkleSimpleTree = new BalanceTree({ + [addrs[1]]: BigNumber.from(MERKLE_DISTRIBUTOR_BALANCE), + }); + + // Get the waiting period. + merkleWaitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + await merkleDistributor.grantRole(await merkleDistributor.CLAIM_OPERATOR_ROLE(), claimsProxy.address); + + // send tokens to rewards treasury vester + await dydxToken.transfer(testEnv.rewardsTreasuryVester.address, toWad(100_000_000)); + + // Advance to the start of safety module rewards (safety module rewards start after liquidity module rewards). + await incrementTimeToTimestamp(safetyDistributionStart); + + // mock out IPFS response + axiosStub = sinon.stub(axios, 'get'); + axiosStub.returns({ data: [[addrs[1], MERKLE_DISTRIBUTOR_BALANCE]] }); + + snapshots.set(afterSetup, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(); + }); + + after(() => { + axiosStub.restore(); + }); + + it('claims from safety module only', async () => { + // Setup: stake funds and wait for rewards to accumulate. + await safetyModule.connect(users[1].signer).stake(SAFETY_STAKE) + await increaseTimeAndMine(1000); + + // Get expected rewards. + const claimableHumanUnits = await txBuilder.claimsProxyService.getUserUnclaimedRewards(addrs[1]); + const claimable = new BNJS(claimableHumanUnits).shiftedBy(DYDX_TOKEN_DECIMALS).toNumber(); + expect(claimable).not.to.equal(0); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const txs = await txBuilder.claimsProxyService.claimRewards(addrs[1]); + await sendTransactions(txs, users[1]); + + // Check the change in balance. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + expect(diff).not.to.equal(0); + expect(diff.toNumber()).to.be.closeTo(claimable, 45); + }); + + it('claims from liquidity module only', async () => { + // Setup: stake funds for an epoch, then let the funds become inactive. + await liquidityStaking.connect(users[1].signer).stake(LIQUIDITY_STAKE); + await liquidityStaking.connect(users[1].signer).requestWithdrawal(LIQUIDITY_STAKE); + const remaining = await liquidityStaking.getTimeRemainingInCurrentEpoch(); + await increaseTimeAndMine(remaining.toNumber()); + + // Get expected rewards. + const claimableHumanUnits = await txBuilder.claimsProxyService.getUserUnclaimedRewards(addrs[1]); + const claimable = new BNJS(claimableHumanUnits).shiftedBy(DYDX_TOKEN_DECIMALS).toNumber(); + expect(claimable).not.to.equal(0); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const txs = await txBuilder.claimsProxyService.claimRewards(addrs[1]); + await sendTransactions(txs, users[1]); + + // Check the change in balance. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + expect(diff).to.equal(claimable); + }); + + describe('with Merkle distributor rewards', async () => { + + let userRewardsAddress: string; + + beforeEach(async () => { + // Set up Merkle tree. + await mockRewardsOracle.setMockValue(merkleSimpleTree.getHexRoot(), 0, ipfsBytesHash); + await merkleDistributor.proposeRoot(); + await incrementTimeToTimestamp((await timeLatest()).plus(merkleWaitingPeriod).toNumber()); + await merkleDistributor.updateRoot(); + userRewardsAddress = addrs[1]; + }); + + it('claims from Merkle distributor only', async () => { + // Get expected rewards. + const claimableHumanUnits = await txBuilder.claimsProxyService.getUserUnclaimedRewards(userRewardsAddress); + const claimable = new BNJS(claimableHumanUnits).shiftedBy(DYDX_TOKEN_DECIMALS).toNumber(); + expect(claimable).to.equal(MERKLE_DISTRIBUTOR_BALANCE); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const txs = await txBuilder.claimsProxyService.claimRewards(userRewardsAddress); + await sendTransactions(txs, users[1]); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + expect(diff).to.equal(MERKLE_DISTRIBUTOR_BALANCE); + }); + + it('claims from all contracts', async () => { + // Start at beginning of epoch + const remaining1 = await liquidityStaking.getTimeRemainingInCurrentEpoch(); + await increaseTimeAndMine(remaining1.toNumber()); + + // Liquidity module: stake funds for an epoch, then let the funds become inactive. + await liquidityStaking.connect(users[1].signer).stake(LIQUIDITY_STAKE); + await liquidityStaking.connect(users[1].signer).requestWithdrawal(LIQUIDITY_STAKE); + const remaining2 = await liquidityStaking.getTimeRemainingInCurrentEpoch(); + await increaseTimeAndMine(remaining2.toNumber()); + + // Stake in safety module and elapse time. + await safetyModule.connect(users[1].signer).stake(SAFETY_STAKE) + await increaseTimeAndMine(1000); + + const expectedRewards: number = MERKLE_DISTRIBUTOR_BALANCE + (15 * 1000) + EPOCH_LENGTH.mul(25).toNumber(); + + // Get expected rewards. + const claimableHumanUnits = await txBuilder.claimsProxyService.getUserUnclaimedRewards(userRewardsAddress); + const claimable = new BNJS(claimableHumanUnits).shiftedBy(DYDX_TOKEN_DECIMALS); + const claimableError = claimable.minus(expectedRewards).abs().toNumber() + expect(claimableError).to.be.lte(400); + + // Claim rewards. + const balanceBefore = await dydxToken.balanceOf(addrs[1]); + const txs = await txBuilder.claimsProxyService.claimRewards(userRewardsAddress); + await sendTransactions(txs, users[1]); + + // Check balance after. + const balanceAfter = await dydxToken.balanceOf(addrs[1]); + const diff = balanceAfter.sub(balanceBefore); + const error = diff.sub(expectedRewards).abs().toNumber(); + expect(error).to.be.lte(45); + }); + }); +}); + +async function revertTestChanges(): Promise { + await evmRevert(snapshots.get(afterSetup)!); + // retake snapshot since each snapshot can be used once + snapshots.set(afterSetup, await evmSnapshot()); +} + +async function incrementTimeToTimestamp(timestampString: BigNumberish): Promise { + const latestBlockTimestamp = (await timeLatest()).toNumber(); + const timestamp = BigNumber.from(timestampString); + // we can increase time in this method, assert that user isn't trying to move time backwards + expect(latestBlockTimestamp).to.be.at.most(timestamp.toNumber()); + const timestampDiff = timestamp.sub(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/tx-builder/dydx-governance.spec.ts b/test/tx-builder/dydx-governance.spec.ts new file mode 100644 index 0000000..9a4f02f --- /dev/null +++ b/test/tx-builder/dydx-governance.spec.ts @@ -0,0 +1,540 @@ +import BNJS from 'bignumber.js'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { ipfsBytes32Hash } from '../../helpers/constants'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { BigNumber } from 'ethers'; +import { formatEther } from 'ethers/lib/utils'; +import { + evmRevert, + evmSnapshot, + DRE, + waitForTx, + timeLatest, + increaseTimeAndMine, +} from '../../helpers/misc-utils'; +import { + emptyBalances, + getInitContractData, + setBalance, + encodeSetDelay, + deployTestExecutor, +} from '../test-helpers/gov-utils'; +import { + TxBuilder, + Network, + tDistinctGovernanceAddresses, + GovCreateType, + ExecutorType, + tTokenAddresses, + EthereumTransactionTypeExtended, + tEthereumAddress, + Proposal, + ProposalState, + ProposalMetadata, +} from '../../src'; +import { sendTransactions } from '../test-helpers/tx-builder'; +import { tSafetyModuleAddresses, tStringDecimalUnits } from '../../src/tx-builder/types/index'; +import { ipfsHashBytesToIpfsHashString } from '../../src/tx-builder/utils/ipfs'; +import DydxGovernanceService from '../../src/tx-builder/services/DydxGovernance'; +import { Executor } from '../../types/Executor'; +import { getSortedProposalVotesQuery, SubgraphProposalVote, SubgraphUser } from '../../src/tx-builder/utils/subgraph'; +import { toWad } from '../../helpers/misc-utils'; + +enum DelegateAction { + PROPOSITION, + VOTING, + PROPOSITION_AND_VOTING, +} + +const snapshots = new Map(); +const beforeStake = 'BeforeStake'; +const afterVoting = 'afterVoting'; +const afterPartialStake = 'AfterPartialStake'; +const afterFullStake = 'AfterFullStake'; + +makeSuite('dYdX client governance tests', deployPhase2, (testEnv: TestEnv) => { + let votingDelay: BigNumber; + let minimumPower: BigNumber; + let txBuilder: TxBuilder; + let distributor: SignerWithAddress; + let distributorBalance: tStringDecimalUnits; + let delegatee: SignerWithAddress; + let executor: Executor; + + before(async () => { + const {governor, strategy, dydxToken, safetyModule, users} = testEnv; + + distributor = testEnv.deployer; + delegatee = users[1]; + + executor = await deployTestExecutor(testEnv); + + ({ + minimumPower, + } = await getInitContractData(testEnv, executor)); + + votingDelay = BigNumber.from(100); + + // set governance delay to 100 blocks to speed up local tests + await waitForTx(await governor.setVotingDelay(votingDelay)); + + const hardhatGovernanceAddresses: tDistinctGovernanceAddresses = { + DYDX_GOVERNANCE: governor.address, + DYDX_GOVERNANCE_EXECUTOR_SHORT: executor.address, + DYDX_GOVERNANCE_EXECUTOR_LONG: executor.address, + DYDX_GOVERNANCE_EXECUTOR_MERKLE_PAUSER: executor.address, + DYDX_GOVERNANCE_PRIORITY_EXECUTOR_STARKWARE: executor.address, + DYDX_GOVERNANCE_STRATEGY: strategy.address, + } + + const hardhatTokenAddresses: tTokenAddresses = { + TOKEN_ADDRESS: dydxToken.address, + } + + const hardhatSafetyModuleAddresses: tSafetyModuleAddresses = { + SAFETY_MODULE_ADDRESS: safetyModule.address, + } + + const hardhatProvider = DRE.ethers.provider; + txBuilder = new TxBuilder({ + network: Network.hardhat, + hardhatGovernanceAddresses, + hardhatTokenAddresses, + hardhatSafetyModuleAddresses, + injectedProvider: hardhatProvider, + }); + + // Cleaning users balances + await emptyBalances(users, testEnv); + + distributorBalance = formatEther(await dydxToken.balanceOf(distributor.address)); + + // move time forward to epoch zero start so users can stake + const epochParameters: { offset: BigNumber } = await safetyModule.getEpochParameters(); + await incrementTimeToTimestamp(epochParameters.offset.toString()); + + await saveSnapshot(beforeStake); + }); + + describe('creating governance proposals', () => { + before(async () => { + await saveSnapshot(beforeStake); + }); + + afterEach(async () => { + // Revert to starting state + await loadSnapshot(beforeStake); + }); + + it('Can create proposal', async () => { + const user1 = testEnv.users[0]; + const governor = testEnv.governor; + + // Giving user 1 enough power to propose + await setBalance(user1, minimumPower, testEnv); + + // encode function arguments for `setVotingDelay` function + const callData = await encodeSetDelay('400', testEnv); + + // Creating first proposal: Changing delay to 400 via no sig + calldata + const createArgs: GovCreateType = { + user: user1.address, + targets: [governor.address], + values: ['0'], + signatures: [''], + calldatas: [callData], + withDelegateCalls: [false], + ipfsHash: ipfsBytes32Hash, + executor: ExecutorType.Short, + }; + const txs = await txBuilder.dydxGovernanceService.create(createArgs); + await sendTransactions(txs, user1); + + const proposals: Proposal[] = await txBuilder.dydxGovernanceService.getLatestProposals(10); + expect(proposals.length).to.equal(1); + const proposal: Proposal = proposals[0]; + expect(proposal.ipfsHash).to.equal(ipfsHashBytesToIpfsHashString(ipfsBytes32Hash, true)); + expect(proposal.id).to.equal(0); + expect(proposal.state).to.equal(ProposalState.Pending); + expect(proposal.totalVotingSupply).to.equal('1000000000.0'); + expect(proposal.minimumQuorum).to.equal('200000000.0'); + expect(proposal.minimumDiff).to.equal('50000000.0'); + expect(proposal.forVotes).to.equal('0.0'); + expect(proposal.againstVotes).to.equal('0.0'); + }) + + it('Can create multiple proposals and finds the most recent', async () => { + const user1 = testEnv.users[0]; + const governor = testEnv.governor; + + // Giving user 1 enough power to propose + await setBalance(user1, minimumPower, testEnv); + + // encode function arguments for `setVotingDelay` function + const callData = await encodeSetDelay('400', testEnv); + + // Creating first proposal: Changing delay to 400 via no sig + calldata + const createArgs: GovCreateType = { + user: user1.address, + targets: [governor.address], + values: ['0'], + signatures: [''], + calldatas: [callData], + withDelegateCalls: [false], + ipfsHash: ipfsBytes32Hash, + executor: ExecutorType.Short, + }; + const numProposals = 3; + let createdProposals = 0; + while (createdProposals < numProposals) { + const txs = await txBuilder.dydxGovernanceService.create(createArgs); + await sendTransactions(txs, user1); + createdProposals++; + } + + // get 2 latest proposals + const proposals: Proposal[] = await txBuilder.dydxGovernanceService.getLatestProposals(2); + expect(proposals.length).to.equal(2); + proposals.forEach((proposal: Proposal, i: number) => { + expect(proposal.ipfsHash).to.equal(ipfsHashBytesToIpfsHashString(ipfsBytes32Hash, true)); + expect(proposal.id).to.equal(numProposals - i - 1); + expect(proposal.state).to.equal(ProposalState.Pending); + }); + }) + }) + + describe('voting on governance proposals', () => { + const proposalId: number = 0; + const limit: number = 5; + let subgraphStub: sinon.SinonStub; + + before(async () => { + const deployer = testEnv.deployer; + const governor = testEnv.governor; + + // encode function arguments for `setVotingDelay` function + const callData = await encodeSetDelay('400', testEnv); + + // Creating first proposal: Changing delay to 400 via no sig + calldata + const createArgs: GovCreateType = { + user: deployer.address, + targets: [governor.address], + values: ['0'], + signatures: [''], + calldatas: [callData], + withDelegateCalls: [false], + ipfsHash: ipfsBytes32Hash, + executor: ExecutorType.Short, + }; + const txs = await txBuilder.dydxGovernanceService.create(createArgs); + await sendTransactions(txs, deployer); + + subgraphStub = sinon.stub(txBuilder.dydxGovernanceService.subgraphClient, 'query') + await saveSnapshot(afterVoting); + }); + + afterEach(async () => { + await loadSnapshot(afterVoting); + }); + + after(async () => { + // Revert to starting state + await loadSnapshot(beforeStake); + subgraphStub.restore(); + }); + + it('Can fetch proposal metadata for a proposal', async () => { + const topForVotesQuery = getSortedProposalVotesQuery(proposalId, true, limit); + const forUsers: [string, string][] = [ + [testEnv.deployer.address, toWad(77)], + [testEnv.users[0].address, toWad(7)], + ]; + mockSubgraphProposalVotesQuery(topForVotesQuery, forUsers); + + const topAgainstVotesQuery = getSortedProposalVotesQuery(proposalId, false, limit); + const againstUsers: [string, string][] = [ + [testEnv.users[1].address, toWad(66)], + [testEnv.users[2].address, toWad(6)], + ]; + mockSubgraphProposalVotesQuery(topAgainstVotesQuery, againstUsers); + + const proposalVotes: ProposalMetadata = await txBuilder.dydxGovernanceService.getProposalMetadata( + proposalId, + limit, + ); + verifyProposalVotes(forUsers, proposalVotes.topForVotes, true); + verifyProposalVotes(againstUsers, proposalVotes.topAgainstVotes, false); + }); + + it('Can fetch proposal metadata for a proposal if no votes exist', async () => { + const topForVotesQuery = getSortedProposalVotesQuery(proposalId, true, limit); + const forUsers: [string, string][] = []; + mockSubgraphProposalVotesQuery(topForVotesQuery, forUsers); + + const topAgainstVotesQuery = getSortedProposalVotesQuery(proposalId, false, limit); + const againstUsers: [string, string][] = []; + mockSubgraphProposalVotesQuery(topAgainstVotesQuery, againstUsers); + + const proposalVotes: ProposalMetadata = await txBuilder.dydxGovernanceService.getProposalMetadata( + proposalId, + limit, + ); + verifyProposalVotes(forUsers, proposalVotes.topForVotes, true); + verifyProposalVotes(againstUsers, proposalVotes.topAgainstVotes, false); + }); + + it('Throws error if limit is less than 1 or greater than 1000', async () => { + const badLimit: number = 0; + + try { + await txBuilder.dydxGovernanceService.getProposalMetadata( + proposalId, + badLimit, + ); + expect.fail('Expect bad limit to throw error'); + } catch(error) { + expect(error.message).to.equal("The limit parameter must be between 0 and 1000 (exclusive)."); + } + }); + + function mockSubgraphProposalVotesQuery(query: string, users: [string, string][]): void { + const proposalVotes: { user: SubgraphUser, votingPower: string }[] = []; + users.forEach((user) => { + proposalVotes.push({ + user: { id: user[0] }, + votingPower: user[1], + }); + }); + + subgraphStub.withArgs(query).returns({ + toPromise: async function() { + return Promise.resolve({ data: { + proposalVotes, + }}); + } + }); + } + + function verifyProposalVotes( + expectedUsers: [string, string][], + proposalVotes: SubgraphProposalVote[], + support: boolean, + ): void { + expect(expectedUsers.length).to.equal(proposalVotes.length); + + proposalVotes.forEach((pv: SubgraphProposalVote, i: number) => { + const address: string = expectedUsers[i][0]; + const votingPower: tStringDecimalUnits = formatEther(expectedUsers[i][1]); + expect(pv.userAddress).to.equal(address); + expect(pv.votingPower).to.equal(votingPower); + expect(pv.support).to.equal(support); + }); + } + }) + + describe('delegate before staking', () => { + afterEach(async () => { + // Revert to starting state + await loadSnapshot(beforeStake); + }); + + it('Initially finds 0 proposals', async () => { + const proposals: Proposal[] = await txBuilder.dydxGovernanceService.getLatestProposals(10); + + expect(proposals.length).to.equal(0); + }); + + it('Users can delegate DYDX proposing power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.STAKED_TOKEN!).to.be.undefined; + }) + + it('Users can delegate DYDX voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.VOTING); + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.STAKED_TOKEN!).to.be.undefined; + }) + + it('Users can delegate DYDX proposing and voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION_AND_VOTING); + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!).to.be.undefined; + }) + }); + + describe('delegate after partial staking', () => { + const stakeAmount: string = '777'; + + before(async () => { + // Stake. + const txs = await txBuilder.safetyModuleService.stake( + distributor.address, + stakeAmount, + ); + await sendTransactions(txs, distributor); + + await saveSnapshot(afterPartialStake); + }); + + afterEach(async () => { + await loadSnapshot(afterPartialStake); + }); + + it('Users can delegate DYDX proposing power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(distributor.address); + }) + + it('Users can delegate DYDX voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.VOTING); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + }) + + it('Users can delegate DYDX proposing and voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION_AND_VOTING); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + }) + }); + + describe('delegate after full staking', () => { + let stakeAmount: string; + + before(async () => { + // stake full distributor balance (`stake` input is not in wei, so divide by 10^18 first) + stakeAmount = formatEther(await testEnv.dydxToken.balanceOf(distributor.address)); + + // Stake. + const txs = await txBuilder.safetyModuleService.stake( + distributor.address, + stakeAmount, + ); + await sendTransactions(txs, distributor); + + await saveSnapshot(afterFullStake); + }); + + afterEach(async () => { + await loadSnapshot(afterFullStake); + }); + + it('Users can delegate DYDX proposing power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.TOKEN!).to.be.undefined; + }) + + it('Users can delegate DYDX voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.VOTING); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(distributor.address); + expect(userDelegatees.TOKEN!).to.be.undefined; + }) + + it('Users can delegate DYDX proposing and voting power', async () => { + await testDelegatePower(distributor, delegatee.address, txBuilder.dydxGovernanceService, DelegateAction.PROPOSITION_AND_VOTING); + + const userDelegatees = await txBuilder.dydxGovernanceService.getUserDelegatees(distributor.address); + expect(userDelegatees.STAKED_TOKEN!.VOTING_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.STAKED_TOKEN!.PROPOSITION_DELEGATEE).to.equal(delegatee.address); + expect(userDelegatees.TOKEN!).to.be.undefined; + }) + }); + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + } + + async function testDelegatePower( + user: SignerWithAddress, + delegatee: tEthereumAddress, + governance: DydxGovernanceService, + delegateAction: DelegateAction, + ): Promise { + let txs: EthereumTransactionTypeExtended[] = []; + let checkPropositionPower = false; + let checkVotingPower = false; + switch (delegateAction) { + case DelegateAction.PROPOSITION: { + txs = await governance.delegatePropositionPower(user.address, delegatee); + checkPropositionPower = true; + break + } + case DelegateAction.VOTING: { + checkVotingPower = true; + txs = await governance.delegateVotingPower(user.address, delegatee); + break + } + case DelegateAction.PROPOSITION_AND_VOTING: { + checkPropositionPower = true; + checkVotingPower = true; + txs = await governance.delegatePropositionAndVotingPower(user.address, delegatee); + break + } + } + + await sendTransactions(txs, user); + + if (checkPropositionPower) { + const proposingPower: string = await governance.getCurrentPropositionPower(delegatee); + expect(proposingPower).to.equal(distributorBalance); + } + + if (checkVotingPower) { + const votingPower: string = await governance.getCurrentVotingPower(delegatee); + expect(votingPower).to.equal(distributorBalance); + } + } +}); + +export async function incrementTimeToTimestamp(timestampString: string): Promise { + const latestBlockTimestamp = await timeLatest(); + const timestamp: BNJS = new BNJS(timestampString); + if (latestBlockTimestamp.toNumber() > timestamp.toNumber()) { + throw new Error('incrementTimeToTimestamp: Cannot move backwards in time'); + } + const timestampDiff: number = timestamp.minus(latestBlockTimestamp).toNumber(); + await increaseTimeAndMine(timestampDiff); +} diff --git a/test/tx-builder/dydx-token.spec.ts b/test/tx-builder/dydx-token.spec.ts new file mode 100644 index 0000000..e5f2cdb --- /dev/null +++ b/test/tx-builder/dydx-token.spec.ts @@ -0,0 +1,292 @@ +import BNJS from 'bignumber.js'; +import {expect} from 'chai'; +import { BigNumber } from 'ethers'; +import { formatEther, formatUnits } from 'ethers/lib/utils'; +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmRevert, + evmSnapshot, + DRE, + waitForTx, + timeLatest, + toWad, + incrementTimeToTimestamp, +} from '../../helpers/misc-utils'; +import { TxBuilder, Network } from '../../src'; +import { tStringDecimalUnits } from '../../src/tx-builder/types'; +import { parseNumberToEthersBigNumber } from '../../src/tx-builder/utils/parsings'; +import { DYDX_TOKEN_DECIMALS, LOCKED_ALLOCATION, MERKLE_DISTRIBUTOR_REWARDS_PER_EPOCH, ONE_DAY_SECONDS, RETROACTIVE_MINING_REWARDS } from '../../src/tx-builder/config/index'; +import { DEPLOY_CONFIG } from '../../tasks/helpers/deploy-config'; +import { deployMockRewardsOracle, deployMulticall2 } from '../../helpers/contracts-deployments'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { ipfsBytes32Hash, ONE_DAY } from '../../helpers/constants'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; +import { Multicall2 } from '../../types/Multicall2'; + +const snapshots = new Map(); + +const beforeTransferRestriction = 'beforeTransferRestriction'; +const afterTransferRestriction = 'afterTransferRestriction'; +const afterRootUpdate = 'afterRootUpdate'; + +makeSuite('dYdX client DYDX token tests', deployPhase2, (testEnv: TestEnv) => { + let txBuilder: TxBuilder; + let distributor: SignerWithAddress; + let transfersRestrictedBefore: BigNumber; + + // 0.1585489619 rewards per second * 60 seconds * 60 minutes * 24 hours + const liquidityModuleRewardsPerDay: BNJS = new BNJS('13698.63030816'); + const safetyModuleRewardsPerDay: BNJS = new BNJS('13698.63030816'); + + before(async () => { + distributor = testEnv.deployer; + + const multicall: Multicall2 = await deployMulticall2(); + + const hardhatProvider = DRE.ethers.provider; + txBuilder = new TxBuilder({ + hardhatTokenAddresses: { + TOKEN_ADDRESS: testEnv.dydxToken.address, + }, + hardhatTreasuryAddresses: { + REWARDS_TREASURY_ADDRESS: testEnv.rewardsTreasury.address, + REWARDS_TREASURY_VESTER_ADDRESS: testEnv.rewardsTreasuryVester.address, + COMMUNITY_TREASURY_ADDRESS: testEnv.communityTreasury.address, + COMMUNITY_TREASURY_VESTER_ADDRESS: testEnv.communityTreasuryVester.address, + }, + hardhatSafetyModuleAddresses: { + SAFETY_MODULE_ADDRESS: testEnv.safetyModule.address, + }, + hardhatLiquidityModuleAddresses: { + LIQUIDITY_MODULE_ADDRESS: testEnv.liquidityStaking.address, + }, + hardhatMerkleDistributorAddresses: { + MERKLE_DISTRIBUTOR_ADDRESS: testEnv.merkleDistributor.address, + }, + hardhatMulticallAddresses: { + MULTICALL_ADDRESS: multicall.address, + }, + network: Network.hardhat, + injectedProvider: hardhatProvider, + }); + + transfersRestrictedBefore = await testEnv.dydxToken._transfersRestrictedBefore(); + + snapshots.set(beforeTransferRestriction, await evmSnapshot()); + }); + + describe('before transfer restriction', () => { + + it('Can read circulating supply of token before transfer restriction', async () => { + // circulating supply is 0 before end of transfer restriction + const circulatingSupply: tStringDecimalUnits = await txBuilder.dydxTokenService.circulatingSupply(); + + expect(circulatingSupply).to.equal('0.0'); + }); + + it('Can read tokens distributed today before transfer restriction', async () => { + // distributed today is 0 before end of transfer restriction + const distributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + expect(distributedToday).to.equal('0.0'); + }); + }); + + describe('after transfer restriction', () => { + + before(async () => { + await incrementTimeToTimestamp(transfersRestrictedBefore.toNumber()); + + snapshots.set(afterTransferRestriction, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(afterTransferRestriction); + }); + + after(async () => { + await revertTestChanges(beforeTransferRestriction); + }); + + it('Can read distributor balance', async () => { + const distributorBalance: tStringDecimalUnits = await txBuilder.dydxTokenService.balanceOf(distributor.address); + + // distributor owns all tokens minus rewards treasury tokens after phase 2 deployment + const expectedDistributorBalance: BigNumber = BigNumber.from( + toWad(1_000_000_000)).sub(DEPLOY_CONFIG.REWARDS_TREASURY.FRONTLOADED_FUNDS); + expect(distributorBalance).to.equal(formatEther(expectedDistributorBalance)); + }); + + it('Can read token decimals', async () => { + const tokenDecimals: number = await txBuilder.dydxTokenService.decimalsOf(); + + expect(tokenDecimals).to.equal(DYDX_TOKEN_DECIMALS); + }); + + it('Distributor balance updates after sending tokens', async () => { + const user1 = testEnv.users[0]; + + const preTransferDistributorBalanceWei: BigNumber = await testEnv.dydxToken.balanceOf(distributor.address); + + const transferAmount: string = '10.0'; + const transferAmountWei: BigNumber = parseNumberToEthersBigNumber(transferAmount, DYDX_TOKEN_DECIMALS); + await waitForTx(await testEnv.dydxToken.transfer(user1.address, transferAmountWei)); + + const [ + distributorBalance, + user1Balance, + ]: [ + tStringDecimalUnits, + tStringDecimalUnits, + ] = await Promise.all([ + txBuilder.dydxTokenService.balanceOf(distributor.address), + txBuilder.dydxTokenService.balanceOf(user1.address), + ]); + + expect(distributorBalance).to.equal(formatUnits(preTransferDistributorBalanceWei.sub(transferAmountWei), DYDX_TOKEN_DECIMALS)); + expect(user1Balance).to.equal(transferAmount); + }); + + it('Can read total supply of token', async () => { + const totalSupply: tStringDecimalUnits = await txBuilder.dydxTokenService.totalSupply(); + + expect(totalSupply).to.equal('1000000000.0'); + }); + + it('Can read circulating supply of token after transfer restriction', async () => { + const circulatingSupply: tStringDecimalUnits = await txBuilder.dydxTokenService.circulatingSupply(); + + const distributorBalance: BigNumber = await testEnv.dydxToken.balanceOf(distributor.address); + // Tokens held by distributor are liquid (minus locked tokens) + const expectedCirculatingSupplyWei: BigNumber = distributorBalance.sub(toWad(new BNJS(LOCKED_ALLOCATION).toString())); + expect(circulatingSupply).to.equal(formatEther(expectedCirculatingSupplyWei)); + }); + + it('Can read tokens distributed today after transfer restriction', async () => { + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + // within one day of transfer restriction end, we take into account an additional 36 days of liquidity + // module rewards becoming liquid + expect(tokensDistributedToday).to.equal(safetyModuleRewardsPerDay + .plus(liquidityModuleRewardsPerDay.multipliedBy(37)) + .toString() + ); + }); + + it('Can read tokens distributed today more than one day after transfer restriction', async () => { + await incrementTimeToTimestamp(transfersRestrictedBefore.add(ONE_DAY.toNumber()).toNumber() + 1); + + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + expect(tokensDistributedToday).to.equal(safetyModuleRewardsPerDay.plus(liquidityModuleRewardsPerDay).toString()); + }); + }); + + describe('with merkle root update', () => { + + let simpleTree: BalanceTree; + let simpleTreeRoot: string; + let waitingPeriod: number; + let merkleDistributor: MerkleDistributorV1; + let mockRewardsOracle: MockRewardsOracle; + + before(async () => { + await revertTestChanges(beforeTransferRestriction); + + merkleDistributor = testEnv.merkleDistributor; + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await merkleDistributor.setRewardsOracle(mockRewardsOracle.address); + + // Simple tree example that gives 1 token to distributor address. + simpleTree = new BalanceTree({ + [distributor.address]: 1, + }); + simpleTreeRoot = simpleTree.getHexRoot(); + + // Get the waiting period. + waitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + ipfsBytes32Hash, + ); + await merkleDistributor.proposeRoot(); + + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + await merkleDistributor.updateRoot(); + + snapshots.set(afterRootUpdate, await evmSnapshot()); + }); + + afterEach(async () => { + await revertTestChanges(afterRootUpdate); + }); + + it('Can read distributed today with first root update event before transfer restriction', async () => { + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + expect(tokensDistributedToday).to.equal('0.0'); + }); + + it('Can read distributed today with first root update event after transfer restriction (includes retroactive rewards)', async () => { + await incrementTimeToTimestamp(transfersRestrictedBefore.toNumber()); + + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + const expectedTokensDistributedToday = safetyModuleRewardsPerDay + .plus(liquidityModuleRewardsPerDay.multipliedBy(37)) + .plus(MERKLE_DISTRIBUTOR_REWARDS_PER_EPOCH) + .plus(RETROACTIVE_MINING_REWARDS) + .toString(); + expect(tokensDistributedToday).to.equal(expectedTokensDistributedToday); + }); + + it('Can read distributed today with root update event 1 day in the past', async () => { + await incrementTimeToTimestamp(transfersRestrictedBefore.add(ONE_DAY.toNumber() + 1).toNumber()); + + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + expect(tokensDistributedToday).to.equal(safetyModuleRewardsPerDay.plus(liquidityModuleRewardsPerDay).toString()); + }); + + it('Can read distributed today with second root update event (does not include retroactive rewards)', async () => { + await incrementTimeToTimestamp(transfersRestrictedBefore.add(ONE_DAY.toNumber() + 1).toNumber()); + + const simpleTreeRoot2 = simpleTree.getHexRoot(); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot2, + 1, + ipfsBytes32Hash, + ); + await merkleDistributor.proposeRoot(); + + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + await merkleDistributor.updateRoot(); + const tokensDistributedToday: tStringDecimalUnits = await txBuilder.dydxTokenService.distributedToday(); + + const expectedTokensDistributedToday = safetyModuleRewardsPerDay + .plus(liquidityModuleRewardsPerDay) + .plus(MERKLE_DISTRIBUTOR_REWARDS_PER_EPOCH) + .toString(); + expect(tokensDistributedToday).to.equal(expectedTokensDistributedToday); + }); + }); +}); + +async function revertTestChanges(snapshotName: string): Promise { + await evmRevert(snapshots.get(snapshotName)!); + // retake snapshot since each snapshot can only be used once + snapshots.set(snapshotName, await evmSnapshot()); +} diff --git a/test/tx-builder/erc20.spec.ts b/test/tx-builder/erc20.spec.ts new file mode 100644 index 0000000..7aa8118 --- /dev/null +++ b/test/tx-builder/erc20.spec.ts @@ -0,0 +1,53 @@ +import { expect, use } from 'chai'; +import { + makeSuite, + TestEnv, + deployPhase2, +} from '../test-helpers/make-suite'; +import { BigNumber } from 'ethers'; +import { formatEther } from 'ethers/lib/utils'; +import { solidity } from 'ethereum-waffle'; +import { + evmRevert, + evmSnapshot, + DRE, + toWad, +} from '../../helpers/misc-utils'; +import { TxBuilder, Network } from '../../src'; +import { tStringDecimalUnits } from '../../src/tx-builder/types'; +import { DEPLOY_CONFIG } from '../../tasks/helpers/deploy-config'; + +const snapshots = new Map(); + +makeSuite('dYdX client ERC20 tests', deployPhase2, (testEnv: TestEnv) => { + let txBuilder: TxBuilder; + + before(async () => { + const hardhatProvider = DRE.ethers.provider; + txBuilder = new TxBuilder({ + network: Network.hardhat, + injectedProvider: hardhatProvider, + }); + + snapshots.set('start', await evmSnapshot()); + }); + + afterEach(async () => { + // Revert to starting state + await evmRevert(snapshots.get('start') || '1'); + // EVM Snapshots are consumed, need to snapshot again for next test + snapshots.set('start', await evmSnapshot()); + }); + + it('Can read distributor balance', async () => { + const distributor = testEnv.deployer; + + const distributorBalance: tStringDecimalUnits = await txBuilder.erc20Service.balanceOf(testEnv.dydxToken.address, distributor.address); + + // distributor owns all tokens minus rewards treasury tokens after phase 2 deployment + const expectedDistributorBalance: BigNumber = BigNumber.from( + toWad(1_000_000_000)).sub(DEPLOY_CONFIG.REWARDS_TREASURY.FRONTLOADED_FUNDS); + + expect(distributorBalance).to.equal(formatEther(expectedDistributorBalance)); + }) +}); diff --git a/test/tx-builder/liquidity-module.spec.ts b/test/tx-builder/liquidity-module.spec.ts new file mode 100644 index 0000000..d7342ca --- /dev/null +++ b/test/tx-builder/liquidity-module.spec.ts @@ -0,0 +1,346 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import { formatUnits } from 'ethers/lib/utils'; + +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, + incrementTimeToTimestamp, +} from '../../helpers/misc-utils'; +import { EPOCH_LENGTH } from '../../helpers/constants'; +import { DRE } from '../../helpers/misc-utils'; +import { Network, tStringDecimalUnits, TxBuilder } from '../../src'; +import { LiquidityStakingV1 } from '../../types/LiquidityStakingV1'; +import { MintableErc20 } from '../../types/MintableErc20'; +import { sendTransactions } from '../test-helpers/tx-builder'; +import { DEFAULT_APPROVE_AMOUNT, DYDX_TOKEN_DECIMALS, USDC_TOKEN_DECIMALS } from '../../src/tx-builder/config'; +import { parseNumberToString, parseNumberToEthersBigNumber } from '../../src/tx-builder/utils/parsings'; +import { DydxToken } from '../../types/DydxToken'; + +const snapshots = new Map(); +const afterMockTokenMint = 'AfterMockTokenMint'; +const afterStake = 'AfterStack'; +const stakerInitialBalanceWei = BigNumber.from(10).pow(24); + +makeSuite('TxBuilder.liquidityModuleService', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let liquidityStaking: LiquidityStakingV1; + let mockStakedToken: MintableErc20; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + let distributionStart: string; + + let txBuilder: TxBuilder; + + before(async () => { + ({ + liquidityStaking, + mockStakedToken, + dydxToken, + } = testEnv); + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Create TxBuilder for sending transactions. + txBuilder = new TxBuilder({ + network: Network.hardhat, + hardhatLiquidityModuleAddresses: { + LIQUIDITY_MODULE_ADDRESS: liquidityStaking.address, + }, + injectedProvider: DRE.ethers.provider, + }); + + // Initialization steps. + await mockStakedToken.mint(staker1.address, stakerInitialBalanceWei); + distributionStart = (await liquidityStaking.DISTRIBUTION_START()).toString(); + await incrementTimeToTimestamp(distributionStart); + await liquidityStaking.setRewardsPerSecond(1e10); + + await saveSnapshot(afterMockTokenMint); + }); + + describe('before staking', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Total liquidity module balance starts at 0', async () => { + const totalLiquidity: tStringDecimalUnits = await txBuilder.liquidityModuleService.getTotalStake(); + + expect(totalLiquidity).to.equal('0.0'); + }) + + it('Rewards rate starts at 1e10', async () => { + const rewardsPerSecond: tStringDecimalUnits = await txBuilder.liquidityModuleService.getRewardsPerSecond(); + + expect(rewardsPerSecond).to.equal(formatUnits(1e10, DYDX_TOKEN_DECIMALS)); + }) + + it('User stake starts at 0', async () => { + const userStake: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStake(staker1.address); + + expect(userStake.toString()).to.equal('0.0'); + }) + + it('User stake pending withdraw starts at 0', async () => { + const userStakePendingWithdraw: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStakePendingWithdraw(staker1.address); + + expect(userStakePendingWithdraw).to.equal('0.0'); + }) + + it('User stake available to withdraw starts at 0', async () => { + const userStakeToWithdraw: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStakeAvailableToWithdraw(staker1.address); + + expect(userStakeToWithdraw).to.equal('0.0'); + }) + + it('User has not approved liquidity module', async () => { + const approvalValue: tStringDecimalUnits = await txBuilder.liquidityModuleService.allowance(staker1.address); + + expect(approvalValue).to.equal('0.0'); + }) + + it('User rewards are initially 0', async () => { + const userRewards: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserUnclaimedRewards(staker1.address); + + expect(userRewards.toString()).to.equal('0.0'); + }) + + it('Time until next epoch is equal to epoch length', async () => { + const timeUntilNextEpoch: BigNumber = await txBuilder.liquidityModuleService.getTimeRemainingInCurrentEpoch(); + const epochParams = await liquidityStaking.getEpochParameters(); + + expect(timeUntilNextEpoch.toNumber()).to.be.approximately(epochParams.interval.toNumber(), 2); + }) + + it('Blackout window is equal to blackout window defined on contract', async () => { + const blackoutWindow: BigNumber = await txBuilder.liquidityModuleService.getLengthOfBlackoutWindow(); + + const contractBlackoutWindow: BigNumber = await liquidityStaking.getBlackoutWindow(); + + expect(blackoutWindow.toString()).to.equal(contractBlackoutWindow.toString()); + }) + + it('stake', async () => { + // Stake. + const stakeAmount = 1e6; + const txs = await txBuilder.liquidityModuleService.stake( + staker1.address, + stakeAmount.toString(), + ); + await sendTransactions(txs, staker1); + + const balance = await txBuilder.liquidityModuleService.getUserBalanceOfStakedToken(staker1.address); + + const stakeAmountWei: BigNumber = parseNumberToEthersBigNumber(stakeAmount.toString(), USDC_TOKEN_DECIMALS); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei.sub(stakeAmountWei), USDC_TOKEN_DECIMALS)); + }); + + it('Allows specifying a custom gas limit for staking', async () => { + const gasLimit: number = 217654; + const txs = await txBuilder.liquidityModuleService.stake( + staker1.address, + '1', + undefined, + gasLimit, + ) + + // Should contain both approve and stake TX + expect(txs.length).to.equal(2); + const populatedTx = await txs[1].tx(); + expect(populatedTx?.gasLimit).to.equal(gasLimit); + }) + }); + + describe('after staking', () => { + // `stakeAmount` should be even (so it has no remainder when divided by 2) + const stakeAmount = 1e6; + + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Stake. + const txs = await txBuilder.liquidityModuleService.stake( + staker1.address, + stakeAmount.toString(), + ); + await sendTransactions(txs, staker1); + + await saveSnapshot(afterStake); + }); + + beforeEach(async () => { + await loadSnapshot(afterStake); + }); + + it('Total liquidity module balance is non-zero', async () => { + const totalLiquidity: tStringDecimalUnits = await txBuilder.liquidityModuleService.getTotalStake(); + + expect(totalLiquidity).to.equal(formatUnits(stakeAmount, 0)); + }) + + it('User has approved liquidity module', async () => { + const approvalValue: tStringDecimalUnits = await txBuilder.liquidityModuleService.allowance(staker1.address); + const expectedApprovalValue: BigNumber = BigNumber.from(DEFAULT_APPROVE_AMOUNT).sub( + parseNumberToString(stakeAmount.toString(), USDC_TOKEN_DECIMALS)); + + expect(approvalValue).to.equal(formatUnits(expectedApprovalValue, USDC_TOKEN_DECIMALS)); + }) + + it('User stake is non-zero', async () => { + const userStake: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStake(staker1.address); + + expect(userStake).to.equal(formatUnits(stakeAmount, 0)); + }) + + it('User rewards are non-zero', async () => { + // move time 1 second forward so staker1 earns rewards + await increaseTimeAndMine(1); + + const userUnclaimedRewards: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserUnclaimedRewards(staker1.address); + + const convertedUserUnclaimedRewards: BigNumber = parseNumberToEthersBigNumber(userUnclaimedRewards, DYDX_TOKEN_DECIMALS); + + expect(convertedUserUnclaimedRewards.gt('0')).to.be.true; + }) + + it('user stake pending withdraw is non-zero after requesting to withdraw', async () => { + // Request withdrawal. + { + const txs = await txBuilder.liquidityModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + const userStakePendingWithdraw: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStakePendingWithdraw(staker1.address); + + expect(userStakePendingWithdraw).to.equal(formatUnits(stakeAmount, 0)); + }); + + it('user stake available to withdraw is non-zero after requesting to withdraw and waiting an epoch', async () => { + // Request withdrawal. + { + const txs = await txBuilder.liquidityModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + const userStakeToWithdraw: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserStakeAvailableToWithdraw(staker1.address); + + expect(userStakeToWithdraw).to.equal(formatUnits(stakeAmount, 0)); + }); + + it('withdraws', async () => { + // Request withdrawal. + { + const txs = await txBuilder.liquidityModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + // Withdraw. + { + const txs = await txBuilder.liquidityModuleService.withdrawStake( + staker1.address, + stakeAmount.toString(), // Withdraw all. + ); + await sendTransactions(txs, staker1); + } + + const balance: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserBalanceOfStakedToken(staker1.address); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei, USDC_TOKEN_DECIMALS)); + }); + + it('withdraws max', async () => { + // Request withdrawal. + { + const txs = await txBuilder.liquidityModuleService.requestWithdrawal( + staker1.address, + (stakeAmount / 2).toString(), // Request half. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + // Withdraw. + { + const txs = await txBuilder.liquidityModuleService.withdrawStake( + staker1.address, + '-1', // Withdraw max. + ); + await sendTransactions(txs, staker1); + } + + const balance: tStringDecimalUnits = await txBuilder.liquidityModuleService.getUserBalanceOfStakedToken(staker1.address); + // half of the funds are remaining in the contract + const remainingFundsWei: BigNumber = parseNumberToEthersBigNumber((stakeAmount / 2).toString(), USDC_TOKEN_DECIMALS); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei.sub(remainingFundsWei), USDC_TOKEN_DECIMALS)); + }); + + it('claims rewards', async () => { + const rewardsBefore = await dydxToken.balanceOf(staker1.address); + + // Claim rewards. + const txs = await txBuilder.liquidityModuleService.claimRewards(staker1.address); + await sendTransactions(txs, staker1); + + const rewardsAfter = await dydxToken.balanceOf(staker1.address); + expect(rewardsAfter.sub(rewardsBefore)).not.to.equal(0); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await liquidityStaking.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + } +}); diff --git a/test/tx-builder/merkle-distributor.spec.ts b/test/tx-builder/merkle-distributor.spec.ts new file mode 100644 index 0000000..78b60b1 --- /dev/null +++ b/test/tx-builder/merkle-distributor.spec.ts @@ -0,0 +1,492 @@ +import BNJS from 'bignumber.js'; +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import axios from 'axios'; +import sinon from 'sinon'; + +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmSnapshot, + evmRevert, + incrementTimeToTimestamp, + timeLatest, +} from '../../helpers/misc-utils'; +import { DRE } from '../../helpers/misc-utils'; +import { + MerkleProof, + tStringDecimalUnits, + Network, + TxBuilder, + } from '../../src'; +import { MerkleDistributorV1 } from '../../types/MerkleDistributorV1'; +import { MockRewardsOracle } from '../../types/MockRewardsOracle'; +import BalanceTree from '../../src/merkle-tree-helpers/balance-tree'; +import { deployMockRewardsOracle } from '../../helpers/contracts-deployments'; +import { ipfsBytesHash, ipfsBytesHash2 } from '../../helpers/constants'; +import { formatEther } from 'ethers/lib/utils'; +import { EthereumTransactionTypeExtended } from '../../src/tx-builder/types/index'; +import { sendTransactions } from '../test-helpers/tx-builder'; +import { DydxToken } from '../../types/DydxToken'; +import { UserRewardsData } from '../../src/tx-builder/types/GovernanceReturnTypes'; + +const snapshots = new Map(); +const beforeEpochZero: string = 'beforeEpochZero'; +const afterEpochZero: string = 'afterEpochZero'; +const afterProposingMerkleRoot: string = 'afterProposingMerkleRoot'; +const afterUpdatingMerkleRoot: string = 'afterUpdatingMerkleRoot'; + +makeSuite('TxBuilder.merkleDistributor', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let user1: SignerWithAddress; + let dydxToken: DydxToken; + let merkleDistributor: MerkleDistributorV1; + let mockRewardsOracle: MockRewardsOracle; + let simpleTree: BalanceTree; + let simpleTreeRoot: string; + let waitingPeriod: number; + let axiosStub: sinon.SinonStub; + let epochLength: BigNumber; + let epochZeroStart: BigNumber; + + let txBuilder: TxBuilder; + + before(async () => { + ({ + deployer, + dydxToken, + merkleDistributor, + } = testEnv); + + user1 = testEnv.users[0]; + + // Deploy and use mock rewards oracle. + mockRewardsOracle = await deployMockRewardsOracle(); + await merkleDistributor.setRewardsOracle(mockRewardsOracle.address); + + // Create TxBuilder for sending transactions. + txBuilder = new TxBuilder({ + network: Network.hardhat, + hardhatMerkleDistributorAddresses: { + MERKLE_DISTRIBUTOR_ADDRESS: merkleDistributor.address, + }, + injectedProvider: DRE.ethers.provider, + }); + + // Simple tree example that gives 1 token to deployer and 2 tokens to user1. + simpleTree = new BalanceTree({ + [deployer.address]: 1, + [user1.address]: 2, + }); + simpleTreeRoot = simpleTree.getHexRoot(); + + // Get the waiting period. + waitingPeriod = (await merkleDistributor.WAITING_PERIOD()).toNumber(); + + // Advance to the start of epoch zero. + const epochParameters: { interval: BigNumber, offset: BigNumber } = await merkleDistributor.getEpochParameters(); + + epochZeroStart = epochParameters.offset; + epochLength = epochParameters.interval; + + await incrementTimeToTimestamp(epochZeroStart); + + axiosStub = sinon.stub(axios, 'get'); + + snapshots.set(beforeEpochZero, await evmSnapshot()); + }); + + after(() => { + axiosStub.restore(); + }); + + describe('at epoch zero (before transfer restriction)', () => { + + before(async () => { + await advanceToEpoch(0); + + snapshots.set(afterEpochZero, await evmSnapshot()); + }); + + beforeEach(async () => { + txBuilder.merkleDistributorService.clearCachedRewardsData(); + + await loadSnapshot(afterEpochZero); + }); + + it('Can read rewards with no proposed root set', async () => { + const rewards = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards, + rewardsPerEpoch: [], + }); + }); + + it('Deployer has empty active root merkle proof', async () => { + const merkleProof: MerkleProof = await txBuilder.merkleDistributorService.getActiveRootMerkleProof(deployer.address); + + expect(merkleProof.merkleProof).to.be.empty; + expect(merkleProof.cumulativeAmount).to.equal(0); + }); + }); + + describe('after proposing root', () => { + + before(async () => { + await loadSnapshot(afterEpochZero); + + await advanceToEpoch(2); // after transfer restriction + + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + ipfsBytesHash, + ); + + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + snapshots.set(afterProposingMerkleRoot, await evmSnapshot()); + }); + + beforeEach(async () => { + txBuilder.merkleDistributorService.clearCachedRewardsData(); + + axiosStub.returns({ data: [[deployer.address, 1], [user1.address, 2]] }); + + await loadSnapshot(afterProposingMerkleRoot); + }); + + it('Can read proposed root rewards with proposed root (but no active root) set', async () => { + const rewards = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards, + rewardsPerEpoch: [], + hasPendingRoot: true, + pendingRootRewards: formatEther(1), + }); + + const rewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(user1.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards1, + rewardsPerEpoch: [], + hasPendingRoot: true, + pendingRootRewards: formatEther(2), + }); + }); + + it('Proposed root rewards refresh if new root is proposed', async () => { + const simpleTree2 = new BalanceTree({ + [deployer.address]: 7, + [user1.address]: 77, + }); + const simpleTreeRoot2 = simpleTree2.getHexRoot(); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot2, + 0, + ipfsBytesHash2, + ); + + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + axiosStub.returns({ data: [[deployer.address, 7], [user1.address, 77]] }); + + const rewards = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards, + rewardsPerEpoch: [], + hasPendingRoot: true, + pendingRootRewards: formatEther(7), + }); + + const rewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(user1.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards1, + rewardsPerEpoch: [], + hasPendingRoot: true, + pendingRootRewards: formatEther(77), + }); + }); + + it('Deployer has empty active root merkle proof', async () => { + const merkleProof: MerkleProof = await txBuilder.merkleDistributorService.getActiveRootMerkleProof(deployer.address); + + expect(merkleProof.merkleProof).to.be.empty; + expect(merkleProof.cumulativeAmount).to.equal(0); + }); + }); + + describe('after updating root', () => { + + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + before(async () => { + await loadSnapshot(afterEpochZero); + + await advanceToEpoch(2); // after transfer restriction + + user2 = testEnv.users[1]; + user3 = testEnv.users[2]; + + await mockRewardsOracle.setMockValue( + simpleTreeRoot, + 0, + ipfsBytesHash, + ); + + // Propose the root. + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Update the root. + await expect(merkleDistributor.updateRoot()).to.emit(merkleDistributor, 'RootUpdated'); + + snapshots.set(afterUpdatingMerkleRoot, await evmSnapshot()); + }); + + beforeEach(async () => { + txBuilder.merkleDistributorService.clearCachedRewardsData(); + + axiosStub.returns({ data: [[deployer.address, 1], [user1.address, 2]] }); + + await loadSnapshot(afterUpdatingMerkleRoot); + }); + + it('User not in merkle tree has no rewards', async () => { + const rewards4 = await txBuilder.merkleDistributorService.getUserRewardsData(user3.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards4, + rewardsPerEpoch: [formatEther(0)], + }); + }); + + it('Can read rewards per epoch with active root set', async () => { + const rewards1: UserRewardsData = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards1, + rewardsPerEpoch: [formatEther(1)], + }); + + const rewards2 = await txBuilder.merkleDistributorService.getUserRewardsData(user1.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards2, + rewardsPerEpoch: [formatEther(2)], + }); + }); + + + it('Can read rewards per epoch with new proposed root set', async () => { + // get proposed root rewards for current merkle root so it caches the current user balances + const oldRewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: oldRewards1, + rewardsPerEpoch: [formatEther(1)], + }); + + // give an additional token to user 1 and add another user to merkle tree + const simpleTree2 = new BalanceTree({ + [deployer.address]: 1, + [user1.address]: 3, + [user2.address]: 4, + }); + const simpleTreeRoot2 = simpleTree2.getHexRoot(); + + await advanceToEpoch(3); + + await mockRewardsOracle.setMockValue( + simpleTreeRoot2, + 1, + ipfsBytesHash2, + ); + + axiosStub.returns({ data: [[deployer.address, 1], [user1.address, 3], [user2.address, 4]] }); + + // Propose the root. + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + const rewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards1, + rewardsPerEpoch: [formatEther(1)], + hasPendingRoot: true, + }); + + const rewards2 = await txBuilder.merkleDistributorService.getUserRewardsData(user1.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards2, + rewardsPerEpoch: [formatEther(2)], + hasPendingRoot: true, + pendingRootRewards: formatEther(1), + }); + + // can read rewards with lowercased address (not checksummed) + const rewards3 = await txBuilder.merkleDistributorService.getUserRewardsData(user2.address.toLowerCase()); + await verifyUserRewardsMetadata({ + rewardsData: rewards3, + rewardsPerEpoch: [formatEther(0)], + hasPendingRoot: true, + pendingRootRewards: formatEther(4), + }); + }); + + it('Can read active root rewards with new active root set', async () => { + // get active root rewards for current merkle root so it caches the current user balances + const oldRewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: oldRewards1, + rewardsPerEpoch: [formatEther(1)], + }); + + // give an additional token to user 1 and add another user to merkle tree + const simpleTree2 = new BalanceTree({ + [deployer.address]: 1, + [user1.address]: 3, + [user2.address]: 4, + }); + const simpleTreeRoot2 = simpleTree2.getHexRoot(); + await mockRewardsOracle.setMockValue( + simpleTreeRoot2, + 1, + ipfsBytesHash2, + ); + + axiosStub.returns({ data: [[deployer.address, 1], [user1.address, 3], [user2.address, 4]] }); + + await advanceToEpoch(3); + + // Propose the root. + await expect(merkleDistributor.proposeRoot()).to.emit(merkleDistributor, 'RootProposed'); + + await incrementTimeToTimestamp((await timeLatest()).plus(waitingPeriod).toNumber()); + + // Update the root. + await expect(merkleDistributor.updateRoot()).to.emit(merkleDistributor, 'RootUpdated'); + + // verify new merkle tree overwrites old cached merkle tree + const rewards1 = await txBuilder.merkleDistributorService.getUserRewardsData(deployer.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards1, + rewardsPerEpoch: [formatEther(1), formatEther(1)], + }); + + const rewards2 = await txBuilder.merkleDistributorService.getUserRewardsData(user1.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards2, + rewardsPerEpoch: [formatEther(2), formatEther(3)], + }); + + const rewards3 = await txBuilder.merkleDistributorService.getUserRewardsData(user2.address); + await verifyUserRewardsMetadata({ + rewardsData: rewards3, + rewardsPerEpoch: [formatEther(0), formatEther(4)], + }); + }); + + it('Deployer can claim rewards', async () => { + const balanceBefore: BigNumber = await dydxToken.balanceOf(deployer.address); + + const txs: EthereumTransactionTypeExtended[] = await txBuilder.merkleDistributorService.claimRewards(deployer.address); + + await sendTransactions(txs, deployer); + + const balanceAfter: BigNumber = await dydxToken.balanceOf(deployer.address); + + // deployer should have claimed 1 wei of tokens as a reward + expect(balanceAfter.sub(balanceBefore)).to.equal(1); + }); + }); + + async function verifyUserRewardsMetadata({ + rewardsData, + rewardsPerEpoch, + claimedRewards = '0.0', + pendingRootRewards = '0.0', + }: { + rewardsData: UserRewardsData, + rewardsPerEpoch: tStringDecimalUnits[], + hasPendingRoot?: boolean, + claimedRewards?: tStringDecimalUnits, + pendingRootRewards?: tStringDecimalUnits, + }): Promise { + const [ + hasPendingRoot, + waitingPeriodEndBN, + contractCurrentEpoch, + epochParams, + waitingPeriodLength, + currentBlocktime, + ]: [ + boolean, + BigNumber, + BigNumber, + { interval: BigNumber, offset: BigNumber }, + BigNumber, + BNJS, + ] = await Promise.all([ + merkleDistributor.hasPendingRoot(), + merkleDistributor.getWaitingPeriodEnd(), + merkleDistributor.getCurrentEpoch(), + merkleDistributor.getEpochParameters(), + merkleDistributor.WAITING_PERIOD(), + timeLatest(), + ]); + + // verify rewards data + expect(rewardsData.newPendingRootRewards).to.equal(hasPendingRoot ? pendingRootRewards : '0.0'); + expect(rewardsData.claimedRewards).to.equal(claimedRewards); + + const numRootUpdates: number = Object.keys(rewardsData.rewardsPerEpoch).length; + expect(numRootUpdates).to.equal(rewardsPerEpoch.length); + + for (let i = 0; i < numRootUpdates; i++) { + expect(rewardsData.rewardsPerEpoch[i]).to.equal(rewardsPerEpoch[i]); + } + + // verify pending root data + expect(rewardsData.pendingRootData.hasPendingRoot).to.equal(hasPendingRoot); + expect(rewardsData.pendingRootData.waitingPeriodEnd).to.equal( + hasPendingRoot + ? waitingPeriodEndBN.toNumber() + : 0 + ); + + // verify epoch data + const expectedCurrentEpoch: number = contractCurrentEpoch.toNumber(); + + const secondsSinceEpochZero: BNJS = currentBlocktime.minus(epochZeroStart.toNumber()); + const epochLength: number = epochParams.interval.toNumber(); + + const currentEpoch: number = secondsSinceEpochZero + .dividedToIntegerBy(epochLength) + .toNumber(); + + const startOfEpochTimestamp: number = epochZeroStart.add(epochParams.interval.mul(currentEpoch)).toNumber(); + const endOfEpochTimestamp: number = epochZeroStart.add(epochParams.interval.mul(currentEpoch + 1)).toNumber(); + + expect(rewardsData.epochData.currentEpoch).to.equal(expectedCurrentEpoch); + expect(rewardsData.epochData.startOfEpochTimestamp).to.equal(startOfEpochTimestamp); + expect(rewardsData.epochData.endOfEpochTimestamp).to.equal(endOfEpochTimestamp); + expect(rewardsData.epochData.waitingPeriodLength).to.equal(waitingPeriodLength.toNumber()); + expect(rewardsData.epochData.epochLength).to.equal(epochLength); + } + + async function advanceToEpoch(epoch: number): Promise { + await incrementTimeToTimestamp(epochZeroStart.add(epochLength.mul(epoch))); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + } +}); diff --git a/test/tx-builder/safety-module.spec.ts b/test/tx-builder/safety-module.spec.ts new file mode 100644 index 0000000..ffcb80a --- /dev/null +++ b/test/tx-builder/safety-module.spec.ts @@ -0,0 +1,349 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import { formatUnits } from 'ethers/lib/utils'; + +import { + makeSuite, + TestEnv, + deployPhase2, + SignerWithAddress, +} from '../test-helpers/make-suite'; +import { + evmSnapshot, + evmRevert, + increaseTime, + increaseTimeAndMine, + incrementTimeToTimestamp, +} from '../../helpers/misc-utils'; +import { EPOCH_LENGTH } from '../../helpers/constants'; +import { DRE } from '../../helpers/misc-utils'; +import { Network, tStringDecimalUnits, TxBuilder } from '../../src'; +import { SafetyModuleV1 } from '../../types/SafetyModuleV1'; +import { sendTransactions } from '../test-helpers/tx-builder'; +import { DEFAULT_APPROVE_AMOUNT, DYDX_TOKEN_DECIMALS } from '../../src/tx-builder/config'; +import { parseNumberToString, parseNumberToEthersBigNumber } from '../../src/tx-builder/utils/parsings'; +import { DydxToken } from '../../types/DydxToken'; + +const snapshots = new Map(); +const afterMockTokenMint = 'AfterMockTokenMint'; +const afterStake = 'AfterStack'; +const stakerInitialBalanceWei = BigNumber.from(10).pow(24); + +makeSuite('TxBuilder.safetyModuleService', deployPhase2, (testEnv: TestEnv) => { + // Contracts. + let deployer: SignerWithAddress; + let safetyModule: SafetyModuleV1; + let dydxToken: DydxToken; + + // Users. + let staker1: SignerWithAddress; + let staker2: SignerWithAddress; + let fundsRecipient: SignerWithAddress; + + let distributionStart: string; + + let txBuilder: TxBuilder; + + before(async () => { + ({ + deployer, + safetyModule, + dydxToken, + } = testEnv); + + // Users. + [staker1, staker2, fundsRecipient] = testEnv.users.slice(1); + + // Create TxBuilder for sending transactions. + txBuilder = new TxBuilder({ + network: Network.hardhat, + hardhatSafetyModuleAddresses: { + SAFETY_MODULE_ADDRESS: safetyModule.address, + }, + injectedProvider: DRE.ethers.provider, + }); + + // Initialization steps. + await dydxToken.connect(deployer.signer).transfer(staker1.address, stakerInitialBalanceWei); + + // To simplify tests, since Safety Module distribution start does not line up with an epoch + // start, advance to the next epoch start after the distribution start. + distributionStart = (await safetyModule.DISTRIBUTION_START()).toString(); + await incrementTimeToTimestamp(distributionStart); + await elapseEpoch(); + await safetyModule.setRewardsPerSecond(1e10); + + await saveSnapshot(afterMockTokenMint); + }); + + describe('before staking', () => { + + beforeEach(async () => { + await loadSnapshot(afterMockTokenMint); + }); + + it('Total liquidity module balance starts at 0', async () => { + const totalLiquidity: tStringDecimalUnits = await txBuilder.safetyModuleService.getTotalStake(); + + expect(totalLiquidity).to.equal('0.0'); + }) + + it('Rewards rate starts at 1e10', async () => { + const rewardsPerSecond: tStringDecimalUnits = await txBuilder.safetyModuleService.getRewardsPerSecond(); + + expect(rewardsPerSecond).to.equal(formatUnits(1e10, DYDX_TOKEN_DECIMALS)); + }) + + it('User stake starts at 0', async () => { + const userStake: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStake(staker1.address); + + expect(userStake.toString()).to.equal('0.0'); + }) + + it('User stake pending withdraw starts at 0', async () => { + const userStakePendingWithdraw: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStakePendingWithdraw(staker1.address); + + expect(userStakePendingWithdraw).to.equal('0.0'); + }) + + it('User stake available to withdraw starts at 0', async () => { + const userStakeToWithdraw: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStakeAvailableToWithdraw(staker1.address); + + expect(userStakeToWithdraw).to.equal('0.0'); + }) + + it('User has not approved liquidity module', async () => { + const approvalValue: tStringDecimalUnits = await txBuilder.safetyModuleService.allowance(staker1.address); + + expect(approvalValue).to.equal('0.0'); + }) + + it('User rewards are initially 0', async () => { + const userRewards: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserUnclaimedRewards(staker1.address); + + expect(userRewards.toString()).to.equal('0.0'); + }) + + it('Time until next epoch is equal to epoch length', async () => { + const timeUntilNextEpoch: BigNumber = await txBuilder.safetyModuleService.getTimeRemainingInCurrentEpoch(); + const epochParams = await safetyModule.getEpochParameters(); + + expect(timeUntilNextEpoch.toNumber()).to.be.approximately(epochParams.interval.toNumber(), 1); + }) + + it('Blackout window is equal to blackout window defined on contract', async () => { + const blackoutWindow: BigNumber = await txBuilder.safetyModuleService.getLengthOfBlackoutWindow(); + + const contractBlackoutWindow: BigNumber = await safetyModule.getBlackoutWindow(); + + expect(blackoutWindow.toString()).to.equal(contractBlackoutWindow.toString()); + }) + + it('Stakes DYDX token in the safety module', async () => { + // Stake. + const stakeAmount = 1e6; + const txs = await txBuilder.safetyModuleService.stake( + staker1.address, + stakeAmount.toString(), + ); + await sendTransactions(txs, staker1); + + const balance = await txBuilder.safetyModuleService.getUserBalanceOfStakedToken(staker1.address); + + const stakeAmountWei: BigNumber = parseNumberToEthersBigNumber(stakeAmount.toString(), DYDX_TOKEN_DECIMALS); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei.sub(stakeAmountWei), DYDX_TOKEN_DECIMALS)); + }); + + it('Allows specifying a custom gas limit for staking', async () => { + const gasLimit: number = 217654; + const txs = await txBuilder.safetyModuleService.stake( + staker1.address, + '1', + undefined, + gasLimit, + ) + + // Should contain both approve and stake TX + expect(txs.length).to.equal(2); + const populatedTx = await txs[1].tx(); + expect(populatedTx?.gasLimit).to.equal(gasLimit); + }) + }); + + describe('after staking', () => { + // `stakeAmount` should be even (so it has no remainder when divided by 2) + const stakeAmount = 1e6; + + before(async () => { + await loadSnapshot(afterMockTokenMint); + + // Stake. + const txs = await txBuilder.safetyModuleService.stake( + staker1.address, + stakeAmount.toString(), + ); + await sendTransactions(txs, staker1); + + await saveSnapshot(afterStake); + }); + + beforeEach(async () => { + await loadSnapshot(afterStake); + }); + + it('Total liquidity module balance is non-zero', async () => { + const totalLiquidity: tStringDecimalUnits = await txBuilder.safetyModuleService.getTotalStake(); + + expect(totalLiquidity).to.equal(formatUnits(stakeAmount, 0)); + }) + + it('User has approved liquidity module', async () => { + const approvalValue: tStringDecimalUnits = await txBuilder.safetyModuleService.allowance(staker1.address); + const expectedApprovalValue: BigNumber = BigNumber.from(DEFAULT_APPROVE_AMOUNT).sub( + parseNumberToString(stakeAmount.toString(), DYDX_TOKEN_DECIMALS)); + + expect(approvalValue).to.equal(formatUnits(expectedApprovalValue, DYDX_TOKEN_DECIMALS)); + }) + + it('User stake is non-zero', async () => { + const userStake: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStake(staker1.address); + + expect(userStake).to.equal(formatUnits(stakeAmount, 0)); + }) + + it('User rewards are non-zero', async () => { + // move time 1 second forward so staker1 earns rewards + await increaseTimeAndMine(1); + + const userUnclaimedRewards: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserUnclaimedRewards(staker1.address); + + const convertedUserUnclaimedRewards: BigNumber = parseNumberToEthersBigNumber(userUnclaimedRewards, DYDX_TOKEN_DECIMALS); + + expect(convertedUserUnclaimedRewards.gt('0')).to.be.true; + }) + + it('user stake pending withdraw is non-zero after requesting to withdraw', async () => { + // Request withdrawal. + { + const txs = await txBuilder.safetyModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + const userStakePendingWithdraw: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStakePendingWithdraw(staker1.address); + + expect(userStakePendingWithdraw).to.equal(formatUnits(stakeAmount, 0)); + }); + + it('user stake available to withdraw is non-zero after requesting to withdraw and waiting an epoch', async () => { + // Request withdrawal. + { + const txs = await txBuilder.safetyModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + const userStakeToWithdraw: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserStakeAvailableToWithdraw(staker1.address); + + expect(userStakeToWithdraw).to.equal(formatUnits(stakeAmount, 0)); + }); + + it('withdraws', async () => { + // Request withdrawal. + { + const txs = await txBuilder.safetyModuleService.requestWithdrawal( + staker1.address, + stakeAmount.toString(), // Request full withdrawal. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + // Withdraw. + { + const txs = await txBuilder.safetyModuleService.withdrawStake( + staker1.address, + stakeAmount.toString(), // Withdraw all. + ); + await sendTransactions(txs, staker1); + } + + const balance: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserBalanceOfStakedToken(staker1.address); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei, DYDX_TOKEN_DECIMALS)); + }); + + it('withdraws max', async () => { + // Request withdrawal. + { + const txs = await txBuilder.safetyModuleService.requestWithdrawal( + staker1.address, + (stakeAmount / 2).toString(), // Request half. + ); + await sendTransactions(txs, staker1); + } + + // Advance to the next epoch. + await elapseEpoch(); + + // Withdraw. + { + const txs = await txBuilder.safetyModuleService.withdrawStake( + staker1.address, + '-1', // Withdraw max. + ); + await sendTransactions(txs, staker1); + } + + const balance: tStringDecimalUnits = await txBuilder.safetyModuleService.getUserBalanceOfStakedToken(staker1.address); + // half of the funds are remaining in the contract + const remainingFundsWei: BigNumber = parseNumberToEthersBigNumber((stakeAmount / 2).toString(), DYDX_TOKEN_DECIMALS); + expect(balance).to.equal(formatUnits(stakerInitialBalanceWei.sub(remainingFundsWei), DYDX_TOKEN_DECIMALS)); + }); + + it('claims rewards', async () => { + const rewardsBefore = await dydxToken.balanceOf(staker1.address); + + // Claim rewards. + const txs = await txBuilder.safetyModuleService.claimRewards(staker1.address); + await sendTransactions(txs, staker1); + + const rewardsAfter = await dydxToken.balanceOf(staker1.address); + expect(rewardsAfter.sub(rewardsBefore)).not.to.equal(0); + }); + }); + + /** + * Progress to the start of the next epoch. May be a bit after if mining a block. + */ + async function elapseEpoch(mineBlock: boolean = true): Promise { + let remaining = (await safetyModule.getTimeRemainingInCurrentEpoch()).toNumber(); + remaining ||= EPOCH_LENGTH.toNumber(); + if (mineBlock) { + await increaseTimeAndMine(remaining); + } else { + await increaseTime(remaining); + } + } + + async function saveSnapshot(label: string): Promise { + snapshots.set(label, await evmSnapshot()); + } + + async function loadSnapshot(label: string): Promise { + const snapshot = snapshots.get(label); + if (!snapshot) { + throw new Error(`Cannot load since snapshot has not been saved: ${label}`); + } + await evmRevert(snapshot); + snapshots.set(label, await evmSnapshot()); + } +});