diff --git a/contracts/truefi2/SAFU.sol b/contracts/truefi2/SAFU.sol index 5ae26c035..81d0ec0bc 100644 --- a/contracts/truefi2/SAFU.sol +++ b/contracts/truefi2/SAFU.sol @@ -36,7 +36,7 @@ contract SAFU is ISAFU, UpgradeableClaimable { I1Inch3 public _1Inch; mapping(ILoanToken2 => IDeficiencyToken) public override deficiencyToken; - mapping(address => uint256) public override poolDeficit; + mapping(address => uint256) public internalPoolDeficit; // ======= STORAGE DECLARATION END ============ @@ -51,7 +51,7 @@ contract SAFU is ISAFU, UpgradeableClaimable { /** * @dev Emitted when a loan gets liquidated * @param loan Loan that has been liquidated - * @param repaid Amount repaid to the pool + * @param repaid DEPRECATED Amount repaid to the pool * @param deficiencyToken Deficiency token representing a deficit that is owed to the pool by SAFU * @param deficit Deficit amount that SAFU still owes the pool */ @@ -81,9 +81,16 @@ contract SAFU is ISAFU, UpgradeableClaimable { } /** - * @dev Liquidates a defaulted Loan, withdraws a portion of tru from staking pool - * then tries to cover the loan with own funds, to compensate TrueFiPool - * If SAFU does not have enough funds, deficit is saved to be redeemed later + * @dev Dummy view so that tfTOKEN.deficitValue() discounts deficiency tokens to zero value. + * Does not affect SAFU internal deficiency token tracking. + */ + function poolDeficit(address) external override view returns (uint256) { + return 0; + } + + /** + * @dev Liquidates a defaulted Loan and withdraws a portion of tru from staking pool + * to compensate TrueFiPool. Deficit is saved to be redeemed later * @param loan Loan to be liquidated */ function liquidate(ILoanToken2 loan) external { @@ -91,23 +98,15 @@ contract SAFU is ISAFU, UpgradeableClaimable { require(loan.status() == ILoanToken2.Status.Defaulted, "SAFU: Loan is not defaulted"); ITrueFiPool2 pool = ITrueFiPool2(loan.pool()); - IERC20 token = IERC20(pool.token()); liquidator.liquidate(loan); pool.liquidate(loan); - uint256 owedToPool = loan.debt().mul(tokenBalance(loan)).div(loan.totalSupply()); - uint256 safuTokenBalance = tokenBalance(token); - - uint256 deficit = 0; - uint256 toTransfer = owedToPool; - if (owedToPool > safuTokenBalance) { - deficit = owedToPool.sub(safuTokenBalance); - toTransfer = safuTokenBalance; - deficiencyToken[loan] = new DeficiencyToken(loan, deficit); - poolDeficit[address(loan.pool())] = poolDeficit[address(loan.pool())].add(deficit); - } - token.safeTransfer(address(pool), toTransfer); - emit Liquidated(loan, toTransfer, deficiencyToken[loan], deficit); + + uint256 deficit = loan.debt().mul(tokenBalance(loan)).div(loan.totalSupply()); + deficiencyToken[loan] = new DeficiencyToken(loan, deficit); + internalPoolDeficit[address(pool)] = internalPoolDeficit[address(pool)].add(deficit); + + emit Liquidated(loan, 0, deficiencyToken[loan], deficit); } /** @@ -145,7 +144,7 @@ contract SAFU is ISAFU, UpgradeableClaimable { require(address(dToken) != address(0), "SAFU: No deficiency token found for loan"); require(dToken.balanceOf(poolAddress) > 0, "SAFU: Pool does not have deficiency tokens to be reclaimed"); - poolDeficit[poolAddress] = poolDeficit[poolAddress].sub(amount); + internalPoolDeficit[poolAddress] = internalPoolDeficit[poolAddress].sub(amount); dToken.burnFrom(msg.sender, amount); loan.token().safeTransfer(poolAddress, amount); diff --git a/deployments.json b/deployments.json index a23da6b68..155096d30 100644 --- a/deployments.json +++ b/deployments.json @@ -557,8 +557,8 @@ "address": "0xCB829B1Aa77B8B57D320AF05a780757c8c2B88C1" }, "sAFU": { - "txHash": "0x069ca631faace9d865bf47ad09df5326633eb4d1d34aafafd0df3bcd7c37cd14", - "address": "0xC83E731e0cab21ce5B0Bbbe3252bAefD0e11fc03" + "txHash": "0x5856ef56a67575ed46d74f86e7c5c6a4eb344a516ddfdc83fac445b324ac96ac", + "address": "0xc7B4BB7c8e3620A6c4F9E96524ccB8a81D52A1b1" }, "sAFU_proxy": { "txHash": "0xaa5bbaa6ca71899793cea116bfa42e4b06791c7ab85523e83bf911a4b9e12c42", diff --git a/test/integration/safuHotfix20221013.ts b/test/integration/safuHotfix20221013.ts new file mode 100644 index 000000000..09d6415b0 --- /dev/null +++ b/test/integration/safuHotfix20221013.ts @@ -0,0 +1,118 @@ +import { forkChain } from './suite20221013' +import { setupDeploy } from 'scripts/utils' +import { Erc20, Erc20__factory, LoanToken2, LoanToken2__factory, OwnedUpgradeabilityProxy__factory, Safu, Safu__factory, TrueFiPool2, TrueFiPool2__factory } from 'contracts' +import { expect, use } from 'chai' +import { solidity } from 'ethereum-waffle' +import { parseEth } from 'utils' +import { JsonRpcSigner } from '@ethersproject/providers' + +use(solidity) + +describe('SAFU hotfix 2022-10-13', () => { + const SAFU_OWNER = '0x16cEa306506c387713C70b9C1205fd5aC997E78E' + const SAFU_ADDRESS = '0x1eA63189eB1F4c109B10Cf6567f328C826AA6151' + const TFBUSD_ADDRESS = '0x1Ed460D149D48FA7d91703bf4890F97220C09437' + const LOAN_ADDRESS = '0x4A66a867f52DF4Ed1D8580A1C383B2dD036a3C47' + const ETH_HOLDER = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + const BUSD_HOLDER = '0xF977814e90dA44bFA03b6295A0616a897441aceC' + const BUSD_ADDRESS = '0x4Fabb145d64652a948d72533023f6E7A623C7C53' + const BLOCK_NUMBER = 15734123 // 2022-10-12 + + let safuOwner: JsonRpcSigner + let safu: Safu + let tfBUSD: TrueFiPool2 + let loan: LoanToken2 + let busd: Erc20 + + beforeEach(async () => { + const provider = forkChain([SAFU_OWNER, ETH_HOLDER, BUSD_HOLDER], BLOCK_NUMBER - 1) + + safuOwner = provider.getSigner(SAFU_OWNER) + safu = Safu__factory.connect(SAFU_ADDRESS, safuOwner) + loan = LoanToken2__factory.connect(LOAN_ADDRESS, safuOwner) + tfBUSD = TrueFiPool2__factory.connect(TFBUSD_ADDRESS, safuOwner) + + const ethHolder = provider.getSigner(ETH_HOLDER) + await ethHolder.sendTransaction({ value: parseEth(100), to: SAFU_OWNER }) + + const busdHolder = provider.getSigner(BUSD_HOLDER) + busd = Erc20__factory.connect(BUSD_ADDRESS, busdHolder) + }) + + describe('before SAFU upgrade', () => { + it('tfBUSD pool value includes 100% of deficiency token value', async () => { + await safu.liquidate(loan.address) + + const poolValue = await tfBUSD.poolValue() + const totalSupply = await tfBUSD.totalSupply() + const deficitValue = await tfBUSD.deficitValue() + const liquidValue = await tfBUSD.liquidValue() + const loansValue = await tfBUSD.loansValue() + + expect(totalSupply).to.be.lt(poolValue) + expect(deficitValue).to.equal(await loan.debt()) + expect(poolValue).to.equal(liquidValue.add(loansValue).add(deficitValue)) + }) + + it('SAFU transfers BUSD tokens to pool during liquidation', async () => { + await busd.transfer(safu.address, parseEth(1_000_000_000)) + + const liquidValueBefore = await tfBUSD.liquidValue() + + await safu.liquidate(loan.address) + + const poolValue = await tfBUSD.poolValue() + const totalSupply = await tfBUSD.totalSupply() + const deficitValue = await tfBUSD.deficitValue() + const liquidValue = await tfBUSD.liquidValue() + const loansValue = await tfBUSD.loansValue() + + expect(totalSupply).to.be.lt(poolValue) + expect(deficitValue).to.equal(0) + expect(poolValue).to.equal(liquidValue.add(loansValue)) + expect(liquidValue).to.equal(liquidValueBefore.add(await loan.debt())) + }) + }) + + describe('after SAFU upgrade', () => { + beforeEach(async () => { + const deployContract = setupDeploy(safuOwner) + const newSAFU = await deployContract(Safu__factory) + const safuProxy = OwnedUpgradeabilityProxy__factory.connect(SAFU_ADDRESS, safuOwner) + await safuProxy.upgradeTo(newSAFU.address) + }) + + it('tfBUSD pool value includes 0% of deficiency token value', async () => { + await safu.liquidate(loan.address) + + const poolValue = await tfBUSD.poolValue() + const totalSupply = await tfBUSD.totalSupply() + const deficitValue = await tfBUSD.deficitValue() + const liquidValue = await tfBUSD.liquidValue() + const loansValue = await tfBUSD.loansValue() + + expect(totalSupply).to.be.gt(poolValue) + expect(deficitValue).to.equal(0) + expect(poolValue).to.equal(liquidValue.add(loansValue)) + }) + + it('SAFU does not transfer BUSD tokens to pool during liquidation', async () => { + await busd.transfer(safu.address, parseEth(1_000_000_000)) + + const liquidValueBefore = await tfBUSD.liquidValue() + + await safu.liquidate(loan.address) + + const poolValue = await tfBUSD.poolValue() + const totalSupply = await tfBUSD.totalSupply() + const deficitValue = await tfBUSD.deficitValue() + const liquidValue = await tfBUSD.liquidValue() + const loansValue = await tfBUSD.loansValue() + + expect(totalSupply).to.be.gt(poolValue) + expect(deficitValue).to.equal(0) + expect(poolValue).to.equal(liquidValue.add(loansValue)) + expect(liquidValue).to.equal(liquidValueBefore) + }) + }) +}) diff --git a/test/integration/suite20221013.ts b/test/integration/suite20221013.ts new file mode 100644 index 000000000..115532570 --- /dev/null +++ b/test/integration/suite20221013.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-redeclare */ +import { BigNumberish, Contract, providers } from 'ethers' +import { ContractFactoryConstructor, deployContract } from 'scripts/utils/deployContract' +import ganache from 'ganache-core' +import { OwnedUpgradeabilityProxy__factory } from 'contracts' +import { expect } from 'chai' +import { parseEth } from 'utils' + +export const CONTRACTS_OWNER = '0x16cEa306506c387713C70b9C1205fd5aC997E78E' +export const ETHER_HOLDER = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' + +function _forkChain (rpc: string, unlockedAccounts: string[] = [], blockNumber?: BigNumberish, +) { + return new providers.Web3Provider(ganache.provider({ + fork: blockNumber ? `${rpc}@${blockNumber.toString()}` : rpc, + unlocked_accounts: unlockedAccounts, + })) +} + +export function forkChain (unlockedAccounts: string[] = [], blockNumber?: BigNumberish) { + const infura_key = process.env.INFURA_PROJECT_ID + const infura_secret = process.env.INFURA_PROJECT_SECRET + + const rpc = infura_key ? `https://:${infura_secret}@mainnet.infura.io/v3/${infura_key}` : 'https://eth-mainnet.alchemyapi.io/v2/Vc3xNXIWdxEbDOToa69DhWeyhgFVBDWl' + return _forkChain(rpc, unlockedAccounts, blockNumber) +} + +type Getter = keyof T['callStatic'] | ((contract: T) => any) + +const execGetter = (contract: T) => async (getter: Getter) => { + if (typeof getter === 'function') { + return getter(contract) + } + return contract[getter]() +} + +export const TEST_STATE_BLOCK_NUMBER = 12010725 + +export function upgradeSuite(blockNumber: number, Factory: ContractFactoryConstructor, currentAddress: string, + getters: Getter[], contractsOwner?: string): Promise +export function upgradeSuite(Factory: ContractFactoryConstructor, currentAddress: string, + getters: Getter[], contractsOwner?: string): Promise +export function upgradeSuite (...args: any[]): any { + if (typeof args[0] === 'number') { + const [bn, factory, address, getters, owner] = args + return _upgradeSuite(factory, address, getters, owner, bn) + } + const [factory, address, getters, owner] = args + return _upgradeSuite(factory, address, getters, owner) +} + +async function _upgradeSuite ( + Factory: ContractFactoryConstructor, + currentAddress: string, + getters: Getter[], + contractsOwner: string = CONTRACTS_OWNER, + blockNumber?: number | undefined, +) { + const provider = forkChain([contractsOwner, ETHER_HOLDER], blockNumber) + const owner = provider.getSigner(contractsOwner) + const holder = provider.getSigner(ETHER_HOLDER) + await holder.sendTransaction({ value: parseEth(100), to: contractsOwner }) + const newContract = await deployContract(owner, Factory) + const existingContract = new Factory(owner).attach(currentAddress) + const oldValues = await Promise.all(getters.map(execGetter(existingContract))) + const proxy = new OwnedUpgradeabilityProxy__factory(owner).attach(currentAddress) + await (await proxy.upgradeTo(newContract.address)).wait() + const newValues = await Promise.all(getters.map(execGetter(existingContract))) + for (let i = 0; i < oldValues.length; i++) { + expect(oldValues[i], `Possible corrupted storage: +Getter: ${getters[i]} +Current: ${oldValues[i].toString()} +Post upgrade: ${newValues[i].toString()} \n`).to.deep.equal(newValues[i]) + } + return existingContract +} diff --git a/test/truefi2/SAFU.test.ts b/test/truefi2/SAFU.test.ts index c12b39a2a..4cda2d3b8 100644 --- a/test/truefi2/SAFU.test.ts +++ b/test/truefi2/SAFU.test.ts @@ -116,7 +116,7 @@ describe('SAFU', () => { }) }) - describe('Handles debt repay', () => { + describe('Does not repay debt', () => { beforeEach(async () => { await timeTravel(DAY * 400) await loan.enterDefault() @@ -127,30 +127,37 @@ describe('SAFU', () => { await token.mint(safu.address, defaultAmount) }) - it('takes funds from safu', async () => { + it('does not take funds from safu', async () => { await expect(() => safu.liquidate(loan.address)) - .to.changeTokenBalance(token, safu, defaultAmount.mul(-1)) + .to.changeTokenBalance(token, safu, 0) }) - it('transfers funds to the pool', async () => { + it('does not transfer funds to the pool', async () => { await expect(() => safu.liquidate(loan.address)) - .to.changeTokenBalance(token, pool, defaultAmount) + .to.changeTokenBalance(token, pool, 0) }) it('sets deficiencyToken', async () => { + const tx = await safu.liquidate(loan.address) + const deficiencyToken = (await tx.wait()).events[7].args.deficiencyToken + expect(await safu.deficiencyToken(loan.address)).to.eq(deficiencyToken) + }) + + it('increases internal pool deficit', async () => { await safu.liquidate(loan.address) - expect(await safu.deficiencyToken(loan.address)).to.eq(AddressZero) + expect(await safu.internalPoolDeficit(pool.address)).to.eq(defaultAmount) }) - it('increases pool deficit', async () => { + it('does not move pool deficit from zero', async () => { await safu.liquidate(loan.address) expect(await safu.poolDeficit(pool.address)).to.eq(0) }) it('emits event', async () => { - await expect(safu.liquidate(loan.address)) + const tx = await safu.liquidate(loan.address) + await expect(tx) .to.emit(safu, 'Liquidated') - .withArgs(loan.address, defaultAmount, AddressZero, 0) + .withArgs(loan.address, 0, await safu.deficiencyToken(loan.address), defaultAmount) }) }) @@ -159,32 +166,37 @@ describe('SAFU', () => { await token.mint(safu.address, defaultAmount.div(2)) }) - it('takes funds from safu', async () => { + it('does not take funds from safu', async () => { await expect(() => safu.liquidate(loan.address)) - .to.changeTokenBalance(token, safu, defaultAmount.div(2).mul(-1)) + .to.changeTokenBalance(token, safu, 0) }) - it('transfers funds to the pool', async () => { + it('does not transfer funds to the pool', async () => { await expect(() => safu.liquidate(loan.address)) - .to.changeTokenBalance(token, pool, defaultAmount.div(2)) + .to.changeTokenBalance(token, pool, 0) }) it('sets deficiencyToken', async () => { const tx = await safu.liquidate(loan.address) - const deficiencyToken = (await tx.wait()).events[8].args.deficiencyToken + const deficiencyToken = (await tx.wait()).events[7].args.deficiencyToken expect(await safu.deficiencyToken(loan.address)).to.eq(deficiencyToken) }) - it('increases pool deficit', async () => { + it('increases internal pool deficit', async () => { await safu.liquidate(loan.address) - expect(await safu.poolDeficit(pool.address)).to.eq(defaultAmount.div(2)) + expect(await safu.internalPoolDeficit(pool.address)).to.eq(defaultAmount) + }) + + it('does not move pool deficit from zero', async () => { + await safu.liquidate(loan.address) + expect(await safu.poolDeficit(pool.address)).to.eq(0) }) it('emits event', async () => { const tx = await safu.liquidate(loan.address) await expect(tx) .to.emit(safu, 'Liquidated') - .withArgs(loan.address, defaultAmount.div(2), await safu.deficiencyToken(loan.address), defaultAmount.div(2)) + .withArgs(loan.address, 0, await safu.deficiencyToken(loan.address), defaultAmount) }) }) }) @@ -293,22 +305,27 @@ describe('SAFU', () => { it('burns deficiency tokens', async () => { const dToken = new DeficiencyToken__factory(owner).attach(await safu.deficiencyToken(loan.address)) - expect(await dToken.totalSupply()).to.eq(defaultAmount.div(2)) + expect(await dToken.totalSupply()).to.eq(defaultAmount) await pool.reclaimDeficit(loan.address) expect(await dToken.totalSupply()).to.eq(0) }) - it('decreases pool deficit', async () => { + it('decreases internal pool deficit', async () => { + await pool.reclaimDeficit(loan.address) + expect(await safu.internalPoolDeficit(pool.address)).to.eq(0) + }) + + it('does not move pool deficit from zero', async () => { await pool.reclaimDeficit(loan.address) expect(await safu.poolDeficit(pool.address)).to.eq(0) }) it('transfers deficit to the pool', async () => { - await expect(() => pool.reclaimDeficit(loan.address)).changeTokenBalance(token, pool, defaultAmount.div(2)) + await expect(() => pool.reclaimDeficit(loan.address)).changeTokenBalance(token, pool, defaultAmount) }) it('transfers deficit from the safu', async () => { - await expect(() => pool.reclaimDeficit(loan.address)).changeTokenBalance(token, safu, defaultAmount.div(2).mul(-1)) + await expect(() => pool.reclaimDeficit(loan.address)).changeTokenBalance(token, safu, defaultAmount.mul(-1)) }) it('safu keeps excessive funds', async () => { @@ -319,7 +336,7 @@ describe('SAFU', () => { it('emits event', async () => { await expect(pool.reclaimDeficit(loan.address)) .to.emit(safu, 'Reclaimed') - .withArgs(loan.address, defaultAmount.div(2)) + .withArgs(loan.address, defaultAmount) }) }) })