infrastructure-upgrade/lib/v3-core/test/UniswapV3Pool.swaps.spec.ts
T-Hax 735546619e
init
Signed-off-by: T-Hax <>
2023-04-08 18:46:18 +00:00

589 lines
18 KiB
TypeScript

import { Decimal } from 'decimal.js'
import { BigNumber, BigNumberish, ContractTransaction, Wallet } from 'ethers'
import { ethers, waffle } from 'hardhat'
import { MockTimeUniswapV3Pool } from '../typechain/MockTimeUniswapV3Pool'
import { TestERC20 } from '../typechain/TestERC20'
import { TestUniswapV3Callee } from '../typechain/TestUniswapV3Callee'
import { expect } from './shared/expect'
import { poolFixture } from './shared/fixtures'
import { formatPrice, formatTokenAmount } from './shared/format'
import {
createPoolFunctions,
encodePriceSqrt,
expandTo18Decimals,
FeeAmount,
getMaxLiquidityPerTick,
getMaxTick,
getMinTick,
MAX_SQRT_RATIO,
MaxUint128,
MIN_SQRT_RATIO,
TICK_SPACINGS,
} from './shared/utilities'
Decimal.config({ toExpNeg: -500, toExpPos: 500 })
const createFixtureLoader = waffle.createFixtureLoader
const { constants } = ethers
interface BaseSwapTestCase {
zeroForOne: boolean
sqrtPriceLimit?: BigNumber
}
interface SwapExact0For1TestCase extends BaseSwapTestCase {
zeroForOne: true
exactOut: false
amount0: BigNumberish
sqrtPriceLimit?: BigNumber
}
interface SwapExact1For0TestCase extends BaseSwapTestCase {
zeroForOne: false
exactOut: false
amount1: BigNumberish
sqrtPriceLimit?: BigNumber
}
interface Swap0ForExact1TestCase extends BaseSwapTestCase {
zeroForOne: true
exactOut: true
amount1: BigNumberish
sqrtPriceLimit?: BigNumber
}
interface Swap1ForExact0TestCase extends BaseSwapTestCase {
zeroForOne: false
exactOut: true
amount0: BigNumberish
sqrtPriceLimit?: BigNumber
}
interface SwapToHigherPrice extends BaseSwapTestCase {
zeroForOne: false
sqrtPriceLimit: BigNumber
}
interface SwapToLowerPrice extends BaseSwapTestCase {
zeroForOne: true
sqrtPriceLimit: BigNumber
}
type SwapTestCase =
| SwapExact0For1TestCase
| Swap0ForExact1TestCase
| SwapExact1For0TestCase
| Swap1ForExact0TestCase
| SwapToHigherPrice
| SwapToLowerPrice
function swapCaseToDescription(testCase: SwapTestCase): string {
const priceClause = testCase?.sqrtPriceLimit ? ` to price ${formatPrice(testCase.sqrtPriceLimit)}` : ''
if ('exactOut' in testCase) {
if (testCase.exactOut) {
if (testCase.zeroForOne) {
return `swap token0 for exactly ${formatTokenAmount(testCase.amount1)} token1${priceClause}`
} else {
return `swap token1 for exactly ${formatTokenAmount(testCase.amount0)} token0${priceClause}`
}
} else {
if (testCase.zeroForOne) {
return `swap exactly ${formatTokenAmount(testCase.amount0)} token0 for token1${priceClause}`
} else {
return `swap exactly ${formatTokenAmount(testCase.amount1)} token1 for token0${priceClause}`
}
}
} else {
if (testCase.zeroForOne) {
return `swap token0 for token1${priceClause}`
} else {
return `swap token1 for token0${priceClause}`
}
}
}
type PoolFunctions = ReturnType<typeof createPoolFunctions>
// can't use address zero because the ERC20 token does not allow it
const SWAP_RECIPIENT_ADDRESS = constants.AddressZero.slice(0, -1) + '1'
const POSITION_PROCEEDS_OUTPUT_ADDRESS = constants.AddressZero.slice(0, -1) + '2'
async function executeSwap(
pool: MockTimeUniswapV3Pool,
testCase: SwapTestCase,
poolFunctions: PoolFunctions
): Promise<ContractTransaction> {
let swap: ContractTransaction
if ('exactOut' in testCase) {
if (testCase.exactOut) {
if (testCase.zeroForOne) {
swap = await poolFunctions.swap0ForExact1(testCase.amount1, SWAP_RECIPIENT_ADDRESS, testCase.sqrtPriceLimit)
} else {
swap = await poolFunctions.swap1ForExact0(testCase.amount0, SWAP_RECIPIENT_ADDRESS, testCase.sqrtPriceLimit)
}
} else {
if (testCase.zeroForOne) {
swap = await poolFunctions.swapExact0For1(testCase.amount0, SWAP_RECIPIENT_ADDRESS, testCase.sqrtPriceLimit)
} else {
swap = await poolFunctions.swapExact1For0(testCase.amount1, SWAP_RECIPIENT_ADDRESS, testCase.sqrtPriceLimit)
}
}
} else {
if (testCase.zeroForOne) {
swap = await poolFunctions.swapToLowerPrice(testCase.sqrtPriceLimit, SWAP_RECIPIENT_ADDRESS)
} else {
swap = await poolFunctions.swapToHigherPrice(testCase.sqrtPriceLimit, SWAP_RECIPIENT_ADDRESS)
}
}
return swap
}
const DEFAULT_POOL_SWAP_TESTS: SwapTestCase[] = [
// swap large amounts in/out
{
zeroForOne: true,
exactOut: false,
amount0: expandTo18Decimals(1),
},
{
zeroForOne: false,
exactOut: false,
amount1: expandTo18Decimals(1),
},
{
zeroForOne: true,
exactOut: true,
amount1: expandTo18Decimals(1),
},
{
zeroForOne: false,
exactOut: true,
amount0: expandTo18Decimals(1),
},
// swap large amounts in/out with a price limit
{
zeroForOne: true,
exactOut: false,
amount0: expandTo18Decimals(1),
sqrtPriceLimit: encodePriceSqrt(50, 100),
},
{
zeroForOne: false,
exactOut: false,
amount1: expandTo18Decimals(1),
sqrtPriceLimit: encodePriceSqrt(200, 100),
},
{
zeroForOne: true,
exactOut: true,
amount1: expandTo18Decimals(1),
sqrtPriceLimit: encodePriceSqrt(50, 100),
},
{
zeroForOne: false,
exactOut: true,
amount0: expandTo18Decimals(1),
sqrtPriceLimit: encodePriceSqrt(200, 100),
},
// swap small amounts in/out
{
zeroForOne: true,
exactOut: false,
amount0: 1000,
},
{
zeroForOne: false,
exactOut: false,
amount1: 1000,
},
{
zeroForOne: true,
exactOut: true,
amount1: 1000,
},
{
zeroForOne: false,
exactOut: true,
amount0: 1000,
},
// swap arbitrary input to price
{
sqrtPriceLimit: encodePriceSqrt(5, 2),
zeroForOne: false,
},
{
sqrtPriceLimit: encodePriceSqrt(2, 5),
zeroForOne: true,
},
{
sqrtPriceLimit: encodePriceSqrt(5, 2),
zeroForOne: true,
},
{
sqrtPriceLimit: encodePriceSqrt(2, 5),
zeroForOne: false,
},
]
interface Position {
tickLower: number
tickUpper: number
liquidity: BigNumberish
}
interface PoolTestCase {
description: string
feeAmount: number
tickSpacing: number
startingPrice: BigNumber
positions: Position[]
swapTests?: SwapTestCase[]
}
const TEST_POOLS: PoolTestCase[] = [
{
description: 'low fee, 1:1 price, 2e18 max range liquidity',
feeAmount: FeeAmount.LOW,
tickSpacing: TICK_SPACINGS[FeeAmount.LOW],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.LOW]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.LOW]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, 1:1 price, 2e18 max range liquidity',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'high fee, 1:1 price, 2e18 max range liquidity',
feeAmount: FeeAmount.HIGH,
tickSpacing: TICK_SPACINGS[FeeAmount.HIGH],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.HIGH]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.HIGH]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, 10:1 price, 2e18 max range liquidity',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(10, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, 1:10 price, 2e18 max range liquidity',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 10),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, 1:1 price, 0 liquidity, all liquidity around current price',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: -TICK_SPACINGS[FeeAmount.MEDIUM],
liquidity: expandTo18Decimals(2),
},
{
tickLower: TICK_SPACINGS[FeeAmount.MEDIUM],
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, 1:1 price, additional liquidity around current price',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: -TICK_SPACINGS[FeeAmount.MEDIUM],
liquidity: expandTo18Decimals(2),
},
{
tickLower: TICK_SPACINGS[FeeAmount.MEDIUM],
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'low fee, large liquidity around current price (stable swap)',
feeAmount: FeeAmount.LOW,
tickSpacing: TICK_SPACINGS[FeeAmount.LOW],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: -TICK_SPACINGS[FeeAmount.LOW],
tickUpper: TICK_SPACINGS[FeeAmount.LOW],
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, token0 liquidity only',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: 0,
tickUpper: 2000 * TICK_SPACINGS[FeeAmount.MEDIUM],
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'medium fee, token1 liquidity only',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: -2000 * TICK_SPACINGS[FeeAmount.MEDIUM],
tickUpper: 0,
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'close to max price',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(BigNumber.from(2).pow(127), 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'close to min price',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, BigNumber.from(2).pow(127)),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'max full range liquidity at 1:1 price with default fee',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: encodePriceSqrt(1, 1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: getMaxLiquidityPerTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
},
],
},
{
description: 'initialized at the max ratio',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: MAX_SQRT_RATIO.sub(1),
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
{
description: 'initialized at the min ratio',
feeAmount: FeeAmount.MEDIUM,
tickSpacing: TICK_SPACINGS[FeeAmount.MEDIUM],
startingPrice: MIN_SQRT_RATIO,
positions: [
{
tickLower: getMinTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
tickUpper: getMaxTick(TICK_SPACINGS[FeeAmount.MEDIUM]),
liquidity: expandTo18Decimals(2),
},
],
},
]
describe('UniswapV3Pool swap tests', () => {
let wallet: Wallet, other: Wallet
let loadFixture: ReturnType<typeof createFixtureLoader>
before('create fixture loader', async () => {
;[wallet, other] = await (ethers as any).getSigners()
loadFixture = createFixtureLoader([wallet])
})
for (const poolCase of TEST_POOLS) {
describe(poolCase.description, () => {
const poolCaseFixture = async () => {
const { createPool, token0, token1, swapTargetCallee: swapTarget } = await poolFixture(
[wallet],
waffle.provider
)
const pool = await createPool(poolCase.feeAmount, poolCase.tickSpacing)
const poolFunctions = createPoolFunctions({ swapTarget, token0, token1, pool })
await pool.initialize(poolCase.startingPrice)
// mint all positions
for (const position of poolCase.positions) {
await poolFunctions.mint(wallet.address, position.tickLower, position.tickUpper, position.liquidity)
}
const [poolBalance0, poolBalance1] = await Promise.all([
token0.balanceOf(pool.address),
token1.balanceOf(pool.address),
])
return { token0, token1, pool, poolFunctions, poolBalance0, poolBalance1, swapTarget }
}
let token0: TestERC20
let token1: TestERC20
let poolBalance0: BigNumber
let poolBalance1: BigNumber
let pool: MockTimeUniswapV3Pool
let swapTarget: TestUniswapV3Callee
let poolFunctions: PoolFunctions
beforeEach('load fixture', async () => {
;({ token0, token1, pool, poolFunctions, poolBalance0, poolBalance1, swapTarget } = await loadFixture(
poolCaseFixture
))
})
afterEach('check can burn positions', async () => {
for (const { liquidity, tickUpper, tickLower } of poolCase.positions) {
await pool.burn(tickLower, tickUpper, liquidity)
await pool.collect(POSITION_PROCEEDS_OUTPUT_ADDRESS, tickLower, tickUpper, MaxUint128, MaxUint128)
}
})
for (const testCase of poolCase.swapTests ?? DEFAULT_POOL_SWAP_TESTS) {
it(swapCaseToDescription(testCase), async () => {
const slot0 = await pool.slot0()
const tx = executeSwap(pool, testCase, poolFunctions)
try {
await tx
} catch (error) {
expect({
swapError: error.message,
poolBalance0: poolBalance0.toString(),
poolBalance1: poolBalance1.toString(),
poolPriceBefore: formatPrice(slot0.sqrtPriceX96),
tickBefore: slot0.tick,
}).to.matchSnapshot('swap error')
return
}
const [
poolBalance0After,
poolBalance1After,
slot0After,
liquidityAfter,
feeGrowthGlobal0X128,
feeGrowthGlobal1X128,
] = await Promise.all([
token0.balanceOf(pool.address),
token1.balanceOf(pool.address),
pool.slot0(),
pool.liquidity(),
pool.feeGrowthGlobal0X128(),
pool.feeGrowthGlobal1X128(),
])
const poolBalance0Delta = poolBalance0After.sub(poolBalance0)
const poolBalance1Delta = poolBalance1After.sub(poolBalance1)
// check all the events were emitted corresponding to balance changes
if (poolBalance0Delta.eq(0)) await expect(tx).to.not.emit(token0, 'Transfer')
else if (poolBalance0Delta.lt(0))
await expect(tx)
.to.emit(token0, 'Transfer')
.withArgs(pool.address, SWAP_RECIPIENT_ADDRESS, poolBalance0Delta.mul(-1))
else await expect(tx).to.emit(token0, 'Transfer').withArgs(wallet.address, pool.address, poolBalance0Delta)
if (poolBalance1Delta.eq(0)) await expect(tx).to.not.emit(token1, 'Transfer')
else if (poolBalance1Delta.lt(0))
await expect(tx)
.to.emit(token1, 'Transfer')
.withArgs(pool.address, SWAP_RECIPIENT_ADDRESS, poolBalance1Delta.mul(-1))
else await expect(tx).to.emit(token1, 'Transfer').withArgs(wallet.address, pool.address, poolBalance1Delta)
// check that the swap event was emitted too
await expect(tx)
.to.emit(pool, 'Swap')
.withArgs(
swapTarget.address,
SWAP_RECIPIENT_ADDRESS,
poolBalance0Delta,
poolBalance1Delta,
slot0After.sqrtPriceX96,
liquidityAfter,
slot0After.tick
)
const executionPrice = new Decimal(poolBalance1Delta.toString()).div(poolBalance0Delta.toString()).mul(-1)
expect({
amount0Before: poolBalance0.toString(),
amount1Before: poolBalance1.toString(),
amount0Delta: poolBalance0Delta.toString(),
amount1Delta: poolBalance1Delta.toString(),
feeGrowthGlobal0X128Delta: feeGrowthGlobal0X128.toString(),
feeGrowthGlobal1X128Delta: feeGrowthGlobal1X128.toString(),
tickBefore: slot0.tick,
poolPriceBefore: formatPrice(slot0.sqrtPriceX96),
tickAfter: slot0After.tick,
poolPriceAfter: formatPrice(slot0After.sqrtPriceX96),
executionPrice: executionPrice.toPrecision(5),
}).to.matchSnapshot('balances')
})
}
})
}
})