diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-math/concentrate-liquidity-math.test.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-math/concentrate-liquidity-math.test.ts index 947cf39..372e1ba 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-math/concentrate-liquidity-math.test.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-math/concentrate-liquidity-math.test.ts @@ -258,6 +258,80 @@ describe('concentrate-liquidity-math', () => { expect(actualGt).toBe(330119361793825978647n) }) + describe('single-sided position at 1800 USDC per WETH market price', () => { + // USDC < WETH + const USDC_DECIMALS = 6n + const WETH_DECIMALS = 18n + + const sqrtFromHumanUsdcPerWeth = (usdcPerWeth: bigint): bigint => { + const rawPrice = (10n ** WETH_DECIMALS * ONE_E18) / (usdcPerWeth * 10n ** USDC_DECIMALS) + + return bigintSqrt(rawPrice * ONE_E18) + } + + // Higher USDC-per-WETH human quote => lower sqrt(P) for this pair + const sqrtPrice1500 = sqrtFromHumanUsdcPerWeth(1500n) + const sqrtPrice1800 = sqrtFromHumanUsdcPerWeth(1800n) + const sqrtPrice2200 = sqrtFromHumanUsdcPerWeth(2200n) + + const maxAvailableBalanceUSDC = parseUnits('1000000', 6) + const maxAvailableBalanceWETH = parseUnits('400', 18) + + it('should build a WETH-only position when the range (1800 - 2200) is above the market', () => { + // Spot at 1800 equals the sqrt max bound => no USDC is needed + const { targetL, actualLt, actualGt } = computeLiquidityFromAmounts( + maxAvailableBalanceUSDC, + maxAvailableBalanceWETH, + sqrtPrice1800, + sqrtPrice2200, + sqrtPrice1800, + ) + + expect(actualLt).toBe(0n) + expect(actualGt).toBe(399999999999999999543n) + expect(targetL).toBe(177765578793446005n) + }) + + it('should build a USDC-only position when the range (1500 - 1800) is below the market', () => { + // Spot at 1800 equals the sqrt min bound => no WETH is needed + const { targetL, actualLt, actualGt } = computeLiquidityFromAmounts( + maxAvailableBalanceUSDC, + maxAvailableBalanceWETH, + sqrtPrice1800, + sqrtPrice1800, + sqrtPrice1500, + ) + + expect(actualLt).toBe(999999999999n) + expect(actualGt).toBe(0n) + expect(targetL).toBe(270520801110890067n) + }) + + it('should recover the spot at the range bound from WETH-only balances', () => { + const { liquidity, sqrtPriceSpot } = computeLiquidityAndPrice( + 0n, + 399999999999999999543n, + sqrtPrice2200, + sqrtPrice1800, + ) + + expect(liquidity).toBe(177765578793445997n) + expect(sqrtPriceSpot).toBe(23570226039552883488070n) + }) + + it('should recover the spot at the range bound from USDC-only balances', () => { + const { liquidity, sqrtPriceSpot } = computeLiquidityAndPrice( + 999999999999n, + 0n, + sqrtPrice1800, + sqrtPrice1500, + ) + + expect(liquidity).toBe(270520801110619533n) + expect(sqrtPriceSpot).toBe(23570226039551772382874n) + }) + }) + it('should compute available liquidity for the given price range and one specified amount', () => { // USDC < WETH const USDC_DECIMALS = 6n diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.test.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.test.ts index 228d1fe..1f7eb2f 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.test.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.test.ts @@ -315,6 +315,85 @@ describe('PriceRange', () => { }) }) + describe('single-sided positions (1800 USDC per WETH market price)', () => { + const maxUsdc = 1_000_000n * 10n ** 6n + const maxWeth = 400n * 10n ** 18n + + const maxReservesUsdcWeth = (): TokenReserves => ({ + reserveA: TokenReserve.new({ token: USDC, reserve: maxUsdc }), + reserveB: TokenReserve.new({ token: WETH, reserve: maxWeth }), + }) + + /** + * Range 1800-2200 USDC per WETH with market at 1800: spot sits at the range bound + * closest to the market. Bounds are ordered by sqrt(P), so the higher human quote + * (2200) is the `minPrice` and the lower one (1800) is the `maxPrice`. + */ + const rangeAboveMarket = (): PriceRange => + PriceRange.new({ + minPrice: Price.fromHuman('2200', pairUsdcQuoteWethBase), + spotPrice: Price.fromHuman('1800', pairUsdcQuoteWethBase), + maxPrice: Price.fromHuman('1800', pairUsdcQuoteWethBase), + }) + + /** Range 1500-1800 USDC per WETH with market at 1800 (spot at the other bound). */ + const rangeBelowMarket = (): PriceRange => + PriceRange.new({ + minPrice: Price.fromHuman('1800', pairUsdcQuoteWethBase), + spotPrice: Price.fromHuman('1800', pairUsdcQuoteWethBase), + maxPrice: Price.fromHuman('1500', pairUsdcQuoteWethBase), + }) + + it('should allocate only WETH when the range is above the market price', () => { + const result = rangeAboveMarket().computeMaxAllocation(maxReservesUsdcWeth()) + + expect(result.reserve0.reserve).toBe(0n) + expect(result.reserve1.reserve).toBe(399999999999999999543n) + }) + + it('should allocate only USDC when the range is below the market price', () => { + const result = rangeBelowMarket().computeMaxAllocation(maxReservesUsdcWeth()) + + expect(result.reserve0.reserve).toBe(999999999999n) + expect(result.reserve1.reserve).toBe(0n) + }) + + it('should compute WETH-only fixed allocation for the range above the market price', () => { + const fixedWeth = 100n * 10n ** 18n + + const result = rangeAboveMarket().computeFixedAllocation( + TokenReserve.new({ token: WETH, reserve: fixedWeth }), + ) + + expect(result.reserve0.reserve).toBe(0n) + expect(result.reserve1.reserve).toBe(99999999999999999323n) + }) + + it('should compute USDC-only fixed allocation for the range below the market price', () => { + const fixedUsdc = 500_000n * 10n ** 6n + + const result = rangeBelowMarket().computeFixedAllocation( + TokenReserve.new({ token: USDC, reserve: fixedUsdc }), + ) + + expect(result.reserve0.reserve).toBe(499999999999n) + expect(result.reserve1.reserve).toBe(0n) + }) + + it('should recover the 1800 spot price from a USDC-only allocation', () => { + const range = rangeBelowMarket() + const allocation = range.computeMaxAllocation(maxReservesUsdcWeth()) + + const recovered = PriceRange.fromPriceBounds( + { minPrice: range.minPrice, maxPrice: range.maxPrice }, + { reserveA: allocation.reserve0, reserveB: allocation.reserve1 }, + ) + + expect(recovered.spotPrice.toSqrt()).toBe(23570226039551772382874n) + expect(recovered.spotPrice.toHuman(USDC)).toBe('1800') + }) + }) + describe('fromPriceBounds', () => { const sqrtPriceMin = 9n * 10n ** 17n const sqrtPriceMax = 11n * 10n ** 17n diff --git a/typescript/swap-vm/tests/swap-vm.spec.ts b/typescript/swap-vm/tests/swap-vm.spec.ts index b8f0033..ba25d1d 100644 --- a/typescript/swap-vm/tests/swap-vm.spec.ts +++ b/typescript/swap-vm/tests/swap-vm.spec.ts @@ -877,6 +877,274 @@ describe('SwapVM', () => { expect(price).to.equal(59999.739411764705) }) + test('should swap by AquaXYCAmmStrategy Concentrated single-sided WETH-only position (1800 - 2200 range) at 1800 market price (USDC -> WETH)', async () => { + const liquidityProvider = forkNode.liqProvider + const swapper = forkNode.swapper + + const aqua = new AquaProtocolContract(new Address(forkNode.addresses.aqua)) + const swapVM = new SwapVMContract(new Address(forkNode.addresses.swapVMAquaRouter)) + + const USDC = new Address(ADDRESSES.USDC) + const WETH = new Address(ADDRESSES.WETH) + const USDC_DECIMALS = 6n + const WETH_DECIMALS = 18n + + const pairUsdcWeth: PricePair = { + quoteToken: { address: USDC, decimals: USDC_DECIMALS }, + baseToken: { address: WETH, decimals: WETH_DECIMALS }, + } + + // Market price is 1800 USDC per WETH and the whole 1800 - 2200 range is above it, + // so the position is single-sided: it holds only WETH and sells it as the price rises. + // Bounds are ordered by sqrt(P), so the higher human quote (2200) is the min bound. + const info = concentratedLiquidityMax( + pairUsdcWeth, + { min: '2200', spot: '1800', max: '1800' }, + new Map([ + [USDC.toString().toLowerCase(), 1_000_000n * 10n ** USDC_DECIMALS], + [WETH.toString().toLowerCase(), 400n * 10n ** WETH_DECIMALS], + ]), + ) + + // Single-sided: no USDC is needed at all + expect(info.token0Reserve).to.equal(0n) + expect(info.token1Reserve).to.equal(399999999999999999543n) + + const program = AquaXYCAmmStrategy.newConcentrate({ + sqrtPriceMin: info.sqrtPriceMin, + sqrtPriceMax: info.sqrtPriceMax, + }) + .withSalt(1800n) + .build() + + const order = Order.new({ + maker: new Address(liqProviderAddress), + program, + traits: MakerTraits.default(), + }) + + const strategyHash = order + .hash({ + chainId: forkNode.chainId as NetworkEnum, + name: 'TestAquaSwapVMRouter', + version: '1.0', + verifyingContract: new Address(forkNode.addresses.swapVMAquaRouter), + }) + .toString() + + // Both tokens must be registered in the strategy, but the USDC side is shipped with 0 + const tx = aqua.ship({ + app: new Address(forkNode.addresses.swapVMAquaRouter), + strategy: order.encode(), + amountsAndTokens: [ + { + token: info.token0Address, + amount: info.token0Reserve, + }, + { + token: info.token1Address, + amount: info.token1Reserve, + }, + ], + }) + + await liquidityProvider.send(tx) + + // The position holds no USDC yet, so it cannot pay out USDC for a WETH -> USDC swap + // (the quote over virtual reserves succeeds, but on-chain execution reverts) + await expect( + swapper.send({ + ...swapVM.swap({ + order, + amount: parseUnits('1', 18), + takerTraits: TakerTraits.default(), + tokenIn: WETH, + tokenOut: USDC, + }), + allowFail: false, + }), + ).rejects.toThrow('Transaction failed') + + const srcAmount = parseUnits('1800', 6) + + const swapParams = { + order, + amount: srcAmount, + takerTraits: TakerTraits.default(), + tokenIn: USDC, + tokenOut: WETH, + } + + // Simulate the call to get the dstAmount + const simulateResult = await forkNode.provider.call({ + account: swapperAddress, + ...swapVM.quote(swapParams), + }) + + const [_, dstAmount] = decodeFunctionResult({ + abi: SWAP_VM_ABI, + functionName: 'quote', + data: simulateResult.data!, + }) + + const swap = swapVM.swap(swapParams) + + await trackBalances( + swapper, + strategyHash, + swapParams.tokenIn, + swapParams.tokenOut, + swapParams.amount, + dstAmount, + async () => { + const { txHash } = await swapper.send({ ...swap, allowFail: false }) + + // await forkNode.printTrace(txHash) + return txHash + }, + ) + + // Buying WETH from the single-sided position starts right at the 1800 bound + const price = +formatUnits(srcAmount, 6) / +formatUnits(dstAmount, 18) + + expect(dstAmount).to.equal(999761392031661534n) + expect(price).to.equal(1800.4295968482404) + }) + + test('should swap by AquaXYCAmmStrategy Concentrated single-sided USDC-only position (1500 - 1800 range) at 1800 market price (WETH -> USDC)', async () => { + const liquidityProvider = forkNode.liqProvider + const swapper = forkNode.swapper + + const aqua = new AquaProtocolContract(new Address(forkNode.addresses.aqua)) + const swapVM = new SwapVMContract(new Address(forkNode.addresses.swapVMAquaRouter)) + + const USDC = new Address(ADDRESSES.USDC) + const WETH = new Address(ADDRESSES.WETH) + const USDC_DECIMALS = 6n + const WETH_DECIMALS = 18n + + const pairUsdcWeth: PricePair = { + quoteToken: { address: USDC, decimals: USDC_DECIMALS }, + baseToken: { address: WETH, decimals: WETH_DECIMALS }, + } + + // Market price is 1800 USDC per WETH and the whole 1500 - 1800 range is below it, + // so the position is single-sided: it holds only USDC and buys WETH as the price falls. + // Bounds are ordered by sqrt(P), so the lower human quote (1500) is the max bound. + const info = concentratedLiquidityMax( + pairUsdcWeth, + { min: '1800', spot: '1800', max: '1500' }, + new Map([ + [USDC.toString().toLowerCase(), 1_000_000n * 10n ** USDC_DECIMALS], + [WETH.toString().toLowerCase(), 400n * 10n ** WETH_DECIMALS], + ]), + ) + + // Single-sided: no WETH is needed at all + expect(info.token0Reserve).to.equal(999999999999n) + expect(info.token1Reserve).to.equal(0n) + + const program = AquaXYCAmmStrategy.newConcentrate({ + sqrtPriceMin: info.sqrtPriceMin, + sqrtPriceMax: info.sqrtPriceMax, + }) + .withSalt(1801n) + .build() + + const order = Order.new({ + maker: new Address(liqProviderAddress), + program, + traits: MakerTraits.default(), + }) + + const strategyHash = order + .hash({ + chainId: forkNode.chainId as NetworkEnum, + name: 'TestAquaSwapVMRouter', + version: '1.0', + verifyingContract: new Address(forkNode.addresses.swapVMAquaRouter), + }) + .toString() + + // Both tokens must be registered in the strategy, but the WETH side is shipped with 0 + const tx = aqua.ship({ + app: new Address(forkNode.addresses.swapVMAquaRouter), + strategy: order.encode(), + amountsAndTokens: [ + { + token: info.token0Address, + amount: info.token0Reserve, + }, + { + token: info.token1Address, + amount: info.token1Reserve, + }, + ], + }) + + await liquidityProvider.send(tx) + + // The position holds no WETH yet, so it cannot pay out WETH for a USDC -> WETH swap + // (the quote over virtual reserves succeeds, but on-chain execution reverts) + await expect( + swapper.send({ + ...swapVM.swap({ + order, + amount: parseUnits('1800', 6), + takerTraits: TakerTraits.default(), + tokenIn: USDC, + tokenOut: WETH, + }), + allowFail: false, + }), + ).rejects.toThrow('Transaction failed') + + const srcAmount = parseUnits('1', 18) + + const swapParams = { + order, + amount: srcAmount, + takerTraits: TakerTraits.default(), + tokenIn: WETH, + tokenOut: USDC, + } + + // Simulate the call to get the dstAmount + const simulateResult = await forkNode.provider.call({ + account: swapperAddress, + ...swapVM.quote(swapParams), + }) + + const [_, dstAmount] = decodeFunctionResult({ + abi: SWAP_VM_ABI, + functionName: 'quote', + data: simulateResult.data!, + }) + + const swap = swapVM.swap(swapParams) + + await trackBalances( + swapper, + strategyHash, + swapParams.tokenIn, + swapParams.tokenOut, + swapParams.amount, + dstAmount, + async () => { + const { txHash } = await swapper.send({ ...swap, allowFail: false }) + + // await forkNode.printTrace(txHash) + return txHash + }, + ) + + // Selling WETH to the single-sided position starts right at the 1800 bound + const price = +formatUnits(dstAmount, 6) / +formatUnits(srcAmount, 18) + + expect(dstAmount).to.equal(1799717746n) + expect(price).to.equal(1799.717746) + }) + test('should swap by AquaXYCAmmStrategy Concentrated (2000 - 3000 range) and 30bps flat fees', async () => { const liquidityProvider = forkNode.liqProvider const swapper = forkNode.swapper