diff --git a/test/swap4tokensV2.ts b/test/swap4tokensV2.ts new file mode 100644 index 00000000..1f80c234 --- /dev/null +++ b/test/swap4tokensV2.ts @@ -0,0 +1,1205 @@ +import chai from "chai" +import { BigNumber, Signer } from "ethers" +import { deployments } from "hardhat" +import { + AmplificationUtilsV2, + GenericERC20, + GenericERC20__factory, + LPTokenV2, + SwapUtilsV2, + SwapV2, +} from "../build/typechain" +import { + asyncForEach, + deployContractWithLibraries, + getCurrentBlockTimestamp, + getPoolBalances, + getUserTokenBalance, + getUserTokenBalances, + MAX_UINT256, + setTimestamp, + TIME, +} from "./testUtils" +import SwapV2Artifact from "../build/artifacts/contracts/SwapV2.sol/SwapV2.json" + + +const { expect } = chai + +describe("SwapV2 with 4 tokens", () => { + let signers: Array + let swap: SwapV2 + let DAI: GenericERC20 + let USDC: GenericERC20 + let USDT: GenericERC20 + let SUSD: GenericERC20 + let swapToken: LPTokenV2 + let owner: Signer + let user1: Signer + let user2: Signer + let attacker: Signer + let ownerAddress: string + let user1Address: string + let user2Address: string + let swapStorage: { + initialA: BigNumber + futureA: BigNumber + initialATime: BigNumber + futureATime: BigNumber + swapFee: BigNumber + adminFee: BigNumber + lpToken: string + } + + // Test Values + const INITIAL_A_VALUE = 50 + const SWAP_FEE = 1e7 + const LP_TOKEN_NAME = "Test LP Token Name" + const LP_TOKEN_SYMBOL = "TESTLP" + const TOKENS: GenericERC20[] = [] + + const setupTest = deployments.createFixture( + async ({ deployments, ethers }) => { + const { get, deploy } = deployments + await deployments.fixture(["Swap"]) // ensure you start from a fresh deployments + + TOKENS.length = 0 + signers = await ethers.getSigners() + owner = signers[0] + user1 = signers[1] + user2 = signers[2] + attacker = signers[10] + ownerAddress = await owner.getAddress() + user1Address = await user1.getAddress() + user2Address = await user2.getAddress() + + const erc20Factory: GenericERC20__factory = + await ethers.getContractFactory("GenericERC20") + + DAI = await erc20Factory.deploy("DAI", "DAI", "18") + USDC = await erc20Factory.deploy("USDC", "USDC", "6") + USDT = await erc20Factory.deploy("USDT", "USDT", "6") + SUSD = await erc20Factory.deploy("SUSD", "SUSD", "18") + + TOKENS.push(DAI, USDC, USDT, SUSD) + + // Mint dummy tokens + await asyncForEach( + [ownerAddress, user1Address, user2Address, await attacker.getAddress()], + async (address) => { + await DAI.mint(address, String(1e20)) + await USDC.mint(address, String(1e8)) + await USDT.mint(address, String(1e8)) + await SUSD.mint(address, String(1e20)) + }, + ) + + // Deploy Swap Libraries + const amplificationUtilsV2 = (await ( + await ethers.getContractFactory("AmplificationUtilsV2") + ).deploy()) as AmplificationUtilsV2 + await amplificationUtilsV2.deployed() + const swapUtilsV2 = (await ( + await ethers.getContractFactory("SwapUtilsV2") + ).deploy()) as SwapUtilsV2 + await swapUtilsV2.deployed() + const LPTokenV2 = ( await ( + await ethers.getContractFactory("LPTokenV2") + ).deploy()) as LPTokenV2 + + + // Swap Contract + swap = (await deployContractWithLibraries(owner, SwapV2Artifact, { + SwapUtilsV2: swapUtilsV2.address, + AmplificationUtilsV2: amplificationUtilsV2.address, + })) as SwapV2 + await swap.deployed() + + await swap.initialize( + [DAI.address, USDC.address, USDT.address, SUSD.address], + [18, 6, 6, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + LPTokenV2.address, + ) + + expect(await swap.getVirtualPrice()).to.be.eq(0) + + swapStorage = await swap.swapStorage() + + swapToken = (await ethers.getContractAt( + "LPToken", + swapStorage.lpToken, + )) as LPTokenV2 + + await asyncForEach([owner, user1, user2, attacker], async (signer) => { + await DAI.connect(signer).approve(swap.address, MAX_UINT256) + await USDC.connect(signer).approve(swap.address, MAX_UINT256) + await USDT.connect(signer).approve(swap.address, MAX_UINT256) + await SUSD.connect(signer).approve(swap.address, MAX_UINT256) + }) + + // Populate the pool with initial liquidity + await swap.addLiquidity( + [String(50e18), String(50e6), String(50e6), String(50e18)], + 0, + MAX_UINT256, + ) + + expect(await swap.getTokenBalance(0)).to.be.eq(String(50e18)) + expect(await swap.getTokenBalance(1)).to.be.eq(String(50e6)) + expect(await swap.getTokenBalance(2)).to.be.eq(String(50e6)) + expect(await swap.getTokenBalance(3)).to.be.eq(String(50e18)) + expect(await getUserTokenBalance(owner, swapToken)).to.be.eq( + String(200e18), + ) + }, + ) + + beforeEach(async () => { + await setupTest() + }) + + describe("addLiquidity", () => { + it("Add liquidity succeeds with pool with 4 tokens", async () => { + const calcTokenAmount = await swap.calculateTokenAmount( + [String(1e18), 0, 0, 0], + true, + ) + expect(calcTokenAmount).to.be.eq("999854620735777893") + + // Add liquidity as user1 + await swap + .connect(user1) + .addLiquidity( + [String(1e18), 0, 0, 0], + calcTokenAmount.mul(99).div(100), + (await getCurrentBlockTimestamp()) + 60, + ) + + // Verify swapToken balance + expect(await swapToken.balanceOf(await user1.getAddress())).to.be.eq( + "999355335447632820", + ) + }) + }) + + describe("swap", () => { + it("Swap works between tokens with different decimals", async () => { + const calcTokenAmount = await swap + .connect(user1) + .calculateSwap(2, 0, String(1e6)) + expect(calcTokenAmount).to.be.eq("998608238366733809") + const DAIBefore = await getUserTokenBalance(user1, DAI) + await USDT.connect(user1).approve(swap.address, String(1e6)) + await swap + .connect(user1) + .swap( + 2, + 0, + String(1e6), + calcTokenAmount, + (await getCurrentBlockTimestamp()) + 60, + ) + const DAIAfter = await getUserTokenBalance(user1, DAI) + + // Verify user1 balance changes + expect(DAIAfter.sub(DAIBefore)).to.be.eq("998608238366733809") + + // Verify pool balance changes + expect(await swap.getTokenBalance(0)).to.be.eq("49001391761633266191") + }) + }) + + describe("removeLiquidity", () => { + it("Remove Liquidity succeeds", async () => { + const calcTokenAmount = await swap.calculateTokenAmount( + [String(1e18), 0, 0, 0], + true, + ) + expect(calcTokenAmount).to.be.eq("999854620735777893") + + // Add liquidity (1e18 DAI) as user1 + await swap + .connect(user1) + .addLiquidity( + [String(1e18), 0, 0, 0], + calcTokenAmount.mul(99).div(100), + (await getCurrentBlockTimestamp()) + 60, + ) + + // Verify swapToken balance + expect(await swapToken.balanceOf(await user1.getAddress())).to.be.eq( + "999355335447632820", + ) + + // Calculate expected amounts of tokens user1 will receive + const expectedAmounts = await swap.calculateRemoveLiquidity( + "999355335447632820", + ) + + expect(expectedAmounts[0]).to.be.eq("253568584947798923") + expect(expectedAmounts[1]).to.be.eq("248596") + expect(expectedAmounts[2]).to.be.eq("248596") + expect(expectedAmounts[3]).to.be.eq("248596651909606787") + + // Allow burn of swapToken + await swapToken.connect(user1).approve(swap.address, "999355335447632820") + const beforeTokenBalances = await getUserTokenBalances(user1, TOKENS) + + // Withdraw user1's share via all tokens in proportion to pool's balances + await swap + .connect(user1) + .removeLiquidity( + "999355335447632820", + expectedAmounts, + (await getCurrentBlockTimestamp()) + 60, + ) + + const afterTokenBalances = await getUserTokenBalances(user1, TOKENS) + + // Verify the received amounts are correct + expect(afterTokenBalances[0].sub(beforeTokenBalances[0])).to.be.eq( + "253568584947798923", + ) + expect(afterTokenBalances[1].sub(beforeTokenBalances[1])).to.be.eq( + "248596", + ) + expect(afterTokenBalances[2].sub(beforeTokenBalances[2])).to.be.eq( + "248596", + ) + expect(afterTokenBalances[3].sub(beforeTokenBalances[3])).to.be.eq( + "248596651909606787", + ) + }) + }) + + describe("withdrawAdminFees", () => { + it("Reverts when called by non-owners", async () => { + await expect(swap.connect(user1).withdrawAdminFees()).to.be.reverted + await expect(swap.connect(user2).withdrawAdminFees()).to.be.reverted + }) + + it("Succeeds when there are no fees withdrawn", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + + const balancesBefore = await getUserTokenBalances(owner, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + await swap.withdrawAdminFees() + + const balancesAfter = await getUserTokenBalances(owner, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + expect(balancesBefore).to.eql(balancesAfter) + }) + + it("Succeeds with expected amount of fees withdrawn", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + await swap.connect(user1).swap(0, 1, String(1e18), 0, MAX_UINT256) + await swap.connect(user1).swap(1, 0, String(1e6), 0, MAX_UINT256) + + expect(await swap.getAdminBalance(0)).to.eq(String(10003917589952)) + expect(await swap.getAdminBalance(1)).to.eq(String(9)) + + const balancesBefore = await getUserTokenBalances(owner, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + await swap.withdrawAdminFees() + + const balancesAfter = await getUserTokenBalances(owner, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + expect(balancesAfter[0].sub(balancesBefore[0])).to.eq( + String(10003917589952), + ) + expect(balancesAfter[1].sub(balancesBefore[1])).to.eq(String(9)) + }) + + it("Withdrawing admin fees has no impact on users' withdrawal", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + await swap + .connect(user1) + .addLiquidity( + [String(1e18), String(1e6), String(1e6), String(1e18)], + 0, + MAX_UINT256, + ) + + for (let i = 0; i < 10; i++) { + await swap.connect(user2).swap(0, 1, String(1e18), 0, MAX_UINT256) + await swap.connect(user2).swap(1, 0, String(1e6), 0, MAX_UINT256) + } + + expect(await swap.getAdminBalance(0)).to.eq(String(100038269603084)) + expect(await swap.getAdminBalance(1)).to.eq(String(90)) + + await swap.withdrawAdminFees() + + const balancesBefore = await getUserTokenBalances(user1, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + const user1LPTokenBalance = await swapToken.balanceOf(user1Address) + await swapToken.connect(user1).approve(swap.address, user1LPTokenBalance) + await swap + .connect(user1) + .removeLiquidity(user1LPTokenBalance, [0, 0, 0, 0], MAX_UINT256) + + const balancesAfter = await getUserTokenBalances(user1, [ + DAI, + USDC, + USDT, + SUSD, + ]) + + expect(balancesAfter[0].sub(balancesBefore[0])).to.eq( + BigNumber.from("1000119153497686425"), + ) + + expect(balancesAfter[1].sub(balancesBefore[1])).to.eq( + BigNumber.from("1000269"), + ) + }) + }) + + describe("Check for timestamp manipulations", () => { + it("Check for maximum differences in A and virtual price when increasing", async () => { + // Create imbalanced pool to measure virtual price change + // Number of tokens are in 2:1:1:1 ratio + // We expect virtual price to increase as A increases + await swap + .connect(user1) + .addLiquidity([String(1e20), 0, 0, 0], 0, MAX_UINT256) + + // Start ramp + await swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // +0 seconds since ramp A + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000166120891616093") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // +900 seconds since ramp A + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5003) + expect(await swap.getVirtualPrice()).to.be.eq("1000168045277768276") + + // Max change of A between two blocks + // 5003 / 5000 + // = 1.0006 + + // Max change of virtual price between two blocks + // 1000168045277768276 / 1000166120891616093 + // = 1.00000192407 + }) + + it("Check for maximum differences in A and virtual price when decreasing", async () => { + // Create imbalanced pool to measure virtual price change + // Number of tokens are in 2:1:1:1 ratio + // We expect virtual price to decrease as A decreases + await swap + .connect(user1) + .addLiquidity([String(1e20), 0, 0, 0], 0, MAX_UINT256) + + // Start ramp + await swap.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // +0 seconds since ramp A + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000166120891616093") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // +900 seconds since ramp A + expect(await swap.getA()).to.be.eq(49) + expect(await swap.getAPrecise()).to.be.eq(4999) + expect(await swap.getVirtualPrice()).to.be.eq("1000165478934301535") + + // Max change of A between two blocks + // 4999 / 5000 + // = 0.9998 + + // Max change of virtual price between two blocks + // 1000165478934301535 / 1000166120891616093 + // = 0.99999935814 + }) + + // Below tests try to verify the issues found in Curve Vulnerability Report are resolved. + // https://medium.com/@peter_4205/curve-vulnerability-report-a1d7630140ec + // The two cases we are most concerned are: + // + // 1. A is ramping up, and the pool is at imbalanced state. + // + // Attacker can 'resolve' the imbalance prior to the change of A. Then try to recreate the imbalance after A has + // changed. Due to the price curve becoming more linear, recreating the imbalance will become a lot cheaper. Thus + // benefiting the attacker. + // + // 2. A is ramping down, and the pool is at balanced state + // + // Attacker can create the imbalance in token balances prior to the change of A. Then try to resolve them + // near 1:1 ratio. Since downward change of A will make the price curve less linear, resolving the token balances + // to 1:1 ratio will be cheaper. Thus benefiting the attacker + // + // For visual representation of how price curves differ based on A, please refer to Figure 1 in the above + // Curve Vulnerability Report. + + describe("Check for attacks while A is ramping upwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + + beforeEach(async () => { + initialAttackerBalances = await getUserTokenBalances(attacker, TOKENS) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[2]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[3]).to.be.eq(String(1e20)) + + // Start ramp upwards + await swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 10, + ) + expect(await swap.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swap, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(50e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swap.getAPrecise()).to.be.eq(5003) + + // Trade SUSD to USDC, taking advantage of the imbalance and change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15967909") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("32091") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 3.209e4 USDC (0.201% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "32091", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 3.209e4 USDC (0.0642% of USDC balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS + 10, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swap.getAPrecise()).to.be.eq(10000) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15913488") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("86512") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 8.65e4 USDC (0.54% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "86512", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 8.65e4 USDC (0.173024% of USDC balance) + // The attack did not benefit the attacker. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + // This attack is achieved by attempting to resolve the imbalance by getting as close to 1:1 ratio of tokens. + // Then re-creating the imbalance when A has changed. + + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swap + .connect(user2) + .addLiquidity( + [0, 0, 0, String(50e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swap, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(100e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swapping 25e6 of USDC to SUSD, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 25e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swap.getAPrecise()).to.be.eq(5003) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24950174") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("49826") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 4.982e4 USDC (0.199% of initial attack deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "49826", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 4.982e4 USDC (0.0996% of USDC balance of pool) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 25e6 of USDC to SUSD, resolving the imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS + 10, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swap.getAPrecise()).to.be.eq(10000) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("25031387") + // Attack was successful! + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(initialAttackerBalances[1]).to.be.lt( + finalAttackerBalances[1], + ) + expect(initialAttackerBalances[3]).to.be.eq( + finalAttackerBalances[3], + ) + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("31387") + expect( + finalAttackerBalances[3].sub(initialAttackerBalances[3]), + ).to.be.eq("0") + // Attacker gained 3.139e4 USDC (0.12556% of attack deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.lt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "31387", + ) + expect(initialPoolBalances[3].sub(finalPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 3.139e4 USDC (0.06278% of USDC balance in pool) + + // The attack benefited the attacker. + // Note that this attack is only possible when there are no swaps happening during the 2 weeks ramp period. + }) + }, + ) + }) + + describe("Check for attacks while A is ramping downwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + + beforeEach(async () => { + // Set up the downward ramp A + initialAttackerBalances = await getUserTokenBalances(attacker, TOKENS) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[2]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[3]).to.be.eq(String(1e20)) + + // Start ramp downwards + await swap.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 10, + ) + expect(await swap.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swap, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(50e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15967995") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("32005") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 3.2e4 USDC (0.2% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "32005", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 3.2e4 USDC (0.064% of USDC pool balance) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test is to show how dangerous rapid A ramp is. + + // Swap 16e6 USDC to sUSD, causing imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS + 10, + ) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("16073391") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.gt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("73391") + expect( + finalAttackerBalances[3].sub(initialAttackerBalances[3]), + ).to.be.eq("0") + // Attacker gained 7.34e4 USDC (0.45875% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.lt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "73391", + ) + expect(initialPoolBalances[3].sub(finalPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 7.34e4 USDC (0.1468% of USDC balance) + + // The attack was successful. The change of A (-50%) gave the attacker a chance to swap + // more efficiently. The swap fee (0.1%) was not sufficient to counter the efficient trade, giving + // the attacker more tokens than initial deposit. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + // This attack is achieved by attempting to resolve the imbalance by getting as close to 1:1 ratio of tokens. + // Then re-creating the imbalance when A has changed. + + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swap + .connect(user2) + .addLiquidity( + [0, 0, 0, String(50e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swap, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(100e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 25e6 of USDC to SUSD, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 25e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24950046") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("49954") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 4.995e4 USDC (0.2% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "49954", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 1.22e6 USDC (0.1% of pool balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test case is to mimic rapid ramp down of A. + + // Swap 25e6 of USDC to SUSD, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 1e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swap.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swap.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS + 10, + ) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swap.connect(attacker).swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24794844") + // Attack was not successful + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("205156") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 2.05e5 USDC (0.820624% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swap, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "205156", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 2.05e5 USDC (0.410312% of USDC balance of pool) + // The attack did not benefit the attacker + }) + }, + ) + }) + }) +}) diff --git a/test/swapDeployerV2.ts b/test/swapDeployerV2.ts new file mode 100644 index 00000000..814ee4de --- /dev/null +++ b/test/swapDeployerV2.ts @@ -0,0 +1,1143 @@ +import chai from "chai" +import { BigNumber, Signer } from "ethers" +import { deployments } from "hardhat" +import { + AmplificationUtilsV2, + GenericERC20, + LPTokenV2, + SwapV2, + SwapDeployerV2, + SwapUtilsV2, +} from "../build/typechain" +import { + asyncForEach, + deployContractWithLibraries, + forceAdvanceOneBlock, + getCurrentBlockTimestamp, + getPoolBalances, + getUserTokenBalance, + getUserTokenBalances, + MAX_UINT256, + setTimestamp, + TIME, +} from "./testUtils" +import SwapV2Artifact from "../build/artifacts/contracts/SwapV2.sol/SwapV2.json" + + + +const { expect } = chai + +describe("Swap DeployerV2", () => { + let signers: Array + let swap: SwapV2 + let swapClone: SwapV2 + let swapDeployer: SwapDeployerV2 + let DAI: GenericERC20 + let USDC: GenericERC20 + let USDT: GenericERC20 + let SUSD: GenericERC20 + let swapToken: LPTokenV2 + let owner: Signer + let user1: Signer + let user2: Signer + let attacker: Signer + let ownerAddress: string + let user1Address: string + let user2Address: string + let swapStorage: { + initialA: BigNumber + futureA: BigNumber + initialATime: BigNumber + futureATime: BigNumber + swapFee: BigNumber + adminFee: BigNumber + lpToken: string + } + + // Test Values + const INITIAL_A_VALUE = 50 + const SWAP_FEE = 1e7 + const LP_TOKEN_NAME = "Test LP Token Name" + const LP_TOKEN_SYMBOL = "TESTLP" + const TOKENS: GenericERC20[] = [] + + const setupTest = deployments.createFixture( + async ({ deployments, ethers }) => { + const { get, deploy } = deployments + await deployments.fixture(["USDPool"]) // ensure you start from a fresh deployments + + TOKENS.length = 0 + signers = await ethers.getSigners() + owner = signers[0] + user1 = signers[1] + user2 = signers[2] + attacker = signers[10] + ownerAddress = await owner.getAddress() + user1Address = await user1.getAddress() + user2Address = await user2.getAddress() + + await deploy("SUSD", { + from: ownerAddress, + contract: "GenericERC20", + args: ["SUSD", "Synthetix USD", "18"], + skipIfAlreadyDeployed: true, + }) + + DAI = await ethers.getContract("DAI") + USDC = await ethers.getContract("USDC") + USDT = await ethers.getContract("USDT") + SUSD = await ethers.getContract("SUSD") + + TOKENS.push(DAI, USDC, USDT, SUSD) + + // Mint dummy tokens + await asyncForEach( + [ownerAddress, user1Address, user2Address, await attacker.getAddress()], + async (address) => { + await DAI.mint(address, String(1e20)) + await USDC.mint(address, String(1e8)) + await USDT.mint(address, String(1e8)) + await SUSD.mint(address, String(1e20)) + }, + ) + + // Deploy Swap Libraries + const amplificationUtilsV2 = (await ( + await ethers.getContractFactory("AmplificationUtilsV2") + ).deploy()) as AmplificationUtilsV2 + await amplificationUtilsV2.deployed() + const swapUtilsV2 = (await ( + await ethers.getContractFactory("SwapUtilsV2") + ).deploy()) as SwapUtilsV2 + await swapUtilsV2.deployed() + const lpToken = (await ( + await ethers.getContractFactory("LPTokenV2") + ).deploy()) as LPTokenV2 + + // Swap Contract + swap = (await deployContractWithLibraries(owner, SwapV2Artifact, { + SwapUtilsV2: swapUtilsV2.address, + AmplificationUtilsV2: amplificationUtilsV2.address, + })) as SwapV2 + await swap.deployed() + + // Deploy Swap Deployer + swapDeployer = (await ( + await ethers.getContractFactory("SwapDeployerV2") + ).deploy()) as SwapDeployerV2 + + const swapCloneAddress = await swapDeployer.callStatic.deploy( + swap.address, + [DAI.address, USDC.address, USDT.address, SUSD.address], + [18, 6, 6, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpToken.address, + ) + + await swapDeployer.deploy( + swap.address, + [DAI.address, USDC.address, USDT.address, SUSD.address], + [18, 6, 6, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpToken.address, + ) + + swapClone = await ethers.getContractAt("SwapV2", swapCloneAddress) + + expect(await swapClone.getVirtualPrice()).to.be.eq(0) + + swapStorage = await swapClone.swapStorage() + + swapToken = (await ethers.getContractAt( + "LPTokenV2", + swapStorage.lpToken, + )) as LPTokenV2 + + await asyncForEach([owner, user1, user2, attacker], async (signer) => { + await DAI.connect(signer).approve(swapClone.address, MAX_UINT256) + await USDC.connect(signer).approve(swapClone.address, MAX_UINT256) + await USDT.connect(signer).approve(swapClone.address, MAX_UINT256) + await SUSD.connect(signer).approve(swapClone.address, MAX_UINT256) + }) + + // Populate the pool with initial liquidity + await swapClone.addLiquidity( + [String(50e18), String(50e6), String(50e6), String(50e18)], + 0, + MAX_UINT256, + ) + + expect(await swapClone.getTokenBalance(0)).to.be.eq(String(50e18)) + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(50e6)) + expect(await swapClone.getTokenBalance(2)).to.be.eq(String(50e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq(String(50e18)) + expect(await getUserTokenBalance(owner, swapToken)).to.be.eq( + String(200e18), + ) + }, + ) + + beforeEach(async () => { + await setupTest() + }) + + describe("addLiquidity", () => { + it("Add liquidity succeeds with pool with 4 tokens", async () => { + const calcTokenAmount = await swapClone.calculateTokenAmount( + [String(1e18), 0, 0, 0], + true, + ) + expect(calcTokenAmount).to.be.eq("999854620735777893") + + // Add liquidity as user1 + await swapClone + .connect(user1) + .addLiquidity( + [String(1e18), 0, 0, 0], + calcTokenAmount.mul(99).div(100), + (await getCurrentBlockTimestamp()) + 60, + ) + + // Verify swapToken balance + expect(await swapToken.balanceOf(await user1.getAddress())).to.be.eq( + "999355335447632820", + ) + }) + }) + + describe("swap", () => { + it("Swap works between tokens with different decimals", async () => { + const calcTokenAmount = await swapClone + .connect(user1) + .calculateSwap(2, 0, String(1e6)) + expect(calcTokenAmount).to.be.eq("998608238366733809") + const DAIBefore = await getUserTokenBalance(user1, DAI) + await swapClone + .connect(user1) + .swap( + 2, + 0, + String(1e6), + calcTokenAmount, + (await getCurrentBlockTimestamp()) + 60, + ) + const DAIAfter = await getUserTokenBalance(user1, DAI) + + // Verify user1 balance changes + expect(DAIAfter.sub(DAIBefore)).to.be.eq("998608238366733809") + + // Verify pool balance changes + expect(await swapClone.getTokenBalance(0)).to.be.eq( + "49001391761633266191", + ) + }) + }) + + describe("removeLiquidity", () => { + it("Remove Liquidity succeeds", async () => { + const calcTokenAmount = await swapClone.calculateTokenAmount( + [String(1e18), 0, 0, 0], + true, + ) + expect(calcTokenAmount).to.be.eq("999854620735777893") + + // Add liquidity (1e18 DAI) as user1 + await swapClone + .connect(user1) + .addLiquidity( + [String(1e18), 0, 0, 0], + calcTokenAmount.mul(99).div(100), + (await getCurrentBlockTimestamp()) + 60, + ) + + // Verify swapToken balance + expect(await swapToken.balanceOf(await user1.getAddress())).to.be.eq( + "999355335447632820", + ) + + // Calculate expected amounts of tokens user1 will receive + const expectedAmounts = await swapClone.calculateRemoveLiquidity( + "999355335447632820", + ) + + expect(expectedAmounts[0]).to.be.eq("253568584947798923") + expect(expectedAmounts[1]).to.be.eq("248596") + expect(expectedAmounts[2]).to.be.eq("248596") + expect(expectedAmounts[3]).to.be.eq("248596651909606787") + + // Allow burn of swapToken + await swapToken + .connect(user1) + .approve(swapClone.address, "999355335447632820") + const beforeTokenBalances = await getUserTokenBalances(user1, TOKENS) + + // Withdraw user1's share via all tokens in proportion to pool's balances + await swapClone + .connect(user1) + .removeLiquidity( + "999355335447632820", + expectedAmounts, + (await getCurrentBlockTimestamp()) + 60, + ) + + const afterTokenBalances = await getUserTokenBalances(user1, TOKENS) + + // Verify the received amounts are correct + expect(afterTokenBalances[0].sub(beforeTokenBalances[0])).to.be.eq( + "253568584947798923", + ) + expect(afterTokenBalances[1].sub(beforeTokenBalances[1])).to.be.eq( + "248596", + ) + expect(afterTokenBalances[2].sub(beforeTokenBalances[2])).to.be.eq( + "248596", + ) + expect(afterTokenBalances[3].sub(beforeTokenBalances[3])).to.be.eq( + "248596651909606787", + ) + }) + }) + + describe("Check for timestamp manipulations", () => { + beforeEach(async () => { + await forceAdvanceOneBlock() + }) + it("Check for maximum differences in A and virtual price when increasing", async () => { + // Create imbalanced pool to measure virtual price change + // Number of tokens are in 2:1:1:1 ratio + // We expect virtual price to increase as A increases + await swapClone + .connect(user1) + .addLiquidity([String(1e20), 0, 0, 0], 0, MAX_UINT256) + + // Start ramp + await swapClone.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // +0 seconds since ramp A + expect(await swapClone.getA()).to.be.eq(50) + expect(await swapClone.getAPrecise()).to.be.eq(5000) + expect(await swapClone.getVirtualPrice()).to.be.eq("1000166120891616093") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // +900 seconds since ramp A + expect(await swapClone.getA()).to.be.eq(50) + expect(await swapClone.getAPrecise()).to.be.eq(5003) + expect(await swapClone.getVirtualPrice()).to.be.eq("1000168045277768276") + + // Max change of A between two blocks + // 5003 / 5000 + // = 1.0006 + + // Max change of virtual price between two blocks + // 1000168045277768276 / 1000166120891616093 + // = 1.00000192407 + }) + + it("Check for maximum differences in A and virtual price when decreasing", async () => { + // Create imbalanced pool to measure virtual price change + // Number of tokens are in 2:1:1:1 ratio + // We expect virtual price to decrease as A decreases + await swapClone + .connect(user1) + .addLiquidity([String(1e20), 0, 0, 0], 0, MAX_UINT256) + + // Start ramp + await swapClone.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // +0 seconds since ramp A + expect(await swapClone.getA()).to.be.eq(50) + expect(await swapClone.getAPrecise()).to.be.eq(5000) + expect(await swapClone.getVirtualPrice()).to.be.eq("1000166120891616093") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // +900 seconds since ramp A + expect(await swapClone.getA()).to.be.eq(49) + expect(await swapClone.getAPrecise()).to.be.eq(4999) + expect(await swapClone.getVirtualPrice()).to.be.eq("1000165478934301535") + + // Max change of A between two blocks + // 4999 / 5000 + // = 0.9998 + + // Max change of virtual price between two blocks + // 1000165478934301535 / 1000166120891616093 + // = 0.99999935814 + }) + + // Below tests try to verify the issues found in Curve Vulnerability Report are resolved. + // https://medium.com/@peter_4205/curve-vulnerability-report-a1d7630140ec + // The two cases we are most concerned are: + // + // 1. A is ramping up, and the pool is at imbalanced state. + // + // Attacker can 'resolve' the imbalance prior to the change of A. Then try to recreate the imbalance after A has + // changed. Due to the price curve becoming more linear, recreating the imbalance will become a lot cheaper. Thus + // benefiting the attacker. + // + // 2. A is ramping down, and the pool is at balanced state + // + // Attacker can create the imbalance in token balances prior to the change of A. Then try to resolve them + // near 1:1 ratio. Since downward change of A will make the price curve less linear, resolving the token balances + // to 1:1 ratio will be cheaper. Thus benefiting the attacker + // + // For visual representation of how price curves differ based on A, please refer to Figure 1 in the above + // Curve Vulnerability Report. + + describe("Check for attacks while A is ramping upwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + + beforeEach(async () => { + initialAttackerBalances = await getUserTokenBalances(attacker, TOKENS) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[2]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[3]).to.be.eq(String(1e20)) + + // Start ramp upwards + await swapClone.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + expect(await swapClone.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swapClone, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(50e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swapClone.getAPrecise()).to.be.eq(5003) + + // Trade SUSD to USDC, taking advantage of the imbalance and change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15967909") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("32091") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 3.209e4 USDC (0.201% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "32091", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 3.209e4 USDC (0.0642% of USDC balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swapClone.getAPrecise()).to.be.eq(10000) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15913488") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("86512") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 8.65e4 USDC (0.54% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "86512", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 8.65e4 USDC (0.173024% of USDC balance) + // The attack did not benefit the attacker. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + // This attack is achieved by attempting to resolve the imbalance by getting as close to 1:1 ratio of tokens. + // Then re-creating the imbalance when A has changed. + + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swapClone + .connect(user2) + .addLiquidity( + [0, 0, 0, String(50e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swapClone, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(100e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swapping 25e6 of USDC to SUSD, resolving imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 25e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swapClone.getAPrecise()).to.be.eq(5003) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24950174") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("49826") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 4.982e4 USDC (0.199% of initial attack deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "49826", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 4.982e4 USDC (0.0996% of USDC balance of pool) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 25e6 of USDC to SUSD, resolving the imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swapClone.getAPrecise()).to.be.eq(10000) + + // Trade SUSD to USDC, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("25031387") + // Attack was successful! + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + expect(initialAttackerBalances[1]).to.be.lt( + finalAttackerBalances[1], + ) + expect(initialAttackerBalances[3]).to.be.eq( + finalAttackerBalances[3], + ) + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("31387") + expect( + finalAttackerBalances[3].sub(initialAttackerBalances[3]), + ).to.be.eq("0") + // Attacker gained 3.139e4 USDC (0.12556% of attack deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.lt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "31387", + ) + expect(initialPoolBalances[3].sub(finalPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 3.139e4 USDC (0.06278% of USDC balance in pool) + + // The attack benefited the attacker. + // Note that this attack is only possible when there are no swaps happening during the 2 weeks ramp period. + }) + }, + ) + }) + + describe("Check for attacks while A is ramping downwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + + beforeEach(async () => { + // Set up the downward ramp A + initialAttackerBalances = await getUserTokenBalances(attacker, TOKENS) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[2]).to.be.eq(String(1e8)) + expect(initialAttackerBalances[3]).to.be.eq(String(1e20)) + + // Start ramp downwards + await swapClone.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + expect(await swapClone.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swapClone, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(50e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 16e6 of USDC to SUSD, causing massive imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swapClone.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("15967995") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("32005") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 3.2e4 USDC (0.2% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "32005", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 3.2e4 USDC (0.064% of USDC pool balance) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test is to show how dangerous rapid A ramp is. + + // Swap 16e6 USDC to sUSD, causing imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(16e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 15.87e18 of SUSD + expect(SUSDOutput).to.be.eq("15873636661935380627") + + // Pool is imbalanced! Now trades from SUSD -> USDC may be profitable in small sizes + // USDC balance in the pool : 66e6 + // SUSD balance in the pool : 34.13e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(66e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "34126363338064619373", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed downwards + expect(await swapClone.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 16e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("16073391") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.gt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("73391") + expect( + finalAttackerBalances[3].sub(initialAttackerBalances[3]), + ).to.be.eq("0") + // Attacker gained 7.34e4 USDC (0.45875% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.lt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "73391", + ) + expect(initialPoolBalances[3].sub(finalPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 7.34e4 USDC (0.1468% of USDC balance) + + // The attack was successful. The change of A (-50%) gave the attacker a chance to swap + // more efficiently. The swap fee (0.1%) was not sufficient to counter the efficient trade, giving + // the attacker more tokens than initial deposit. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + // This attack is achieved by attempting to resolve the imbalance by getting as close to 1:1 ratio of tokens. + // Then re-creating the imbalance when A has changed. + + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swapClone + .connect(user2) + .addLiquidity( + [0, 0, 0, String(50e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = await getPoolBalances(swapClone, 4) + expect(initialPoolBalances[0]).to.be.eq(String(50e18)) + expect(initialPoolBalances[1]).to.be.eq(String(50e6)) + expect(initialPoolBalances[2]).to.be.eq(String(50e6)) + expect(initialPoolBalances[3]).to.be.eq(String(100e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 25e6 of USDC to SUSD, resolving imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 25e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swapClone.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24950046") + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("49954") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 4.995e4 USDC (0.2% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "49954", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 1.22e6 USDC (0.1% of pool balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test case is to mimic rapid ramp down of A. + + // Swap 25e6 of USDC to SUSD, resolving imbalance in the pool + await swapClone + .connect(attacker) + .swap(1, 3, String(25e6), 0, MAX_UINT256) + const SUSDOutput = (await getUserTokenBalance(attacker, SUSD)).sub( + initialAttackerBalances[3], + ) + + // First trade results in 25.14e18 of SUSD + // Because the pool was imbalanced in the beginning, this trade results in more than 1e18 SUSD + expect(SUSDOutput).to.be.eq("25140480043410581418") + + // Pool is now almost balanced! + // USDC balance in the pool : 75.00e6 + // SUSD balance in the pool : 74.86e18 + expect(await swapClone.getTokenBalance(1)).to.be.eq(String(75e6)) + expect(await swapClone.getTokenBalance(3)).to.be.eq( + "74859519956589418582", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed downwards + expect(await swapClone.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance(attacker, USDC) + await swapClone + .connect(attacker) + .swap(3, 1, SUSDOutput, 0, MAX_UINT256) + const USDCOutput = (await getUserTokenBalance(attacker, USDC)).sub( + balanceBefore, + ) + + // If USDCOutput > 25e6, the attacker leaves with more USDC than the start. + expect(USDCOutput).to.be.eq("24794844") + // Attack was not successful + + const finalAttackerBalances = await getUserTokenBalances( + attacker, + TOKENS, + ) + + // Check for attacker's balance changes + expect(finalAttackerBalances[1]).to.be.lt( + initialAttackerBalances[1], + ) + expect(finalAttackerBalances[3]).to.be.eq( + initialAttackerBalances[3], + ) + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("205156") + expect( + initialAttackerBalances[3].sub(finalAttackerBalances[3]), + ).to.be.eq("0") + // Attacker lost 2.05e5 USDC (0.820624% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = await getPoolBalances(swapClone, 4) + + expect(finalPoolBalances[1]).to.be.gt(initialPoolBalances[1]) + expect(finalPoolBalances[3]).to.be.eq(initialPoolBalances[3]) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "205156", + ) + expect(finalPoolBalances[3].sub(initialPoolBalances[3])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 2.05e5 USDC (0.410312% of USDC balance of pool) + // The attack did not benefit the attacker + }) + }, + ) + }) + }) +}) diff --git a/test/swapInitializeV2.ts b/test/swapInitializeV2.ts new file mode 100644 index 00000000..00bfd0a3 --- /dev/null +++ b/test/swapInitializeV2.ts @@ -0,0 +1,226 @@ +import chai from "chai" +import { Signer } from "ethers" +import { deployments } from "hardhat" +import { GenericERC20, SwapV2, AmplificationUtilsV2, LPTokenV2, SwapUtilsV2 } from "../build/typechain" +import { ZERO_ADDRESS, deployContractWithLibraries } from "./testUtils" +import SwapV2Artifact from "../build/artifacts/contracts/SwapV2.sol/SwapV2.json" + +const { expect } = chai + +describe("SwapV2 Initialize", () => { + let signers: Array + let swap: SwapV2 + let lpTokenV2: LPTokenV2 + let firstToken: GenericERC20 + let secondToken: GenericERC20 + let owner: Signer + + // Test Values + const INITIAL_A_VALUE = 50 + const SWAP_FEE = 1e7 + const LP_TOKEN_NAME = "Test LP Token Name" + const LP_TOKEN_SYMBOL = "TESTLP" + + const setupTest = deployments.createFixture( + async ({ deployments, ethers }) => { + const { get } = deployments + await deployments.fixture(["Swap"]) // ensure you start from a fresh deployments + + signers = await ethers.getSigners() + owner = signers[0] + + // Deploy dummy tokens + const erc20Factory = await ethers.getContractFactory("GenericERC20") + + firstToken = (await erc20Factory.deploy( + "First Token", + "FIRST", + "18", + )) as GenericERC20 + + secondToken = (await erc20Factory.deploy( + "Second Token", + "SECOND", + "18", + )) as GenericERC20 + + // Deploy Swap Libraries + const amplificationUtilsV2 = (await ( + await ethers.getContractFactory("AmplificationUtilsV2") + ).deploy()) as AmplificationUtilsV2 + await amplificationUtilsV2.deployed() + const swapUtilsV2 = (await ( + await ethers.getContractFactory("SwapUtilsV2") + ).deploy()) as SwapUtilsV2 + await swapUtilsV2.deployed() + lpTokenV2 = ( await ( + await ethers.getContractFactory("LPTokenV2") + ).deploy()) as LPTokenV2 + + + // Swap Contract + swap = (await deployContractWithLibraries(owner, SwapV2Artifact, { + SwapUtilsV2: swapUtilsV2.address, + AmplificationUtilsV2: amplificationUtilsV2.address, + })) as SwapV2 + await swap.deployed() + + }, + ) + + beforeEach(async () => { + await setupTest() + }) + + describe("swapStorage#constructor", () => { + it("Reverts with '_pooledTokens.length <= 1'", async () => { + await expect( + swap.initialize( + [], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("_pooledTokens.length <= 1") + }) + + it("Reverts with '_pooledTokens.length > 32'", async () => { + await expect( + swap.initialize( + Array(33).fill(firstToken.address), + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("_pooledTokens.length > 32") + }) + + it("Reverts with '_pooledTokens decimals mismatch'", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("_pooledTokens decimals mismatch") + }) + + it("Reverts with 'Duplicate tokens'", async () => { + await expect( + swap.initialize( + [firstToken.address, firstToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("Duplicate tokens") + }) + + it("Reverts with 'The 0 address isn't an ERC-20'", async () => { + await expect( + swap.initialize( + [ZERO_ADDRESS, ZERO_ADDRESS], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("The 0 address isn't an ERC-20") + }) + + it("Reverts with 'Token decimals exceeds max'", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [19, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("Token decimals exceeds max") + }) + + it("Reverts with '_a exceeds maximum'", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + 10e6 + 1, + SWAP_FEE, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("_a exceeds maximum") + }) + + it("Reverts with '_fee exceeds maximum'", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + 10e8 + 1, + 0, + lpTokenV2.address, + ), + ).to.be.revertedWith("_fee exceeds maximum") + }) + + it("Reverts with '_adminFee exceeds maximum'", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 10e10 + 1, + lpTokenV2.address, + ), + ).to.be.revertedWith("_adminFee exceeds maximum") + }) + + it("Reverts when the LPToken target does not implement initialize function", async () => { + await expect( + swap.initialize( + [firstToken.address, secondToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + ZERO_ADDRESS, + ), + ).to.be.revertedWithoutReason() + }) + }) +}) diff --git a/test/swapv2.ts b/test/swapv2.ts new file mode 100644 index 00000000..086ff0f5 --- /dev/null +++ b/test/swapv2.ts @@ -0,0 +1,2503 @@ +import chai from "chai" +import { BigNumber, Signer } from "ethers" +import { deployments } from "hardhat" +import { + AmplificationUtilsV2, + GenericERC20, + LPTokenV2, + SwapV2, + SwapUtilsV2, + TestSwapReturnValues, +} from "../build/typechain" +import { + asyncForEach, + deployContractWithLibraries, + forceAdvanceOneBlock, + getCurrentBlockTimestamp, + getUserTokenBalance, + getUserTokenBalances, + MAX_UINT256, + setNextTimestamp, + setTimestamp, + TIME, + ZERO_ADDRESS, +} from "./testUtils" +import SwapV2Artifact from "../build/artifacts/contracts/SwapV2.sol/SwapV2.json" +import { SwapV2Interface } from "../build/typechain/contracts/SwapV2" + +const { expect } = chai + +describe("SwapV2", async () => { + let signers: Array + let swap: SwapV2 + let testSwapReturnValues: TestSwapReturnValues + let swapUtils: SwapUtilsV2 + let firstToken: GenericERC20 + let secondToken: GenericERC20 + let swapToken: LPTokenV2 + let owner: Signer + let user1: Signer + let user2: Signer + let ownerAddress: string + let user1Address: string + let user2Address: string + let swapStorage: { + initialA: BigNumber + futureA: BigNumber + initialATime: BigNumber + futureATime: BigNumber + swapFee: BigNumber + adminFee: BigNumber + lpToken: string + } + + // Test Values + const INITIAL_A_VALUE = 50 + const SWAP_FEE = 1e7 + const LP_TOKEN_NAME = "Test LP Token Name" + const LP_TOKEN_SYMBOL = "TESTLP" + + const setupTest = deployments.createFixture( + async ({ deployments, ethers }) => { + const { get } = deployments + await deployments.fixture(["Swap", "LPToken"]) // ensure you start from a fresh deployments + + signers = await ethers.getSigners() + owner = signers[0] + user1 = signers[1] + user2 = signers[2] + ownerAddress = await owner.getAddress() + user1Address = await user1.getAddress() + user2Address = await user2.getAddress() + + // Deploy dummy tokens + const erc20Factory = await ethers.getContractFactory("GenericERC20") + + firstToken = (await erc20Factory.deploy( + "First Token", + "FIRST", + "18", + )) as GenericERC20 + + secondToken = (await erc20Factory.deploy( + "Second Token", + "SECOND", + "18", + )) as GenericERC20 + + // Mint dummy tokens + await asyncForEach([owner, user1, user2], async (signer) => { + const address = await signer.getAddress() + await firstToken.mint(address, String(1e20)) + await secondToken.mint(address, String(1e20)) + }) + + // Deploy Swap Libraries + const amplificationUtilsV2 = (await ( + await ethers.getContractFactory("AmplificationUtilsV2") + ).deploy()) as AmplificationUtilsV2 + await amplificationUtilsV2.deployed() + const swapUtilsV2 = (await ( + await ethers.getContractFactory("SwapUtilsV2") + ).deploy()) as SwapUtilsV2 + await swapUtilsV2.deployed() + const LPTokenV2 = ( await ( + await ethers.getContractFactory("LPTokenV2") + ).deploy()) as LPTokenV2 + + + // Swap Contract + swap = (await deployContractWithLibraries(owner, SwapV2Artifact, { + SwapUtilsV2: swapUtilsV2.address, + AmplificationUtilsV2: amplificationUtilsV2.address, + })) as SwapV2 + await swap.deployed() + + await swap.initialize( + [firstToken.address, secondToken.address], + [18, 18], + LP_TOKEN_NAME, + LP_TOKEN_SYMBOL, + INITIAL_A_VALUE, + SWAP_FEE, + 0, + LPTokenV2.address, + ) + + expect(await swap.getVirtualPrice()).to.be.eq(0) + + swapStorage = await swap.swapStorage() + + swapToken = (await ethers.getContractAt( + "LPTokenV2", + swapStorage.lpToken, + )) as LPTokenV2 + + const testSwapReturnValuesFactory = await ethers.getContractFactory( + "TestSwapReturnValues", + ) + testSwapReturnValues = (await testSwapReturnValuesFactory.deploy( + swap.address, + swapToken.address, + 2, + )) as TestSwapReturnValues + + await asyncForEach([owner, user1, user2], async (signer) => { + await firstToken.connect(signer).approve(swap.address, MAX_UINT256) + await secondToken.connect(signer).approve(swap.address, MAX_UINT256) + await swapToken.connect(signer).approve(swap.address, MAX_UINT256) + }) + + await swap.addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + + expect(await firstToken.balanceOf(swap.address)).to.eq(String(1e18)) + expect(await secondToken.balanceOf(swap.address)).to.eq(String(1e18)) + }, + ) + + beforeEach(async () => { + await setupTest() + }) + + describe("swapStorage", () => { + describe("lpToken", async () => { + it("Returns correct lpTokenName", async () => { + expect(await swapToken.name()).to.eq(LP_TOKEN_NAME) + }) + + it("Returns correct lpTokenSymbol", async () => { + expect(await swapToken.symbol()).to.eq(LP_TOKEN_SYMBOL) + }) + + it("Returns true after successfully calling transferFrom", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + + // User 1 approves User 2 for MAX_UINT256 + swapToken.connect(user1).approve(user2Address, MAX_UINT256) + + // User 2 transfers 1337 from User 1 to themselves using transferFrom + await swapToken + .connect(user2) + .transferFrom(user1Address, user2Address, 1337) + + expect(await swapToken.balanceOf(user2Address)).to.eq(1337) + }) + }) + + describe("A", async () => { + it("Returns correct A value", async () => { + expect(await swap.getA()).to.eq(INITIAL_A_VALUE) + expect(await swap.getAPrecise()).to.eq(INITIAL_A_VALUE * 100) + }) + }) + + describe("fee", async () => { + it("Returns correct fee value", async () => { + expect((await swap.swapStorage()).swapFee).to.eq(SWAP_FEE) + }) + }) + + describe("adminFee", async () => { + it("Returns correct adminFee value", async () => { + expect(swapStorage.adminFee).to.eq(0) + }) + }) + }) + + describe("getToken", () => { + it("Returns correct addresses of pooled tokens", async () => { + expect(await swap.getToken(0)).to.eq(firstToken.address) + expect(await swap.getToken(1)).to.eq(secondToken.address) + }) + + it("Reverts when index is out of range", async () => { + await expect(swap.getToken(2)).to.be.reverted + }) + }) + + describe("getTokenIndex", () => { + it("Returns correct token indexes", async () => { + expect(await swap.getTokenIndex(firstToken.address)).to.be.eq(0) + expect(await swap.getTokenIndex(secondToken.address)).to.be.eq(1) + }) + + it("Reverts when token address is not found", async () => { + await expect(swap.getTokenIndex(ZERO_ADDRESS)).to.be.revertedWith( + "Token does not exist", + ) + }) + }) + + describe("getTokenBalance", () => { + it("Returns correct balances of pooled tokens", async () => { + expect(await swap.getTokenBalance(0)).to.eq(BigNumber.from(String(1e18))) + expect(await swap.getTokenBalance(1)).to.eq(BigNumber.from(String(1e18))) + }) + + it("Reverts when index is out of range", async () => { + await expect(swap.getTokenBalance(2)).to.be.reverted + }) + }) + + describe("getA", () => { + it("Returns correct value", async () => { + expect(await swap.getA()).to.eq(INITIAL_A_VALUE) + }) + }) + + describe("addLiquidity", () => { + it("Reverts when contract is paused", async () => { + await swap.pause() + + await expect( + swap + .connect(user1) + .addLiquidity([String(1e18), String(3e18)], 0, MAX_UINT256), + ).to.be.reverted + + // unpause + await swap.unpause() + + await swap + .connect(user1) + .addLiquidity([String(1e18), String(3e18)], 0, MAX_UINT256) + + const actualPoolTokenAmount = await swapToken.balanceOf(user1Address) + expect(actualPoolTokenAmount).to.eq(BigNumber.from("3991672211258372957")) + }) + + it("Reverts with 'Amounts must match pooled tokens'", async () => { + await expect( + swap.connect(user1).addLiquidity([String(1e16)], 0, MAX_UINT256), + ).to.be.revertedWith("Amounts must match pooled tokens") + }) + + it("Reverts with 'Cannot withdraw more than available'", async () => { + await expect( + swap + .connect(user1) + .calculateTokenAmount([MAX_UINT256, String(3e18)], false), + ).to.be.revertedWith("Cannot withdraw more than available") + }) + + it("Reverts with 'Must supply all tokens in pool'", async () => { + swapToken.approve(swap.address, String(2e18)) + await swap.removeLiquidity(String(2e18), [0, 0], MAX_UINT256) + await expect( + swap + .connect(user1) + .addLiquidity([0, String(3e18)], MAX_UINT256, MAX_UINT256), + ).to.be.revertedWith("Must supply all tokens in pool") + }) + + it("Succeeds with expected output amount of pool tokens", async () => { + const calculatedPoolTokenAmount = await swap + .connect(user1) + .calculateTokenAmount([String(1e18), String(3e18)], true) + + const calculatedPoolTokenAmountWithSlippage = calculatedPoolTokenAmount + .mul(999) + .div(1000) + + await swap + .connect(user1) + .addLiquidity( + [String(1e18), String(3e18)], + calculatedPoolTokenAmountWithSlippage, + MAX_UINT256, + ) + + const actualPoolTokenAmount = await swapToken.balanceOf(user1Address) + + // The actual pool token amount is less than 4e18 due to the imbalance of the underlying tokens + expect(actualPoolTokenAmount).to.eq(BigNumber.from("3991672211258372957")) + }) + + it("Succeeds with actual pool token amount being within ±0.1% range of calculated pool token", async () => { + const calculatedPoolTokenAmount = await swap + .connect(user1) + .calculateTokenAmount([String(1e18), String(3e18)], true) + + const calculatedPoolTokenAmountWithNegativeSlippage = + calculatedPoolTokenAmount.mul(999).div(1000) + + const calculatedPoolTokenAmountWithPositiveSlippage = + calculatedPoolTokenAmount.mul(1001).div(1000) + + await swap + .connect(user1) + .addLiquidity( + [String(1e18), String(3e18)], + calculatedPoolTokenAmountWithNegativeSlippage, + MAX_UINT256, + ) + + const actualPoolTokenAmount = await swapToken.balanceOf(user1Address) + + expect(actualPoolTokenAmount).to.gte( + calculatedPoolTokenAmountWithNegativeSlippage, + ) + + expect(actualPoolTokenAmount).to.lte( + calculatedPoolTokenAmountWithPositiveSlippage, + ) + }) + + it("Succeeds with correctly updated tokenBalance after imbalanced deposit", async () => { + await swap + .connect(user1) + .addLiquidity([String(1e18), String(3e18)], 0, MAX_UINT256) + + // Check updated token balance + expect(await swap.getTokenBalance(0)).to.eq(BigNumber.from(String(2e18))) + expect(await swap.getTokenBalance(1)).to.eq(BigNumber.from(String(4e18))) + }) + + it("Returns correct minted lpToken amount", async () => { + await firstToken.mint(testSwapReturnValues.address, String(1e20)) + await secondToken.mint(testSwapReturnValues.address, String(1e20)) + + await testSwapReturnValues.test_addLiquidity( + [String(1e18), String(2e18)], + 0, + ) + }) + + it("Reverts when minToMint is not reached due to front running", async () => { + const calculatedLPTokenAmount = await swap + .connect(user1) + .calculateTokenAmount([String(1e18), String(3e18)], true) + + const calculatedLPTokenAmountWithSlippage = calculatedLPTokenAmount + .mul(999) + .div(1000) + + // Someone else deposits thus front running user 1's deposit + await swap.addLiquidity([String(1e18), String(3e18)], 0, MAX_UINT256) + + await expect( + swap + .connect(user1) + .addLiquidity( + [String(1e18), String(3e18)], + calculatedLPTokenAmountWithSlippage, + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when block is mined after deadline", async () => { + const currentTimestamp = await getCurrentBlockTimestamp() + await setNextTimestamp(currentTimestamp + 60 * 10) + + await expect( + swap + .connect(user1) + .addLiquidity( + [String(2e18), String(1e16)], + 0, + currentTimestamp + 60 * 5, + ), + ).to.be.revertedWith("Deadline not met") + }) + + it("Emits addLiquidity event", async () => { + const calculatedLPTokenAmount = await swap + .connect(user1) + .calculateTokenAmount([String(2e18), String(1e16)], true) + + const calculatedLPTokenAmountWithSlippage = calculatedLPTokenAmount + .mul(999) + .div(1000) + + await expect( + swap + .connect(user1) + .addLiquidity( + [String(2e18), String(1e16)], + calculatedLPTokenAmountWithSlippage, + MAX_UINT256, + ), + ).to.emit(swap.connect(user1), "AddLiquidity") + }) + }) + + describe("removeLiquidity", () => { + it("Reverts with 'Cannot exceed total supply'", async () => { + await expect( + swap.calculateRemoveLiquidity(MAX_UINT256), + ).to.be.revertedWith("Cannot exceed total supply") + }) + + it("Reverts with 'minAmounts must match poolTokens'", async () => { + await expect( + swap.removeLiquidity(String(2e18), [0], MAX_UINT256), + ).to.be.revertedWith("minAmounts must match poolTokens") + }) + + it("Succeeds even when contract is paused", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // Owner pauses the contract + await swap.pause() + + // Owner and user 1 try to remove liquidity + swapToken.approve(swap.address, String(2e18)) + swapToken.connect(user1).approve(swap.address, currentUser1Balance) + + await swap.removeLiquidity(String(2e18), [0, 0], MAX_UINT256) + await swap + .connect(user1) + .removeLiquidity(currentUser1Balance, [0, 0], MAX_UINT256) + expect(await firstToken.balanceOf(swap.address)).to.eq(0) + expect(await secondToken.balanceOf(swap.address)).to.eq(0) + }) + + it("Succeeds with expected return amounts of underlying tokens", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + + const [ + firstTokenBalanceBefore, + secondTokenBalanceBefore, + poolTokenBalanceBefore, + ] = await getUserTokenBalances(user1, [ + firstToken, + secondToken, + swapToken, + ]) + + expect(poolTokenBalanceBefore).to.eq( + BigNumber.from("1996275270169644725"), + ) + + const [expectedFirstTokenAmount, expectedSecondTokenAmount] = + await swap.calculateRemoveLiquidity(poolTokenBalanceBefore) + + expect(expectedFirstTokenAmount).to.eq( + BigNumber.from("1498601924450190405"), + ) + expect(expectedSecondTokenAmount).to.eq( + BigNumber.from("504529314564897436"), + ) + + // User 1 removes liquidity + await swapToken + .connect(user1) + .approve(swap.address, poolTokenBalanceBefore) + await swap + .connect(user1) + .removeLiquidity( + poolTokenBalanceBefore, + [expectedFirstTokenAmount, expectedSecondTokenAmount], + MAX_UINT256, + ) + + const [firstTokenBalanceAfter, secondTokenBalanceAfter] = + await getUserTokenBalances(user1, [firstToken, secondToken]) + + // Check the actual returned token amounts match the expected amounts + expect(firstTokenBalanceAfter.sub(firstTokenBalanceBefore)).to.eq( + expectedFirstTokenAmount, + ) + expect(secondTokenBalanceAfter.sub(secondTokenBalanceBefore)).to.eq( + expectedSecondTokenAmount, + ) + }) + + it("Returns correct amounts of received tokens", async () => { + await firstToken.mint(testSwapReturnValues.address, String(1e20)) + await secondToken.mint(testSwapReturnValues.address, String(1e20)) + + await testSwapReturnValues.test_addLiquidity( + [String(1e18), String(2e18)], + 0, + ) + const tokenBalance = await swapToken.balanceOf( + testSwapReturnValues.address, + ) + + await testSwapReturnValues.test_removeLiquidity(tokenBalance, [0, 0]) + }) + + it("Reverts when user tries to burn more LP tokens than they own", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + await expect( + swap + .connect(user1) + .removeLiquidity( + currentUser1Balance.add(1), + [MAX_UINT256, MAX_UINT256], + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when minAmounts of underlying tokens are not reached due to front running", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + const [expectedFirstTokenAmount, expectedSecondTokenAmount] = + await swap.calculateRemoveLiquidity(currentUser1Balance) + + expect(expectedFirstTokenAmount).to.eq( + BigNumber.from("1498601924450190405"), + ) + expect(expectedSecondTokenAmount).to.eq( + BigNumber.from("504529314564897436"), + ) + + // User 2 adds liquidity, which leads to change in balance of underlying tokens + await swap + .connect(user2) + .addLiquidity([String(1e16), String(2e18)], 0, MAX_UINT256) + + // User 1 tries to remove liquidity which get reverted due to front running + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidity( + currentUser1Balance, + [expectedFirstTokenAmount, expectedSecondTokenAmount], + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when block is mined after deadline", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + const currentTimestamp = await getCurrentBlockTimestamp() + await setNextTimestamp(currentTimestamp + 60 * 10) + + // User 1 tries removing liquidity with deadline of +5 minutes + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidity( + currentUser1Balance, + [0, 0], + currentTimestamp + 60 * 5, + ), + ).to.be.revertedWith("Deadline not met") + }) + + it("Emits removeLiquidity event", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + // User 1 tries removes liquidity + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidity(currentUser1Balance, [0, 0], MAX_UINT256), + ).to.emit(swap.connect(user1), "RemoveLiquidity") + }) + }) + + describe("removeLiquidityImbalance", () => { + it("Reverts when contract is paused", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // Owner pauses the contract + await swap.pause() + + // Owner and user 1 try to initiate imbalanced liquidity withdrawal + swapToken.approve(swap.address, MAX_UINT256) + swapToken.connect(user1).approve(swap.address, MAX_UINT256) + + await expect( + swap.removeLiquidityImbalance( + [String(1e18), String(1e16)], + MAX_UINT256, + MAX_UINT256, + ), + ).to.be.reverted + + await expect( + swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + MAX_UINT256, + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts with 'Amounts should match pool tokens'", async () => { + await expect( + swap.removeLiquidityImbalance([String(1e18)], MAX_UINT256, MAX_UINT256), + ).to.be.revertedWith("Amounts should match pool tokens") + }) + + it("Reverts with 'Cannot withdraw more than available'", async () => { + await expect( + swap.removeLiquidityImbalance( + [MAX_UINT256, MAX_UINT256], + 1, + MAX_UINT256, + ), + ).to.be.revertedWith("Cannot withdraw more than available") + }) + + it("Succeeds with calculated max amount of pool token to be burned (±0.1%)", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // User 1 calculates amount of pool token to be burned + const maxPoolTokenAmountToBeBurned = await swap.calculateTokenAmount( + [String(1e18), String(1e16)], + false, + ) + + // ±0.1% range of pool token to be burned + const maxPoolTokenAmountToBeBurnedNegativeSlippage = + maxPoolTokenAmountToBeBurned.mul(1001).div(1000) + const maxPoolTokenAmountToBeBurnedPositiveSlippage = + maxPoolTokenAmountToBeBurned.mul(999).div(1000) + + const [ + firstTokenBalanceBefore, + secondTokenBalanceBefore, + poolTokenBalanceBefore, + ] = await getUserTokenBalances(user1, [ + firstToken, + secondToken, + swapToken, + ]) + + // User 1 withdraws imbalanced tokens + await swapToken + .connect(user1) + .approve(swap.address, maxPoolTokenAmountToBeBurnedNegativeSlippage) + await swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + maxPoolTokenAmountToBeBurnedNegativeSlippage, + MAX_UINT256, + ) + + const [ + firstTokenBalanceAfter, + secondTokenBalanceAfter, + poolTokenBalanceAfter, + ] = await getUserTokenBalances(user1, [ + firstToken, + secondToken, + swapToken, + ]) + + // Check the actual returned token amounts match the requested amounts + expect(firstTokenBalanceAfter.sub(firstTokenBalanceBefore)).to.eq( + String(1e18), + ) + expect(secondTokenBalanceAfter.sub(secondTokenBalanceBefore)).to.eq( + String(1e16), + ) + + // Check the actual burned pool token amount + const actualPoolTokenBurned = poolTokenBalanceBefore.sub( + poolTokenBalanceAfter, + ) + + expect(actualPoolTokenBurned).to.eq(String("1000934178112841889")) + expect(actualPoolTokenBurned).to.gte( + maxPoolTokenAmountToBeBurnedPositiveSlippage, + ) + expect(actualPoolTokenBurned).to.lte( + maxPoolTokenAmountToBeBurnedNegativeSlippage, + ) + }) + + it("Returns correct amount of burned lpToken", async () => { + await firstToken.mint(testSwapReturnValues.address, String(1e20)) + await secondToken.mint(testSwapReturnValues.address, String(1e20)) + + await testSwapReturnValues.test_addLiquidity( + [String(1e18), String(2e18)], + 0, + ) + + const tokenBalance = await swapToken.balanceOf( + testSwapReturnValues.address, + ) + await testSwapReturnValues.test_removeLiquidityImbalance( + [String(1e18), String(1e17)], + tokenBalance, + ) + }) + + it("Reverts when user tries to burn more LP tokens than they own", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + await expect( + swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + currentUser1Balance.add(1), + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when minAmounts of underlying tokens are not reached due to front running", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // User 1 calculates amount of pool token to be burned + const maxPoolTokenAmountToBeBurned = await swap.calculateTokenAmount( + [String(1e18), String(1e16)], + false, + ) + + // Calculate +0.1% of pool token to be burned + const maxPoolTokenAmountToBeBurnedNegativeSlippage = + maxPoolTokenAmountToBeBurned.mul(1001).div(1000) + + // User 2 adds liquidity, which leads to change in balance of underlying tokens + await swap + .connect(user2) + .addLiquidity([String(1e16), String(1e20)], 0, MAX_UINT256) + + // User 1 tries to remove liquidity which get reverted due to front running + await swapToken + .connect(user1) + .approve(swap.address, maxPoolTokenAmountToBeBurnedNegativeSlippage) + await expect( + swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + maxPoolTokenAmountToBeBurnedNegativeSlippage, + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when block is mined after deadline", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + const currentTimestamp = await getCurrentBlockTimestamp() + await setNextTimestamp(currentTimestamp + 60 * 10) + + // User 1 tries removing liquidity with deadline of +5 minutes + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + currentUser1Balance, + currentTimestamp + 60 * 5, + ), + ).to.be.revertedWith("Deadline not met") + }) + + it("Emits RemoveLiquidityImbalance event", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + // User 1 removes liquidity + await swapToken.connect(user1).approve(swap.address, MAX_UINT256) + + await expect( + swap + .connect(user1) + .removeLiquidityImbalance( + [String(1e18), String(1e16)], + currentUser1Balance, + MAX_UINT256, + ), + ).to.emit(swap.connect(user1), "RemoveLiquidityImbalance") + }) + }) + + describe("removeLiquidityOneToken", () => { + it("Reverts when contract is paused.", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // Owner pauses the contract + await swap.pause() + + // Owner and user 1 try to remove liquidity via single token + swapToken.approve(swap.address, String(2e18)) + swapToken.connect(user1).approve(swap.address, currentUser1Balance) + + await expect( + swap.removeLiquidityOneToken(String(2e18), 0, 0, MAX_UINT256), + ).to.be.reverted + await expect( + swap + .connect(user1) + .removeLiquidityOneToken(currentUser1Balance, 0, 0, MAX_UINT256), + ).to.be.reverted + }) + + it("Reverts with 'Token index out of range'", async () => { + await expect( + swap.calculateRemoveLiquidityOneToken(1, 5), + ).to.be.revertedWith("Token index out of range") + }) + + it("Reverts with 'Withdraw exceeds available'", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + await expect( + swap.calculateRemoveLiquidityOneToken(currentUser1Balance.mul(2), 0), + ).to.be.revertedWith("Withdraw exceeds available") + }) + + it("Reverts with 'Token not found'", async () => { + await expect( + swap.connect(user1).removeLiquidityOneToken(0, 9, 1, MAX_UINT256), + ).to.be.revertedWith("Token not found") + }) + + it("Succeeds with calculated token amount as minAmount", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // User 1 calculates the amount of underlying token to receive. + const calculatedFirstTokenAmount = + await swap.calculateRemoveLiquidityOneToken(currentUser1Balance, 0) + expect(calculatedFirstTokenAmount).to.eq( + BigNumber.from("2008990034631583696"), + ) + + // User 1 initiates one token withdrawal + const before = await firstToken.balanceOf(user1Address) + swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await swap + .connect(user1) + .removeLiquidityOneToken( + currentUser1Balance, + 0, + calculatedFirstTokenAmount, + MAX_UINT256, + ) + const after = await firstToken.balanceOf(user1Address) + + expect(after.sub(before)).to.eq(BigNumber.from("2008990034631583696")) + }) + + it("Returns correct amount of received token", async () => { + await firstToken.mint(testSwapReturnValues.address, String(1e20)) + await secondToken.mint(testSwapReturnValues.address, String(1e20)) + await testSwapReturnValues.test_addLiquidity( + [String(1e18), String(2e18)], + 0, + ) + await testSwapReturnValues.test_removeLiquidityOneToken( + String(2e18), + 0, + 0, + ) + }) + + it("Reverts when user tries to burn more LP tokens than they own", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + await expect( + swap + .connect(user1) + .removeLiquidityOneToken( + currentUser1Balance.add(1), + 0, + 0, + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when minAmount of underlying token is not reached due to front running", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + expect(currentUser1Balance).to.eq(BigNumber.from("1996275270169644725")) + + // User 1 calculates the amount of underlying token to receive. + const calculatedFirstTokenAmount = + await swap.calculateRemoveLiquidityOneToken(currentUser1Balance, 0) + expect(calculatedFirstTokenAmount).to.eq( + BigNumber.from("2008990034631583696"), + ) + + // User 2 adds liquidity before User 1 initiates withdrawal + await swap + .connect(user2) + .addLiquidity([String(1e16), String(1e20)], 0, MAX_UINT256) + + // User 1 initiates one token withdrawal + swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidityOneToken( + currentUser1Balance, + 0, + calculatedFirstTokenAmount, + MAX_UINT256, + ), + ).to.be.reverted + }) + + it("Reverts when block is mined after deadline", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + const currentTimestamp = await getCurrentBlockTimestamp() + await setNextTimestamp(currentTimestamp + 60 * 10) + + // User 1 tries removing liquidity with deadline of +5 minutes + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidityOneToken( + currentUser1Balance, + 0, + 0, + currentTimestamp + 60 * 5, + ), + ).to.be.revertedWith("Deadline not met") + }) + + it("Emits RemoveLiquidityOne event", async () => { + // User 1 adds liquidity + await swap + .connect(user1) + .addLiquidity([String(2e18), String(1e16)], 0, MAX_UINT256) + const currentUser1Balance = await swapToken.balanceOf(user1Address) + + await swapToken.connect(user1).approve(swap.address, currentUser1Balance) + await expect( + swap + .connect(user1) + .removeLiquidityOneToken(currentUser1Balance, 0, 0, MAX_UINT256), + ).to.emit(swap.connect(user1), "RemoveLiquidityOne") + }) + }) + + describe("swap", () => { + it("Reverts when contract is paused", async () => { + // Owner pauses the contract + await swap.pause() + + // User 1 try to initiate swap + await expect(swap.connect(user1).swap(0, 1, String(1e16), 0, MAX_UINT256)) + .to.be.reverted + }) + + it("Reverts with 'Token index out of range'", async () => { + await expect(swap.calculateSwap(0, 9, String(1e17))).to.be.revertedWith( + "Token index out of range", + ) + }) + + it("Reverts with 'Cannot swap more than you own'", async () => { + await expect( + swap.connect(user1).swap(0, 1, MAX_UINT256, 0, MAX_UINT256), + ).to.be.revertedWith("Cannot swap more than you own") + }) + + it("Succeeds with expected swap amounts", async () => { + // User 1 calculates how much token to receive + const calculatedSwapReturn = await swap.calculateSwap(0, 1, String(1e17)) + expect(calculatedSwapReturn).to.eq(BigNumber.from("99702611562565289")) + + const [tokenFromBalanceBefore, tokenToBalanceBefore] = + await getUserTokenBalances(user1, [firstToken, secondToken]) + + // User 1 successfully initiates swap + await swap + .connect(user1) + .swap(0, 1, String(1e17), calculatedSwapReturn, MAX_UINT256) + + // Check the sent and received amounts are as expected + const [tokenFromBalanceAfter, tokenToBalanceAfter] = + await getUserTokenBalances(user1, [firstToken, secondToken]) + expect(tokenFromBalanceBefore.sub(tokenFromBalanceAfter)).to.eq( + BigNumber.from(String(1e17)), + ) + expect(tokenToBalanceAfter.sub(tokenToBalanceBefore)).to.eq( + calculatedSwapReturn, + ) + }) + + it("Reverts when minDy (minimum amount token to receive) is not reached due to front running", async () => { + // User 1 calculates how much token to receive + const calculatedSwapReturn = await swap.calculateSwap(0, 1, String(1e17)) + expect(calculatedSwapReturn).to.eq(BigNumber.from("99702611562565289")) + + // User 2 swaps before User 1 does + await swap.connect(user2).swap(0, 1, String(1e17), 0, MAX_UINT256) + + // User 1 initiates swap + await expect( + swap + .connect(user1) + .swap(0, 1, String(1e17), calculatedSwapReturn, MAX_UINT256), + ).to.be.reverted + }) + + it("Succeeds when using lower minDy even when transaction is front-ran", async () => { + // User 1 calculates how much token to receive with 1% slippage + const calculatedSwapReturn = await swap.calculateSwap(0, 1, String(1e17)) + expect(calculatedSwapReturn).to.eq(BigNumber.from("99702611562565289")) + + const [tokenFromBalanceBefore, tokenToBalanceBefore] = + await getUserTokenBalances(user1, [firstToken, secondToken]) + + const calculatedSwapReturnWithNegativeSlippage = calculatedSwapReturn + .mul(99) + .div(100) + + // User 2 swaps before User 1 does + await swap.connect(user2).swap(0, 1, String(1e17), 0, MAX_UINT256) + + // User 1 successfully initiates swap with 1% slippage from initial calculated amount + await swap + .connect(user1) + .swap( + 0, + 1, + String(1e17), + calculatedSwapReturnWithNegativeSlippage, + MAX_UINT256, + ) + + // Check the sent and received amounts are as expected + const [tokenFromBalanceAfter, tokenToBalanceAfter] = + await getUserTokenBalances(user1, [firstToken, secondToken]) + + expect(tokenFromBalanceBefore.sub(tokenFromBalanceAfter)).to.eq( + BigNumber.from(String(1e17)), + ) + + const actualReceivedAmount = tokenToBalanceAfter.sub(tokenToBalanceBefore) + + expect(actualReceivedAmount).to.eq(BigNumber.from("99286252365528551")) + expect(actualReceivedAmount).to.gt( + calculatedSwapReturnWithNegativeSlippage, + ) + expect(actualReceivedAmount).to.lt(calculatedSwapReturn) + }) + + it("Returns correct amount of received token", async () => { + await firstToken.mint(testSwapReturnValues.address, String(1e20)) + await secondToken.mint(testSwapReturnValues.address, String(1e20)) + await testSwapReturnValues.test_addLiquidity( + [String(1e18), String(2e18)], + 0, + ) + await testSwapReturnValues.test_swap(0, 1, String(1e18), 0) + }) + + it("Reverts when block is mined after deadline", async () => { + const currentTimestamp = await getCurrentBlockTimestamp() + await setNextTimestamp(currentTimestamp + 60 * 10) + + // User 1 tries swapping with deadline of +5 minutes + await expect( + swap + .connect(user1) + .swap(0, 1, String(1e17), 0, currentTimestamp + 60 * 5), + ).to.be.revertedWith("Deadline not met") + }) + + it("Emits TokenSwap event", async () => { + // User 1 initiates swap + await expect( + swap.connect(user1).swap(0, 1, String(1e17), 0, MAX_UINT256), + ).to.emit(swap, "TokenSwap") + }) + }) + + describe("getVirtualPrice", () => { + it("Returns expected value after initial deposit", async () => { + expect(await swap.getVirtualPrice()).to.eq(BigNumber.from(String(1e18))) + }) + + it("Returns expected values after swaps", async () => { + // With each swap, virtual price will increase due to the fees + await swap.connect(user1).swap(0, 1, String(1e17), 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000050005862349911"), + ) + + await swap.connect(user1).swap(1, 0, String(1e17), 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000100104768517937"), + ) + }) + + it("Returns expected values after imbalanced withdrawal", async () => { + await swap + .connect(user1) + .addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + await swap + .connect(user2) + .addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq(BigNumber.from(String(1e18))) + + await swapToken.connect(user1).approve(swap.address, String(2e18)) + await swap + .connect(user1) + .removeLiquidityImbalance([String(1e18), 0], String(2e18), MAX_UINT256) + + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000100094088440633"), + ) + + await swapToken.connect(user2).approve(swap.address, String(2e18)) + await swap + .connect(user2) + .removeLiquidityImbalance([0, String(1e18)], String(2e18), MAX_UINT256) + + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000200154928939884"), + ) + }) + + it("Value is unchanged after balanced deposits", async () => { + // pool is 1:1 ratio + expect(await swap.getVirtualPrice()).to.eq(BigNumber.from(String(1e18))) + await swap + .connect(user1) + .addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq(BigNumber.from(String(1e18))) + + // pool changes to 2:1 ratio, thus changing the virtual price + await swap + .connect(user2) + .addLiquidity([String(2e18), String(0)], 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000167146429977312"), + ) + // User 2 makes balanced deposit, keeping the ratio 2:1 + await swap + .connect(user2) + .addLiquidity([String(2e18), String(1e18)], 0, MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq( + BigNumber.from("1000167146429977312"), + ) + }) + + it("Value is unchanged after balanced withdrawals", async () => { + await swap + .connect(user1) + .addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + await swapToken.connect(user1).approve(swap.address, String(1e18)) + await swap + .connect(user1) + .removeLiquidity(String(1e18), ["0", "0"], MAX_UINT256) + expect(await swap.getVirtualPrice()).to.eq(BigNumber.from(String(1e18))) + }) + }) + + describe("setSwapFee", () => { + it("Emits NewSwapFee event", async () => { + await expect(swap.setSwapFee(BigNumber.from(1e8))).to.emit( + swap, + "NewSwapFee", + ) + }) + + it("Reverts when called by non-owners", async () => { + await expect(swap.connect(user1).setSwapFee(0)).to.be.reverted + await expect(swap.connect(user2).setSwapFee(BigNumber.from(1e8))).to.be + .reverted + }) + + it("Reverts when fee is higher than the limit", async () => { + await expect(swap.setSwapFee(BigNumber.from(1e8).add(1))).to.be.reverted + }) + + it("Succeeds when fee is within the limit", async () => { + await swap.setSwapFee(BigNumber.from(1e8)) + expect((await swap.swapStorage()).swapFee).to.eq(BigNumber.from(1e8)) + }) + }) + + describe("setAdminFee", () => { + it("Emits NewAdminFee event", async () => { + await expect(swap.setAdminFee(BigNumber.from(1e10))).to.emit( + swap, + "NewAdminFee", + ) + }) + + it("Reverts when called by non-owners", async () => { + await expect(swap.connect(user1).setSwapFee(0)).to.be.reverted + await expect(swap.connect(user2).setSwapFee(BigNumber.from(1e10))).to.be + .reverted + }) + + it("Reverts when adminFee is higher than the limit", async () => { + await expect(swap.setAdminFee(BigNumber.from(1e10).add(1))).to.be.reverted + }) + + it("Succeeds when adminFee is within the limit", async () => { + await swap.setAdminFee(BigNumber.from(1e10)) + expect((await swap.swapStorage()).adminFee).to.eq(BigNumber.from(1e10)) + }) + }) + + describe("getAdminBalance", () => { + it("Reverts with 'Token index out of range'", async () => { + await expect(swap.getAdminBalance(3)).to.be.revertedWith( + "Token index out of range", + ) + }) + + it("Is always 0 when adminFee is set to 0", async () => { + expect(await swap.getAdminBalance(0)).to.eq(0) + expect(await swap.getAdminBalance(1)).to.eq(0) + + await swap.connect(user1).swap(0, 1, String(1e17), 0, MAX_UINT256) + + expect(await swap.getAdminBalance(0)).to.eq(0) + expect(await swap.getAdminBalance(1)).to.eq(0) + }) + + it("Returns expected amounts after swaps when adminFee is higher than 0", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + await swap.connect(user1).swap(0, 1, String(1e17), 0, MAX_UINT256) + + expect(await swap.getAdminBalance(0)).to.eq(0) + expect(await swap.getAdminBalance(1)).to.eq(String(998024139765)) + + // After the first swap, the pool becomes imbalanced; there are more 0th token than 1st token in the pool. + // Therefore swapping from 1st -> 0th will result in more 0th token returned + // Also results in higher fees collected on the second swap. + + await swap.connect(user1).swap(1, 0, String(1e17), 0, MAX_UINT256) + + expect(await swap.getAdminBalance(0)).to.eq(String(1001973776101)) + expect(await swap.getAdminBalance(1)).to.eq(String(998024139765)) + }) + }) + + describe("withdrawAdminFees", () => { + it("Reverts when called by non-owners", async () => { + await expect(swap.connect(user1).withdrawAdminFees()).to.be.reverted + await expect(swap.connect(user2).withdrawAdminFees()).to.be.reverted + }) + + it("Succeeds when there are no fees withdrawn", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + + const [firstTokenBefore, secondTokenBefore] = await getUserTokenBalances( + owner, + [firstToken, secondToken], + ) + + await swap.withdrawAdminFees() + + const [firstTokenAfter, secondTokenAfter] = await getUserTokenBalances( + owner, + [firstToken, secondToken], + ) + + expect(firstTokenBefore).to.eq(firstTokenAfter) + expect(secondTokenBefore).to.eq(secondTokenAfter) + }) + + it("Succeeds with expected amount of fees withdrawn", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + await swap.connect(user1).swap(0, 1, String(1e17), 0, MAX_UINT256) + await swap.connect(user1).swap(1, 0, String(1e17), 0, MAX_UINT256) + + expect(await swap.getAdminBalance(0)).to.eq(String(1001973776101)) + expect(await swap.getAdminBalance(1)).to.eq(String(998024139765)) + + const [firstTokenBefore, secondTokenBefore] = await getUserTokenBalances( + owner, + [firstToken, secondToken], + ) + + await swap.withdrawAdminFees() + + const [firstTokenAfter, secondTokenAfter] = await getUserTokenBalances( + owner, + [firstToken, secondToken], + ) + + expect(firstTokenAfter.sub(firstTokenBefore)).to.eq(String(1001973776101)) + expect(secondTokenAfter.sub(secondTokenBefore)).to.eq( + String(998024139765), + ) + }) + + it("Withdrawing admin fees has no impact on users' withdrawal", async () => { + // Sets adminFee to 1% of the swap fees + await swap.setAdminFee(BigNumber.from(10 ** 8)) + await swap + .connect(user1) + .addLiquidity([String(1e18), String(1e18)], 0, MAX_UINT256) + + for (let i = 0; i < 10; i++) { + await swap.connect(user2).swap(0, 1, String(1e17), 0, MAX_UINT256) + await swap.connect(user2).swap(1, 0, String(1e17), 0, MAX_UINT256) + } + + await swap.withdrawAdminFees() + + const [firstTokenBefore, secondTokenBefore] = await getUserTokenBalances( + user1, + [firstToken, secondToken], + ) + + const user1LPTokenBalance = await swapToken.balanceOf(user1Address) + await swapToken.connect(user1).approve(swap.address, user1LPTokenBalance) + await swap + .connect(user1) + .removeLiquidity(user1LPTokenBalance, [0, 0], MAX_UINT256) + + const [firstTokenAfter, secondTokenAfter] = await getUserTokenBalances( + user1, + [firstToken, secondToken], + ) + + expect(firstTokenAfter.sub(firstTokenBefore)).to.eq( + BigNumber.from("1000009516257264879"), + ) + + expect(secondTokenAfter.sub(secondTokenBefore)).to.eq( + BigNumber.from("1000980987206499309"), + ) + }) + }) + + describe("rampA", () => { + beforeEach(async () => { + await forceAdvanceOneBlock() + }) + + it("Emits RampA event", async () => { + await expect( + swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ), + ).to.emit(swap, "RampA") + }) + + it("Succeeds to ramp upwards", async () => { + // Create imbalanced pool to measure virtual price change + // We expect virtual price to increase as A decreases + await swap.addLiquidity([String(1e18), 0], 0, MAX_UINT256) + + // call rampA(), changing A to 100 within a span of 14 days + const endTimestamp = + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1 + await swap.rampA(100, endTimestamp) + + // +0 seconds since ramp A + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000167146429977312") + + // set timestamp to +100000 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 100000) + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + expect(await swap.getVirtualPrice()).to.be.eq("1000258443200231295") + + // set timestamp to the end of ramp period + await setTimestamp(endTimestamp) + expect(await swap.getA()).to.be.eq(100) + expect(await swap.getAPrecise()).to.be.eq(10000) + expect(await swap.getVirtualPrice()).to.be.eq("1000771363829405068") + }) + + it("Succeeds to ramp downwards", async () => { + // Create imbalanced pool to measure virtual price change + // We expect virtual price to decrease as A decreases + await swap.addLiquidity([String(1e18), 0], 0, MAX_UINT256) + + // call rampA() + const endTimestamp = + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1 + await swap.rampA(25, endTimestamp) + + // +0 seconds since ramp A + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000167146429977312") + + // set timestamp to +100000 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 100000) + expect(await swap.getA()).to.be.eq(47) + expect(await swap.getAPrecise()).to.be.eq(4794) + expect(await swap.getVirtualPrice()).to.be.eq("1000115870150391894") + + // set timestamp to the end of ramp period + await setTimestamp(endTimestamp) + expect(await swap.getA()).to.be.eq(25) + expect(await swap.getAPrecise()).to.be.eq(2500) + expect(await swap.getVirtualPrice()).to.be.eq("998999574522335473") + }) + + it("Reverts when non-owner calls it", async () => { + await expect( + swap + .connect(user1) + .rampA(55, (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1), + ).to.be.reverted + }) + + it("Reverts with 'Wait 1 day before starting ramp'", async () => { + await swap.rampA( + 55, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + await expect( + swap.rampA(55, (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1), + ).to.be.revertedWith("Wait 1 day before starting ramp") + }) + + it("Reverts with 'Insufficient ramp time'", async () => { + await expect( + swap.rampA(55, (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS - 1), + ).to.be.revertedWith("Insufficient ramp time") + }) + + it("Reverts with 'futureA_ must be > 0 and < MAX_A'", async () => { + await expect( + swap.rampA(0, (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1), + ).to.be.revertedWith("futureA_ must be > 0 and < MAX_A") + }) + + it("Reverts with 'futureA_ is too small'", async () => { + await expect( + swap.rampA(24, (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1), + ).to.be.revertedWith("futureA_ is too small") + }) + + it("Reverts with 'futureA_ is too large'", async () => { + await expect( + swap.rampA( + 101, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ), + ).to.be.revertedWith("futureA_ is too large") + }) + }) + + describe("stopRampA", () => { + it("Emits StopRampA event", async () => { + // call rampA() + await swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 100, + ) + + // Stop ramp + expect(swap.stopRampA()).to.emit(swap, "StopRampA") + }) + + it("Stop ramp succeeds", async () => { + // call rampA() + const endTimestamp = + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 100 + await swap.rampA(100, endTimestamp) + + // set timestamp to +100000 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 100000) + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + + // Stop ramp + await swap.stopRampA() + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + + // set timestamp to endTimestamp + await setTimestamp(endTimestamp) + + // verify ramp has stopped + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + }) + + it("Reverts with 'Ramp is already stopped'", async () => { + // call rampA() + const endTimestamp = + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 100 + await swap.rampA(100, endTimestamp) + + // set timestamp to +10000 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 100000) + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + + // Stop ramp + await swap.stopRampA() + expect(await swap.getA()).to.be.eq(54) + expect(await swap.getAPrecise()).to.be.eq(5413) + + // check call reverts when ramp is already stopped + await expect(swap.stopRampA()).to.be.revertedWith( + "Ramp is already stopped", + ) + }) + }) + + describe("Check for timestamp manipulations", () => { + beforeEach(async () => { + await forceAdvanceOneBlock() + }) + + it("Check for maximum differences in A and virtual price when A is increasing", async () => { + // Create imbalanced pool to measure virtual price change + // Sets the pool in 2:1 ratio where firstToken is significantly cheaper than secondToken + await swap.addLiquidity([String(1e18), 0], 0, MAX_UINT256) + + // Initial A and virtual price + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000167146429977312") + + // Start ramp + await swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5003) + expect(await swap.getVirtualPrice()).to.be.eq("1000167862696363286") + + // Max increase of A between two blocks + // 5003 / 5000 + // = 1.0006 + + // Max increase of virtual price between two blocks (at 2:1 ratio of tokens, starting A = 50) + // 1000167862696363286 / 1000167146429977312 + // = 1.00000071615 + }) + + it("Check for maximum differences in A and virtual price when A is decreasing", async () => { + // Create imbalanced pool to measure virtual price change + // Sets the pool in 2:1 ratio where firstToken is significantly cheaper than secondToken + await swap.addLiquidity([String(1e18), 0], 0, MAX_UINT256) + + // Initial A and virtual price + expect(await swap.getA()).to.be.eq(50) + expect(await swap.getAPrecise()).to.be.eq(5000) + expect(await swap.getVirtualPrice()).to.be.eq("1000167146429977312") + + // Start ramp + await swap.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + expect(await swap.getA()).to.be.eq(49) + expect(await swap.getAPrecise()).to.be.eq(4999) + expect(await swap.getVirtualPrice()).to.be.eq("1000166907487883089") + + // Max decrease of A between two blocks + // 4999 / 5000 + // = 0.9998 + + // Max decrease of virtual price between two blocks (at 2:1 ratio of tokens, starting A = 50) + // 1000166907487883089 / 1000167146429977312 + // = 0.99999976109 + }) + + // Below tests try to verify the issues found in Curve Vulnerability Report are resolved. + // https://medium.com/@peter_4205/curve-vulnerability-report-a1d7630140ec + // The two cases we are most concerned are: + // + // 1. A is ramping up, and the pool is at imbalanced state. + // + // Attacker can 'resolve' the imbalance prior to the change of A. Then try to recreate the imbalance after A has + // changed. Due to the price curve becoming more linear, recreating the imbalance will become a lot cheaper. Thus + // benefiting the attacker. + // + // 2. A is ramping down, and the pool is at balanced state + // + // Attacker can create the imbalance in token balances prior to the change of A. Then try to resolve them + // near 1:1 ratio. Since downward change of A will make the price curve less linear, resolving the token balances + // to 1:1 ratio will be cheaper. Thus benefiting the attacker + // + // For visual representation of how price curves differ based on A, please refer to Figure 1 in the above + // Curve Vulnerability Report. + + describe("Check for attacks while A is ramping upwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + let attacker: Signer + + beforeEach(async () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + attacker = user1 + + initialAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e20)) + + // Start ramp upwards + await swap.rampA( + 100, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + expect(await swap.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + expect(initialPoolBalances[0]).to.be.eq(String(1e18)) + expect(initialPoolBalances[1]).to.be.eq(String(1e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 1e18 of firstToken to secondToken, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 9.085e17 of secondToken + expect(secondTokenOutput).to.be.eq("908591742545002306") + + // Pool is imbalanced! Now trades from secondToken -> firstToken may be profitable in small sizes + // firstToken balance in the pool : 2.00e18 + // secondToken balance in the pool : 9.14e16 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq("91408257454997694") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swap.getAPrecise()).to.be.eq(5003) + + // Trade secondToken to firstToken, taking advantage of the imbalance and change of A + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("997214696574405737") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("2785303425594263") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 2.785e15 firstToken (0.2785% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [] + finalPoolBalances.push(await swap.getTokenBalance(0)) + finalPoolBalances.push(await swap.getTokenBalance(1)) + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "2785303425594263", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 2.785e15 firstToken (0.2785% of firstToken balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 1e18 of firstToken to secondToken, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 9.085e17 of secondToken + expect(secondTokenOutput).to.be.eq("908591742545002306") + + // Pool is imbalanced! Now trades from secondToken -> firstToken may be profitable in small sizes + // firstToken balance in the pool : 2.00e18 + // secondToken balance in the pool : 9.14e16 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq("91408257454997694") + + // Assume no transactions occur during 2 weeks + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swap.getAPrecise()).to.be.eq(10000) + + // Trade secondToken to firstToken, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("955743484403042509") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("44256515596957491") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 4.426e16 firstToken (4.426%) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "44256515596957491", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 4.426e16 firstToken (4.426% of firstToken balance of the pool) + // The attack did not benefit the attacker. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swap + .connect(user2) + .addLiquidity( + [String(0), String(2e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + expect(initialPoolBalances[0]).to.be.eq(String(1e18)) + expect(initialPoolBalances[1]).to.be.eq(String(3e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 1e18 of firstToken to secondToken, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 1.012e18 of secondToken + // Because the pool was imbalanced in the beginning, this trade results in more than 1e18 secondToken + expect(secondTokenOutput).to.be.eq("1011933251060681353") + + // Pool is now almost balanced! + // firstToken balance in the pool : 2.000e18 + // secondToken balance in the pool : 1.988e18 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq( + "1988066748939318647", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed upwards + // 5000 -> 5003 (0.06%) + expect(await swap.getAPrecise()).to.be.eq(5003) + + // Trade secondToken to firstToken, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the attacker leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("998017518949630644") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("1982481050369356") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 1.982e15 firstToken (0.1982% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [] + finalPoolBalances.push(await swap.getTokenBalance(0)) + finalPoolBalances.push(await swap.getTokenBalance(1)) + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "1982481050369356", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 1.982e15 firstToken (0.1982% of firstToken balance) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping up. + // Purpose of this test case is to mimic rapid ramp up of A. + + // Swap 1e18 of firstToken to secondToken, resolving the imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 9.085e17 of secondToken + expect(secondTokenOutput).to.be.eq("1011933251060681353") + + // Pool is now almost balanced! + // firstToken balance in the pool : 2.000e18 + // secondToken balance in the pool : 1.988e18 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq( + "1988066748939318647", + ) + + // Assume 2 weeks go by without any other transactions + // This mimics rapid change of A + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed upwards + // 5000 -> 10000 (100%) + expect(await swap.getAPrecise()).to.be.eq(10000) + + // Trade secondToken to firstToken, taking advantage of the imbalance and sudden change of A + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("1004298818514364451") + // Attack was successful! + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(initialAttackerBalances[0]).to.be.lt( + finalAttackerBalances[0], + ) + expect(initialAttackerBalances[1]).to.be.eq( + finalAttackerBalances[1], + ) + expect( + finalAttackerBalances[0].sub(initialAttackerBalances[0]), + ).to.be.eq("4298818514364451") + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("0") + // Attacker gained 4.430e15 firstToken (0.430%) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.lt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(initialPoolBalances[0].sub(finalPoolBalances[0])).to.be.eq( + "4298818514364451", + ) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 4.430e15 firstToken (0.430% of firstToken balance) + + // The attack benefited the attacker. + // Note that this attack is only possible when there are no swaps happening during the 2 weeks ramp period. + }) + }, + ) + }) + + describe("Check for attacks while A is ramping downwards", () => { + let initialAttackerBalances: BigNumber[] = [] + let initialPoolBalances: BigNumber[] = [] + let attacker: Signer + + beforeEach(async () => { + // Set up the downward ramp A + attacker = user1 + + initialAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + expect(initialAttackerBalances[0]).to.be.eq(String(1e20)) + expect(initialAttackerBalances[1]).to.be.eq(String(1e20)) + + // Start ramp downwards + await swap.rampA( + 25, + (await getCurrentBlockTimestamp()) + 14 * TIME.DAYS + 1, + ) + expect(await swap.getAPrecise()).to.be.eq(5000) + + // Check current pool balances + initialPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + expect(initialPoolBalances[0]).to.be.eq(String(1e18)) + expect(initialPoolBalances[1]).to.be.eq(String(1e18)) + }) + + describe( + "When tokens are priced equally: " + + "attacker creates massive imbalance prior to A change, and resolves it after", + () => { + // This attack is achieved by creating imbalance in the first block then + // trading in reverse direction in the second block. + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 1e18 of firstToken to secondToken, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 9.085e17 of secondToken + expect(secondTokenOutput).to.be.eq("908591742545002306") + + // Pool is imbalanced! Now trades from secondToken -> firstToken may be profitable in small sizes + // firstToken balance in the pool : 2.00e18 + // secondToken balance in the pool : 9.14e16 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq("91408257454997694") + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("997276754500361021") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + // Check for attacker's balance changes + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("2723245499638979") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 2.723e15 firstToken (0.2723% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "2723245499638979", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 2.723e15 firstToken (0.2723% of firstToken balance) + // The attack did not benefit the attacker. + }) + + it("Attack succeeds with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test is to show how dangerous rapid A ramp is. + + // Swap 1e18 of firstToken to secondToken, causing massive imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 9.085e17 of secondToken + expect(secondTokenOutput).to.be.eq("908591742545002306") + + // Pool is imbalanced! Now trades from secondToken -> firstToken may be profitable in small sizes + // firstToken balance in the pool : 2.00e18 + // secondToken balance in the pool : 9.14e16 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq("91408257454997694") + + // Assume no transactions occur during 2 weeks ramp time + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("1066252480054180588") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + // Check for attacker's balance changes + expect(finalAttackerBalances[0]).to.be.gt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + finalAttackerBalances[0].sub(initialAttackerBalances[0]), + ).to.be.eq("66252480054180588") + expect( + finalAttackerBalances[1].sub(initialAttackerBalances[1]), + ).to.be.eq("0") + // Attacker gained 6.625e16 firstToken (6.625% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.lt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(initialPoolBalances[0].sub(finalPoolBalances[0])).to.be.eq( + "66252480054180588", + ) + expect(initialPoolBalances[1].sub(finalPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) lost 6.625e16 firstToken (6.625% of firstToken balance) + + // The attack was successful. The change of A (-50%) gave the attacker a chance to swap + // more efficiently. The swap fee (0.1%) was not sufficient to counter the efficient trade, giving + // the attacker more tokens than initial deposit. + }) + }, + ) + + describe( + "When token price is unequal: " + + "attacker 'resolves' the imbalance prior to A change, then recreates the imbalance.", + () => { + beforeEach(async () => { + // Set up pool to be imbalanced prior to the attack + await swap + .connect(user2) + .addLiquidity( + [String(0), String(2e18)], + 0, + (await getCurrentBlockTimestamp()) + 60, + ) + + // Check current pool balances + initialPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + expect(initialPoolBalances[0]).to.be.eq(String(1e18)) + expect(initialPoolBalances[1]).to.be.eq(String(3e18)) + }) + + it("Attack fails with 900 seconds between blocks", async () => { + // Swap 1e18 of firstToken to secondToken, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 1.012e18 of secondToken + // Because the pool was imbalanced in the beginning, this trade results in more than 1e18 secondToken + expect(secondTokenOutput).to.be.eq("1011933251060681353") + + // Pool is now almost balanced! + // firstToken balance in the pool : 2.000e18 + // secondToken balance in the pool : 1.988e18 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq( + "1988066748939318647", + ) + + // Malicious miner skips 900 seconds + await setTimestamp((await getCurrentBlockTimestamp()) + 900) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(4999) + + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("998007711333645455") + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + // Check for attacker's balance changes + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("1992288666354545") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 1.992e15 firstToken (0.1992% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "1992288666354545", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 1.992e15 firstToken (0.1992% of firstToken balance) + // The attack did not benefit the attacker. + }) + + it("Attack fails with 2 weeks between transactions (mimics rapid A change)", async () => { + // This test assumes there are no other transactions during the 2 weeks period of ramping down. + // Purpose of this test case is to mimic rapid ramp down of A. + + // Swap 1e18 of firstToken to secondToken, resolving imbalance in the pool + await swap + .connect(attacker) + .swap(0, 1, String(1e18), 0, MAX_UINT256) + const secondTokenOutput = ( + await getUserTokenBalance(attacker, secondToken) + ).sub(initialAttackerBalances[1]) + + // First trade results in 1.012e18 of secondToken + // Because the pool was imbalanced in the beginning, this trade results in more than 1e18 secondToken + expect(secondTokenOutput).to.be.eq("1011933251060681353") + + // Pool is now almost balanced! + // firstToken balance in the pool : 2.000e18 + // secondToken balance in the pool : 1.988e18 + expect(await swap.getTokenBalance(0)).to.be.eq(String(2e18)) + expect(await swap.getTokenBalance(1)).to.be.eq( + "1988066748939318647", + ) + + // Assume no other transactions occur during the 2 weeks ramp period + await setTimestamp( + (await getCurrentBlockTimestamp()) + 2 * TIME.WEEKS, + ) + + // Verify A has changed downwards + expect(await swap.getAPrecise()).to.be.eq(2500) + + const balanceBefore = await getUserTokenBalance( + attacker, + firstToken, + ) + await swap + .connect(attacker) + .swap(1, 0, secondTokenOutput, 0, MAX_UINT256) + const firstTokenOutput = ( + await getUserTokenBalance(attacker, firstToken) + ).sub(balanceBefore) + + // If firstTokenOutput > 1e18, the malicious user leaves with more firstToken than the start. + expect(firstTokenOutput).to.be.eq("986318317546604072") + // Attack was not successful + + const finalAttackerBalances = await getUserTokenBalances(attacker, [ + firstToken, + secondToken, + ]) + + // Check for attacker's balance changes + expect(finalAttackerBalances[0]).to.be.lt( + initialAttackerBalances[0], + ) + expect(finalAttackerBalances[1]).to.be.eq( + initialAttackerBalances[1], + ) + expect( + initialAttackerBalances[0].sub(finalAttackerBalances[0]), + ).to.be.eq("13681682453395928") + expect( + initialAttackerBalances[1].sub(finalAttackerBalances[1]), + ).to.be.eq("0") + // Attacker lost 1.368e16 firstToken (1.368% of initial deposit) + + // Check for pool balance changes + const finalPoolBalances = [ + await swap.getTokenBalance(0), + await swap.getTokenBalance(1), + ] + + expect(finalPoolBalances[0]).to.be.gt(initialPoolBalances[0]) + expect(finalPoolBalances[1]).to.be.eq(initialPoolBalances[1]) + expect(finalPoolBalances[0].sub(initialPoolBalances[0])).to.be.eq( + "13681682453395928", + ) + expect(finalPoolBalances[1].sub(initialPoolBalances[1])).to.be.eq( + "0", + ) + // Pool (liquidity providers) gained 1.368e16 firstToken (1.368% of firstToken balance) + // The attack did not benefit the attacker + }) + }, + ) + }) + }) +})