@dydxfoundation/governance
Version:
dYdX governance smart contracts
323 lines (322 loc) • 21.6 kB
JavaScript
;
/* eslint-disable @typescript-eslint/naming-convention */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const chai_1 = require("chai");
const ethers_1 = require("ethers");
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;
// Users.
let staker1;
let staker2;
let fundsRecipient;
let contract;
async function init(ctx) {
// Users.
[staker1, staker2, fundsRecipient] = ctx.users;
// 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);
// 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);
}
(0, describe_contract_1.describeContractHardhatRevertBeforeEach)('SM1Slashing', init, (ctx) => {
before(() => {
contract.saveSnapshot('main');
});
afterEach(() => {
contract.loadSnapshot('main');
});
describe('slash', () => {
it('slashing when there are no balances does nothing', async () => {
await ctx.safetyModule.slash(0, fundsRecipient.address);
await ctx.safetyModule.slash(1, fundsRecipient.address);
await ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
(0, chai_1.expect)(await ctx.safetyModule.getExchangeRateSnapshotCount()).to.equal(0);
});
it('slashing when there is only one token does nothing', async () => {
await contract.stake(staker1, 1);
await ctx.safetyModule.slash(0, fundsRecipient.address);
await ctx.safetyModule.slash(1, fundsRecipient.address);
await ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
(0, chai_1.expect)(await ctx.safetyModule.getExchangeRateSnapshotCount()).to.equal(0);
});
it('slashing zero does nothing', async () => {
await contract.stake(staker1, stakerInitialBalance);
await ctx.safetyModule.slash(0, fundsRecipient.address);
// Check underlying token balances.
(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.dydxToken.balanceOf(fundsRecipient.address)).to.equal(0);
// Check stake balances.
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(stakerInitialBalance);
// Check slash snapshots.
(0, chai_1.expect)(await ctx.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 ctx.safetyModule.slash(3, fundsRecipient.address);
// Check underlying token balances.
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(1);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(1);
// Check stake balances.
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(2);
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(2);
// Check slash snapshots.
(0, chai_1.expect)(await ctx.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 ctx.safetyModule.slash(slashAmount, fundsRecipient.address);
// Check underlying token balances.
(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(remainingAmount);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(slashAmount);
// Check stake balances.
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(ctx.safetyModule.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(fundsRecipient.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(stakerInitialBalance);
// Check slash snapshots.
await (0, evm_1.advanceBlock)();
await (0, evm_1.advanceBlock)();
const slashBlock = await getLatestSlashBlockNumber(ctx.safetyModule);
(0, chai_1.expect)(await ctx.safetyModule.getExchangeRateSnapshotCount()).to.equal(1);
(0, chai_1.expect)((await ctx.safetyModule.getExchangeRateSnapshot(0))[0]).to.equal(slashBlock);
(0, chai_1.expect)((await ctx.safetyModule.getExchangeRateSnapshot(0))[1]).to.equal('2000000000000000000'); // 2e18
// Check gov power.
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock - 1, 0)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock - 1, 1)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock, 0)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock, 1)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock + 1, 0)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.safetyModule.getPowerAtBlock(staker1.address, slashBlock + 1, 1)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.safetyModule.getPowerCurrent(staker1.address, 0)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.safetyModule.getPowerCurrent(staker1.address, 1)).to.equal(remainingAmount);
// Withdraw funds.
await contract.requestWithdrawal(staker1, stakerInitialBalance); // In stake units
await contract.elapseEpoch();
// Note: Skip invariant check since net deposits will not match the total balance, due to slashing.
const withdrawalAmount = await contract.withdrawMaxStake(staker1, staker1, { skipInvariantChecks: true });
(0, chai_1.expect)(withdrawalAmount).to.equal(stakerInitialBalance); // In stake units
// Check underlying token balances.
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(staker1.address)).to.equal(remainingAmount);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(0);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(slashAmount);
// Check total supply.
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(0);
});
it('does not affect rewards earned', async () => {
await contract.stake(staker1, stakerInitialBalance);
// Earn some rewards before the slash.
const lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.setRewardsPerSecond(150);
await contract.elapseEpoch();
// Slash.
await ctx.safetyModule.slash(stakerInitialBalance / 2, fundsRecipient.address);
// Earn some rewards after the slash.
await contract.elapseEpoch();
// Slash again.
await ctx.safetyModule.slash(stakerInitialBalance / 4, fundsRecipient.address);
// Earn some rewards after the second slash.
await contract.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 ctx.safetyModule.slash(stakerInitialBalance / 2, fundsRecipient.address);
await ctx.safetyModule.slash(stakerInitialBalance / 4, fundsRecipient.address);
await ctx.safetyModule.slash(stakerInitialBalance / 8, fundsRecipient.address);
// Second staker after first three slashes.
//
// Note: Skip invariant check since net deposits will not match the total balance, due to slashing.
await contract.stake(staker2, stakerInitialBalance, { skipInvariantChecks: true });
// Slash again by 50%.
await ctx.safetyModule.slash((await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).div(2), fundsRecipient.address);
// Earn some rewards after the slash.
const lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.setRewardsPerSecond(150);
await contract.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();
(0, chai_1.expect)(error).to.be.lte(300);
});
});
describe('max slash', () => {
it('slashes 95%', async () => {
await contract.stake(staker1, stakerInitialBalance);
await ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
// Check underlying token balances.
(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 / 20);
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(fundsRecipient.address)).to.equal(stakerInitialBalance / 20 * 19);
// Check stake balances.
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(ctx.safetyModule.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(fundsRecipient.address)).to.equal(0);
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(stakerInitialBalance);
// Withdraw.
await ctx.safetyModule.connect(staker1).requestWithdrawal(stakerInitialBalance);
await contract.elapseEpoch();
await ctx.safetyModule.connect(staker1).withdrawMaxStake(staker1.address);
(0, chai_1.expect)(await ctx.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.
const lastTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.setRewardsPerSecond(150);
await contract.elapseEpoch();
// Slash.
await ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
// Earn some rewards after the slash.
await contract.elapseEpoch();
// Slash again.
await ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
// Earn some rewards after the second slash.
await contract.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 ctx.safetyModule.slash(stakerInitialBalance, fundsRecipient.address);
// New deposit multiplied by exchange rate of 20...
//
// Note: Skip invariant check since net deposits will not match the total balance, due to slashing.
await contract.stake(staker2, stakerInitialBalance, { skipInvariantChecks: true });
await contract.transfer(staker2, staker1, stakerInitialBalance * 10, { skipInvariantChecks: true }); // Transfer half.
// Check balances.
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker1.address)).to.equal(stakerInitialBalance * 11);
(0, chai_1.expect)(await ctx.safetyModule.balanceOf(staker2.address)).to.equal(stakerInitialBalance * 10);
(0, chai_1.expect)(await ctx.safetyModule.totalSupply()).to.equal(stakerInitialBalance * 21);
// Check underlying balance of the contract.
(0, chai_1.expect)(await ctx.dydxToken.balanceOf(ctx.safetyModule.address)).to.equal(stakerInitialBalance + stakerInitialBalance / 20);
// Can earn rewards.
const startTimestamp = await (0, evm_1.latestBlockTimestamp)();
await contract.setRewardsPerSecond(150);
await contract.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();
(0, chai_1.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 new types_1.MintableERC20__factory(ctx.deployer).deploy('Mock dYdX', 'DYDX', 18);
const distributionStart_2 = await (0, evm_1.latestBlockTimestamp)() + 100;
const [safetyModule_2] = await (0, deploy_upgradeable_1.deployUpgradeable)(types_1.SafetyModuleV11__factory, ctx.deployer, [
mockDydxToken.address,
ctx.dydxToken.address,
ctx.rewardsTreasury.address,
distributionStart_2,
ctx.config.SM_DISTRIBUTION_END,
], [
ctx.config.EPOCH_LENGTH,
distributionStart_2,
ctx.config.BLACKOUT_WINDOW,
]);
contract = new staking_helper_1.StakingHelper(ctx, safetyModule_2, mockDydxToken, ctx.rewardsTreasury.address, ctx.deployer, ctx.deployer, [staker1, staker2], true);
await (0, evm_1.incrementTimeToTimestamp)(distributionStart_2);
await mockDydxToken.mint(ctx.deployer.address, ethers_1.BigNumber.from(10).pow(30));
// Give stakers a larger balance.
const amountToStake = ethers_1.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(ctx.deployer).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).stake(amountToStake);
}
slashCount++;
}
// Cannot do another max slash.
await (0, chai_1.expect)(safetyModule_2.connect(ctx.deployer).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 = ethers_1.BigNumber.from(10).pow(28);
await contract.mintAndApprove(staker2, veryLargeAmountToStake);
await safetyModule_2.connect(staker2).stake(veryLargeAmountToStake); // Receive ~1.7e72 in staked units
// Cannot deposit much more without overflowing uint240.
const additionalAmount = ethers_1.BigNumber.from(10).pow(27);
await contract.mintAndApprove(staker2, additionalAmount);
await (0, chai_1.expect)(safetyModule_2.connect(staker2).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);
(0, chai_1.expect)(stakedBalance.gt(ethers_1.BigNumber.from(10).pow(72))).to.be.true();
// Check power.
(0, chai_1.expect)(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(veryLargeAmountToStake);
// Request full withdrawal.
await safetyModule_2.connect(staker2).requestWithdrawal(stakedBalance);
// Elapse epoch.
let remaining = (await safetyModule_2.getTimeRemainingInCurrentEpoch()).toNumber();
remaining || (remaining = ctx.config.EPOCH_LENGTH);
await (0, evm_1.increaseTimeAndMine)(remaining);
// Execute full withdrawal.
let balanceBefore = await mockDydxToken.balanceOf(staker2.address);
await safetyModule_2.connect(staker2).withdrawMaxStake(staker2.address);
let balanceAfter = await mockDydxToken.balanceOf(staker2.address);
let receivedAmount = balanceAfter.sub(balanceBefore);
(0, chai_1.expect)(receivedAmount).to.equal(veryLargeAmountToStake);
// Check power.
(0, chai_1.expect)(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(0);
// Stake again.
await mockDydxToken.connect(staker2).approve(safetyModule_2.address, veryLargeAmountToStake);
await safetyModule_2.connect(staker2).stake(veryLargeAmountToStake);
// Check power.
(0, chai_1.expect)(await safetyModule_2.getPowerCurrent(staker2.address, 0)).to.equal(veryLargeAmountToStake);
// Request withdrawal of most of the staked balance.
const requestAmount = ethers_1.BigNumber.from(10).pow(72); // In staked units.
await safetyModule_2.connect(staker2).requestWithdrawal(requestAmount);
// Elapse epoch.
remaining = (await safetyModule_2.getTimeRemainingInCurrentEpoch()).toNumber();
remaining || (remaining = ctx.config.EPOCH_LENGTH);
await (0, evm_1.increaseTimeAndMine)(remaining);
// Execute withdrawal.
balanceBefore = await mockDydxToken.balanceOf(staker2.address);
await safetyModule_2.connect(staker2).withdrawMaxStake(staker2.address);
balanceAfter = await mockDydxToken.balanceOf(staker2.address);
receivedAmount = balanceAfter.sub(balanceBefore);
const expectedReceivedAmount = veryLargeAmountToStake.mul(10).div(17);
const error = new bignumber_js_1.default(expectedReceivedAmount.toString()).minus(receivedAmount.toString()).div(receivedAmount.toString());
(0, chai_1.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);
(0, chai_1.expect)(powerError.toNumber()).to.be.lte(1);
});
});
});
async function getLatestSlashBlockNumber(safetyModule) {
const filter = safetyModule.filters.Slashed(null, null, null);
const events = await safetyModule.queryFilter(filter);
return events[events.length - 1].blockNumber;
}