UNPKG

@dydxfoundation/governance

Version:
854 lines 57.4 kB
"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