@dydxfoundation/governance
Version:
dYdX governance smart contracts
339 lines (338 loc) • 23.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.addStakingTestCases = void 0;
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const chai_1 = require("chai");
const constants_1 = require("../../../src/lib/constants");
const deploy_upgradeable_1 = require("../../../src/migrations/helpers/deploy-upgradeable");
const types_1 = require("../../../types");
const describe_contract_1 = require("../../helpers/describe-contract");
const evm_1 = require("../../helpers/evm");
const staking_helper_1 = require("../../helpers/staking-helper");
const stakerInitialBalance = 1000000;
const stakerInitialBalance2 = 4000000;
function addStakingTestCases(getStakers) {
// Users.
let staker1;
let staker2;
let fundsRecipient;
// Users calling the liquidity staking contract.
let stakerSigner1;
let stakerSigner2;
let distributionEnd;
let contract;
async function init(ctx) {
// Users.
[staker1, staker2] = await getStakers(ctx);
[, , fundsRecipient] = ctx.users;
// Users calling the liquidity staking contract.
stakerSigner1 = ctx.safetyModule.connect(staker1);
stakerSigner2 = ctx.safetyModule.connect(staker2);
distributionEnd = (await ctx.safetyModule.DISTRIBUTION_END()).toString();
// Use helper class to automatically check contract invariants after every update.
contract = new staking_helper_1.StakingHelper(ctx, ctx.safetyModule, ctx.dydxToken, ctx.rewardsTreasury.address, ctx.deployer, ctx.deployer, [staker1, staker2], true);
// Transfer away any existing DYDX balance.
// This is needed when testing on a mainnet fork with the addresses affected by
// the Safety Module bug.
await ctx.dydxToken.connect(staker1).transfer(ctx.deployer.address, await ctx.dydxToken.balanceOf(staker1.address));
await ctx.dydxToken.connect(staker2).transfer(ctx.deployer.address, await ctx.dydxToken.balanceOf(staker2.address));
// Mint to stakers.
await contract.mintAndApprove(staker1, stakerInitialBalance);
await contract.mintAndApprove(staker2, stakerInitialBalance2);
// Set initial rewards rate to zero.
await contract.setRewardsPerSecond(0);
}
(0, describe_contract_1.describeContractHardhatRevertBeforeEach)('SM1Staking', init, (ctx) => {
before(() => {
contract.saveSnapshot('main');
});
afterEach(() => {
contract.loadSnapshot('main');
});
describe('stake', () => {
it('User cannot stake if epoch zero has not started', async () => {
const newDistributionStart = await (0, evm_1.latestBlockTimestamp)() + 100;
const [smBeforeEpochZero] = await (0, deploy_upgradeable_1.deployUpgradeable)(types_1.SafetyModuleV11__factory, ctx.deployer, [
ctx.dydxToken.address,
ctx.dydxToken.address,
ctx.rewardsTreasury.address,
newDistributionStart,
ctx.config.SM_DISTRIBUTION_END,
], [
ctx.config.EPOCH_LENGTH,
newDistributionStart,
ctx.config.BLACKOUT_WINDOW,
]);
await (0, chai_1.expect)(smBeforeEpochZero.stake(stakerInitialBalance)).to.be.revertedWith('SM1EpochSchedule: Epoch zero has not started');
});
it('User can successfully stake if epoch zero has started', async () => {
await contract.stake(staker1, stakerInitialBalance);
// `dydxToken` should be transferred to SafetyModuleV1 contract, and user should be given an
// equivalent amount of `SM1ERC20` tokens
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
});
});
describe('requestWithdrawal', () => {
it('User with nonzero staked balance can request a withdrawal after epoch zero has started', async () => {
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
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
// Check balances. (Note that the staking helper also does a lot of its own checks.)
//
// Expect the next active balance to have decreased relative to the current active balance.
(0, chai_1.expect)(await ctx.safetyModule.getTotalActiveBalanceCurrentEpoch()).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getActiveBalanceCurrentEpoch(staker1.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getActiveBalanceCurrentEpoch(staker2.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getTotalActiveBalanceNextEpoch()).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getActiveBalanceNextEpoch(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getActiveBalanceNextEpoch(staker2.address)).to.equal(0);
// Expect the next inactive balance to have increased relative to the current inactive balance.
(0, chai_1.expect)(await ctx.safetyModule.getTotalInactiveBalanceCurrentEpoch()).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getInactiveBalanceCurrentEpoch(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getInactiveBalanceCurrentEpoch(staker2.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.getTotalInactiveBalanceNextEpoch()).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getInactiveBalanceNextEpoch(staker1.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getInactiveBalanceNextEpoch(staker2.address)).to.equal(0);
});
it('User with nonzero staked balance cannot request a withdrawal during blackout window', async () => {
await contract.stake(staker1, stakerInitialBalance);
const withinBlackoutWindow = (await (0, evm_1.latestBlockTimestamp)() +
Number(await ctx.safetyModule.getTimeRemainingInCurrentEpoch()) +
ctx.config.EPOCH_LENGTH -
ctx.config.BLACKOUT_WINDOW);
await (0, evm_1.incrementTimeToTimestamp)(withinBlackoutWindow);
await (0, chai_1.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
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
});
it('User with zero staked balance cannot request a withdrawal', async () => {
await (0, chai_1.expect)(contract.requestWithdrawal(staker1, 1)).to.be.revertedWith('SM1Staking: Withdraw request exceeds next active balance');
});
});
describe('withdrawStake', () => {
it('Staker can request and withdraw full balance', async () => {
await contract.stake(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker1, stakerInitialBalance);
await contract.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
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(0);
});
it('Staker can request full balance and make multiple partial withdrawals', async () => {
await contract.stake(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker1, stakerInitialBalance);
await contract.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
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(withdrawAmount);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.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 contract.stake(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker1, 100);
await contract.requestWithdrawal(staker1, 10);
await contract.requestWithdrawal(staker1, 1);
await contract.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 contract.stake(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker1, 100);
await contract.requestWithdrawal(staker1, 10);
await contract.requestWithdrawal(staker1, 1);
await contract.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 (0, chai_1.expect)(stakerSigner1.withdrawStake(staker1.address, stakerInitialBalance)).to.be.revertedWith('SM1Staking: Withdraw amount exceeds staker inactive balance');
});
});
describe('withdrawMaxStake', () => {
it('Staker can request and withdraw full balance', async () => {
await contract.stake(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker1, stakerInitialBalance);
await contract.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
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(0);
});
it('Staker can try to withdraw max stake even if there is none', async () => {
await contract.withdrawMaxStake(staker1, staker1);
});
});
describe('Transfer events', () => {
it('Emits transfer events as expected', async () => {
await (0, chai_1.expect)(ctx.safetyModule.connect(staker1).stake(stakerInitialBalance))
.to.emit(ctx.safetyModule, 'Transfer')
.withArgs(constants_1.ZERO_ADDRESS, staker1.address, stakerInitialBalance);
await (0, chai_1.expect)(ctx.safetyModule.connect(staker1).transfer(staker2.address, stakerInitialBalance))
.to.emit(ctx.safetyModule, 'Transfer')
.withArgs(staker1.address, staker2.address, stakerInitialBalance);
await (0, chai_1.expect)(ctx.safetyModule.connect(staker2).requestWithdrawal(stakerInitialBalance))
.not.to.emit(ctx.safetyModule, 'Transfer');
await contract.elapseEpoch(); // increase time to next epoch, so user can withdraw funds
await (0, chai_1.expect)(ctx.safetyModule.connect(staker2).withdrawStake(fundsRecipient.address, stakerInitialBalance))
.to.emit(ctx.safetyModule, 'Transfer')
.withArgs(staker2.address, constants_1.ZERO_ADDRESS, stakerInitialBalance);
});
});
describe('claimRewards', () => {
it('User with staked balance can claim rewards', async () => {
// Repeat with different rewards rates.
await contract.stake(staker1, stakerInitialBalance);
let lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
for (const rewardsRate of [1, ctx.config.SM_REWARDS_PER_SECOND]) {
await contract.setRewardsPerSecond(rewardsRate);
await contract.elapseEpoch(); // Earn one epoch of rewards.
await contract.claimRewards(staker1, fundsRecipient, lastTimestamp);
lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.elapseEpoch(); // Earn one epoch of rewards.
await contract.claimRewards(staker1, fundsRecipient, lastTimestamp);
lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.elapseEpoch(); // Earn one epoch of rewards.
await contract.claimRewards(staker1, fundsRecipient, lastTimestamp);
lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
}
});
it('User with nonzero staked balance for one epoch but emission rate was zero cannot claim rewards', async () => {
await contract.stake(staker1, stakerInitialBalance);
// increase time to next epoch, so user can earn rewards
await contract.elapseEpoch();
// change EMISSION_RATE to be greater than 0
const emissionRate = 1;
await (0, chai_1.expect)(ctx.safetyModule.connect(ctx.deployer).setRewardsPerSecond(emissionRate))
.to.emit(ctx.safetyModule, 'RewardsPerSecondUpdated')
.withArgs(emissionRate);
(0, chai_1.expect)(await stakerSigner1.callStatic.claimRewards(fundsRecipient.address)).to.equal(0);
});
it('Multiple users can stake, requestWithdrawal, withdrawStake, and claimRewards', async () => {
// change EMISSION_RATE to be greater than 0
const emissionRate = 1;
await contract.setRewardsPerSecond(emissionRate);
await contract.stake(staker1, stakerInitialBalance);
const stakeTimestamp1 = await (0, evm_1.latestBlockTimestamp)();
await contract.stake(staker2, stakerInitialBalance2);
const stakeTimestamp2 = await (0, evm_1.latestBlockTimestamp)();
await contract.requestWithdrawal(staker1, stakerInitialBalance);
await contract.requestWithdrawal(staker2, stakerInitialBalance2);
await contract.elapseEpoch();
const totalBalance = stakerInitialBalance + stakerInitialBalance2;
const beforeClaim1 = await (0, evm_1.latestBlockTimestamp)();
const numTokens1 = new bignumber_js_1.default(beforeClaim1)
.minus(stakeTimestamp1)
.times(emissionRate)
.times(stakerInitialBalance)
.div(totalBalance)
.toNumber();
(0, chai_1.expect)((await stakerSigner1.callStatic.claimRewards(staker1.address)).toNumber()).to.be.closeTo(numTokens1, 2);
const beforeClaim2 = await (0, evm_1.latestBlockTimestamp)();
const numTokens2 = new bignumber_js_1.default(beforeClaim2)
.minus(stakeTimestamp2)
.times(emissionRate)
.times(stakerInitialBalance2)
.div(totalBalance)
.toNumber();
(0, chai_1.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 (0, evm_1.incrementTimeToTimestamp)(Number(distributionEnd) - ctx.config.EPOCH_LENGTH);
// change EMISSION_RATE to be greater than 0
const emissionRate = 1;
await (0, chai_1.expect)(ctx.safetyModule.connect(ctx.deployer).setRewardsPerSecond(emissionRate))
.to.emit(ctx.safetyModule, 'RewardsPerSecondUpdated')
.withArgs(emissionRate);
// expect the `stake` call to succeed, else we can't test `claimRewards`
await contract.stake(staker1.address, stakerInitialBalance);
const stakedTimestamp = await (0, evm_1.latestBlockTimestamp)();
// 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 contract.elapseEpoch();
}
const numTokens = new bignumber_js_1.default(distributionEnd)
.minus(stakedTimestamp)
.times(emissionRate)
.toNumber();
await (0, chai_1.expect)(stakerSigner1.claimRewards(staker1.address))
.to.emit(ctx.safetyModule, 'ClaimedRewards')
.withArgs(staker1.address, staker1.address, numTokens);
// verify user can withdraw and doesn't earn additional rewards
await contract.requestWithdrawal(staker1.address, stakerInitialBalance);
await contract.elapseEpoch();
await contract.withdrawStake(staker1.address, staker1.address, stakerInitialBalance);
// user shouldn't have any additional rewards since it's after DISTRIBUTION_END
await (0, chai_1.expect)(stakerSigner1.claimRewards(staker1.address))
.to.emit(ctx.safetyModule, 'ClaimedRewards')
.withArgs(staker1.address, staker1.address, 0);
});
});
describe('transfer', () => {
it('User with staked balance can transfer to another user', async () => {
await contract.stake(staker1, stakerInitialBalance);
await contract.transfer(staker1, staker2, stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker2.address)).to.equal(stakerInitialBalance);
});
it('User with staked balance for one epoch can transfer to another user and claim rewards', async () => {
// change EMISSION_RATE to be greater than 0
const emissionRate = 1;
await (0, chai_1.expect)(ctx.safetyModule.connect(ctx.deployer).setRewardsPerSecond(emissionRate))
.to.emit(ctx.safetyModule, 'RewardsPerSecondUpdated')
.withArgs(emissionRate);
await contract.stake(staker1, stakerInitialBalance);
const stakeTimestamp = await (0, evm_1.latestBlockTimestamp)();
// increase time to next epoch, so user can earn rewards
await contract.elapseEpoch();
await contract.transfer(staker1, staker2, stakerInitialBalance);
const balanceBeforeClaiming = await ctx.dydxToken.balanceOf(staker1.address);
const now = await (0, evm_1.latestBlockTimestamp)();
const numTokens = new bignumber_js_1.default(now)
.minus(stakeTimestamp)
.times(emissionRate)
.toNumber();
await (0, chai_1.expect)(stakerSigner1.claimRewards(staker1.address))
.to.emit(ctx.safetyModule, 'ClaimedRewards')
.withArgs(staker1.address, staker1.address, numTokens);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(balanceBeforeClaiming.add(numTokens).toString());
});
});
describe('transferFrom', () => {
it('User with staked balance can transfer to another user', async () => {
await contract.stake(staker1, stakerInitialBalance);
await contract.approve(staker1, staker2, stakerInitialBalance);
await contract.transferFrom(staker2, staker1, staker2, stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker2.address)).to.equal(stakerInitialBalance);
});
});
});
}
exports.addStakingTestCases = addStakingTestCases;