diff --git a/markets/perps-market/test/integration/Market/CreateMarket.operationAndConfiguration.test.ts b/markets/perps-market/test/integration/Market/CreateMarket.operationAndConfiguration.test.ts new file mode 100644 index 0000000000..d96b57e0cd --- /dev/null +++ b/markets/perps-market/test/integration/Market/CreateMarket.operationAndConfiguration.test.ts @@ -0,0 +1,201 @@ +import { BigNumber, ethers } from 'ethers'; +import { bn, bootstrapMarkets, createKeeperCostNode, STRICT_PRICE_TOLERANCE } from '../bootstrap'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { createOracleNode } from '@synthetixio/oracle-manager/test/common'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; + +describe('Create Market: operation and configuration', () => { + const name = 'Ether', + token = 'snxETH', + price = bn(1000); + + const { systems, signers, owner, provider, trader1 } = bootstrapMarkets({ + synthMarkets: [], + perpsMarkets: [], // don't create a market in bootstrap + traderAccountIds: [2, 3], + skipKeeperCostOracleNode: true, + }); + + let randomAccount: ethers.Signer; + + const restore = snapshotCheckpoint(provider); + before(restore); + + before('identify actors', async () => { + [, , , , randomAccount] = signers(); + }); + + const marketId = BigNumber.from(25); + let oracleNodeId: string; + let keeperCostNodeId: string; + + before('create perps market', async () => { + await systems().PerpsMarket.connect(owner()).createMarket(marketId, name, token); + }); + + before('set max market value', async () => { + await systems().PerpsMarket.connect(owner()).setMaxMarketSize(marketId, bn(99999999)); + await systems().PerpsMarket.connect(owner()).setMaxMarketValue(marketId, bn(999999999999)); + }); + + before('create price node', async () => { + const results = await createOracleNode(owner(), price, systems().OracleManager); + oracleNodeId = results.oracleNodeId; + }); + + before('create keeper reward node', async () => { + const results = await createKeeperCostNode(owner(), systems().OracleManager); + keeperCostNodeId = results.keeperCostNodeId; + }); + + describe('attempt to update price data with non-owner', () => { + it('reverts', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(randomAccount) + .updatePriceData(marketId, oracleNodeId, STRICT_PRICE_TOLERANCE), + 'Unauthorized' + ); + }); + }); + + describe('attempt to update keeper cost with non-owner', () => { + it('reverts', async () => { + await assertRevert( + systems().PerpsMarket.connect(randomAccount).updateKeeperCostNodeId(keeperCostNodeId), + 'Unauthorized' + ); + }); + }); + + describe('before setting up price data', () => { + it('reverts when trying to use the market', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }), + 'PriceFeedNotSet' + ); + }); + }); + + describe('when price data is updated', () => { + before('update price data', async () => { + await systems() + .PerpsMarket.connect(owner()) + .updatePriceData(marketId, oracleNodeId, STRICT_PRICE_TOLERANCE); + }); + + describe('before setting up price data', () => { + it('reverts when trying to use the market', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }), + 'KeeperCostsNotSet' + ); + }); + }); + + describe('when keeper cost data is updated', () => { + before('update keeper reward data', async () => { + await systems().PerpsMarket.connect(owner()).updateKeeperCostNodeId(keeperCostNodeId); + }); + + before('create settlement strategy', async () => { + await systems() + .PerpsMarket.connect(owner()) + .addSettlementStrategy(marketId, { + strategyType: 0, + settlementDelay: 5, + settlementWindowDuration: 120, + commitmentPriceDelay: 0, + priceVerificationContract: ethers.constants.AddressZero, + feedId: ethers.constants.HashZero, + disabled: false, + settlementReward: bn(5), + }); + }); + + before('set skew scale', async () => { + await systems() + .PerpsMarket.connect(owner()) + .setFundingParameters(marketId, bn(100_000), bn(0)); + }); + + before('ensure per account max is set to zero', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 0); + }); + + it('reverts when trying add collateral if max collaterals per account is zero', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)), + 'MaxCollateralsPerAccountReached("0")' + ); + }); + + describe('when max collaterals per account is set to non-zero', () => { + before('set max collaterals per account', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 1000); + }); + + before('add collateral', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)); + }); + + it('reverts when trying to add position if max positions per account is zero', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + + trackingCode: ethers.constants.HashZero, + }), + 'MaxPositionsPerAccountReached("0")' + ); + }); + + describe('when max positions per account is set to non-zero', () => { + before('set max positions per account', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(1000, 1000); + }); + it('should be able to use the market', async () => { + await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + }); + }); + }); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Market/CreateMarket.test.ts b/markets/perps-market/test/integration/Market/CreateMarket.test.ts index 14cb8d7206..3e38f86175 100644 --- a/markets/perps-market/test/integration/Market/CreateMarket.test.ts +++ b/markets/perps-market/test/integration/Market/CreateMarket.test.ts @@ -1,5 +1,5 @@ -import { ethers, BigNumber } from 'ethers'; -import { STRICT_PRICE_TOLERANCE, bn, bootstrapMarkets, createKeeperCostNode } from '../bootstrap'; +import { BigNumber, ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; import assert from 'assert'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; @@ -12,7 +12,7 @@ describe('Create Market test', () => { token = 'snxETH', price = bn(1000); - const { systems, signers, owner, provider, trader1, superMarketId } = bootstrapMarkets({ + const { systems, signers, owner, provider, superMarketId } = bootstrapMarkets({ synthMarkets: [], perpsMarkets: [], // don't create a market in bootstrap traderAccountIds: [2, 3], @@ -112,184 +112,6 @@ describe('Create Market test', () => { }); }); - describe('market operation and configuration', () => { - before(restore); - - const marketId = BigNumber.from(25); - let oracleNodeId: string; - let keeperCostNodeId: string; - - before('create perps market', async () => { - await systems().PerpsMarket.connect(owner()).createMarket(marketId, name, token); - }); - - before('set max market value', async () => { - await systems().PerpsMarket.connect(owner()).setMaxMarketSize(marketId, bn(99999999)); - await systems().PerpsMarket.connect(owner()).setMaxMarketValue(marketId, bn(999999999999)); - }); - - before('create price node', async () => { - const results = await createOracleNode(owner(), price, systems().OracleManager); - oracleNodeId = results.oracleNodeId; - }); - - before('create keeper reward node', async () => { - const results = await createKeeperCostNode(owner(), systems().OracleManager); - keeperCostNodeId = results.keeperCostNodeId; - }); - - describe('attempt to update price data with non-owner', () => { - it('reverts', async () => { - await assertRevert( - systems() - .PerpsMarket.connect(randomAccount) - .updatePriceData(marketId, oracleNodeId, STRICT_PRICE_TOLERANCE), - 'Unauthorized' - ); - }); - }); - - describe('attempt to update keeper cost with non-owner', () => { - it('reverts', async () => { - await assertRevert( - systems().PerpsMarket.connect(randomAccount).updateKeeperCostNodeId(keeperCostNodeId), - 'Unauthorized' - ); - }); - }); - - describe('before setting up price data', () => { - it('reverts when trying to use the market', async () => { - await assertRevert( - systems() - .PerpsMarket.connect(owner()) - .commitOrder({ - marketId: marketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, - }), - 'PriceFeedNotSet' - ); - }); - }); - - describe('when price data is updated', () => { - before('update price data', async () => { - await systems() - .PerpsMarket.connect(owner()) - .updatePriceData(marketId, oracleNodeId, STRICT_PRICE_TOLERANCE); - }); - - describe('before setting up price data', () => { - it('reverts when trying to use the market', async () => { - await assertRevert( - systems() - .PerpsMarket.connect(owner()) - .commitOrder({ - marketId: marketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, - }), - 'KeeperCostsNotSet' - ); - }); - }); - - describe('when keeper cost data is updated', () => { - before('update keeper reward data', async () => { - await systems().PerpsMarket.connect(owner()).updateKeeperCostNodeId(keeperCostNodeId); - }); - - before('create settlement strategy', async () => { - await systems() - .PerpsMarket.connect(owner()) - .addSettlementStrategy(marketId, { - strategyType: 0, - settlementDelay: 5, - settlementWindowDuration: 120, - commitmentPriceDelay: 0, - priceVerificationContract: ethers.constants.AddressZero, - feedId: ethers.constants.HashZero, - disabled: false, - settlementReward: bn(5), - }); - }); - - before('set skew scale', async () => { - await systems() - .PerpsMarket.connect(owner()) - .setFundingParameters(marketId, bn(100_000), bn(0)); - }); - - before('ensure per account max is set to zero', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 0); - }); - - it('reverts when trying add collateral if max collaterals per account is zero', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)), - 'MaxCollateralsPerAccountReached("0")' - ); - }); - - describe('when max collaterals per account is set to non-zero', () => { - before('set max collaterals per account', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 1000); - }); - - before('add collateral', async () => { - await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)); - }); - - it('reverts when trying to add position if max positions per account is zero', async () => { - await assertRevert( - systems() - .PerpsMarket.connect(trader1()) - .commitOrder({ - marketId: marketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - - trackingCode: ethers.constants.HashZero, - }), - 'MaxPositionsPerAccountReached("0")' - ); - }); - - describe('when max positions per account is set to non-zero', () => { - before('set max positions per account', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(1000, 1000); - }); - it('should be able to use the market', async () => { - await systems() - .PerpsMarket.connect(trader1()) - .commitOrder({ - marketId: marketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, - }); - }); - }); - }); - }); - }); - }); - describe('market interface views', () => { before(restore); diff --git a/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxBTC.test.ts b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxBTC.test.ts new file mode 100644 index 0000000000..bdecc27fbe --- /dev/null +++ b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxBTC.test.ts @@ -0,0 +1,333 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets, DEFAULT_SETTLEMENT_STRATEGY } from '../bootstrap'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import { calculateFillPrice, depositCollateral } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; +import { wei } from '@synthetixio/wei'; +import { calcCurrentFundingVelocity } from '../helpers/funding-calcs'; +import { deepEqual } from 'assert/strict'; + +describe('Settle Offchain Async Order test', () => { + const { systems, perpsMarkets, synthMarkets, provider, trader1, keeper } = bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: bn(1000), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let btcSynth: SynthMarkets[number]; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + btcSynth = synthMarkets()[0]; + }); + + before('set Pyth Benchmark Price data', async () => { + const offChainPrice = bn(1000); + + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setBenchmarkPrice(offChainPrice); + }); + + describe('failures before commiting orders', () => { + describe('using settle', () => { + it('reverts if account id is incorrect (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(1337), + 'OrderNotValid()' + ); + }); + + it('reverts if order was not settled before (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'OrderNotValid()' + ); + }); + }); + }); + + const restoreToCommit = snapshotCheckpoint(provider); + + const testCase = { + name: 'only snxBTC', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }; + + describe(`Using ${testCase.name} as collateral`, () => { + let tx: ethers.ContractTransaction; + let startTime: number; + + before(restoreToCommit); + + before('add collateral', async () => { + await depositCollateral(testCase.collateralData); + }); + + before('commit the order', async () => { + tx = await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: ethMarketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + startTime = await getTxTime(provider(), tx); + }); + + const restoreBeforeSettle = snapshotCheckpoint(provider); + + describe('attempts to settle before settlement time', () => { + before(restoreBeforeSettle); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'SettlementWindowNotOpen' + ); + }); + }); + + describe('attempts to settle after settlement window', () => { + before(restoreBeforeSettle); + + before('fast forward to past settlement window', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + + DEFAULT_SETTLEMENT_STRATEGY.settlementWindowDuration + + 1, + provider() + ); + }); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + 'SettlementWindowExpired' + ); + }); + }); + + describe('attempts to settle with invalid pyth price data', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + it('reverts when there is no benchmark price', async () => { + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setAlwaysRevertFlag(true); + + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + `OracleDataRequired("${DEFAULT_SETTLEMENT_STRATEGY.feedId}", ${startTime + 2})` + ); + }); + }); + + describe('attempts to settle with not enough collateral', () => { + // Note: This tests is not valid for the "only snxUSD" case + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + before('update collateral price', async () => { + await btcSynth.sellAggregator().mockSetCurrentPrice(bn(0.1)); + }); + + it('reverts with invalid pyth price timestamp (after time)', async () => { + const availableCollateral = wei(0.1); + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + `InsufficientMargin("${availableCollateral.bn}", "${bn(5).toString()}")` + ); + }); + }); + + describe('settle order', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + describe('disable settlement strategy', () => { + before(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + false + ); + }); + + it('reverts with invalid settlement strategy', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'InvalidSettlementStrategy' + ); + }); + + after(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + true + ); + }); + }); + + describe('settle order', () => { + let settleTx: ethers.ContractTransaction; + + before('settle', async () => { + settleTx = await systems().PerpsMarket.connect(keeper()).settleOrder(2); + }); + + it('emits settle event', async () => { + const accountId = 2; + const fillPrice = calculateFillPrice(wei(0), wei(100_000), wei(1), wei(1000)).toBN(); + const sizeDelta = bn(1); + const newPositionSize = bn(1); + const totalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const settlementReward = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const trackingCode = `"${ethers.constants.HashZero}"`; + const msgSender = `"${await keeper().getAddress()}"`; + const params = [ + ethMarketId, + accountId, + fillPrice, + 0, + 0, + sizeDelta, + newPositionSize, + totalFees, + 0, // referral fees + 0, // collected fees + settlementReward, + trackingCode, + msgSender, + ]; + await assertEvent(settleTx, `OrderSettled(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits market updated event', async () => { + const price = bn(1000); + const marketSize = bn(1); + const marketSkew = bn(1); + const sizeDelta = bn(1); + const currentFundingRate = bn(0); + const currentFundingVelocity = calcCurrentFundingVelocity({ + skew: wei(1), + skewScale: wei(100_000), + maxFundingVelocity: wei(10), + }); + const params = [ + ethMarketId, + price, + marketSkew, + marketSize, + sizeDelta, + currentFundingRate, + currentFundingVelocity.toBN(), // Funding rates should be tested more thoroughly elsewhre + 0, // interest rate is 0 since no params were set + ]; + await assertEvent(settleTx, `MarketUpdated(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits collateral deducted events', async () => { + let pendingTotalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const accountId = 2; + + for (let i = 0; i < testCase.collateralData.collaterals.length; i++) { + const collateral = testCase.collateralData.collaterals[i]; + const synthMarket = collateral.synthMarket ? collateral.synthMarket().marketId() : 0; + let deductedCollateralAmount: ethers.BigNumber = bn(0); + if (synthMarket == 0) { + deductedCollateralAmount = collateral.snxUSDAmount().lt(pendingTotalFees) + ? collateral.snxUSDAmount() + : pendingTotalFees; + } else { + deductedCollateralAmount = pendingTotalFees.div(10_000); + } + pendingTotalFees = pendingTotalFees.sub(deductedCollateralAmount); + + await assertEvent( + settleTx, + `CollateralDeducted(${accountId}, ${synthMarket}, ${deductedCollateralAmount})`, + systems().PerpsMarket + ); + } + }); + + it('check position is live', async () => { + const [pnl, funding, size] = await systems().PerpsMarket.getOpenPosition(2, ethMarketId); + assertBn.equal(pnl, bn(-0.005)); + assertBn.equal(funding, bn(0)); + assertBn.equal(size, bn(1)); + }); + + it('check position size', async () => { + const size = await systems().PerpsMarket.getOpenPositionSize(2, ethMarketId); + assertBn.equal(size, bn(1)); + }); + + it('check account open position market ids', async () => { + const positions = await systems().PerpsMarket.getAccountOpenPositions(2); + deepEqual(positions, [ethMarketId]); + }); + }); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxUSD.test.ts b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxUSD.test.ts new file mode 100644 index 0000000000..54fd5e3432 --- /dev/null +++ b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.only-snxUSD.test.ts @@ -0,0 +1,323 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets, DEFAULT_SETTLEMENT_STRATEGY } from '../bootstrap'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import { calculateFillPrice, depositCollateral } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; +import { wei } from '@synthetixio/wei'; +import { calcCurrentFundingVelocity } from '../helpers/funding-calcs'; +import { deepEqual } from 'assert/strict'; + +describe('Settle Offchain Async Order test', () => { + const { systems, perpsMarkets, synthMarkets, provider, trader1, keeper } = bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: bn(1000), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let btcSynth: SynthMarkets[number]; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + btcSynth = synthMarkets()[0]; + }); + + before('set Pyth Benchmark Price data', async () => { + const offChainPrice = bn(1000); + + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setBenchmarkPrice(offChainPrice); + }); + + describe('failures before commiting orders', () => { + describe('using settle', () => { + it('reverts if account id is incorrect (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(1337), + 'OrderNotValid()' + ); + }); + + it('reverts if order was not settled before (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'OrderNotValid()' + ); + }); + }); + }); + + const restoreToCommit = snapshotCheckpoint(provider); + + const testCase = { + name: 'only snxUSD', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }; + describe(`Using ${testCase.name} as collateral`, () => { + let tx: ethers.ContractTransaction; + let startTime: number; + + before(restoreToCommit); + + before('add collateral', async () => { + await depositCollateral(testCase.collateralData); + }); + + before('commit the order', async () => { + tx = await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: ethMarketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + startTime = await getTxTime(provider(), tx); + }); + + const restoreBeforeSettle = snapshotCheckpoint(provider); + + describe('attempts to settle before settlement time', () => { + before(restoreBeforeSettle); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'SettlementWindowNotOpen' + ); + }); + }); + + describe('attempts to settle after settlement window', () => { + before(restoreBeforeSettle); + + before('fast forward to past settlement window', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + + DEFAULT_SETTLEMENT_STRATEGY.settlementWindowDuration + + 1, + provider() + ); + }); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + 'SettlementWindowExpired' + ); + }); + }); + + describe('attempts to settle with invalid pyth price data', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + it('reverts when there is no benchmark price', async () => { + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setAlwaysRevertFlag(true); + + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + `OracleDataRequired("${DEFAULT_SETTLEMENT_STRATEGY.feedId}", ${startTime + 2})` + ); + }); + }); + + describe('attempts to settle with not enough collateral', () => { + // Note: This tests is not valid for the "only snxUSD" case + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + before('update collateral price', async () => { + await btcSynth.sellAggregator().mockSetCurrentPrice(bn(0.1)); + }); + }); + + describe('settle order', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + describe('disable settlement strategy', () => { + before(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + false + ); + }); + + it('reverts with invalid settlement strategy', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'InvalidSettlementStrategy' + ); + }); + + after(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + true + ); + }); + }); + + describe('settle order', () => { + let settleTx: ethers.ContractTransaction; + + before('settle', async () => { + settleTx = await systems().PerpsMarket.connect(keeper()).settleOrder(2); + }); + + it('emits settle event', async () => { + const accountId = 2; + const fillPrice = calculateFillPrice(wei(0), wei(100_000), wei(1), wei(1000)).toBN(); + const sizeDelta = bn(1); + const newPositionSize = bn(1); + const totalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const settlementReward = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const trackingCode = `"${ethers.constants.HashZero}"`; + const msgSender = `"${await keeper().getAddress()}"`; + const params = [ + ethMarketId, + accountId, + fillPrice, + 0, + 0, + sizeDelta, + newPositionSize, + totalFees, + 0, // referral fees + 0, // collected fees + settlementReward, + trackingCode, + msgSender, + ]; + await assertEvent(settleTx, `OrderSettled(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits market updated event', async () => { + const price = bn(1000); + const marketSize = bn(1); + const marketSkew = bn(1); + const sizeDelta = bn(1); + const currentFundingRate = bn(0); + const currentFundingVelocity = calcCurrentFundingVelocity({ + skew: wei(1), + skewScale: wei(100_000), + maxFundingVelocity: wei(10), + }); + const params = [ + ethMarketId, + price, + marketSkew, + marketSize, + sizeDelta, + currentFundingRate, + currentFundingVelocity.toBN(), // Funding rates should be tested more thoroughly elsewhre + 0, // interest rate is 0 since no params were set + ]; + await assertEvent(settleTx, `MarketUpdated(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits collateral deducted events', async () => { + let pendingTotalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const accountId = 2; + + for (let i = 0; i < testCase.collateralData.collaterals.length; i++) { + const collateral = testCase.collateralData.collaterals[i]; + const synthMarket = collateral.synthMarket ? collateral.synthMarket().marketId() : 0; + let deductedCollateralAmount: ethers.BigNumber = bn(0); + if (synthMarket == 0) { + deductedCollateralAmount = collateral.snxUSDAmount().lt(pendingTotalFees) + ? collateral.snxUSDAmount() + : pendingTotalFees; + } else { + deductedCollateralAmount = pendingTotalFees.div(10_000); + } + pendingTotalFees = pendingTotalFees.sub(deductedCollateralAmount); + + await assertEvent( + settleTx, + `CollateralDeducted(${accountId}, ${synthMarket}, ${deductedCollateralAmount})`, + systems().PerpsMarket + ); + } + }); + + it('check position is live', async () => { + const [pnl, funding, size] = await systems().PerpsMarket.getOpenPosition(2, ethMarketId); + assertBn.equal(pnl, bn(-0.005)); + assertBn.equal(funding, bn(0)); + assertBn.equal(size, bn(1)); + }); + + it('check position size', async () => { + const size = await systems().PerpsMarket.getOpenPositionSize(2, ethMarketId); + assertBn.equal(size, bn(1)); + }); + + it('check account open position market ids', async () => { + const positions = await systems().PerpsMarket.getAccountOpenPositions(2); + deepEqual(positions, [ethMarketId]); + }); + }); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.snxUSD-and-snxBTC.test.ts b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.snxUSD-and-snxBTC.test.ts new file mode 100644 index 0000000000..5347b1d020 --- /dev/null +++ b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.snxUSD-and-snxBTC.test.ts @@ -0,0 +1,338 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets, DEFAULT_SETTLEMENT_STRATEGY } from '../bootstrap'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import { calculateFillPrice, calculatePricePnl, depositCollateral } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; +import { wei } from '@synthetixio/wei'; +import { calcCurrentFundingVelocity } from '../helpers/funding-calcs'; +import { deepEqual } from 'assert/strict'; + +describe('Settle Offchain Async Order test', () => { + const { systems, perpsMarkets, synthMarkets, provider, trader1, keeper } = bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: bn(1000), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let btcSynth: SynthMarkets[number]; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + btcSynth = synthMarkets()[0]; + }); + + before('set Pyth Benchmark Price data', async () => { + const offChainPrice = bn(1000); + + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setBenchmarkPrice(offChainPrice); + }); + + describe('failures before commiting orders', () => { + describe('using settle', () => { + it('reverts if account id is incorrect (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(1337), + 'OrderNotValid()' + ); + }); + + it('reverts if order was not settled before (not valid order)', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'OrderNotValid()' + ); + }); + }); + }); + + const restoreToCommit = snapshotCheckpoint(provider); + + const testCase = { + name: 'snxUSD and snxBTC', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(2), // less than needed to pay for settlementReward + }, + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }; + describe(`Using ${testCase.name} as collateral`, () => { + let tx: ethers.ContractTransaction; + let startTime: number; + + before(restoreToCommit); + + before('add collateral', async () => { + await depositCollateral(testCase.collateralData); + }); + + before('commit the order', async () => { + tx = await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: ethMarketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + startTime = await getTxTime(provider(), tx); + }); + + const restoreBeforeSettle = snapshotCheckpoint(provider); + + describe('attempts to settle before settlement time', () => { + before(restoreBeforeSettle); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'SettlementWindowNotOpen' + ); + }); + }); + + describe('attempts to settle after settlement window', () => { + before(restoreBeforeSettle); + + before('fast forward to past settlement window', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + + DEFAULT_SETTLEMENT_STRATEGY.settlementWindowDuration + + 1, + provider() + ); + }); + + it('with settleOrder', async () => { + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + 'SettlementWindowExpired' + ); + }); + }); + + describe('attempts to settle with invalid pyth price data', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + it('reverts when there is no benchmark price', async () => { + // set Pyth setBenchmarkPrice + await systems().MockPythERC7412Wrapper.setAlwaysRevertFlag(true); + + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + `OracleDataRequired("${DEFAULT_SETTLEMENT_STRATEGY.feedId}", ${startTime + 2})` + ); + }); + }); + + describe('attempts to settle with not enough collateral', () => { + // Note: This tests is not valid for the "only snxUSD" case + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + before('update collateral price', async () => { + await btcSynth.sellAggregator().mockSetCurrentPrice(bn(0.1)); + }); + + it('reverts with invalid pyth price timestamp (after time)', async () => { + const currentSkew = await systems().PerpsMarket.skew(ethMarketId); + const startingPnl = calculatePricePnl(wei(currentSkew), wei(100_000), wei(1), wei(1000)); + const availableCollateral = wei(2.1).add(startingPnl); + + await assertRevert( + systems().PerpsMarket.connect(keeper()).settleOrder(2), + `InsufficientMargin("${availableCollateral.bn}", "${bn(5).toString()}")` + ); + }); + }); + + describe('settle order', () => { + before(restoreBeforeSettle); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo( + startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, + provider() + ); + }); + + describe('disable settlement strategy', () => { + before(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + false + ); + }); + + it('reverts with invalid settlement strategy', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).settleOrder(2), + 'InvalidSettlementStrategy' + ); + }); + + after(async () => { + await systems().PerpsMarket.setSettlementStrategyEnabled( + ethMarketId, + ethSettlementStrategyId, + true + ); + }); + }); + + describe('settle order', () => { + let settleTx: ethers.ContractTransaction; + + before('settle', async () => { + settleTx = await systems().PerpsMarket.connect(keeper()).settleOrder(2); + }); + + it('emits settle event', async () => { + const accountId = 2; + const fillPrice = calculateFillPrice(wei(0), wei(100_000), wei(1), wei(1000)).toBN(); + const sizeDelta = bn(1); + const newPositionSize = bn(1); + const totalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const settlementReward = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const trackingCode = `"${ethers.constants.HashZero}"`; + const msgSender = `"${await keeper().getAddress()}"`; + const params = [ + ethMarketId, + accountId, + fillPrice, + 0, + 0, + sizeDelta, + newPositionSize, + totalFees, + 0, // referral fees + 0, // collected fees + settlementReward, + trackingCode, + msgSender, + ]; + await assertEvent(settleTx, `OrderSettled(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits market updated event', async () => { + const price = bn(1000); + const marketSize = bn(1); + const marketSkew = bn(1); + const sizeDelta = bn(1); + const currentFundingRate = bn(0); + const currentFundingVelocity = calcCurrentFundingVelocity({ + skew: wei(1), + skewScale: wei(100_000), + maxFundingVelocity: wei(10), + }); + const params = [ + ethMarketId, + price, + marketSkew, + marketSize, + sizeDelta, + currentFundingRate, + currentFundingVelocity.toBN(), // Funding rates should be tested more thoroughly elsewhre + 0, // interest rate is 0 since no params were set + ]; + await assertEvent(settleTx, `MarketUpdated(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits collateral deducted events', async () => { + let pendingTotalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; + const accountId = 2; + + for (let i = 0; i < testCase.collateralData.collaterals.length; i++) { + const collateral = testCase.collateralData.collaterals[i]; + const synthMarket = collateral.synthMarket ? collateral.synthMarket().marketId() : 0; + let deductedCollateralAmount: ethers.BigNumber = bn(0); + if (synthMarket == 0) { + deductedCollateralAmount = collateral.snxUSDAmount().lt(pendingTotalFees) + ? collateral.snxUSDAmount() + : pendingTotalFees; + } else { + deductedCollateralAmount = pendingTotalFees.div(10_000); + } + pendingTotalFees = pendingTotalFees.sub(deductedCollateralAmount); + + await assertEvent( + settleTx, + `CollateralDeducted(${accountId}, ${synthMarket}, ${deductedCollateralAmount})`, + systems().PerpsMarket + ); + } + }); + + it('check position is live', async () => { + const [pnl, funding, size] = await systems().PerpsMarket.getOpenPosition(2, ethMarketId); + assertBn.equal(pnl, bn(-0.005)); + assertBn.equal(funding, bn(0)); + assertBn.equal(size, bn(1)); + }); + + it('check position size', async () => { + const size = await systems().PerpsMarket.getOpenPositionSize(2, ethMarketId); + assertBn.equal(size, bn(1)); + }); + + it('check account open position market ids', async () => { + const positions = await systems().PerpsMarket.getAccountOpenPositions(2); + deepEqual(positions, [ethMarketId]); + }); + }); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.test.ts b/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.test.ts deleted file mode 100644 index 6699331746..0000000000 --- a/markets/perps-market/test/integration/Orders/OffchainAsyncOrder.settle.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { ethers } from 'ethers'; -import { DEFAULT_SETTLEMENT_STRATEGY, bn, bootstrapMarkets } from '../bootstrap'; -import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; -import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; -import { SynthMarkets } from '@synthetixio/spot-market/test/common'; -import { DepositCollateralData, depositCollateral } from '../helpers'; -import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; -import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; -import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; -import { calculateFillPrice, calculatePricePnl } from '../helpers/fillPrice'; -import { wei } from '@synthetixio/wei'; -import { calcCurrentFundingVelocity } from '../helpers/funding-calcs'; -import { deepEqual } from 'assert/strict'; - -describe('Settle Offchain Async Order test', () => { - const { systems, perpsMarkets, synthMarkets, provider, trader1, keeper } = bootstrapMarkets({ - synthMarkets: [ - { - name: 'Bitcoin', - token: 'snxBTC', - buyPrice: bn(10_000), - sellPrice: bn(10_000), - }, - ], - perpsMarkets: [ - { - requestedMarketId: 25, - name: 'Ether', - token: 'snxETH', - price: bn(1000), - fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, - }, - ], - traderAccountIds: [2, 3], - }); - let ethMarketId: ethers.BigNumber; - let ethSettlementStrategyId: ethers.BigNumber; - let btcSynth: SynthMarkets[number]; - - before('identify actors', async () => { - ethMarketId = perpsMarkets()[0].marketId(); - ethSettlementStrategyId = perpsMarkets()[0].strategyId(); - btcSynth = synthMarkets()[0]; - }); - - before('set Pyth Benchmark Price data', async () => { - const offChainPrice = bn(1000); - - // set Pyth setBenchmarkPrice - await systems().MockPythERC7412Wrapper.setBenchmarkPrice(offChainPrice); - }); - - describe('failures before commiting orders', () => { - describe('using settle', () => { - it('reverts if account id is incorrect (not valid order)', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).settleOrder(1337), - 'OrderNotValid()' - ); - }); - - it('reverts if order was not settled before (not valid order)', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).settleOrder(2), - 'OrderNotValid()' - ); - }); - }); - }); - - const restoreToCommit = snapshotCheckpoint(provider); - - const testCases: Array<{ name: string; collateralData: DepositCollateralData }> = [ - { - name: 'only snxUSD', - collateralData: { - systems, - trader: trader1, - accountId: () => 2, - collaterals: [ - { - snxUSDAmount: () => bn(10_000), - }, - ], - }, - }, - { - name: 'only snxBTC', - collateralData: { - systems, - trader: trader1, - accountId: () => 2, - collaterals: [ - { - synthMarket: () => btcSynth, - snxUSDAmount: () => bn(10_000), - }, - ], - }, - }, - { - name: 'snxUSD and snxBTC', - collateralData: { - systems, - trader: trader1, - accountId: () => 2, - collaterals: [ - { - snxUSDAmount: () => bn(2), // less than needed to pay for settlementReward - }, - { - synthMarket: () => btcSynth, - snxUSDAmount: () => bn(10_000), - }, - ], - }, - }, - ]; - - for (let idx = 0; idx < testCases.length; idx++) { - const testCase = testCases[idx]; - describe(`Using ${testCase.name} as collateral`, () => { - let tx: ethers.ContractTransaction; - let startTime: number; - - before(restoreToCommit); - - before('add collateral', async () => { - await depositCollateral(testCase.collateralData); - }); - - before('commit the order', async () => { - tx = await systems() - .PerpsMarket.connect(trader1()) - .commitOrder({ - marketId: ethMarketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, - }); - startTime = await getTxTime(provider(), tx); - }); - - const restoreBeforeSettle = snapshotCheckpoint(provider); - - describe('attempts to settle before settlement time', () => { - before(restoreBeforeSettle); - - it('with settleOrder', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).settleOrder(2), - 'SettlementWindowNotOpen' - ); - }); - }); - - describe('attempts to settle after settlement window', () => { - before(restoreBeforeSettle); - - before('fast forward to past settlement window', async () => { - // fast forward to settlement - await fastForwardTo( - startTime + - DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + - DEFAULT_SETTLEMENT_STRATEGY.settlementWindowDuration + - 1, - provider() - ); - }); - - it('with settleOrder', async () => { - await assertRevert( - systems().PerpsMarket.connect(keeper()).settleOrder(2), - 'SettlementWindowExpired' - ); - }); - }); - - describe('attempts to settle with invalid pyth price data', () => { - before(restoreBeforeSettle); - - before('fast forward to settlement time', async () => { - // fast forward to settlement - await fastForwardTo( - startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, - provider() - ); - }); - - it('reverts when there is no benchmark price', async () => { - // set Pyth setBenchmarkPrice - await systems().MockPythERC7412Wrapper.setAlwaysRevertFlag(true); - - await assertRevert( - systems().PerpsMarket.connect(keeper()).settleOrder(2), - `OracleDataRequired("${DEFAULT_SETTLEMENT_STRATEGY.feedId}", ${startTime + 2})` - ); - }); - }); - - describe('attempts to settle with not enough collateral', () => { - // Note: This tests is not valid for the "only snxUSD" case - before(restoreBeforeSettle); - - before('fast forward to settlement time', async () => { - // fast forward to settlement - await fastForwardTo( - startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, - provider() - ); - }); - - before('update collateral price', async () => { - await btcSynth.sellAggregator().mockSetCurrentPrice(bn(0.1)); - }); - - it('reverts with invalid pyth price timestamp (after time)', async () => { - if (testCase.name === 'only snxUSD') { - return; - } - - const currentSkew = await systems().PerpsMarket.skew(ethMarketId); - const startingPnl = calculatePricePnl(wei(currentSkew), wei(100_000), wei(1), wei(1000)); - const availableCollateral = (testCase.name === 'only snxBTC' ? wei(0.1) : wei(2.1)).add( - startingPnl - ); - - await assertRevert( - systems().PerpsMarket.connect(keeper()).settleOrder(2), - `InsufficientMargin("${availableCollateral.bn}", "${bn(5).toString()}")` - ); - }); - }); - - describe('settle order', () => { - before(restoreBeforeSettle); - - before('fast forward to settlement time', async () => { - // fast forward to settlement - await fastForwardTo( - startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, - provider() - ); - }); - - describe('disable settlement strategy', () => { - before(async () => { - await systems().PerpsMarket.setSettlementStrategyEnabled( - ethMarketId, - ethSettlementStrategyId, - false - ); - }); - - it('reverts with invalid settlement strategy', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).settleOrder(2), - 'InvalidSettlementStrategy' - ); - }); - - after(async () => { - await systems().PerpsMarket.setSettlementStrategyEnabled( - ethMarketId, - ethSettlementStrategyId, - true - ); - }); - }); - - describe('settle order', () => { - let settleTx: ethers.ContractTransaction; - - before('settle', async () => { - settleTx = await systems().PerpsMarket.connect(keeper()).settleOrder(2); - }); - - it('emits settle event', async () => { - const accountId = 2; - const fillPrice = calculateFillPrice(wei(0), wei(100_000), wei(1), wei(1000)).toBN(); - const sizeDelta = bn(1); - const newPositionSize = bn(1); - const totalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; - const settlementReward = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; - const trackingCode = `"${ethers.constants.HashZero}"`; - const msgSender = `"${await keeper().getAddress()}"`; - const params = [ - ethMarketId, - accountId, - fillPrice, - 0, - 0, - sizeDelta, - newPositionSize, - totalFees, - 0, // referral fees - 0, // collected fees - settlementReward, - trackingCode, - msgSender, - ]; - await assertEvent( - settleTx, - `OrderSettled(${params.join(', ')})`, - systems().PerpsMarket - ); - }); - - it('emits market updated event', async () => { - const price = bn(1000); - const marketSize = bn(1); - const marketSkew = bn(1); - const sizeDelta = bn(1); - const currentFundingRate = bn(0); - const currentFundingVelocity = calcCurrentFundingVelocity({ - skew: wei(1), - skewScale: wei(100_000), - maxFundingVelocity: wei(10), - }); - const params = [ - ethMarketId, - price, - marketSkew, - marketSize, - sizeDelta, - currentFundingRate, - currentFundingVelocity.toBN(), // Funding rates should be tested more thoroughly elsewhre - 0, // interest rate is 0 since no params were set - ]; - await assertEvent( - settleTx, - `MarketUpdated(${params.join(', ')})`, - systems().PerpsMarket - ); - }); - - it('emits collateral deducted events', async () => { - let pendingTotalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward; - const accountId = 2; - - for (let i = 0; i < testCase.collateralData.collaterals.length; i++) { - const collateral = testCase.collateralData.collaterals[i]; - const synthMarket = collateral.synthMarket ? collateral.synthMarket().marketId() : 0; - let deductedCollateralAmount: ethers.BigNumber = bn(0); - if (synthMarket == 0) { - deductedCollateralAmount = collateral.snxUSDAmount().lt(pendingTotalFees) - ? collateral.snxUSDAmount() - : pendingTotalFees; - } else { - deductedCollateralAmount = pendingTotalFees.div(10_000); - } - pendingTotalFees = pendingTotalFees.sub(deductedCollateralAmount); - - await assertEvent( - settleTx, - `CollateralDeducted(${accountId}, ${synthMarket}, ${deductedCollateralAmount})`, - systems().PerpsMarket - ); - } - }); - - it('check position is live', async () => { - const [pnl, funding, size] = await systems().PerpsMarket.getOpenPosition( - 2, - ethMarketId - ); - assertBn.equal(pnl, bn(-0.005)); - assertBn.equal(funding, bn(0)); - assertBn.equal(size, bn(1)); - }); - - it('check position size', async () => { - const size = await systems().PerpsMarket.getOpenPositionSize(2, ethMarketId); - assertBn.equal(size, bn(1)); - }); - - it('check account open position market ids', async () => { - const positions = await systems().PerpsMarket.getAccountOpenPositions(2); - deepEqual(positions, [ethMarketId]); - }); - }); - }); - }); - } -}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.burnUSD.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.burnUSD.test.ts new file mode 100644 index 0000000000..56b9df830e --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.burnUSD.test.ts @@ -0,0 +1,287 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, constants, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import { verifyUsesFeatureFlag } from '../../verifications'; + +const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); + +describe('IssueUSDModule', function () { + const { signers, systems, provider, accountId, poolId, depositAmount, collateralAddress } = + bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + const feeAddress = '0x1234567890123456789012345678901234567890'; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, await user1.getAddress()); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(150), + liquidationRatioD18: bn(100), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + // eslint-disable-next-line max-params + function verifyAccountState( + accountId: number, + poolId: number, + collateralAmount: ethers.BigNumberish, + debt: ethers.BigNumberish + ) { + return async () => { + assertBn.equal( + await systems().Core.getPositionCollateral(accountId, poolId, collateralAddress()), + collateralAmount + ); + assertBn.equal( + await systems().Core.callStatic.getPositionDebt(accountId, poolId, collateralAddress()), + debt + ); + }; + } + + describe('burnUSD()', () => { + before(restore); + before('mint', async () => { + await systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)); + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + const restoreBurn = snapshotCheckpoint(provider); + + verifyUsesFeatureFlag( + () => systems().Core, + 'burnUsd', + () => + systems() + .Core.connect(user1) + .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)) + ); + + describe('burn from other account', async () => { + before(restoreBurn); + before('transfer burn collateral', async () => { + // send the collateral to account 2 so it can burn on behalf + await systems() + .USD.connect(user1) + .transfer(await user2.getAddress(), depositAmount.div(10)); + }); + + before('user deposit into other account', async () => { + await systems() + .USD.connect(user2) + .approve(systems().Core.address, constants.MaxUint256.toString()); + await systems() + .Core.connect(user2) + .deposit(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + it('other account burn would revert', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)), + 'PermissionDenied' + ); + }); + + it( + 'has correct debt', + verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(10)) + ); + + it('did not took away from user2 balance', async () => { + assertBn.equal(await systems().USD.balanceOf(await user2.getAddress()), 0); + }); + }); + + describe('successful partial burn when fee is levied', async () => { + before(restoreBurn); + before('set fee', async () => { + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('burnUsd_feeRatio'), + ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) + ); // 1% fee levy + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('burnUsd_feeAddress'), + ethers.utils.hexZeroPad(feeAddress, 32) + ); + }); + + before('account partial burn debt', async () => { + await systems() + .USD.connect(user1) + .approve(systems().Core.address, constants.MaxUint256.toString()); + + await systems() + .Core.connect(user1) + .deposit( + accountId, + await systems().Core.getUsdToken(), + depositAmount.div(20).add(depositAmount.div(2000)) + ); + + // in order to burn all with the fee we need a bit more + await systems() + .Core.connect(user1) + .burnUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(20).add(depositAmount.div(2000)) + ); // pay off 50.5 + }); + + it( + 'has correct debt', + verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(20)) + ); + + it('took away from user1', async () => { + assertBn.equal( + await systems().USD.balanceOf(await user1.getAddress()), + ethers.utils.parseEther('49.5') + ); + }); + + it('sent money to the fee address', async () => { + assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(2000)); + }); + }); + + describe('successful max burn when fee is levied', async () => { + before(restoreBurn); + + before('acquire additional balance to pay off fee', async () => { + await systems() + .Core.connect(user1) + .mintUsd(accountId, 0, collateralAddress(), depositAmount.div(1000)); + }); + + before('set fee', async () => { + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('burnUsd_feeRatio'), + ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) + ); // 1% fee levy + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('burnUsd_feeAddress'), + ethers.utils.hexZeroPad(feeAddress, 32) + ); + }); + + let tx: ethers.providers.TransactionResponse; + + before('account partial burn debt', async () => { + // in order to burn all with the fee we need a bit more + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(1000)); + + await systems() + .USD.connect(user1) + .approve(systems().Core.address, constants.MaxUint256.toString()); + + await systems() + .Core.connect(user1) + .deposit( + accountId, + await systems().Core.getUsdToken(), + await systems().USD.balanceOf(await user1.getAddress()) + ); + + tx = await systems() + .Core.connect(user1) + .burnUsd(accountId, poolId, collateralAddress(), depositAmount); // pay off everything + }); + + it('has correct debt', verifyAccountState(accountId, poolId, depositAmount, 0)); + + it('took away from user1', async () => { + assertBn.equal(await systems().USD.balanceOf(await user1.getAddress()), 0); + }); + + it('sent money to the fee address', async () => { + assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(1000)); + }); + + it('emitted event', async () => { + await assertEvent( + tx, + `IssuanceFeePaid(${accountId}, ${poolId}, "${collateralAddress()}", ${depositAmount.div( + 1000 + )})`, + systems().Core + ); + }); + + it('no event emitted when fee address is 0', async () => { + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('burnUsd_feeAddress'), + ethers.utils.hexZeroPad(ethers.constants.AddressZero, 32) + ); + await assertEvent(tx, `IssuanceFeePaid`, systems().Core, true); + }); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.edgeCases.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.edgeCases.test.ts new file mode 100644 index 0000000000..5ed5ccda97 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.edgeCases.test.ts @@ -0,0 +1,143 @@ +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; + +const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); + +describe('IssueUSDModule', function () { + const { signers, systems, provider, accountId, poolId, depositAmount, collateralAddress } = + bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1] = signers(); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, await user1.getAddress()); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(150), + liquidationRatioD18: bn(100), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + describe('edge case: verify debt is excluded from available mint', async () => { + before(restore); + afterEach(restore); + + function exploit(ratio: number) { + return async () => { + // Initial capacity + const capacity = await systems().Core.connect(user1).getWithdrawableMarketUsd(marketId); + + // Mint USD against collateral + await systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10).div(ratio)); + + // Bypass MockMarket internal accounting + await MockMarket.setReportedDebt(depositAmount); + + // Issue max capacity, which has not been reduced + await assertRevert( + MockMarket.connect(user1).sellSynth(capacity), + 'NotEnoughLiquidity(', + systems().Core + ); + + // Should not have been allowed to mint more than the system limit + /*assertBn.equal( + await systems().USD.balanceOf(user1.getAddress()), + depositAmount.div(10).div(ratio) + ); + + // cratio is exactly equal to 1 because that is what the system allows. + assertBn.equal( + await systems().Core.callStatic.getVaultCollateralRatio(poolId, collateralAddress()), + ethers.utils.parseEther('1').div(ratio) + );*/ + }; + } + + // thanks to iosiro for the below test + // quite the edge case + it('try to create unbacked debt', exploit(1)); + + describe('adjust system max c ratio', async () => { + before('adjust max liquidity ratio', async () => { + await systems().Core['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')); + }); + + it('try to create debt beyond system max c ratio', exploit(2)); + }); + }); + + describe('establish a more stringent collateralization ratio for the pool', async () => { + before(restore); + + it('set the pool min collateral issuance ratio to 600%', async () => { + await systems() + .Core.connect(owner) + .setPoolCollateralConfiguration(poolId, collateralAddress(), { + collateralLimitD18: bn(10), + issuanceRatioD18: bn(6), + }); + }); + + it('verifies sufficient c-ratio', async () => { + const price = await systems().Core.getCollateralPrice(collateralAddress()); + + await assertRevert( + systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount), + `InsufficientCollateralRatio("${depositAmount}", "${depositAmount}", "${price}", "${bn( + 6 + ).toString()}")`, + systems().Core + ); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.securityCheck.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.securityCheck.test.ts new file mode 100644 index 0000000000..b325d85a22 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.securityCheck.test.ts @@ -0,0 +1,141 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; + +const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); + +describe('IssueUSDModule', function () { + const { + signers, + systems, + provider, + accountId, + poolId, + depositAmount, + collateralAddress, + collateralContract, + } = bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user3: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1, , user3] = signers(); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, await user1.getAddress()); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(150), + liquidationRatioD18: bn(100), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + describe('mintUsd() / mint/burn security check', async () => { + before(restore); + + before('mint', async () => { + await systems().Core.connect(user1).mintUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(10) // should be enough + ); + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + it('does not let another user pay back the debt without balance', async () => { + // User 1 mint some sUSD + await systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)); + // Mint some collateral for user3. It does not work without user3 having some collateral. (they will not loose any of this though.) + await collateralContract().mint(await user3.getAddress(), depositAmount); + const user3CollateralBalBefore = await collateralContract().balanceOf( + await user3.getAddress() + ); + const user3sUSDBalanceBefore = await systems().USD.balanceOf(await user3.getAddress()); + const user1DebtBefore = await systems() + .Core.connect(user1) + .callStatic.getPositionDebt(accountId, poolId, collateralAddress()); + + const user1SusdBalanceBefore = await systems().USD.balanceOf(await user1.getAddress()); + console.log('user1DebtBefore', user1DebtBefore.toString()); + console.log('user1SusdBalanceBefore', user1SusdBalanceBefore.toString()); + console.log('user3CollateralBalBefore', user3CollateralBalBefore.toString()); + console.log('user3sUSDBalanceBefore', user3sUSDBalanceBefore.toString()); + console.log('Calling burnUSD connected as user3 but passing account id of user1...'); + console.log('Note that user 3 does not have any sUSD'); + + // Try to burn for another user without having any sUSD + await assertRevert( + systems() + .Core.connect(user3) + .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)), + 'PermissionDenied' + ); + + const user3CollateralBalAfter = await collateralContract().balanceOf( + await user3.getAddress() + ); + const user3sUSDBalanceAfter = await systems().USD.balanceOf(await user3.getAddress()); + const user1DebtAfter = await systems() + .Core.connect(user1) + .callStatic.getPositionDebt(accountId, poolId, collateralAddress()); + + const user1SusdBalanceAfter = await systems().USD.balanceOf(await user1.getAddress()); + + console.log('Tx did not revert'); + console.log('user3CollateralBalAfter', user3CollateralBalAfter.toString()); + console.log('user3sUSDBalanceAfter', user3sUSDBalanceAfter.toString()); + console.log('user1DebtAfter', user1DebtAfter.toString()); + console.log('user1SusdBalanceAfter', user1SusdBalanceAfter.toString()); + console.log('User3 have the same amount of collateral, and still 0 sUSD'); + console.log('User1 now have less debt and the same amount of sUSD'); + assertBn.equal(user1DebtBefore, user1DebtAfter); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.test.ts new file mode 100644 index 0000000000..0fa0e0e3fe --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.test.ts @@ -0,0 +1,224 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import Permissions from '../../mixins/AccountRBACMixin.permissions'; +import { verifyUsesFeatureFlag } from '../../verifications'; + +const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); + +describe('IssueUSDModule', function () { + const { signers, systems, provider, accountId, poolId, depositAmount, collateralAddress } = + bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, await user1.getAddress()); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(150), + liquidationRatioD18: bn(100), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + // eslint-disable-next-line max-params + function verifyAccountState( + accountId: number, + poolId: number, + collateralAmount: ethers.BigNumberish, + debt: ethers.BigNumberish + ) { + return async () => { + assertBn.equal( + await systems().Core.getPositionCollateral(accountId, poolId, collateralAddress()), + collateralAmount + ); + assertBn.equal( + await systems().Core.callStatic.getPositionDebt(accountId, poolId, collateralAddress()), + debt + ); + }; + } + + describe('mintUsd()', async () => { + before(restore); + it('verifies permission for account', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount.mul(10)), + `PermissionDenied("1", "${Permissions.MINT}", "${await user2.getAddress()}")`, + systems().Core + ); + }); + + it('verifies sufficient c-ratio', async () => { + const { issuanceRatioD18 } = + await systems().Core.getCollateralConfiguration(collateralAddress()); + const price = await systems().Core.getCollateralPrice(collateralAddress()); + + await assertRevert( + systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount), + `InsufficientCollateralRatio("${depositAmount}", "${depositAmount}", "${price}", "${issuanceRatioD18}")`, + systems().Core + ); + }); + + it('verifies pool exists', async () => { + await assertRevert( + systems().Core.connect(user1).mintUsd( + accountId, + 845628, // invalid pool id + collateralAddress(), + depositAmount.div(10) // should be enough + ), + 'PoolNotFound("845628")', + systems().Core + ); + }); + + it('verifies that deposit is disabled when collateral is disabled', async () => { + const snapshotId = await provider().send('evm_snapshot', []); + + // disable collateral + await systems() + .Core.connect(owner) + .configureCollateral({ + depositingEnabled: false, + issuanceRatioD18: bn(2), + liquidationRatioD18: bn(2), + liquidationRewardD18: 0, + oracleNodeId: ethers.utils.formatBytes32String(''), + tokenAddress: collateralAddress(), + minDelegationD18: 0, + }); + + const txn = systems().Core.connect(user1).mintUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(10) // should be enough + ); + + await assertRevert(txn, 'CollateralDepositDisabled', systems().Core.connect(owner)); + + await provider().send('evm_revert', [snapshotId]); + }); + + verifyUsesFeatureFlag( + () => systems().Core, + 'mintUsd', + () => + systems() + .Core.connect(user1) + .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)) + ); + + describe('successful mint', () => { + before('mint', async () => { + await systems().Core.connect(user1).mintUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(10) // should be enough + ); + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + it( + 'has correct debt', + verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(10)) + ); + + it('sent USD to user1', async () => { + assertBn.equal( + await systems().USD.balanceOf(await user1.getAddress()), + depositAmount.div(10) + ); + }); + + it('decreased available capacity for market', async () => { + assertBn.equal( + await systems().Core.getWithdrawableMarketUsd(marketId), + depositAmount.sub(depositAmount.div(10)) + ); + }); + + describe('subsequent mint', () => { + before('mint again', async () => { + await systems().Core.connect(user1).mintUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(10) // should be enough + ); + + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + it( + 'has correct debt', + verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(5)) + ); + + it('sent more USD to user1', async () => { + assertBn.equal( + await systems().USD.balanceOf(await user1.getAddress()), + depositAmount.div(5) + ); + }); + }); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.withFee.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.withFee.test.ts new file mode 100644 index 0000000000..edd23a9b96 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.mintUsd.withFee.test.ts @@ -0,0 +1,159 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; + +const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); + +describe('IssueUSDModule', function () { + const { signers, systems, provider, accountId, poolId, depositAmount, collateralAddress } = + bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + const feeAddress = '0x1234567890123456789012345678901234567890'; + + before('identify signers', async () => { + [owner, user1] = signers(); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, await user1.getAddress()); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(150), + liquidationRatioD18: bn(100), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + function verifyAccountState( + accountId: number, + poolId: number, + collateralAmount: ethers.BigNumberish, + debt: ethers.BigNumberish + ) { + return async () => { + assertBn.equal( + await systems().Core.getPositionCollateral(accountId, poolId, collateralAddress()), + collateralAmount + ); + assertBn.equal( + await systems().Core.callStatic.getPositionDebt(accountId, poolId, collateralAddress()), + debt + ); + }; + } + + describe('mintUsd() / successful mint when fee is levied', async () => { + before(restore); + + let tx: ethers.providers.TransactionResponse; + + before(async () => { + // set fee + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('mintUsd_feeRatio'), + ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) + ); // 1% fee levy + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('mintUsd_feeAddress'), + ethers.utils.hexZeroPad(feeAddress, 32) + ); + + // mint + tx = await systems().Core.connect(user1).mintUsd( + accountId, + poolId, + collateralAddress(), + depositAmount.div(10) // should be enough + ); + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); + }); + + it( + 'has correct debt', + verifyAccountState( + accountId, + poolId, + depositAmount, + depositAmount.div(10).add(depositAmount.div(1000)) + ) + ); + + it('sent USD to user1', async () => { + assertBn.equal( + await systems().USD.balanceOf(await user1.getAddress()), + depositAmount.div(10) + ); + }); + + it('sent USD to the fee address', async () => { + assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(1000)); + }); + + it('emitted event', async () => { + await assertEvent( + tx, + `IssuanceFeePaid(${accountId}, ${poolId}, "${collateralAddress()}", ${depositAmount.div( + 1000 + )})`, + systems().Core + ); + }); + it('no event emitted when fee address is 0', async () => { + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('mintUsd_feeAddress'), + ethers.utils.hexZeroPad(ethers.constants.AddressZero, 32) + ); + await assertEvent(tx, `IssuanceFeePaid`, systems().Core, true); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts deleted file mode 100644 index 6442cc74a6..0000000000 --- a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts +++ /dev/null @@ -1,634 +0,0 @@ -import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; -import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; -import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; -import { BigNumber, constants, ethers } from 'ethers'; -import hre from 'hardhat'; -import { bn, bootstrapWithStakedPool } from '../../bootstrap'; -import Permissions from '../../mixins/AccountRBACMixin.permissions'; -import { verifyChecksCollateralEnabled, verifyUsesFeatureFlag } from '../../verifications'; - -const MARKET_FEATURE_FLAG = ethers.utils.formatBytes32String('registerMarket'); - -describe('IssueUSDModule', function () { - const { - signers, - systems, - provider, - accountId, - poolId, - depositAmount, - collateralAddress, - collateralContract, - } = bootstrapWithStakedPool(); - - let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer, user3: ethers.Signer; - - let MockMarket: ethers.Contract; - let marketId: BigNumber; - - const feeAddress = '0x1234567890123456789012345678901234567890'; - - before('identify signers', async () => { - [owner, user1, user2, user3] = signers(); - }); - - before('deploy and connect fake market', async () => { - const factory = await hre.ethers.getContractFactory('MockMarket'); - - MockMarket = await factory.connect(owner).deploy(); - - await systems() - .Core.connect(owner) - .addToFeatureFlagAllowlist(MARKET_FEATURE_FLAG, user1.getAddress()); - - marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); - - await systems().Core.connect(user1).registerMarket(MockMarket.address); - - await MockMarket.connect(owner).initialize( - systems().Core.address, - marketId, - ethers.utils.parseEther('1') - ); - - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { - marketId: marketId, - weightD18: ethers.utils.parseEther('1'), - maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), - }, - ]); - - await systems() - .Core.connect(owner) - .configureCollateral({ - tokenAddress: await systems().Core.getUsdToken(), - oracleNodeId: ethers.utils.formatBytes32String(''), - issuanceRatioD18: bn(150), - liquidationRatioD18: bn(100), - liquidationRewardD18: 0, - minDelegationD18: 0, - depositingEnabled: true, - }); - }); - - const restore = snapshotCheckpoint(provider); - - // eslint-disable-next-line max-params - function verifyAccountState( - accountId: number, - poolId: number, - collateralAmount: ethers.BigNumberish, - debt: ethers.BigNumberish - ) { - return async () => { - assertBn.equal( - await systems().Core.getPositionCollateral(accountId, poolId, collateralAddress()), - collateralAmount - ); - assertBn.equal( - await systems().Core.callStatic.getPositionDebt(accountId, poolId, collateralAddress()), - debt - ); - }; - } - - describe('mintUsd()', async () => { - before(restore); - it('verifies permission for account', async () => { - await assertRevert( - systems() - .Core.connect(user2) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount.mul(10)), - `PermissionDenied("1", "${Permissions.MINT}", "${await user2.getAddress()}")`, - systems().Core - ); - }); - - it('verifies sufficient c-ratio', async () => { - const { issuanceRatioD18 } = - await systems().Core.getCollateralConfiguration(collateralAddress()); - const price = await systems().Core.getCollateralPrice(collateralAddress()); - - await assertRevert( - systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount), - `InsufficientCollateralRatio("${depositAmount}", "${depositAmount}", "${price}", "${issuanceRatioD18}")`, - systems().Core - ); - }); - - it('verifies pool exists', async () => { - await assertRevert( - systems().Core.connect(user1).mintUsd( - accountId, - 845628, // invalid pool id - collateralAddress(), - depositAmount.div(10) // should be enough - ), - 'PoolNotFound("845628")', - systems().Core - ); - }); - - verifyChecksCollateralEnabled( - () => systems().Core.connect(owner), - collateralAddress, - () => - systems().Core.connect(user1).mintUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(10) // should be enough - ) - ); - - verifyUsesFeatureFlag( - () => systems().Core, - 'mintUsd', - () => - systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)) - ); - - describe('successful mint', () => { - before('mint', async () => { - await systems().Core.connect(user1).mintUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(10) // should be enough - ); - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - it( - 'has correct debt', - verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(10)) - ); - - it('sent USD to user1', async () => { - assertBn.equal( - await systems().USD.balanceOf(await user1.getAddress()), - depositAmount.div(10) - ); - }); - - it('decreased available capacity for market', async () => { - assertBn.equal( - await systems().Core.getWithdrawableMarketUsd(marketId), - depositAmount.sub(depositAmount.div(10)) - ); - }); - - describe('subsequent mint', () => { - before('mint again', async () => { - await systems().Core.connect(user1).mintUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(10) // should be enough - ); - - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - it( - 'has correct debt', - verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(5)) - ); - - it('sent more USD to user1', async () => { - assertBn.equal( - await systems().USD.balanceOf(await user1.getAddress()), - depositAmount.div(5) - ); - }); - }); - }); - - describe('mint/burn security check', () => { - before(restore); - - before('mint', async () => { - await systems().Core.connect(user1).mintUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(10) // should be enough - ); - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - it('does not let another user pay back the debt without balance', async () => { - // User 1 mint some sUSD - await systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)); - // Mint some collateral for user3. It does not work without user3 having some collateral. (they will not loose any of this though.) - await collateralContract().mint(await user3.getAddress(), depositAmount); - const user3CollateralBalBefore = await collateralContract().balanceOf( - await user3.getAddress() - ); - const user3sUSDBalanceBefore = await systems().USD.balanceOf(await user3.getAddress()); - const user1DebtBefore = await systems() - .Core.connect(user1) - .callStatic.getPositionDebt(accountId, poolId, collateralAddress()); - - const user1SusdBalanceBefore = await systems().USD.balanceOf(await user1.getAddress()); - console.log('user1DebtBefore', user1DebtBefore.toString()); - console.log('user1SusdBalanceBefore', user1SusdBalanceBefore.toString()); - console.log('user3CollateralBalBefore', user3CollateralBalBefore.toString()); - console.log('user3sUSDBalanceBefore', user3sUSDBalanceBefore.toString()); - console.log('Calling burnUSD connected as user3 but passing account id of user1...'); - console.log('Note that user 3 does not have any sUSD'); - - // Try to burn for another user without having any sUSD - await assertRevert( - systems() - .Core.connect(user3) - .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)), - 'PermissionDenied' - ); - - const user3CollateralBalAfter = await collateralContract().balanceOf( - await user3.getAddress() - ); - const user3sUSDBalanceAfter = await systems().USD.balanceOf(await user3.getAddress()); - const user1DebtAfter = await systems() - .Core.connect(user1) - .callStatic.getPositionDebt(accountId, poolId, collateralAddress()); - - const user1SusdBalanceAfter = await systems().USD.balanceOf(await user1.getAddress()); - - console.log('Tx did not revert'); - console.log('user3CollateralBalAfter', user3CollateralBalAfter.toString()); - console.log('user3sUSDBalanceAfter', user3sUSDBalanceAfter.toString()); - console.log('user1DebtAfter', user1DebtAfter.toString()); - console.log('user1SusdBalanceAfter', user1SusdBalanceAfter.toString()); - console.log('User3 have the same amount of collateral, and still 0 sUSD'); - console.log('User1 now have less debt and the same amount of sUSD'); - assertBn.equal(user1DebtBefore, user1DebtAfter); - }); - }); - - describe('successful mint when fee is levied', async () => { - before(restore); - before('set fee', async () => { - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('mintUsd_feeRatio'), - ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) - ); // 1% fee levy - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('mintUsd_feeAddress'), - ethers.utils.hexZeroPad(feeAddress, 32) - ); - }); - - let tx: ethers.providers.TransactionResponse; - - before('mint', async () => { - tx = await systems().Core.connect(user1).mintUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(10) // should be enough - ); - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - it( - 'has correct debt', - verifyAccountState( - accountId, - poolId, - depositAmount, - depositAmount.div(10).add(depositAmount.div(1000)) - ) - ); - - it('sent USD to user1', async () => { - assertBn.equal( - await systems().USD.balanceOf(await user1.getAddress()), - depositAmount.div(10) - ); - }); - - it('sent USD to the fee address', async () => { - assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(1000)); - }); - - it('emitted event', async () => { - await assertEvent( - tx, - `IssuanceFeePaid(${accountId}, ${poolId}, "${collateralAddress()}", ${depositAmount.div( - 1000 - )})`, - systems().Core - ); - }); - it('no event emitted when fee address is 0', async () => { - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('mintUsd_feeAddress'), - ethers.utils.hexZeroPad(ethers.constants.AddressZero, 32) - ); - await assertEvent(tx, `IssuanceFeePaid`, systems().Core, true); - }); - }); - }); - - describe('burnUSD()', () => { - before(restore); - before('mint', async () => { - await systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)); - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - const restoreBurn = snapshotCheckpoint(provider); - - verifyUsesFeatureFlag( - () => systems().Core, - 'burnUsd', - () => - systems() - .Core.connect(user1) - .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)) - ); - - describe('burn from other account', async () => { - before(restoreBurn); - before('transfer burn collateral', async () => { - // send the collateral to account 2 so it can burn on behalf - await systems() - .USD.connect(user1) - .transfer(await user2.getAddress(), depositAmount.div(10)); - }); - - before('user deposit into other account', async () => { - await systems() - .USD.connect(user2) - .approve(systems().Core.address, constants.MaxUint256.toString()); - await systems() - .Core.connect(user2) - .deposit(accountId, await systems().Core.getUsdToken(), depositAmount.div(10)); - }); - - it('other account burn would revert', async () => { - await assertRevert( - systems() - .Core.connect(user2) - .burnUsd(accountId, poolId, collateralAddress(), depositAmount.div(10)), - 'PermissionDenied' - ); - }); - - it( - 'has correct debt', - verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(10)) - ); - - it('did not took away from user2 balance', async () => { - assertBn.equal(await systems().USD.balanceOf(await user2.getAddress()), 0); - }); - }); - - describe('successful partial burn when fee is levied', async () => { - before(restoreBurn); - before('set fee', async () => { - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('burnUsd_feeRatio'), - ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) - ); // 1% fee levy - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('burnUsd_feeAddress'), - ethers.utils.hexZeroPad(feeAddress, 32) - ); - }); - - before('account partial burn debt', async () => { - await systems() - .USD.connect(user1) - .approve(systems().Core.address, constants.MaxUint256.toString()); - - await systems() - .Core.connect(user1) - .deposit( - accountId, - await systems().Core.getUsdToken(), - depositAmount.div(20).add(depositAmount.div(2000)) - ); - - // in order to burn all with the fee we need a bit more - await systems() - .Core.connect(user1) - .burnUsd( - accountId, - poolId, - collateralAddress(), - depositAmount.div(20).add(depositAmount.div(2000)) - ); // pay off 50.5 - }); - - it( - 'has correct debt', - verifyAccountState(accountId, poolId, depositAmount, depositAmount.div(20)) - ); - - it('took away from user1', async () => { - assertBn.equal( - await systems().USD.balanceOf(await user1.getAddress()), - ethers.utils.parseEther('49.5') - ); - }); - - it('sent money to the fee address', async () => { - assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(2000)); - }); - }); - - describe('successful max burn when fee is levied', async () => { - before(restoreBurn); - - before('acquire additional balance to pay off fee', async () => { - await systems() - .Core.connect(user1) - .mintUsd(accountId, 0, collateralAddress(), depositAmount.div(1000)); - }); - - before('set fee', async () => { - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('burnUsd_feeRatio'), - ethers.utils.hexZeroPad(ethers.utils.parseEther('0.01').toHexString(), 32) - ); // 1% fee levy - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('burnUsd_feeAddress'), - ethers.utils.hexZeroPad(feeAddress, 32) - ); - }); - - let tx: ethers.providers.TransactionResponse; - - before('account partial burn debt', async () => { - // in order to burn all with the fee we need a bit more - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), depositAmount.div(1000)); - - await systems() - .USD.connect(user1) - .approve(systems().Core.address, constants.MaxUint256.toString()); - - await systems() - .Core.connect(user1) - .deposit( - accountId, - await systems().Core.getUsdToken(), - await systems().USD.balanceOf(await user1.getAddress()) - ); - - tx = await systems() - .Core.connect(user1) - .burnUsd(accountId, poolId, collateralAddress(), depositAmount); // pay off everything - }); - - it('has correct debt', verifyAccountState(accountId, poolId, depositAmount, 0)); - - it('took away from user1', async () => { - assertBn.equal(await systems().USD.balanceOf(await user1.getAddress()), 0); - }); - - it('sent money to the fee address', async () => { - assertBn.equal(await systems().USD.balanceOf(feeAddress), depositAmount.div(1000)); - }); - - it('emitted event', async () => { - await assertEvent( - tx, - `IssuanceFeePaid(${accountId}, ${poolId}, "${collateralAddress()}", ${depositAmount.div( - 1000 - )})`, - systems().Core - ); - }); - - it('no event emitted when fee address is 0', async () => { - await systems() - .Core.connect(owner) - .setConfig( - ethers.utils.formatBytes32String('burnUsd_feeAddress'), - ethers.utils.hexZeroPad(ethers.constants.AddressZero, 32) - ); - await assertEvent(tx, `IssuanceFeePaid`, systems().Core, true); - }); - }); - }); - - describe('edge case: verify debt is excluded from available mint', async () => { - before(restore); - afterEach(restore); - - function exploit(ratio: number) { - return async () => { - // Initial capacity - const capacity = await systems().Core.connect(user1).getWithdrawableMarketUsd(marketId); - - // Mint USD against collateral - await systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount.div(10).div(ratio)); - - // Bypass MockMarket internal accounting - await MockMarket.setReportedDebt(depositAmount); - - // Issue max capacity, which has not been reduced - await assertRevert( - MockMarket.connect(user1).sellSynth(capacity), - 'NotEnoughLiquidity(', - systems().Core - ); - - // Should not have been allowed to mint more than the system limit - /*assertBn.equal( - await systems().USD.balanceOf(user1.getAddress()), - depositAmount.div(10).div(ratio) - ); - - // cratio is exactly equal to 1 because that is what the system allows. - assertBn.equal( - await systems().Core.callStatic.getVaultCollateralRatio(poolId, collateralAddress()), - ethers.utils.parseEther('1').div(ratio) - );*/ - }; - } - - // thanks to iosiro for the below test - // quite the edge case - it('try to create unbacked debt', exploit(1)); - - describe('adjust system max c ratio', async () => { - before('adjust max liquidity ratio', async () => { - await systems().Core['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')); - }); - - it('try to create debt beyond system max c ratio', exploit(2)); - }); - }); - - describe('establish a more stringent collateralization ratio for the pool', async () => { - before(restore); - - it('set the pool min collateral issuance ratio to 600%', async () => { - await systems() - .Core.connect(owner) - .setPoolCollateralConfiguration(poolId, collateralAddress(), { - collateralLimitD18: bn(10), - issuanceRatioD18: bn(6), - }); - }); - - it('verifies sufficient c-ratio', async () => { - const price = await systems().Core.getCollateralPrice(collateralAddress()); - - await assertRevert( - systems() - .Core.connect(user1) - .mintUsd(accountId, poolId, collateralAddress(), depositAmount), - `InsufficientCollateralRatio("${depositAmount}", "${depositAmount}", "${price}", "${bn( - 6 - ).toString()}")`, - systems().Core - ); - }); - }); -}); diff --git a/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.configureMaximumMarketCollateral.test.ts b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.configureMaximumMarketCollateral.test.ts new file mode 100644 index 0000000000..e01cb4c50c --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.configureMaximumMarketCollateral.test.ts @@ -0,0 +1,68 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { ContractTransaction, ethers, Signer } from 'ethers'; +import { bootstrapWithMockMarketAndPool } from '../../../bootstrap'; + +describe('MarketCollateralModule.configureMaximumMarketCollateral()', function () { + const { signers, systems, marketId, collateralAddress, restore } = + bootstrapWithMockMarketAndPool(); + + let owner: Signer, user1: Signer; + + before('identify signers', async () => { + [owner, user1] = signers(); + + // The owner assigns a maximum of 1,000 + await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000); + }); + + const configuredMaxAmount = ethers.utils.parseEther('1234'); + + before(restore); + + it('is only owner', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + describe('successful invoke', () => { + let tx: ContractTransaction; + before('configure', async () => { + tx = await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); + }); + + it('sets the new configured amount', async () => { + assertBn.equal( + await systems().Core.getMaximumMarketCollateral(marketId(), collateralAddress()), + configuredMaxAmount + ); + }); + + it('only applies the amount to the specified market', async () => { + assertBn.equal( + await systems() + .Core.connect(user1) + .getMaximumMarketCollateral(marketId().add(1), collateralAddress()), + 0 + ); + }); + + it('emits event', async () => { + await assertEvent( + tx, + `MaximumMarketCollateralConfigured(${marketId()}, "${collateralAddress()}", ${configuredMaxAmount}, "${await owner.getAddress()}")`, + systems().Core + ); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.depositMarketCollateral.test.ts b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.depositMarketCollateral.test.ts new file mode 100644 index 0000000000..cb454e29af --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.depositMarketCollateral.test.ts @@ -0,0 +1,143 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { ContractTransaction, ethers, Signer } from 'ethers'; +import { bootstrapWithMockMarketAndPool } from '../../../bootstrap'; +import { verifyUsesFeatureFlag } from '../../../verifications'; + +describe('MarketCollateralModule.depositMarketCollateral()', function () { + const { signers, systems, MockMarket, marketId, collateralAddress, collateralContract, restore } = + bootstrapWithMockMarketAndPool(); + + let owner: Signer, user1: Signer; + + before('identify signers', async () => { + [owner, user1] = signers(); + + // The owner assigns a maximum of 1,000 + await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000); + }); + + const configuredMaxAmount = ethers.utils.parseEther('1234'); + + before(restore); + + before('configure max', async () => { + await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); + }); + + before('user approves', async () => { + await collateralContract() + .connect(user1) + .approve(MockMarket().address, ethers.constants.MaxUint256); + }); + + let beforeCollateralBalance: ethers.BigNumber; + before('record user collateral balance', async () => { + beforeCollateralBalance = await collateralContract().balanceOf(await user1.getAddress()); + }); + + it('only works for the market matching marketId', async () => { + await assertRevert( + systems().Core.connect(user1).depositMarketCollateral(marketId(), collateralAddress(), 1), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + it('does not work when depositing over max amount', async () => { + await assertRevert( + MockMarket() + .connect(user1) + .depositCollateral(collateralAddress(), configuredMaxAmount.add(1)), + `InsufficientMarketCollateralDepositable("${marketId()}", "${collateralAddress()}", "${configuredMaxAmount.add( + 1 + )}")`, + systems().Core + ); + }); + + verifyUsesFeatureFlag( + () => systems().Core, + 'depositMarketCollateral', + () => MockMarket().connect(user1).depositCollateral(collateralAddress(), configuredMaxAmount) + ); + + describe('invoked successfully', () => { + let tx: ContractTransaction; + before('deposit', async () => { + tx = await MockMarket() + .connect(user1) + .depositCollateral(collateralAddress(), configuredMaxAmount); + }); + + it('pulls in collateral', async () => { + assertBn.equal(await collateralContract().balanceOf(MockMarket().address), 0); + assertBn.equal( + await collateralContract().balanceOf(await user1.getAddress()), + beforeCollateralBalance.sub(configuredMaxAmount) + ); + }); + + it('returns collateral added', async () => { + assertBn.equal( + await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()), + configuredMaxAmount + ); + }); + + it('reduces total balance', async () => { + assertBn.equal( + await systems().Core.connect(user1).getMarketTotalDebt(marketId()), + configuredMaxAmount.sub(configuredMaxAmount.mul(2)) + ); + }); + + it('emits event', async () => { + const tokenAmount = configuredMaxAmount.toString(); + const sender = MockMarket().address; + const creditCapacity = ethers.utils.parseEther('1000'); + const netIssuance = 0; + const depositedCollateralValue = ( + await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()) + ).toString(); + const reportedDebt = 0; + await assertEvent( + tx, + `MarketCollateralDeposited(${[ + marketId(), + `"${collateralAddress()}"`, + tokenAmount, + `"${sender}"`, + creditCapacity, + netIssuance, + depositedCollateralValue, + reportedDebt, + ].join(', ')})`, + systems().Core + ); + }); + + describe('when withdrawing all usd', async () => { + let withdrawable: ethers.BigNumber; + before('do it', async () => { + withdrawable = await systems().Core.getWithdrawableMarketUsd(marketId()); + // because of the way the mock market works we must first increase reported debt + await MockMarket().connect(user1).setReportedDebt(withdrawable); + }); + + it('should be able to withdrawn', async () => { + // now actually withdraw + await (await MockMarket().connect(user1).sellSynth(withdrawable)).wait(); + }); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.test.ts b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.test.ts deleted file mode 100644 index 66edbdd35e..0000000000 --- a/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; -import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; -import { ContractTransaction, ethers, Signer } from 'ethers'; -import { bootstrapWithMockMarketAndPool } from '../../../bootstrap'; -import { verifyUsesFeatureFlag } from '../../../verifications'; - -describe('MarketCollateralModule', function () { - const { signers, systems, MockMarket, marketId, collateralAddress, collateralContract, restore } = - bootstrapWithMockMarketAndPool(); - - let owner: Signer, user1: Signer; - - describe('MarketCollateralModule', function () { - before('identify signers', async () => { - [owner, user1] = signers(); - - // The owner assigns a maximum of 1,000 - await systems() - .Core.connect(owner) - .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000); - }); - - const configuredMaxAmount = ethers.utils.parseEther('1234'); - - describe('configureMaximumMarketCollateral()', () => { - before(restore); - - it('is only owner', async () => { - await assertRevert( - systems() - .Core.connect(user1) - .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000), - `Unauthorized("${await user1.getAddress()}")`, - systems().Core - ); - }); - - describe('successful invoke', () => { - let tx: ContractTransaction; - before('configure', async () => { - tx = await systems() - .Core.connect(owner) - .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); - }); - - it('sets the new configured amount', async () => { - assertBn.equal( - await systems().Core.getMaximumMarketCollateral(marketId(), collateralAddress()), - configuredMaxAmount - ); - }); - - it('only applies the amount to the specified market', async () => { - assertBn.equal( - await systems() - .Core.connect(user1) - .getMaximumMarketCollateral(marketId().add(1), collateralAddress()), - 0 - ); - }); - - it('emits event', async () => { - await assertEvent( - tx, - `MaximumMarketCollateralConfigured(${marketId()}, "${collateralAddress()}", ${configuredMaxAmount}, "${await owner.getAddress()}")`, - systems().Core - ); - }); - }); - }); - - describe('depositMarketCollateral()', async () => { - before(restore); - - before('configure max', async () => { - await systems() - .Core.connect(owner) - .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); - }); - - before('user approves', async () => { - await collateralContract() - .connect(user1) - .approve(MockMarket().address, ethers.constants.MaxUint256); - }); - - let beforeCollateralBalance: ethers.BigNumber; - before('record user collateral balance', async () => { - beforeCollateralBalance = await collateralContract().balanceOf(await user1.getAddress()); - }); - - it('only works for the market matching marketId', async () => { - await assertRevert( - systems().Core.connect(user1).depositMarketCollateral(marketId(), collateralAddress(), 1), - `Unauthorized("${await user1.getAddress()}")`, - systems().Core - ); - }); - - it('does not work when depositing over max amount', async () => { - await assertRevert( - MockMarket() - .connect(user1) - .depositCollateral(collateralAddress(), configuredMaxAmount.add(1)), - `InsufficientMarketCollateralDepositable("${marketId()}", "${collateralAddress()}", "${configuredMaxAmount.add( - 1 - )}")`, - systems().Core - ); - }); - - verifyUsesFeatureFlag( - () => systems().Core, - 'depositMarketCollateral', - () => - MockMarket().connect(user1).depositCollateral(collateralAddress(), configuredMaxAmount) - ); - - describe('invoked successfully', () => { - let tx: ContractTransaction; - before('deposit', async () => { - tx = await MockMarket() - .connect(user1) - .depositCollateral(collateralAddress(), configuredMaxAmount); - }); - - it('pulls in collateral', async () => { - assertBn.equal(await collateralContract().balanceOf(MockMarket().address), 0); - assertBn.equal( - await collateralContract().balanceOf(await user1.getAddress()), - beforeCollateralBalance.sub(configuredMaxAmount) - ); - }); - - it('returns collateral added', async () => { - assertBn.equal( - await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()), - configuredMaxAmount - ); - }); - - it('reduces total balance', async () => { - assertBn.equal( - await systems().Core.connect(user1).getMarketTotalDebt(marketId()), - configuredMaxAmount.sub(configuredMaxAmount.mul(2)) - ); - }); - - it('emits event', async () => { - const tokenAmount = configuredMaxAmount.toString(); - const sender = MockMarket().address; - const creditCapacity = ethers.utils.parseEther('1000'); - const netIssuance = 0; - const depositedCollateralValue = ( - await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()) - ).toString(); - const reportedDebt = 0; - await assertEvent( - tx, - `MarketCollateralDeposited(${[ - marketId(), - `"${collateralAddress()}"`, - tokenAmount, - `"${sender}"`, - creditCapacity, - netIssuance, - depositedCollateralValue, - reportedDebt, - ].join(', ')})`, - systems().Core - ); - }); - - describe('when withdrawing all usd', async () => { - let withdrawable: ethers.BigNumber; - before('do it', async () => { - withdrawable = await systems().Core.getWithdrawableMarketUsd(marketId()); - // because of the way the mock market works we must first increase reported debt - await MockMarket().connect(user1).setReportedDebt(withdrawable); - }); - - it('should be able to withdrawn', async () => { - // now actually withdraw - await (await MockMarket().connect(user1).sellSynth(withdrawable)).wait(); - }); - }); - }); - }); - - describe('WithdrawMarketCollateral()', async () => { - before(restore); - - before('configure max', async () => { - await systems() - .Core.connect(owner) - .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); - }); - - before('deposit', async () => { - await collateralContract() - .connect(user1) - .approve(MockMarket().address, ethers.constants.MaxUint256); - await MockMarket() - .connect(user1) - .depositCollateral(collateralAddress(), configuredMaxAmount.div(2)); - }); - - let beforeCollateralBalance: ethers.BigNumber; - before('record user collateral balance', async () => { - beforeCollateralBalance = await collateralContract().balanceOf(await user1.getAddress()); - }); - - it('only works for the market matching marketId', async () => { - await assertRevert( - systems() - .Core.connect(user1) - .withdrawMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount.div(2)), - `Unauthorized("${await user1.getAddress()}")`, - systems().Core - ); - }); - - it('cannot release more than the deposited amount', async () => { - await assertRevert( - MockMarket() - .connect(user1) - .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).add(1)), - `InsufficientMarketCollateralWithdrawable("${marketId()}", "${collateralAddress()}", "${configuredMaxAmount - .div(2) - .add(1) - .toString()}")`, - systems().Core - ); - }); - - verifyUsesFeatureFlag( - () => systems().Core, - 'withdrawMarketCollateral', - () => - MockMarket() - .connect(user1) - .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).div(4)) - ); - - describe('successful withdraw partial', async () => { - let tx: ethers.providers.TransactionReceipt; - before('withdraw', async () => { - tx = await ( - await MockMarket() - .connect(user1) - .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).div(4)) - ).wait(); - }); - - it('pushes out collateral', async () => { - assertBn.equal(await collateralContract().balanceOf(MockMarket().address), 0); - assertBn.equal( - await collateralContract().balanceOf(await user1.getAddress()), - beforeCollateralBalance.add(configuredMaxAmount.div(2).div(4)) - ); - }); - - it('reduces market deposited collateral', async () => { - assertBn.equal( - await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()), - configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) - ); - }); - - it('increases total balance', async () => { - assertBn.equal( - await systems().Core.connect(user1).getMarketTotalDebt(marketId()), - ethers.BigNumber.from(0).sub( - configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) - ) - ); - }); - - it('emits event', async () => { - const tokenAmount = configuredMaxAmount.div(2).div(4).toString(); - const sender = MockMarket().address; - const creditCapacity = ethers.utils.parseEther('1000'); - const netIssuance = 0; - const depositedCollateralValue = ( - await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()) - ).toString(); - const reportedDebt = 0; - await assertEvent( - tx, - `MarketCollateralWithdrawn(${[ - marketId(), - `"${collateralAddress()}"`, - tokenAmount, - `"${sender}"`, - creditCapacity, - netIssuance, - depositedCollateralValue, - reportedDebt, - ].join(', ')})`, - systems().Core - ); - }); - - describe('successful withdraw full', async () => { - before('withdraw', async () => { - // this should be the amount remaining - await MockMarket() - .connect(user1) - .withdrawCollateral( - collateralAddress(), - configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) - ); - }); - - it('shows market has no more deposited collateral', async () => { - assertBn.equal( - await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()), - 0 - ); - }); - }); - }); - - describe('cannot withdraw collateral when credit capacity is over-utilized', async () => { - before('deposit collateral and withdraw maximum amount of USD', async () => { - await MockMarket() - .connect(user1) - .depositCollateral(collateralAddress(), configuredMaxAmount); - - const totalWithdrawableUsd = await systems() - .Core.connect(user1) - .getWithdrawableMarketUsd(marketId()); - await MockMarket().connect(user1).withdrawUsd(totalWithdrawableUsd); - }); - - it('reverts when attempting to withdraw collateral', async () => { - const totalWithdrawableCollateral = await systems() - .Core.connect(user1) - .getMarketCollateralAmount(marketId(), collateralAddress()); - await assertRevert( - MockMarket() - .connect(user1) - .withdrawCollateral(collateralAddress(), totalWithdrawableCollateral), - `InsufficientMarketCollateralWithdrawable("${marketId()}", "${collateralAddress()}", "${totalWithdrawableCollateral}")`, - systems().Core - ); - }); - }); - }); - }); -}); diff --git a/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.withdrawMarketCollateral.test.ts b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.withdrawMarketCollateral.test.ts new file mode 100644 index 0000000000..3d23b29543 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/MarketCollateralModule/MarketCollateralModule.withdrawMarketCollateral.test.ts @@ -0,0 +1,187 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { ethers, Signer } from 'ethers'; +import { bootstrapWithMockMarketAndPool } from '../../../bootstrap'; +import { verifyUsesFeatureFlag } from '../../../verifications'; + +describe('MarketCollateralModule.withdrawMarketCollateral()', function () { + const { signers, systems, MockMarket, marketId, collateralAddress, collateralContract, restore } = + bootstrapWithMockMarketAndPool(); + + let owner: Signer, user1: Signer; + + before('identify signers', async () => { + [owner, user1] = signers(); + + // The owner assigns a maximum of 1,000 + await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), 1000); + }); + + const configuredMaxAmount = ethers.utils.parseEther('1234'); + + before(restore); + + before('configure max', async () => { + await systems() + .Core.connect(owner) + .configureMaximumMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount); + }); + + before('deposit', async () => { + await collateralContract() + .connect(user1) + .approve(MockMarket().address, ethers.constants.MaxUint256); + await MockMarket() + .connect(user1) + .depositCollateral(collateralAddress(), configuredMaxAmount.div(2)); + }); + + let beforeCollateralBalance: ethers.BigNumber; + before('record user collateral balance', async () => { + beforeCollateralBalance = await collateralContract().balanceOf(await user1.getAddress()); + }); + + it('only works for the market matching marketId', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .withdrawMarketCollateral(marketId(), collateralAddress(), configuredMaxAmount.div(2)), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + it('cannot release more than the deposited amount', async () => { + await assertRevert( + MockMarket() + .connect(user1) + .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).add(1)), + `InsufficientMarketCollateralWithdrawable("${marketId()}", "${collateralAddress()}", "${configuredMaxAmount + .div(2) + .add(1) + .toString()}")`, + systems().Core + ); + }); + + verifyUsesFeatureFlag( + () => systems().Core, + 'withdrawMarketCollateral', + () => + MockMarket() + .connect(user1) + .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).div(4)) + ); + + describe('successful withdraw partial', async () => { + let tx: ethers.providers.TransactionReceipt; + before('withdraw', async () => { + tx = await ( + await MockMarket() + .connect(user1) + .withdrawCollateral(collateralAddress(), configuredMaxAmount.div(2).div(4)) + ).wait(); + }); + + it('pushes out collateral', async () => { + assertBn.equal(await collateralContract().balanceOf(MockMarket().address), 0); + assertBn.equal( + await collateralContract().balanceOf(await user1.getAddress()), + beforeCollateralBalance.add(configuredMaxAmount.div(2).div(4)) + ); + }); + + it('reduces market deposited collateral', async () => { + assertBn.equal( + await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()), + configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) + ); + }); + + it('increases total balance', async () => { + assertBn.equal( + await systems().Core.connect(user1).getMarketTotalDebt(marketId()), + ethers.BigNumber.from(0).sub( + configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) + ) + ); + }); + + it('emits event', async () => { + const tokenAmount = configuredMaxAmount.div(2).div(4).toString(); + const sender = MockMarket().address; + const creditCapacity = ethers.utils.parseEther('1000'); + const netIssuance = 0; + const depositedCollateralValue = ( + await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()) + ).toString(); + const reportedDebt = 0; + await assertEvent( + tx, + `MarketCollateralWithdrawn(${[ + marketId(), + `"${collateralAddress()}"`, + tokenAmount, + `"${sender}"`, + creditCapacity, + netIssuance, + depositedCollateralValue, + reportedDebt, + ].join(', ')})`, + systems().Core + ); + }); + + describe('successful withdraw full', async () => { + before('withdraw', async () => { + // this should be the amount remaining + await MockMarket() + .connect(user1) + .withdrawCollateral( + collateralAddress(), + configuredMaxAmount.div(2).sub(configuredMaxAmount.div(2).div(4)) + ); + }); + + it('shows market has no more deposited collateral', async () => { + assertBn.equal( + await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()), + 0 + ); + }); + }); + }); + + describe('cannot withdraw collateral when credit capacity is over-utilized', async () => { + before('deposit collateral and withdraw maximum amount of USD', async () => { + await MockMarket().connect(user1).depositCollateral(collateralAddress(), configuredMaxAmount); + + const totalWithdrawableUsd = await systems() + .Core.connect(user1) + .getWithdrawableMarketUsd(marketId()); + await MockMarket().connect(user1).withdrawUsd(totalWithdrawableUsd); + }); + + it('reverts when attempting to withdraw collateral', async () => { + const totalWithdrawableCollateral = await systems() + .Core.connect(user1) + .getMarketCollateralAmount(marketId(), collateralAddress()); + await assertRevert( + MockMarket() + .connect(user1) + .withdrawCollateral(collateralAddress(), totalWithdrawableCollateral), + `InsufficientMarketCollateralWithdrawable("${marketId()}", "${collateralAddress()}", "${totalWithdrawableCollateral}")`, + systems().Core + ); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.createPool.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.createPool.test.ts new file mode 100644 index 0000000000..b456bb68ba --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.createPool.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import { ethers } from 'ethers'; +import { bootstrapWithMockMarketAndPool } from '../../bootstrap'; + +describe('PoolModule Admin createPool()', function () { + const { signers, systems, restore } = bootstrapWithMockMarketAndPool(); + + let owner: ethers.Signer, user1: ethers.Signer; + + const secondPoolId = 3384692; + + before('identify signers', async () => { + [owner, user1] = signers(); + }); + + before(restore); + + it('fails when pool already exists', async () => {}); + + before('give user1 permission to create pool', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('createPool'), + await user1.getAddress() + ); + }); + + before('create a pool', async () => { + await ( + await systems() + .Core.connect(user1) + .createPool(secondPoolId, await user1.getAddress()) + ).wait(); + }); + + it('pool is created', async () => { + assert.equal(await systems().Core.getPoolOwner(secondPoolId), await user1.getAddress()); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setIssuanceRatio.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setIssuanceRatio.test.ts new file mode 100644 index 0000000000..fe55be5151 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setIssuanceRatio.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { ethers } from 'ethers'; +import { bn, bootstrapWithMockMarketAndPool } from '../../bootstrap'; + +describe('PoolModule Admin set pool collateral issuance ratio', function () { + const { signers, systems, collateralAddress, restore } = bootstrapWithMockMarketAndPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + const thirdPoolId = 3384633; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before(restore); + + before('give user1 permission to create pool', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('createPool'), + await user1.getAddress() + ); + }); + + before('create a pool', async () => { + await ( + await systems() + .Core.connect(user1) + .createPool(thirdPoolId, await user1.getAddress()) + ).wait(); + }); + + it('only works for owner', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { + collateralLimitD18: bn(10), + issuanceRatioD18: bn(2), + }), + `Unauthorized("${await user2.getAddress()}")`, + systems().Core + ); + }); + + it('min collateral ratio is set to zero for the pool by default', async () => { + assert.equal( + await systems().Core.getPoolCollateralIssuanceRatio(thirdPoolId, collateralAddress()), + 0 + ); + }); + + it('set the pool collateal issuance ratio to 200%', async () => { + await systems() + .Core.connect(user1) + .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { + collateralLimitD18: bn(10), + issuanceRatioD18: bn(2), + }); + + assertBn.equal( + await systems().Core.getPoolCollateralIssuanceRatio(thirdPoolId, collateralAddress()), + bn(2) + ); + }); + + it('can get pool collateral configuration', async () => { + await systems() + .Core.connect(user1) + .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { + collateralLimitD18: bn(123), + issuanceRatioD18: bn(345), + }); + + const { collateralLimitD18, issuanceRatioD18 } = + await systems().Core.getPoolCollateralConfiguration(thirdPoolId, collateralAddress()); + assert.deepEqual( + { collateralLimitD18, issuanceRatioD18 }, + { collateralLimitD18: bn(123), issuanceRatioD18: bn(345) } + ); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setMinLiquidityRatio.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setMinLiquidityRatio.test.ts new file mode 100644 index 0000000000..88f6554912 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setMinLiquidityRatio.test.ts @@ -0,0 +1,30 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { ethers } from 'ethers'; +import { bootstrapWithMockMarketAndPool } from '../../bootstrap'; + +describe('PoolModule Admin setMinLiquidityRatio(uint256)', function () { + const { signers, systems, restore } = bootstrapWithMockMarketAndPool(); + + let owner: ethers.Signer, user1: ethers.Signer; + + before('identify signers', async () => { + [owner, user1] = signers(); + }); + + before(restore); + + it('only works for owner', async () => { + await assertRevert( + systems().Core.connect(user1)['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + it('is set when invoked successfully', async () => { + const value = ethers.utils.parseEther('2'); + await systems().Core.connect(owner)['setMinLiquidityRatio(uint256)'](value); + assertBn.equal(await systems().Core['getMinLiquidityRatio()'](), value); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setPoolConfiguration.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setPoolConfiguration.test.ts new file mode 100644 index 0000000000..8a1692e4b0 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.setPoolConfiguration.test.ts @@ -0,0 +1,632 @@ +import assert from 'node:assert'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { ethers } from 'ethers'; +import hre from 'hardhat'; +import { bootstrapWithMockMarketAndPool } from '../../bootstrap'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; + +describe('PoolModule Admin setPoolConfiguration()', function () { + const { + signers, + systems, + provider, + accountId, + poolId, + MockMarket, + marketId, + collateralAddress, + depositAmount, + } = bootstrapWithMockMarketAndPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + const secondPoolId = 3384692; + + const One = ethers.utils.parseEther('1'); + const Hundred = ethers.utils.parseEther('100'); + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('createPool'), + await user1.getAddress() + ); + + await ( + await systems() + .Core.connect(user1) + .createPool(secondPoolId, await user1.getAddress()) + ).wait(); + }); + + const marketId2 = 2; + + before('set dummy markets', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + const MockMarket2 = await factory.connect(owner).deploy(); + const MockMarket3 = await factory.connect(owner).deploy(); + + // owner has permission to register markets via bootstrap + await (await systems().Core.connect(owner).registerMarket(MockMarket2.address)).wait(); + await (await systems().Core.connect(owner).registerMarket(MockMarket3.address)).wait(); + }); + + const restore = snapshotCheckpoint(provider); + + it('reverts when pool does not exist', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .setPoolConfiguration(834693286, [{ marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }]), + 'PoolNotFound("834693286")', + systems().Core + ); + }); + + it('reverts when not owner', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .setPoolConfiguration(poolId, [{ marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }]), + `Unauthorized("${await user2.getAddress()}")`, + systems().Core + ); + }); + + // in particular, this test needs to go here because we want to see it fail + // even when there is no liquidity to rebalance + it('reverts when a marketId does not exist', async () => { + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, + { marketId: 2, weightD18: 1, maxDebtShareValueD18: 0 }, + { marketId: 92197628, weightD18: 1, maxDebtShareValueD18: 0 }, + ]), + 'MarketNotFound("92197628")', + systems().Core + ); + }); + + it('reverts when a marketId is duplicated', async () => { + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, + { marketId: 1, weightD18: 2, maxDebtShareValueD18: 0 }, + ]), + 'InvalidParameter("markets", "must be supplied in strictly ascending order")', + systems().Core + ); + }); + + it('reverts when a weight is 0', async () => { + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, + { marketId: 2, weightD18: 0, maxDebtShareValueD18: 0 }, + ]), + 'InvalidParameter("weights", "weight must be non-zero")', + systems().Core + ); + }); + + it('sets market collateral configuration on bootstrap', async () => { + assertBn.equal( + await systems().Core.connect(owner).getMarketCollateral(marketId()), + depositAmount + ); + }); + + describe('repeat pool sets position', async () => { + before(restore); + + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + ]); + }); + + it('sets market collateral', async () => { + assertBn.equal( + await systems().Core.connect(owner).getMarketCollateral(marketId()), + depositAmount + ); + }); + + describe('if one of the markets has a min delegation time', () => { + const restore = snapshotCheckpoint(provider); + + before('set market min delegation time to something high', async () => { + await MockMarket().setMinDelegationTime(86400); + }); + + it('fails when min delegation timeout not elapsed', async () => { + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + ]), + `MinDelegationTimeoutPending("${poolId}",`, + systems().Core + ); + }); + + describe('after time passes', () => { + before('fast forward', async () => { + // for some reason `fastForward` doesn't seem to work with anvil + await fastForwardTo((await getTime(provider())) + 86400, provider()); + }); + + it('works', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + ]); + }); + }); + + after(restore); + }); + + describe('pool changes staking position to add another market', async () => { + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + ]); + }); + + it('returns pool position correctly', async () => { + const distributions = await systems().Core.getPoolConfiguration(poolId); + + assertBn.equal(distributions[0].marketId, marketId()); + assertBn.equal(distributions[0].weightD18, 1); + assertBn.equal(distributions[0].maxDebtShareValueD18, One); + assertBn.equal(distributions[1].marketId, marketId2); + assertBn.equal(distributions[1].weightD18, 3); + assertBn.equal(distributions[1].maxDebtShareValueD18, One); + }); + + it('sets market available liquidity', async () => { + assertBn.equal( + await systems().Core.connect(owner).getMarketCollateral(marketId()), + depositAmount.div(4) + ); + assertBn.equal( + await systems().Core.connect(owner).getMarketCollateral(marketId2), + depositAmount.mul(3).div(4) + ); + }); + + describe('market a little debt (below pool max)', () => { + const debtAmount = Hundred.div(10); + + before('set market debt', async () => { + await (await MockMarket().connect(owner).setReportedDebt(debtAmount)).wait(); + }); + + it('market gave the end vault debt', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + debtAmount + ); + }); + + it('market still has withdrawable usd', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + depositAmount.div(4) + ); + }); + + it('cannot exit if market is locked', async () => { + await MockMarket().setLocked(ethers.utils.parseEther('1000')); + + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + // increase the weight of market2 to make the first market lower liquidity overall + { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + ]), + `CapacityLocked("${marketId()}")`, + systems().Core + ); + + // reduce market lock + await MockMarket().setLocked(ethers.utils.parseEther('105')); + + // now the call should work (call static to not modify the state) + await systems() + .Core.connect(owner) + .callStatic.setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + ]); + + // but a full pull-out shouldn't work + await assertRevert( + systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + // completely remove the first market + { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + ]), + `CapacityLocked("${marketId()}")` + //systems().Core + ); + + // undo lock change + await MockMarket().setLocked(ethers.utils.parseEther('0')); + }); + + describe('exit first market', () => { + const restore = snapshotCheckpoint(provider); + + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + ]); + }); + + it('returns pool position correctly', async () => { + const distributions = await systems().Core.getPoolConfiguration(poolId); + + assertBn.equal(distributions[0].marketId, marketId2); + assertBn.equal(distributions[0].weightD18, 3); + assertBn.equal(distributions[0].maxDebtShareValueD18, One); + }); + + it('markets have same available liquidity', async () => { + // marketId() gets to keep its available liquidity because when + // the market exited when it did it "committed" + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + debtAmount + ); + + assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId()), 0); + }); + + after(restore); + }); + + describe('exit second market', () => { + const restore = snapshotCheckpoint(provider); + + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 2, maxDebtShareValueD18: One.mul(2) }, + ]); + }); + + it('returns pool position correctly', async () => { + const distributions = await systems().Core.getPoolConfiguration(poolId); + + assertBn.equal(distributions[0].marketId, marketId()); + assertBn.equal(distributions[0].weightD18, 2); + assertBn.equal(distributions[0].maxDebtShareValueD18, One.mul(2)); + }); + + it('available liquidity taken away from second market', async () => { + // marketId2 never reported an increased balance so its liquidity is 0 as ever + assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); + }); + + after(restore); + }); + + describe('exit both market', () => { + before('set pool position', async () => { + await systems().Core.connect(owner).setPoolConfiguration(poolId, []); + }); + + it('returns pool position correctly', async () => { + assert.deepEqual(await systems().Core.getPoolConfiguration(poolId), []); + }); + + it('debt is still assigned', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + debtAmount + ); + }); + + it('markets have same available liquidity', async () => { + // marketId() gets to keep its available liquidity because when + // the market exited when it did it "committed" + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + debtAmount + ); + + // marketId2 never reported an increased balance so its liquidity is 0 as ever + assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); + }); + }); + }); + }); + }); + + describe('sets max debt below current debt share', async () => { + before(restore); + + before('raise maxLiquidityRatio', async () => { + const value = ethers.utils.parseEther('0.2'); + // need to do this for the below test to work + await systems().Core.connect(owner)['setMinLiquidityRatio(uint256)'](value); + }); + + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One.mul(One.mul(-1)).div(depositAmount), + }, + ]); + }); + + // the second pool is here to test the calculation weighted average + // and to test pool entering/joining after debt shifts + before('set second pool position position', async () => { + await systems().Core.connect(user1).delegateCollateral( + accountId, + secondPoolId, + collateralAddress(), + // deposit much more than the poolId pool so as to + // skew the limit much higher than what it set + depositAmount, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(user1) + .setPoolConfiguration(secondPoolId, [ + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One.mul(One).div(depositAmount).mul(2), + }, + ]); + }); + + it('has only second pool market withdrawable usd', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + One.mul(2) + ); + }); + + it('market collateral value is amount of only vault 2', async () => { + assertBn.equal( + await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), + depositAmount + ); + }); + + describe('and then the market goes below max debt and the pool is bumped', async () => { + before('buy into the market', async () => { + // to go below max debt, we have to get user to invest + // in the market, and then reset the market + + await systems().Core.connect(user1).mintUsd(accountId, 0, collateralAddress(), Hundred); + await systems() + .Core.connect(user1) + .withdraw(accountId, await systems().Core.getUsdToken(), Hundred); + await systems().USD.connect(user1).approve(MockMarket().address, Hundred); + await MockMarket().connect(user1).buySynth(Hundred); + + // "bump" the vault to get it to accept the position (in case there is a bug) + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + }); + + it('has same amount withdrawable usd + the allowed amount by the vault', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + One.mul(2).add(Hundred) + ); + + // market hasn't reported any reduction in balance + assertBn.equal(await systems().Core.connect(owner).getMarketTotalDebt(marketId()), 0); + }); + + it('did not change debt for connected vault', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + 0 + ); + }); + + describe('and then the market reports 0 balance', () => { + before('set market', async () => { + await MockMarket().connect(user1).setReportedDebt(0); + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + }); + + it('has accurate amount withdrawable usd', async () => { + // should be exactly 102 (market2 2 + 100 deposit) + // (market1 assumes no new debt as a result of balance going down, + // but accounts/can pool can withdraw at a profit) + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + Hundred.add(One.mul(2)) + ); + }); + + it('vault is credited', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + One.mul(-99).div(2) + ); + }); + + describe('and then the market reports 50 balance', () => { + before('', async () => { + await MockMarket().connect(user1).setReportedDebt(Hundred.div(2)); + }); + + it('has same amount of withdrawable usd', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + Hundred.add(One.mul(2)) + ); + }); + + it('vault is debted', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + One.mul(-49).div(2) + ); + }); + + const restore = snapshotCheckpoint(provider); + + describe('and then the market reports balance above limit again', () => { + before(restore); + // testing the "soft" limit + before('set market', async () => { + await MockMarket().connect(user1).setReportedDebt(Hundred.add(One)); + }); + + it('has same amount withdrawable usd + the allowed amount by the vault', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + Hundred.add(One.mul(2)) + ); + }); + + it('vault no longer has a surplus', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + 0 + ); + }); + + it('vault 2 assumes expected amount of debt', async () => { + // vault 2 assumes the 1 dollar in debt that was not absorbed by the first pool + // still below limit though + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(secondPoolId, collateralAddress()), + One + ); + }); + + it('market collateral value is amount of only vault 2', async () => { + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + assertBn.equal( + await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), + depositAmount + ); + }); + }); + + describe('and then the market reports balance above both pools limits', () => { + before(restore); + // testing the "soft" limit + before('set market', async () => { + await MockMarket().connect(user1).setReportedDebt(Hundred.mul(1234)); + }); + + it('has same amount withdrawable usd + the allowed amount by the vault', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + Hundred.add(One.mul(2)) + ); + }); + + it('vault 1 assumes no debt', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + 0 + ); + }); + + it('vault 2 assumes expected amount of debt', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(secondPoolId, collateralAddress()), + One.mul(2) + ); + }); + + it('the market has no more collateral assigned to it', async () => { + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + assertBn.equal( + await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), + 0 + ); + }); + }); + }); + }); + }); + }); + + describe('when limit is higher than minLiquidityRatio', async () => { + before(restore); + + before('set minLiquidityRatio', async () => { + const value = ethers.utils.parseEther('2'); + // need to do this for the below test to work + await systems().Core.connect(owner)['setMinLiquidityRatio(uint256)'](value); + }); + + before('set pool position', async () => { + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + ]); + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + }); + + it('withdrawable usd reflects minLiquidityRatio', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + depositAmount.div(2) + ); + }); + + describe('when minLiquidityRatio is decreased', async () => { + before('set minLiquidityRatio', async () => { + const value = ethers.utils.parseEther('1'); + await systems().Core.connect(owner)['setMinLiquidityRatio(uint256)'](value); + + // bump the vault + await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); + }); + + it('withdrawable usd reflects configured by pool', async () => { + assertBn.equal( + await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), + depositAmount + ); + }); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts deleted file mode 100644 index e05fb940ff..0000000000 --- a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts +++ /dev/null @@ -1,795 +0,0 @@ -/* eslint-disable no-unexpected-multiline */ -import assert from 'node:assert'; -import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; -import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; -import { ethers } from 'ethers'; -import hre from 'hardhat'; -import { bn, bootstrapWithMockMarketAndPool } from '../../bootstrap'; -import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; - -describe('PoolModule Admin', function () { - const { - signers, - systems, - provider, - accountId, - poolId, - MockMarket, - marketId, - collateralAddress, - depositAmount, - restore, - } = bootstrapWithMockMarketAndPool(); - - let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; - - const secondPoolId = 3384692; - const thirdPoolId = 3384633; - - const One = ethers.utils.parseEther('1'); - const Hundred = ethers.utils.parseEther('100'); - - before('identify signers', async () => { - [owner, user1, user2] = signers(); - }); - - describe('createPool()', async () => { - before(restore); - - it('fails when pool already exists', async () => {}); - - describe('success', async () => {}); - - before('give user1 permission to create pool', async () => { - await systems() - .Core.connect(owner) - .addToFeatureFlagAllowlist( - ethers.utils.formatBytes32String('createPool'), - await user1.getAddress() - ); - }); - - before('create a pool', async () => { - await ( - await systems() - .Core.connect(user1) - .createPool(secondPoolId, await user1.getAddress()) - ).wait(); - }); - - it('pool is created', async () => { - assert.equal(await systems().Core.getPoolOwner(secondPoolId), await user1.getAddress()); - }); - }); - - describe('setPoolConfiguration()', async () => { - const marketId2 = 2; - - before('set dummy markets', async () => { - const factory = await hre.ethers.getContractFactory('MockMarket'); - const MockMarket2 = await factory.connect(owner).deploy(); - const MockMarket3 = await factory.connect(owner).deploy(); - - // owner has permission to register markets via bootstrap - await (await systems().Core.connect(owner).registerMarket(MockMarket2.address)).wait(); - await (await systems().Core.connect(owner).registerMarket(MockMarket3.address)).wait(); - }); - - const restore = snapshotCheckpoint(provider); - - it('reverts when pool does not exist', async () => { - await assertRevert( - systems() - .Core.connect(user1) - .setPoolConfiguration(834693286, [ - { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, - ]), - 'PoolNotFound("834693286")', - systems().Core - ); - }); - - it('reverts when not owner', async () => { - await assertRevert( - systems() - .Core.connect(user2) - .setPoolConfiguration(poolId, [{ marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }]), - `Unauthorized("${await user2.getAddress()}")`, - systems().Core - ); - }); - - // in particular, this test needs to go here because we want to see it fail - // even when there is no liquidity to rebalance - it('reverts when a marketId does not exist', async () => { - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, - { marketId: 2, weightD18: 1, maxDebtShareValueD18: 0 }, - { marketId: 92197628, weightD18: 1, maxDebtShareValueD18: 0 }, - ]), - 'MarketNotFound("92197628")', - systems().Core - ); - }); - - it('reverts when a marketId is duplicated', async () => { - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, - { marketId: 1, weightD18: 2, maxDebtShareValueD18: 0 }, - ]), - 'InvalidParameter("markets", "must be supplied in strictly ascending order")', - systems().Core - ); - }); - - it('reverts when a weight is 0', async () => { - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: 1, weightD18: 1, maxDebtShareValueD18: 0 }, - { marketId: 2, weightD18: 0, maxDebtShareValueD18: 0 }, - ]), - 'InvalidParameter("weights", "weight must be non-zero")', - systems().Core - ); - }); - - it('sets market collateral configuration on bootstrap', async () => { - assertBn.equal( - await systems().Core.connect(owner).getMarketCollateral(marketId()), - depositAmount - ); - }); - - describe('repeat pool sets position', async () => { - before(restore); - - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - ]); - }); - - it('sets market collateral', async () => { - assertBn.equal( - await systems().Core.connect(owner).getMarketCollateral(marketId()), - depositAmount - ); - }); - - describe('if one of the markets has a min delegation time', () => { - const restore = snapshotCheckpoint(provider); - - before('set market min delegation time to something high', async () => { - await MockMarket().setMinDelegationTime(86400); - }); - - it('fails when min delegation timeout not elapsed', async () => { - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]), - `MinDelegationTimeoutPending("${poolId}",`, - systems().Core - ); - }); - - describe('after time passes', () => { - before('fast forward', async () => { - // for some reason `fastForward` doesn't seem to work with anvil - await fastForwardTo((await getTime(provider())) + 86400, provider()); - }); - - it('works', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]); - }); - }); - - after(restore); - }); - - describe('pool changes staking position to add another market', async () => { - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]); - }); - - it('returns pool position correctly', async () => { - const distributions = await systems().Core.getPoolConfiguration(poolId); - - assertBn.equal(distributions[0].marketId, marketId()); - assertBn.equal(distributions[0].weightD18, 1); - assertBn.equal(distributions[0].maxDebtShareValueD18, One); - assertBn.equal(distributions[1].marketId, marketId2); - assertBn.equal(distributions[1].weightD18, 3); - assertBn.equal(distributions[1].maxDebtShareValueD18, One); - }); - - it('sets market available liquidity', async () => { - assertBn.equal( - await systems().Core.connect(owner).getMarketCollateral(marketId()), - depositAmount.div(4) - ); - assertBn.equal( - await systems().Core.connect(owner).getMarketCollateral(marketId2), - depositAmount.mul(3).div(4) - ); - }); - - describe('market a little debt (below pool max)', () => { - const debtAmount = Hundred.div(10); - - before('set market debt', async () => { - await (await MockMarket().connect(owner).setReportedDebt(debtAmount)).wait(); - }); - - it('market gave the end vault debt', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - debtAmount - ); - }); - - it('market still has withdrawable usd', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - depositAmount.div(4) - ); - }); - - it('cannot exit if market is locked', async () => { - await MockMarket().setLocked(ethers.utils.parseEther('1000')); - - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - // increase the weight of market2 to make the first market lower liquidity overall - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, - ]), - `CapacityLocked("${marketId()}")`, - systems().Core - ); - - // reduce market lock - await MockMarket().setLocked(ethers.utils.parseEther('105')); - - // now the call should work (call static to not modify the state) - await systems() - .Core.connect(owner) - .callStatic.setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, - ]); - - // but a full pull-out shouldn't work - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - // completely remove the first market - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, - ]), - `CapacityLocked("${marketId()}")` - //systems().Core - ); - - // undo lock change - await MockMarket().setLocked(ethers.utils.parseEther('0')); - }); - - describe('exit first market', () => { - const restore = snapshotCheckpoint(provider); - - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]); - }); - - it('returns pool position correctly', async () => { - const distributions = await systems().Core.getPoolConfiguration(poolId); - - assertBn.equal(distributions[0].marketId, marketId2); - assertBn.equal(distributions[0].weightD18, 3); - assertBn.equal(distributions[0].maxDebtShareValueD18, One); - }); - - it('markets have same available liquidity', async () => { - // marketId() gets to keep its available liquidity because when - // the market exited when it did it "committed" - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - debtAmount - ); - - assertBn.equal( - await systems().Core.connect(owner).getMarketCollateral(marketId()), - 0 - ); - }); - - after(restore); - }); - - describe('exit second market', () => { - const restore = snapshotCheckpoint(provider); - - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 2, maxDebtShareValueD18: One.mul(2) }, - ]); - }); - - it('returns pool position correctly', async () => { - const distributions = await systems().Core.getPoolConfiguration(poolId); - - assertBn.equal(distributions[0].marketId, marketId()); - assertBn.equal(distributions[0].weightD18, 2); - assertBn.equal(distributions[0].maxDebtShareValueD18, One.mul(2)); - }); - - it('available liquidity taken away from second market', async () => { - // marketId2 never reported an increased balance so its liquidity is 0 as ever - assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); - }); - - after(restore); - }); - - describe('exit both market', () => { - before('set pool position', async () => { - await systems().Core.connect(owner).setPoolConfiguration(poolId, []); - }); - - it('returns pool position correctly', async () => { - assert.deepEqual(await systems().Core.getPoolConfiguration(poolId), []); - }); - - it('debt is still assigned', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - debtAmount - ); - }); - - it('markets have same available liquidity', async () => { - // marketId() gets to keep its available liquidity because when - // the market exited when it did it "committed" - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - debtAmount - ); - - // marketId2 never reported an increased balance so its liquidity is 0 as ever - assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); - }); - }); - }); - }); - }); - - describe('sets max debt below current debt share', async () => { - before(restore); - - before('raise maxLiquidityRatio', async () => { - // need to do this for the below test to work - await systems() - .Core.connect(owner) - ['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('0.2')); - }); - - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { - marketId: marketId(), - weightD18: 1, - maxDebtShareValueD18: One.mul(One.mul(-1)).div(depositAmount), - }, - ]); - }); - - // the second pool is here to test the calculation weighted average - // and to test pool entering/joining after debt shifts - before('set second pool position position', async () => { - await systems().Core.connect(user1).delegateCollateral( - accountId, - secondPoolId, - collateralAddress(), - // deposit much more than the poolId pool so as to - // skew the limit much higher than what it set - depositAmount, - ethers.utils.parseEther('1') - ); - - await systems() - .Core.connect(user1) - .setPoolConfiguration(secondPoolId, [ - { - marketId: marketId(), - weightD18: 1, - maxDebtShareValueD18: One.mul(One).div(depositAmount).mul(2), - }, - ]); - }); - - it('has only second pool market withdrawable usd', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - One.mul(2) - ); - }); - - it('market collateral value is amount of only vault 2', async () => { - assertBn.equal( - await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), - depositAmount - ); - }); - - describe('and then the market goes below max debt and the pool is bumped', async () => { - before('buy into the market', async () => { - // to go below max debt, we have to get user to invest - // in the market, and then reset the market - - await systems().Core.connect(user1).mintUsd(accountId, 0, collateralAddress(), Hundred); - await systems() - .Core.connect(user1) - .withdraw(accountId, await systems().Core.getUsdToken(), Hundred); - await systems().USD.connect(user1).approve(MockMarket().address, Hundred); - await MockMarket().connect(user1).buySynth(Hundred); - - // "bump" the vault to get it to accept the position (in case there is a bug) - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - }); - - it('has same amount withdrawable usd + the allowed amount by the vault', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - One.mul(2).add(Hundred) - ); - - // market hasn't reported any reduction in balance - assertBn.equal(await systems().Core.connect(owner).getMarketTotalDebt(marketId()), 0); - }); - - it('did not change debt for connected vault', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - 0 - ); - }); - - describe('and then the market reports 0 balance', () => { - before('set market', async () => { - await MockMarket().connect(user1).setReportedDebt(0); - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - }); - - it('has accurate amount withdrawable usd', async () => { - // should be exactly 102 (market2 2 + 100 deposit) - // (market1 assumes no new debt as a result of balance going down, - // but accounts/can pool can withdraw at a profit) - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - Hundred.add(One.mul(2)) - ); - }); - - it('vault is credited', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - One.mul(-99).div(2) - ); - }); - - describe('and then the market reports 50 balance', () => { - before('', async () => { - await MockMarket().connect(user1).setReportedDebt(Hundred.div(2)); - }); - - it('has same amount of withdrawable usd', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - Hundred.add(One.mul(2)) - ); - }); - - it('vault is debted', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - One.mul(-49).div(2) - ); - }); - - const restore = snapshotCheckpoint(provider); - - describe('and then the market reports balance above limit again', () => { - before(restore); - // testing the "soft" limit - before('set market', async () => { - await MockMarket().connect(user1).setReportedDebt(Hundred.add(One)); - }); - - it('has same amount withdrawable usd + the allowed amount by the vault', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - Hundred.add(One.mul(2)) - ); - }); - - it('vault no longer has a surplus', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - 0 - ); - }); - - it('vault 2 assumes expected amount of debt', async () => { - // vault 2 assumes the 1 dollar in debt that was not absorbed by the first pool - // still below limit though - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(secondPoolId, collateralAddress()), - One - ); - }); - - it('market collateral value is amount of only vault 2', async () => { - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - assertBn.equal( - await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), - depositAmount - ); - }); - }); - - describe('and then the market reports balance above both pools limits', () => { - before(restore); - // testing the "soft" limit - before('set market', async () => { - await MockMarket().connect(user1).setReportedDebt(Hundred.mul(1234)); - }); - - it('has same amount withdrawable usd + the allowed amount by the vault', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - Hundred.add(One.mul(2)) - ); - }); - - it('vault 1 assumes no debt', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), - 0 - ); - }); - - it('vault 2 assumes expected amount of debt', async () => { - assertBn.equal( - await systems().Core.callStatic.getVaultDebt(secondPoolId, collateralAddress()), - One.mul(2) - ); - }); - - it('the market has no more collateral assigned to it', async () => { - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - assertBn.equal( - await systems().Core.connect(user1).callStatic.getMarketCollateral(marketId()), - 0 - ); - }); - }); - }); - }); - }); - }); - - describe('when limit is higher than minLiquidityRatio', async () => { - before(restore); - - before('set minLiquidityRatio', async () => { - // need to do this for the below test to work - await systems() - .Core.connect(owner) - ['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')); - }); - - before('set pool position', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - ]); - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - }); - - it('withdrawable usd reflects minLiquidityRatio', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - depositAmount.div(2) - ); - }); - - describe('when minLiquidityRatio is decreased', async () => { - before('set minLiquidityRatio', async () => { - await systems() - .Core.connect(owner) - ['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('1')); - - // bump the vault - await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); - }); - - it('withdrawable usd reflects configured by pool', async () => { - assertBn.equal( - await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), - depositAmount - ); - }); - }); - }); - }); - - describe('setMinLiquidityRatio()', async () => { - before(restore); - - it('only works for owner', async () => { - await assertRevert( - systems() - .Core.connect(user1) - ['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')), - `Unauthorized("${await user1.getAddress()}")`, - systems().Core - ); - }); - - describe('when invoked successfully', async () => { - before('set', async () => { - await systems() - .Core.connect(owner) - ['setMinLiquidityRatio(uint256)'](ethers.utils.parseEther('2')); - }); - - it('is set', async () => { - assertBn.equal( - await systems().Core['getMinLiquidityRatio()'](), - ethers.utils.parseEther('2') - ); - }); - }); - }); - - describe('disable/enable collateral for a pool ', async () => { - before(restore); - - before('give user1 permission to create pool', async () => { - await systems() - .Core.connect(owner) - .addToFeatureFlagAllowlist( - ethers.utils.formatBytes32String('createPool'), - await user1.getAddress() - ); - }); - - before('create a pool', async () => { - await ( - await systems() - .Core.connect(user1) - .createPool(thirdPoolId, await user1.getAddress()) - ).wait(); - }); - - it('only works for owner', async () => { - await assertRevert( - systems() - .Core.connect(user2) - .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { - collateralLimitD18: bn(10), - issuanceRatioD18: bn(0), - }), - `Unauthorized("${await user2.getAddress()}")`, - systems().Core - ); - }); - }); - - describe('set pool collateral issuance ratio', async () => { - before(restore); - - before('give user1 permission to create pool', async () => { - await systems() - .Core.connect(owner) - .addToFeatureFlagAllowlist( - ethers.utils.formatBytes32String('createPool'), - await user1.getAddress() - ); - }); - - before('create a pool', async () => { - await ( - await systems() - .Core.connect(user1) - .createPool(thirdPoolId, await user1.getAddress()) - ).wait(); - }); - - it('only works for owner', async () => { - await assertRevert( - systems() - .Core.connect(user2) - .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { - collateralLimitD18: bn(10), - issuanceRatioD18: bn(2), - }), - `Unauthorized("${await user2.getAddress()}")`, - systems().Core - ); - }); - - it('min collateral ratio is set to zero for the pool by default', async () => { - assert.equal( - await systems().Core.getPoolCollateralIssuanceRatio(thirdPoolId, collateralAddress()), - 0 - ); - }); - - it('set the pool collateal issuance ratio to 200%', async () => { - await systems() - .Core.connect(user1) - .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { - collateralLimitD18: bn(10), - issuanceRatioD18: bn(2), - }); - - assertBn.equal( - await systems().Core.getPoolCollateralIssuanceRatio(thirdPoolId, collateralAddress()), - bn(2) - ); - }); - - it('can get pool collateral configuration', async () => { - await systems() - .Core.connect(user1) - .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { - collateralLimitD18: bn(123), - issuanceRatioD18: bn(345), - }); - - const { collateralLimitD18, issuanceRatioD18 } = - await systems().Core.getPoolCollateralConfiguration(thirdPoolId, collateralAddress()); - assert.deepEqual( - { collateralLimitD18, issuanceRatioD18 }, - { collateralLimitD18: bn(123), issuanceRatioD18: bn(345) } - ); - }); - }); -}); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.toggleCollateral.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.toggleCollateral.test.ts new file mode 100644 index 0000000000..f5d9553d92 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.toggleCollateral.test.ts @@ -0,0 +1,47 @@ +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { ethers } from 'ethers'; +import { bn, bootstrapWithMockMarketAndPool } from '../../bootstrap'; + +describe('PoolModule Admin disable/enable collateral for a pool', function () { + const { signers, systems, collateralAddress, restore } = bootstrapWithMockMarketAndPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + const thirdPoolId = 3384633; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before(restore); + + before('give user1 permission to create pool', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('createPool'), + await user1.getAddress() + ); + }); + + before('create a pool', async () => { + await ( + await systems() + .Core.connect(user1) + .createPool(thirdPoolId, await user1.getAddress()) + ).wait(); + }); + + it('only works for owner', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .setPoolCollateralConfiguration(thirdPoolId, collateralAddress(), { + collateralLimitD18: bn(10), + issuanceRatioD18: bn(0), + }), + `Unauthorized("${await user2.getAddress()}")`, + systems().Core + ); + }); +}); diff --git a/protocol/synthetix/test/integration/verifications.ts b/protocol/synthetix/test/integration/verifications.ts index f9948b8be4..82e14da882 100644 --- a/protocol/synthetix/test/integration/verifications.ts +++ b/protocol/synthetix/test/integration/verifications.ts @@ -1,51 +1,18 @@ import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; -import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; import { ethers } from 'ethers'; -import { bn } from '../common'; export function verifyUsesFeatureFlag( c: () => ethers.Contract, flagName: string, txn: () => Promise ) { - describe(`when ${flagName} feature disabled`, () => { - it('it fails with feature unavailable', async () => { - await c().setFeatureFlagDenyAll(ethers.utils.formatBytes32String(flagName), true); - await assertRevert( - txn(), - `FeatureUnavailable("${ethers.utils.formatBytes32String(flagName)}")`, - c() - ); - await c().setFeatureFlagDenyAll(ethers.utils.formatBytes32String(flagName), false); - }); - }); -} - -export function verifyChecksCollateralEnabled( - c: () => ethers.Contract, - collateralAddress: () => string, - txn: () => Promise -) { - describe('collateral is disabled', async () => { - const restore = snapshotCheckpoint( - () => c().signer.provider as ethers.providers.JsonRpcProvider + it(`when ${flagName} feature disabled fails with FeatureUnavailable error`, async () => { + await c().setFeatureFlagDenyAll(ethers.utils.formatBytes32String(flagName), true); + await assertRevert( + txn(), + `FeatureUnavailable("${ethers.utils.formatBytes32String(flagName)}")`, + c() ); - before('disable collateral', async () => { - await c().configureCollateral({ - depositingEnabled: false, - issuanceRatioD18: bn(2), - liquidationRatioD18: bn(2), - liquidationRewardD18: 0, - oracleNodeId: ethers.utils.formatBytes32String(''), - tokenAddress: collateralAddress(), - minDelegationD18: 0, - }); - }); - - after(restore); - - it('verifies collateral is enabled', async () => { - await assertRevert(txn(), 'CollateralDepositDisabled', c()); - }); + await c().setFeatureFlagDenyAll(ethers.utils.formatBytes32String(flagName), false); }); }