@dydxfoundation/governance
Version:
dYdX governance smart contracts
854 lines • 57.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.StakingHelper = void 0;
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const chai_1 = require("chai");
const ethers_1 = require("ethers");
const lodash_1 = __importDefault(require("lodash"));
const config_1 = __importDefault(require("../../src/config"));
const constants_1 = require("../../src/lib/constants");
const util_1 = require("../../src/lib/util");
const evm_1 = require("./evm");
const staking_helper_balance_1 = require("./staking-helper-balance");
// When iterating on tests or debugging, this can be disabled to run the tests faster.
const CHECK_INVARIANTS = config_1.default.STAKING_TESTS_CHECK_INVARIANTS;
const BORROWING_TOTAL_ALLOCATION = 10000;
const SHORTFALL_INDEX_BASE = new bignumber_js_1.default(1e36);
const SNAPSHOTS = {};
function defaultAddrMapping(defaultMaker, baseObj = {}) {
const handler = {
get: function (target, name) {
// Only create default when accessing an Ethereum address key.
if (typeof name === 'string' && name.slice(0, 2) == '0x') {
/* eslint-disable-next-line no-prototype-builtins */
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.
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
baseObj.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.
lodash_1.default.mapValues(baseObj, (val) => lodash_1.default.cloneWith(val, customCloner)));
};
return new Proxy(baseObj, handler);
}
function customCloner(
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
value) {
if (typeof value.clone === 'function') {
return value.clone();
}
}
function asLS(contract) {
return contract;
}
function asSM(contract) {
return contract;
}
class StakingHelper {
constructor(ctx, contract, token, vaultAddress, tokenSource, admin, users, isSafetyModule) {
this.ctx = ctx;
this.contract = contract;
this.token = token;
this.vaultAddress = vaultAddress;
this.tokenSource = tokenSource;
this.admin = contract.connect(admin);
this.users = {};
this.signers = {};
this.roles = {};
for (const signer of users) {
this.users[signer.address] = contract.connect(signer);
this.signers[signer.address] = signer;
}
this.isSafetyModule = isSafetyModule;
this.state = this.makeInitialState();
}
// ============ Snapshots ============
saveSnapshot(label) {
SNAPSHOTS[label] = lodash_1.default.cloneDeepWith(this.state, customCloner);
}
loadSnapshot(label) {
this.state = lodash_1.default.cloneDeepWith(SNAPSHOTS[label], customCloner);
}
// ============ Staked Token ============
async mintAndApprove(account, amount) {
const address = asAddress(account);
const signer = this.signers[address];
await this.token.connect(this.tokenSource).transfer(address, amount);
await this.token.connect(signer).approve(this.contract.address, amount);
}
async approveContract(account, amount) {
const address = asAddress(account);
const signer = this.signers[address];
await this.token.connect(signer).approve(this.contract.address, amount);
}
// ============ LS1Admin ============
async setEpochParameters(interval, offset) {
const shouldVerifyEpoch = await this.contract.hasEpochZeroStarted();
// Get current epoch.
let currentEpoch = -1;
if (shouldVerifyEpoch) {
currentEpoch = (await this.getCurrentEpoch()).toNumber();
}
await (0, chai_1.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) {
await (0, chai_1.expect)(this.admin.setBlackoutWindow(blackoutWindow))
.to.emit(this.contract, 'BlackoutWindowChanged')
.withArgs(blackoutWindow);
// Check getters.
expectEq(await this.contract.getBlackoutWindow(), blackoutWindow, 'setBlackoutWindow');
}
async setRewardsPerSecond(emissionRate) {
await (0, chai_1.expect)(this.admin.setRewardsPerSecond(emissionRate))
.to.emit(this.contract, 'RewardsPerSecondUpdated')
.withArgs(emissionRate);
// Check getters.
expectEq(await this.contract.getRewardsPerSecond(), emissionRate, 'setRewardsPerSecond');
}
async setBorrowerAllocations(allocations) {
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(constants_1.ZERO_ADDRESS)) {
addresses.push(constants_1.ZERO_ADDRESS);
points.push(0);
}
await asLS(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 asLS(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 asLS(this.contract).getAllocationFractionCurrentEpoch(addresses[i]);
expectEq(allocationCurrent, points[i], `setBorrowerAllocations: allocationCurrent[${i}]`);
}
}
// Verify total current and next allocations.
const curSum = await this.sumByAddr((addr) => asLS(this.contract).getAllocationFractionCurrentEpoch(addr), addresses);
expectEq(curSum, BORROWING_TOTAL_ALLOCATION, 'setBorrowerAllocations: curSum');
const nextSum = await this.sumByAddr((addr) => {
return asLS(this.contract).getAllocationFractionNextEpoch(addr);
}, addresses);
expectEq(nextSum, BORROWING_TOTAL_ALLOCATION, 'setBorrowerAllocations: nextSum');
}
async setBorrowingRestriction(borrower, isRestricted) {
const borrowerAddress = asAddress(borrower);
const tx = await asLS(this.contract).setBorrowingRestriction(borrowerAddress, isRestricted);
// Get previous status.
const wasRestricted = await asLS(this.contract).isBorrowingRestrictedForBorrower(borrowerAddress);
// If status changed, expect event.
if (wasRestricted !== isRestricted) {
await (0, chai_1.expect)(tx)
.to.emit(this.contract, 'BorrowingRestrictionChanged')
.withArgs(borrowerAddress, isRestricted);
}
// Check new status.
(0, chai_1.expect)(await asLS(this.contract).isBorrowingRestrictedForBorrower(borrowerAddress)).to.be.equal(isRestricted, 'setBorrowingRestriction');
}
async addOperator(operator, role) {
const address = asAddress(operator);
const roleHash = (0, util_1.getRole)(role);
await (0, chai_1.expect)(this.contract.grantRole(roleHash, address))
.to.emit(this.contract, 'RoleGranted')
.withArgs(roleHash, address, await this.contract.signer.getAddress());
(0, chai_1.expect)(await this.contract.hasRole(roleHash, address)).to.be.true();
}
async removeOperator(operator, role) {
const address = asAddress(operator);
const roleHash = (0, util_1.getRole)(role);
await (0, chai_1.expect)(this.contract.revokeRole(roleHash, address))
.to.emit(this.contract, 'RoleRevoked')
.withArgs(roleHash, address, await this.contract.signer.getAddress());
(0, chai_1.expect)(await this.contract.hasRole(roleHash, address)).to.be.false();
}
// ============ LS1Staking ============
async stake(account, amount, options = {}) {
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 = ethers_1.BigNumber.from(amount);
if (this.isSafetyModule) {
// Get amount after converting by exchange rate (if this is the safety module).
const exchangeRate = await asSM(this.contract).getExchangeRate();
const exchangeRateBase = await asSM(this.contract).EXCHANGE_RATE_BASE();
stakeAmount = stakeAmount.mul(exchangeRate).div(exchangeRateBase);
await (0, chai_1.expect)(signer.stake(amount))
.to.emit(this.contract, 'Staked')
.withArgs(address, address, amount, stakeAmount);
}
else {
await (0, chai_1.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, amount, options = {}) {
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 (0, chai_1.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, recipient, amount, options = {}) {
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 (0, chai_1.expect)(signer.withdrawStake(recipientAddress, amount))
.to.emit(this.contract, 'WithdrewStake')
.withArgs(address, recipientAddress, amount, amount);
}
else {
await (0, chai_1.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, recipient, options = {}) {
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 asSM(this.contract).getExchangeRate();
const exchangeRateBase = await asSM(this.contract).EXCHANGE_RATE_BASE();
underlyingAmount = underlyingAmount.mul(exchangeRateBase).div(exchangeRate);
await (0, chai_1.expect)(signer.withdrawMaxStake(recipientAddress))
.to.emit(this.contract, 'WithdrewStake')
.withArgs(address, recipientAddress, underlyingAmount, amount);
}
else {
await (0, chai_1.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, recipient, amount, options = {}) {
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 (0, chai_1.expect)(asLS(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 asLS(this.contract).getStakerDebtBalance(address), this.state.debtBalanceByStaker[address], `withdrawStake: debt balance ${address}`);
// Update state and check invariants.
await this.checkInvariants(options);
}
async withdrawMaxDebt(account, recipient, options = {}) {
const address = asAddress(account);
const recipientAddress = asAddress(recipient);
const signer = this.users[address];
// Get current debt staker has available to withdraw.
const amount = await asLS(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 (0, chai_1.expect)(asLS(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 asLS(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, recipient, startTimestamp, endTimestamp = null, stakerShare = 1, options = {}) {
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.vaultAddress);
const recipientBalanceBefore = await this.token.balanceOf(recipientAddress);
// Calculate the expected rewards.
const rewardsRate = await this.contract.getRewardsPerSecond();
const end = ethers_1.BigNumber.from(endTimestamp || await (0, evm_1.latestBlockTimestamp)());
const expectedRewards = new bignumber_js_1.default(end.sub(startTimestamp).mul(rewardsRate).toString()).times(stakerShare).toFixed(0);
const optionsWithRounding = {
...options,
roundingTolerance: rewardsRate.mul(2),
};
// Preview rewards.
const claimable = await signer.callStatic.claimRewards(recipientAddress);
expectEqualExceptRounding(claimable, expectedRewards, optionsWithRounding, 'claimRewards: callStatic claimable');
// Send transaction.
const parsedLogs = await this.parseLogs(signer.claimRewards(recipientAddress));
// Check logs.
const logs = lodash_1.default.filter(parsedLogs, { name: 'ClaimedRewards' });
(0, chai_1.expect)(logs).to.have.length(1);
const log = logs[0];
(0, chai_1.expect)(log.args[0]).to.be.equal(stakerAddress);
(0, chai_1.expect)(log.args[1]).to.be.equal(recipientAddress);
expectEqualExceptRounding(log.args[2], expectedRewards, optionsWithRounding, 'claimRewards: logged rewards');
// Check changes in ERC20 token balances.
const vaultBalanceAfter = await this.token.balanceOf(this.vaultAddress);
const recipientBalanceAfter = await this.token.balanceOf(recipientAddress);
expectEqualExceptRounding(vaultBalanceBefore.sub(vaultBalanceAfter), expectedRewards, optionsWithRounding);
expectEqualExceptRounding(recipientBalanceAfter.sub(recipientBalanceBefore), expectedRewards, optionsWithRounding);
return claimable;
}
// ============ LS1ERC20 ============
async transfer(account, recipient, amount, options = {}) {
const address = asAddress(account);
const recipientAddress = asAddress(recipient);
const signer = this.users[address];
await (0, chai_1.expect)(signer.transfer(recipientAddress, amount))
.to.emit(this.contract, 'Transfer')
.withArgs(address, recipientAddress, amount);
// check invariants.
await this.checkInvariants(options);
}
async transferFrom(account, sender, recipient, amount, options = {}) {
const address = asAddress(account);
const senderAddress = asAddress(sender);
const recipientAddress = asAddress(recipient);
const signer = this.users[address];
await (0, chai_1.expect)(signer.transferFrom(senderAddress, recipientAddress, amount))
.to.emit(this.contract, 'Transfer')
.withArgs(senderAddress, recipientAddress, amount);
// check invariants.
await this.checkInvariants(options);
}
async approve(account, spender, amount, options = {}) {
const address = asAddress(account);
const spenderAddress = asAddress(spender);
const signer = this.users[address];
await (0, chai_1.expect)(signer.approve(spenderAddress, amount))
.to.emit(this.contract, 'Approval')
.withArgs(address, spenderAddress, amount);
// check invariants.
await this.checkInvariants(options);
}
// ============ LS1Borrowing ============
async borrowViaProxy(borrower, amount, options = {}) {
const address = asAddress(borrower.address);
const sendTx = async (newBorrowedBalance) => {
await (0, chai_1.expect)(borrower.borrow(amount))
.to.emit(this.contract, 'Borrowed')
.withArgs(address, amount, newBorrowedBalance);
};
return this._borrow(sendTx, address, amount, options);
}
async borrow(account, amount, options = {}) {
const address = asAddress(account);
const signer = this.users[address];
const sendTx = async (newBorrowedBalance) => {
await (0, chai_1.expect)(asLS(signer).borrow(amount))
.to.emit(this.contract, 'Borrowed')
.withArgs(address, amount, newBorrowedBalance);
};
return this._borrow(sendTx, address, amount, options);
}
async _borrow(sendTx, address, amount, options = {}) {
// 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 asLS(this.contract).getBorrowedBalance(address), this.state.netBorrowedByBorrower[address], `borrow: net borrowed balance ${address}`);
// Check invariants.
await this.checkInvariants(options);
}
async repayBorrowViaProxy(borrower, amount, options = {}) {
const address = borrower.address;
const sendTx = async (newBorrowerBalance) => {
await (0, chai_1.expect)(borrower.repayBorrow(amount))
.to.emit(this.contract, 'RepaidBorrow')
.withArgs(address, address, amount, newBorrowerBalance);
};
return this._repayBorrow(sendTx, address, borrower.address, amount, options);
}
async repayBorrow(account, borrower, amount, options = {}) {
const address = asAddress(account);
const borrowerAddress = asAddress(borrower);
const signer = this.users[address];
const sendTx = async (newBorrowerBalance) => {
await (0, chai_1.expect)(asLS(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, address, borrower, amount, options = {}) {
const borrowerAddress = asAddress(borrower);
// 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, borrower, amount, options = {}) {
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 (0, chai_1.expect)(asLS(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 asLS(this.contract).getTotalDebtAvailableToWithdraw(), this.state.netDebtDeposits, 'repayDebt: netDebtDeposits');
// Check invariants.
await this.checkInvariants(options);
}
// ============ StarkProxy ============
async autoPay(borrower, options = {}) {
const [borrowed, repay, debt] = (await borrower.callStatic.autoPayOrBorrow());
const tx = await borrower.autoPayOrBorrow();
const borrowedBalance = await asLS(this.contract).getBorrowedBalance(borrower.address);
const debtBalance = await asLS(this.contract).getBorrowerDebtBalance(borrower.address);
if (!borrowed.eq(0)) {
await (0, chai_1.expect)(tx).to.emit(borrower, 'Borrowed').withArgs(borrowed, borrowedBalance);
}
if (!repay.eq(0)) {
await (0, chai_1.expect)(tx).to.emit(borrower, 'RepaidBorrow').withArgs(repay, borrowedBalance, false);
}
if (!debt.eq(0)) {
await (0, chai_1.expect)(tx).to.emit(borrower, 'RepaidDebt').withArgs(debt, debtBalance, false);
}
// Update state.
const address = borrower.address;
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());
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, newlyRestrictedBorrowers, expectedIndex, options = {}) {
const borrowers = Object.keys(borrowersWithExpectedDebts);
const tx = await asLS(this.contract).markDebt(borrowers);
const parsedLogs = await this.parseLogs(tx);
// Check debt marked logs.
const debtMarked = lodash_1.default.filter(parsedLogs, { name: 'DebtMarked' });
const newDebtByBorrower = lodash_1.default.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 (ethers_1.BigNumber.from(expectedDebt).isZero()) {
(0, chai_1.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 = lodash_1.default.filter(parsedLogs, { name: 'ConvertedInactiveBalancesToDebt' });
(0, chai_1.expect)(slashedInactive).to.have.length(1, 'markDebt: Converted inactive balances log');
const newDebtSum = lodash_1.default.sumBy(lodash_1.default.values(newDebtByBorrower), (x) => x.toNumber());
const slashedLogArgs = slashedInactive[0].args;
const slashedAmount = slashedLogArgs[0];
expectEq(slashedAmount, newDebtSum, 'markDebt: slashedAmount ≠ newDebtSum');
const expectedIndexInt = SHORTFALL_INDEX_BASE.times(expectedIndex).toFixed(0, bignumber_js_1.default.ROUND_FLOOR);
expectEq(slashedLogArgs[1], expectedIndexInt, 'markDebt: expectedIndex');
// Check borrower restricted logs.
const restricted = lodash_1.default.filter(parsedLogs, { name: 'BorrowingRestrictionChanged' });
(0, chai_1.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;
(0, chai_1.expect)(newlyRestrictedBorrowersAddresses).to.contain(restrictedBorrower, `markDebt: Unexpected restricted borrower ${restrictedBorrower}`);
(0, chai_1.expect)(isRestricted, 'markDebt: isRestricted').to.be.true();
// Check getters.
(0, chai_1.expect)(await asLS(this.contract).isBorrowingRestrictedForBorrower(restrictedBorrower)).to.be.true();
}
// Update state and check invariants.
this.state.netDeposits = this.state.netDeposits.sub(newDebtSum);
lodash_1.default.forEach(borrowersWithExpectedDebts, (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(lodash_1.default.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 = ethers_1.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() {
await (0, chai_1.expect)(asLS(this.contract).markDebt([])).to.be.revertedWith('LS1DebtAccounting: No shortfall');
}
// ============ LS1Operators ============
async decreaseStakerDebt(debtOperator, staker, amount, options = {}) {
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 (0, chai_1.expect)(asLS(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 asLS(this.contract).getStakerDebtBalance(stakerAddress), this.state.debtBalanceByStaker[stakerAddress], `repayDebt: debtBalanceByStaker ${stakerAddress}`);
// Check invariants.
await this.checkInvariants(options);
}
async decreaseBorrowerDebt(debtOperator, borrower, amount, options = {}) {
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 (0, chai_1.expect)(asLS(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 asLS(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, amount, options = {}) {
(0, chai_1.expect)(await asLS(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 (0, chai_1.expect)(this.borrowViaProxy(borrower, 1)).to.be.revertedWith('LS1');
}
/**
* Borrow an amount and expect it to be the max borrowable amount.
*/
async fullBorrow(borrower, amount, options = {}) {
(0, chai_1.expect)(await asLS(this.contract).getBorrowableAmount(borrower.address)).to.equal(amount);
if (ethers_1.BigNumber.from(amount).isZero()) {
await (0, chai_1.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 (0, chai_1.expect)(this.borrow(borrower, 1)).to.be.revertedWith('LS1');
}
/**
* Repay a borrower debt and expect it to be the full amount.
*/
async fullRepayDebt(borrower, amount, options = {}) {
(0, chai_1.expect)(await asLS(this.contract).getBorrowerDebtBalance(borrower.address)).to.equal(amount);
await this.repayDebt(borrower, borrower, amount, options);
await (0, chai_1.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, amount, options = {}) {
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 (0, chai_1.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, amount, options = {}) {
// Calculate available amount ourselves.
const contractAvailable = await asLS(this.contract).getTotalDebtAvailableToWithdraw();
const stakerAvailable = await asLS(this.contract).getStakerDebtBalance(staker.address);
const available = contractAvailable.lt(stakerAvailable) ? contractAvailable : stakerAvailable;
// Compare with smart contract calculation.
expectEq(await asLS(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 (0, chai_1.expect)(this.withdrawDebt(staker, staker, 1)).to.be.revertedWith('LS1Staking');
}
// ============ Other ============
async getCurrentEpoch() {
const currentEpoch = await this.contract.getCurrentEpoch();
expectGte(currentEpoch, this.state.lastCurrentEpoch, 'Current epoch went backwards');
this.state.lastCurrentEpoch = currentEpoch;
return currentEpoch;
}
async checkInvariants(options = {}) {
if (options.skipInvariantChecks || !CHECK_INVARIANTS) {
return;
}
// Skip this check if testing safety module.
let availableDebt;
if (!this.isSafetyModule) {
// 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 asLS(this.contract).getTotalBorrowedBalance();
const balance = await this.token.balanceOf(this.contract.address);
availableDebt = await asLS(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');
// Exit early if testing the safety module.
if (this.isSafetyModule) {
return;
}
// Debt accounting
//
// Total Borrower Debt = ∑ Borrower Debt
// Total Borrower Debt ≈ (∑ Staker Debt) - Debt Available to Withdraw
const totalBorrowerDebt = await asLS(this.contract).getTotalBorrowerDebtBalance();
const borrowerDebt = await this.sumByAddr((addr) => asLS(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 asLS(this.contract).getStakerDebtBalance(addr);
});
const totalBorrowerDebtAndAvailableToWithdraw = totalBorrowerDebt.add(availableDebt);
expectEqualExceptRounding(totalBorrowerDebtAndAvailableToWithdraw, stakerDebt, options, 'Invariant: stakerDebt');
}
// Debt available to withdraw.
const availableToWithdraw = await asLS(this.contract).getTotalDebtAvailableToWithdraw();
expectEq(this.state.netDebtDeposits, availableToWithdraw, 'Invariant: availableToWithdraw');
}
async elapseEpochWithExpectedBalanceUpdates(checkActiveBalanceUpdates, checkInactiveBalanceUpdates, options = {}) {
// Get current epoch.
const currentEpoch = (await this.getCurrentEpoch()).toNumber();
// Check current balances before.
await Promise.all(lodash_1.default.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(lodash_1.default.map(checkInactiveBalanceUpdates, async (values, address) => {
expectEq(await this.contract.getInactiveBalanceCurrentEpoch(address), values[0], `elapseEpoch(${currentEpoch} -> ${currentEpoch + 1}): Current inactive balance before ${address}`);
}));
const sumCurrentActiveBefore = lodash_1.default.chain(checkActiveBalanceUpdates)
.values()
.sumBy((x) => ethers_1.BigNumber.from(x[0]).toNumber()) // Before.
.value();
const sumCurrentInactiveBefore = lodash_1.default.chain(checkInactiveBalanceUpdates)
.values()
.sumBy((x) => ethers_1.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(lodash_1.default.map(checkActiveBalanceUpdat