From 0a78e812e986b8f7f19e1bf41d09a50a48198e07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 15:51:57 +0000 Subject: [PATCH 1/2] feat(swap-vm): add computeSingleSidedRange to ConcentrateLiquidityCalculator Build a single-sided range from a spot price, one price bound and a single reserve: the spot itself becomes the second bound (min for a token0 deposit, max for a token1 deposit) and the opposite reserve defaults to zero. Returns the resulting PriceAllocationRange together with the reserves, ready for the allocation methods and the SwapVM concentrate instructions. Also export ConcentrateLiquidityCalculator from the concentrate instructions index (previously only its types were exported, contrary to the docs). Co-authored-by: Kirill --- .../CONCENTRATE-LIQUIDITY-CALCULATOR.md | 22 ++++ .../concentrate-liquidity-calculator.test.ts | 123 ++++++++++++++++++ .../concentrate-liquidity-calculator.ts | 53 ++++++++ .../concentrate-liquidity-calculator/types.ts | 9 ++ .../swap-vm/instructions/concentrate/index.ts | 2 + 5 files changed, 209 insertions(+) create mode 100644 typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md index 3e8364c..c923c4f 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md @@ -125,6 +125,28 @@ const result = calculator.computeFixedAllocation( // result.token0Reserve, result.token1Reserve are the two amounts to use ``` +### `computeSingleSidedRange(spotPrice, priceBound, reserveForToken, reserve)` + +Builds a **single-sided** range: the spot price itself becomes one bound and only one token is deposited (the opposite reserve is zero by default). Which bound the spot sits on follows from the deposited token, in sqrt(token1/token0) terms: + +- depositing **token0**: spot = **min** bound, `priceBound` must be above the spot and becomes the max bound; +- depositing **token1**: spot = **max** bound, `priceBound` must be below the spot and becomes the min bound. + +Returns `SingleSidedRangeInfo`: the `PriceAllocationRange` (`minPrice`, `spotPrice`, `maxPrice` as `Price` objects, ready for the allocation methods) and the `ConcentratedLiquidityInfo` reserves with the depleted side at zero. + +**Use case**: “I only have USDC; open a position that converts into WETH as the price moves through my range.” + +```ts +const result = calculator.computeSingleSidedRange( + spotPrice, // Price: current spot, sits exactly on one bound + priceBound, // Price: the other bound of the range + usdcAddress, // token being deposited + parseUnits('1000000', 6), +) +// result.prices — { minPrice, spotPrice, maxPrice } with spot on a bound +// result.reserves — { token0Reserve: 1000000e6, token1Reserve: 0n } +``` + ### `computeSpotPrice(token0Balance, token1Balance, scaledPriceBounds)` Takes **raw** amounts of token0 and token1 (pool order: lower address = token0) and a **`ScaledPriceBounds`** range. Converts the bounds the same way as allocation methods, then computes the **implied** spot `sqrt(P_spot)` in **1e18** fixed-point (same units as `ConcentratedLiquidityInfo.sqrtPriceSpot`). diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts new file mode 100644 index 0000000..85de50e --- /dev/null +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LicenseRef-Degensoft-SwapVM-1.1 + +import { describe, expect, it } from 'vitest' +import { Address } from '@1inch/sdk-core' +import { ConcentrateLiquidityCalculator } from './concentrate-liquidity-calculator' +import { Price } from '../price' +import type { PricePair, PriceToken } from '../price/types' + +const USDC = new Address('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48') +const WETH = new Address('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') +/** Extra token for negative tests (not USDC/WETH). */ +const DAI = new Address('0x6B175474E89094C44Da98b954EedeAC495271d0F') + +const USDC_TOKEN: PriceToken = { address: USDC, decimals: 6n } +const WETH_TOKEN: PriceToken = { address: WETH, decimals: 18n } +const DAI_TOKEN: PriceToken = { address: DAI, decimals: 18n } + +const pairUsdcQuoteWethBase: PricePair = { quoteToken: USDC_TOKEN, baseToken: WETH_TOKEN } + +/** USDC has the lower address, so it is token0 and WETH is token1. */ +const calculator = ConcentrateLiquidityCalculator.new({ + tokenA: { address: USDC, decimals: 6n, maxAvailableLiquidity: 1_000_000n * 10n ** 6n }, + tokenB: { address: WETH, decimals: 18n, maxAvailableLiquidity: 400n * 10n ** 18n }, +}) + +describe('ConcentrateLiquidityCalculator', () => { + describe('computeSingleSidedRange', () => { + /** + * For a USDC-quoted pair a *lower* human price means a *higher* sqrt(token1/token0), + * so '2000 USDC per WETH' is above '3000 USDC per WETH' in sqrt terms. + */ + const sqrtLowBound = Price.fromHuman('3000', pairUsdcQuoteWethBase) + const spot = Price.fromHuman('2500', pairUsdcQuoteWethBase) + const sqrtHighBound = Price.fromHuman('2000', pairUsdcQuoteWethBase) + + it('should place spot at the min bound for a token0 (USDC) deposit', () => { + const reserve = 1_000_000n * 10n ** 6n + + const result = calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, reserve) + + expect(result.prices.minPrice).toBe(spot) + expect(result.prices.spotPrice).toBe(spot) + expect(result.prices.maxPrice).toBe(sqrtHighBound) + expect(result.reserves.token0Reserve).toBe(reserve) + expect(result.reserves.token1Reserve).toBe(0n) + }) + + it('should place spot at the max bound for a token1 (WETH) deposit', () => { + const reserve = 100n * 10n ** 18n + + const result = calculator.computeSingleSidedRange(spot, sqrtLowBound, WETH, reserve) + + expect(result.prices.minPrice).toBe(sqrtLowBound) + expect(result.prices.spotPrice).toBe(spot) + expect(result.prices.maxPrice).toBe(spot) + expect(result.reserves.token0Reserve).toBe(0n) + expect(result.reserves.token1Reserve).toBe(reserve) + }) + + it('should produce a range consistent with computeFixedAllocation (token0 deposit)', () => { + const reserve = 1_000_000n * 10n ** 6n + + const result = calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, reserve) + const allocation = calculator.computeFixedAllocation(result.prices, USDC, reserve) + + /** Only token0 is required; the fixed side may lose a few wei to integer math. */ + expect(allocation.token1Reserve).toBe(0n) + expect(allocation.token0Reserve).toBeLessThanOrEqual(reserve) + expect(allocation.token0Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) + }) + + it('should produce a range consistent with computeFixedAllocation (token1 deposit)', () => { + const reserve = 100n * 10n ** 18n + + const result = calculator.computeSingleSidedRange(spot, sqrtLowBound, WETH, reserve) + const allocation = calculator.computeFixedAllocation(result.prices, WETH, reserve) + + expect(allocation.token0Reserve).toBe(0n) + expect(allocation.token1Reserve).toBeLessThanOrEqual(reserve) + expect(allocation.token1Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) + }) + + it('should throw when the reserve is zero', () => { + expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, 0n)).toThrow( + 'reserve must be positive', + ) + }) + + it('should throw when the bound is below spot for a token0 deposit', () => { + expect(() => calculator.computeSingleSidedRange(spot, sqrtLowBound, USDC, 1n)).toThrow( + 'price bound should be above spot for a token0 deposit', + ) + }) + + it('should throw when the bound is above spot for a token1 deposit', () => { + expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, WETH, 1n)).toThrow( + 'price bound should be below spot for a token1 deposit', + ) + }) + + it('should throw when the bound equals spot (degenerate range)', () => { + expect(() => calculator.computeSingleSidedRange(spot, spot, USDC, 1n)).toThrow( + 'price bound should be above spot for a token0 deposit', + ) + }) + + it('should throw when the reserve token is not in the pair', () => { + expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, DAI, 1n)).toThrow( + 'reserve should be in some pair token', + ) + }) + + it('should throw when prices are for a different pair', () => { + const pairUsdcQuoteDaiBase: PricePair = { quoteToken: USDC_TOKEN, baseToken: DAI_TOKEN } + const foreignSpot = Price.fromHuman('1', pairUsdcQuoteDaiBase) + const foreignBound = Price.fromHuman('0.9', pairUsdcQuoteDaiBase) + + expect(() => calculator.computeSingleSidedRange(foreignSpot, foreignBound, USDC, 1n)).toThrow( + 'prices should be for the calculator token pair', + ) + }) + }) +}) diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts index a030089..63bc8db 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts @@ -9,7 +9,9 @@ import type { ConcentrateTokenInfo, PriceAllocationRange, PriceBounds, + SingleSidedRangeInfo, } from './types' +import type { Price } from '../price' import { computeLiquidityAndPrice, computeLiquidityFromAmounts, @@ -84,6 +86,57 @@ export class ConcentrateLiquidityCalculator { } } + /** + * Build a single-sided range from a spot price, one price bound and a single reserve. + * + * The spot price itself becomes the second bound, so the position holds only the + * provided token and the opposite reserve is zero by default: + * - depositing token0: spot = min bound, so `priceBound` must be above the spot + * (in sqrt(token1/token0) terms) and becomes the max bound; + * - depositing token1: spot = max bound, so `priceBound` must be below the spot + * and becomes the min bound. + * + * @param spotPrice Current spot price; sits exactly on one bound of the range + * @param priceBound The other bound of the range + * @param reserveForToken Token being deposited (must be one of the pair tokens) + * @param reserve Raw amount of `reserveForToken` to deposit (must be positive) + */ + computeSingleSidedRange( + spotPrice: Price, + priceBound: Price, + reserveForToken: Address, + reserve: bigint, + ): SingleSidedRangeInfo { + assert(reserve > 0n, 'reserve must be positive') + assert( + spotPrice.token0.address.equal(this.token0.address) && + spotPrice.token1.address.equal(this.token1.address), + 'prices should be for the calculator token pair', + ) + assert( + reserveForToken.equal(this.token0.address) || reserveForToken.equal(this.token1.address), + 'reserve should be in some pair token', + ) + + const isReserveLt = reserveForToken.equal(this.token0.address) + + if (isReserveLt) { + assert(priceBound.gt(spotPrice), 'price bound should be above spot for a token0 deposit') + + return { + prices: { minPrice: spotPrice, spotPrice, maxPrice: priceBound }, + reserves: { token0Reserve: reserve, token1Reserve: 0n }, + } + } + + assert(priceBound.lt(spotPrice), 'price bound should be below spot for a token1 deposit') + + return { + prices: { minPrice: priceBound, spotPrice, maxPrice: spotPrice }, + reserves: { token0Reserve: 0n, token1Reserve: reserve }, + } + } + computeSpotPrice(reserves: ConcentratedLiquidityInfo, bounds: PriceBounds): bigint { assert(bounds.maxPrice.gte(bounds.minPrice), 'maxPrice should be >= minPrice') diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts index 0093076..0ea7764 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts @@ -29,6 +29,15 @@ export type ConcentratedLiquidityInfo = { token1Reserve: bigint } +/** + * Result of a single-sided range calculation: the price range with the spot sitting + * exactly on one bound, and the reserves with the opposite (depleted) side at zero. + */ +export type SingleSidedRangeInfo = { + prices: PriceAllocationRange + reserves: ConcentratedLiquidityInfo +} + /** * Constructor argument: the two tokens (tokenA, tokenB). Order is arbitrary; * token0/token1 are derived by address comparison. diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts index 94c334f..6c16d45 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts @@ -13,11 +13,13 @@ export { PriceRange } from './price-range' export type { PriceRangeJSON } from './price-range' export { TokenReserve } from './token-reserve' export type { TokenReserveArgs, TokenReserveJSON } from './token-reserve' +export { ConcentrateLiquidityCalculator } from './concentrate-liquidity-calculator/concentrate-liquidity-calculator' export type { ConcentrateTokenInfo, ConcentrateLiquidityCalculatorArgs, PriceAllocationRange, PriceBounds, ConcentratedLiquidityInfo, + SingleSidedRangeInfo, } from './concentrate-liquidity-calculator/types' export { bigintSqrt } from '../utils/bigint-sqrt' From 9e219085004427be91a5eb4aedf2a5bbe44c090a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 16:09:22 +0000 Subject: [PATCH 2/2] refactor(swap-vm): derive the far bound in computeSingleSidedRange instead of accepting it Per review: the method now accepts only spotPrice, reserveForToken and reserve, and returns the price range. The spot sits on the min bound for a token0 deposit and on the max bound for a token1 deposit; the opposite bound is derived from the other token's maxAvailableLiquidity via the range-order relation sqrtPmin * sqrtPmax = amountGt / amountLt, i.e. the price at which the deposit fully converts into exactly that amount. Drop the now-unneeded SingleSidedRangeInfo type. Co-authored-by: Kirill --- .../CONCENTRATE-LIQUIDITY-CALCULATOR.md | 28 ++- .../concentrate-liquidity-calculator.test.ts | 234 +++++++++++------- .../concentrate-liquidity-calculator.ts | 56 +++-- .../concentrate-liquidity-calculator/types.ts | 9 - .../swap-vm/instructions/concentrate/index.ts | 1 - 5 files changed, 201 insertions(+), 127 deletions(-) diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md index c923c4f..d8326b7 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/CONCENTRATE-LIQUIDITY-CALCULATOR.md @@ -125,26 +125,30 @@ const result = calculator.computeFixedAllocation( // result.token0Reserve, result.token1Reserve are the two amounts to use ``` -### `computeSingleSidedRange(spotPrice, priceBound, reserveForToken, reserve)` +### `computeSingleSidedRange(spotPrice, reserveForToken, reserve)` -Builds a **single-sided** range: the spot price itself becomes one bound and only one token is deposited (the opposite reserve is zero by default). Which bound the spot sits on follows from the deposited token, in sqrt(token1/token0) terms: +Builds a **single-sided** price range from the spot price and a single reserve: only one token is deposited (the opposite reserve is zero) and the spot price sits exactly on one bound. Which bound the spot sits on follows from the deposited token, in sqrt(token1/token0) terms: -- depositing **token0**: spot = **min** bound, `priceBound` must be above the spot and becomes the max bound; -- depositing **token1**: spot = **max** bound, `priceBound` must be below the spot and becomes the min bound. +- depositing **token0**: spot = **min** bound; +- depositing **token1**: spot = **max** bound. -Returns `SingleSidedRangeInfo`: the `PriceAllocationRange` (`minPrice`, `spotPrice`, `maxPrice` as `Price` objects, ready for the allocation methods) and the `ConcentratedLiquidityInfo` reserves with the depleted side at zero. +The opposite bound is derived from the other token's `maxAvailableLiquidity`: it is the price at which the deposited reserve has fully converted into exactly that amount. This is the range-order relation `sqrtPmin * sqrtPmax = amountGt / amountLt` (in 1e18 fixed-point) — the geometric mean of the bounds is the average execution price across the range. Consequently the opposite token's `maxAvailableLiquidity` must exceed the spot-equivalent value of the deposit, otherwise the range would be empty or inverted and the method throws. -**Use case**: “I only have USDC; open a position that converts into WETH as the price moves through my range.” +Returns a `PriceAllocationRange` (`minPrice`, `spotPrice`, `maxPrice` as `Price` objects), ready for the allocation methods. + +**Use case**: “I only have 1,000,000 USDC and want to end up with at most 800 WETH; give me the range that does that starting from the current spot.” ```ts -const result = calculator.computeSingleSidedRange( - spotPrice, // Price: current spot, sits exactly on one bound - priceBound, // Price: the other bound of the range - usdcAddress, // token being deposited +// calculator tokens: USDC (maxAvailableLiquidity 1_000_000e6), WETH (maxAvailableLiquidity 800e18) +const range = calculator.computeSingleSidedRange( + spotPrice, // Price: current spot (e.g. 2500 USDC per WETH), sits on one bound + usdcAddress, // token being deposited parseUnits('1000000', 6), ) -// result.prices — { minPrice, spotPrice, maxPrice } with spot on a bound -// result.reserves — { token0Reserve: 1000000e6, token1Reserve: 0n } +// range.minPrice === spotPrice (token0 deposit) +// range.maxPrice — derived far bound: 625 USDC per WETH here, so the +// average execution across the range is sqrt(2500 * 625) = 1250 USDC per WETH +// and the 1,000,000 USDC fully converts into exactly 800 WETH at the far bound ``` ### `computeSpotPrice(token0Balance, token1Balance, scaledPriceBounds)` diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts index 85de50e..ec2bc1e 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest' import { Address } from '@1inch/sdk-core' +import { UINT_256_MAX } from '@1inch/byte-utils' import { ConcentrateLiquidityCalculator } from './concentrate-liquidity-calculator' import { Price } from '../price' import type { PricePair, PriceToken } from '../price/types' +import { + computeBalances, + computeLiquidityFromAmounts, +} from '../concentrate-liquidity-math/concentrate-liquidity-math' const USDC = new Address('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48') const WETH = new Address('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') @@ -17,107 +22,162 @@ const DAI_TOKEN: PriceToken = { address: DAI, decimals: 18n } const pairUsdcQuoteWethBase: PricePair = { quoteToken: USDC_TOKEN, baseToken: WETH_TOKEN } +const maxUsdc = 1_000_000n * 10n ** 6n +const maxWeth = 800n * 10n ** 18n + /** USDC has the lower address, so it is token0 and WETH is token1. */ const calculator = ConcentrateLiquidityCalculator.new({ - tokenA: { address: USDC, decimals: 6n, maxAvailableLiquidity: 1_000_000n * 10n ** 6n }, - tokenB: { address: WETH, decimals: 18n, maxAvailableLiquidity: 400n * 10n ** 18n }, + tokenA: { address: USDC, decimals: 6n, maxAvailableLiquidity: maxUsdc }, + tokenB: { address: WETH, decimals: 18n, maxAvailableLiquidity: maxWeth }, }) describe('ConcentrateLiquidityCalculator', () => { describe('computeSingleSidedRange', () => { - /** - * For a USDC-quoted pair a *lower* human price means a *higher* sqrt(token1/token0), - * so '2000 USDC per WETH' is above '3000 USDC per WETH' in sqrt terms. - */ - const sqrtLowBound = Price.fromHuman('3000', pairUsdcQuoteWethBase) const spot = Price.fromHuman('2500', pairUsdcQuoteWethBase) - const sqrtHighBound = Price.fromHuman('2000', pairUsdcQuoteWethBase) - - it('should place spot at the min bound for a token0 (USDC) deposit', () => { - const reserve = 1_000_000n * 10n ** 6n - - const result = calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, reserve) - - expect(result.prices.minPrice).toBe(spot) - expect(result.prices.spotPrice).toBe(spot) - expect(result.prices.maxPrice).toBe(sqrtHighBound) - expect(result.reserves.token0Reserve).toBe(reserve) - expect(result.reserves.token1Reserve).toBe(0n) - }) - it('should place spot at the max bound for a token1 (WETH) deposit', () => { - const reserve = 100n * 10n ** 18n - - const result = calculator.computeSingleSidedRange(spot, sqrtLowBound, WETH, reserve) - - expect(result.prices.minPrice).toBe(sqrtLowBound) - expect(result.prices.spotPrice).toBe(spot) - expect(result.prices.maxPrice).toBe(spot) - expect(result.reserves.token0Reserve).toBe(0n) - expect(result.reserves.token1Reserve).toBe(reserve) - }) - - it('should produce a range consistent with computeFixedAllocation (token0 deposit)', () => { + describe('token0 (USDC) deposit', () => { + /** 1,000,000 USDC at 2500 is worth 400 WETH; target is maxWeth = 800 WETH. */ const reserve = 1_000_000n * 10n ** 6n - const result = calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, reserve) - const allocation = calculator.computeFixedAllocation(result.prices, USDC, reserve) - - /** Only token0 is required; the fixed side may lose a few wei to integer math. */ - expect(allocation.token1Reserve).toBe(0n) - expect(allocation.token0Reserve).toBeLessThanOrEqual(reserve) - expect(allocation.token0Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) - }) - - it('should produce a range consistent with computeFixedAllocation (token1 deposit)', () => { - const reserve = 100n * 10n ** 18n - - const result = calculator.computeSingleSidedRange(spot, sqrtLowBound, WETH, reserve) - const allocation = calculator.computeFixedAllocation(result.prices, WETH, reserve) - - expect(allocation.token0Reserve).toBe(0n) - expect(allocation.token1Reserve).toBeLessThanOrEqual(reserve) - expect(allocation.token1Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) - }) - - it('should throw when the reserve is zero', () => { - expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, USDC, 0n)).toThrow( - 'reserve must be positive', - ) - }) - - it('should throw when the bound is below spot for a token0 deposit', () => { - expect(() => calculator.computeSingleSidedRange(spot, sqrtLowBound, USDC, 1n)).toThrow( - 'price bound should be above spot for a token0 deposit', - ) - }) - - it('should throw when the bound is above spot for a token1 deposit', () => { - expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, WETH, 1n)).toThrow( - 'price bound should be below spot for a token1 deposit', - ) + it('should place spot at the min bound and derive the max bound', () => { + const range = calculator.computeSingleSidedRange(spot, USDC, reserve) + + expect(range.minPrice).toBe(spot) + expect(range.spotPrice).toBe(spot) + /** + * Range-order relation: sqrtPmax = targetGt * 1e18^2 / (reserve * sqrtPspot). + * Converting 1,000,000 USDC into 800 WETH means an average execution of + * 1250 USDC/WETH = sqrt(2500 * 625), so the far bound is 625 USDC per WETH. + */ + expect(range.maxPrice.toSqrt()).toBe(2n * spot.toSqrt()) + expect(range.maxPrice.toHuman(USDC)).toBe('625') + }) + + it('should fully convert the reserve into token1 maxAvailableLiquidity at the far bound', () => { + const range = calculator.computeSingleSidedRange(spot, USDC, reserve) + + const { targetL } = computeLiquidityFromAmounts( + reserve, + UINT_256_MAX, + range.spotPrice.toSqrt(), + range.minPrice.toSqrt(), + range.maxPrice.toSqrt(), + ) + /** Same liquidity, spot moved to the far bound: all token0 sold for token1. */ + const atFarBound = computeBalances( + targetL, + range.maxPrice.toSqrt(), + range.minPrice.toSqrt(), + range.maxPrice.toSqrt(), + ) + + expect(atFarBound.bLt).toBe(0n) + expect(atFarBound.bGt).toBe(maxWeth) + }) + + it('should produce a range accepted by computeFixedAllocation', () => { + const range = calculator.computeSingleSidedRange(spot, USDC, reserve) + + const allocation = calculator.computeFixedAllocation(range, USDC, reserve) + + /** Only token0 is required; the fixed side may lose a few wei to integer math. */ + expect(allocation.token1Reserve).toBe(0n) + expect(allocation.token0Reserve).toBeLessThanOrEqual(reserve) + expect(allocation.token0Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) + }) }) - it('should throw when the bound equals spot (degenerate range)', () => { - expect(() => calculator.computeSingleSidedRange(spot, spot, USDC, 1n)).toThrow( - 'price bound should be above spot for a token0 deposit', - ) + describe('token1 (WETH) deposit', () => { + /** 200 WETH at 2500 is worth 500,000 USDC; target is maxUsdc = 1,000,000 USDC. */ + const reserve = 200n * 10n ** 18n + + it('should place spot at the max bound and derive the min bound', () => { + const range = calculator.computeSingleSidedRange(spot, WETH, reserve) + + expect(range.maxPrice).toBe(spot) + expect(range.spotPrice).toBe(spot) + /** + * Range-order relation: sqrtPmin = reserve * 1e18^2 / (targetLt * sqrtPspot). + * Converting 200 WETH into 1,000,000 USDC means an average execution of + * 5000 USDC/WETH = sqrt(2500 * 10000), so the far bound is 10000 USDC per WETH. + */ + expect(range.minPrice.toSqrt()).toBe(spot.toSqrt() / 2n) + expect(range.minPrice.toHuman(USDC)).toBe('10000') + }) + + it('should fully convert the reserve into token0 maxAvailableLiquidity at the far bound', () => { + const range = calculator.computeSingleSidedRange(spot, WETH, reserve) + + const { targetL } = computeLiquidityFromAmounts( + UINT_256_MAX, + reserve, + range.spotPrice.toSqrt(), + range.minPrice.toSqrt(), + range.maxPrice.toSqrt(), + ) + /** Same liquidity, spot moved to the far bound: all token1 sold for token0. */ + const atFarBound = computeBalances( + targetL, + range.minPrice.toSqrt(), + range.minPrice.toSqrt(), + range.maxPrice.toSqrt(), + ) + + expect(atFarBound.bLt).toBe(maxUsdc) + expect(atFarBound.bGt).toBe(0n) + }) + + it('should produce a range accepted by computeFixedAllocation', () => { + const range = calculator.computeSingleSidedRange(spot, WETH, reserve) + + const allocation = calculator.computeFixedAllocation(range, WETH, reserve) + + expect(allocation.token0Reserve).toBe(0n) + expect(allocation.token1Reserve).toBeLessThanOrEqual(reserve) + expect(allocation.token1Reserve).toBeGreaterThan((reserve * 999_999n) / 1_000_000n) + }) }) - it('should throw when the reserve token is not in the pair', () => { - expect(() => calculator.computeSingleSidedRange(spot, sqrtHighBound, DAI, 1n)).toThrow( - 'reserve should be in some pair token', - ) - }) - - it('should throw when prices are for a different pair', () => { - const pairUsdcQuoteDaiBase: PricePair = { quoteToken: USDC_TOKEN, baseToken: DAI_TOKEN } - const foreignSpot = Price.fromHuman('1', pairUsdcQuoteDaiBase) - const foreignBound = Price.fromHuman('0.9', pairUsdcQuoteDaiBase) - - expect(() => calculator.computeSingleSidedRange(foreignSpot, foreignBound, USDC, 1n)).toThrow( - 'prices should be for the calculator token pair', - ) + describe('validation', () => { + it('should throw when the reserve is zero', () => { + expect(() => calculator.computeSingleSidedRange(spot, USDC, 0n)).toThrow( + 'reserve must be positive', + ) + }) + + it('should throw when the reserve token is not in the pair', () => { + expect(() => calculator.computeSingleSidedRange(spot, DAI, 1n)).toThrow( + 'reserve should be in some pair token', + ) + }) + + it('should throw when the spot price is for a different pair', () => { + const pairUsdcQuoteDaiBase: PricePair = { quoteToken: USDC_TOKEN, baseToken: DAI_TOKEN } + const foreignSpot = Price.fromHuman('1', pairUsdcQuoteDaiBase) + + expect(() => calculator.computeSingleSidedRange(foreignSpot, USDC, 1n)).toThrow( + 'prices should be for the calculator token pair', + ) + }) + + it('should throw when token1 maxAvailableLiquidity does not exceed the deposit spot value', () => { + /** 1,000,000 USDC at 2500 is worth exactly 400 WETH — no room for a range. */ + const smallTarget = ConcentrateLiquidityCalculator.new({ + tokenA: { address: USDC, decimals: 6n, maxAvailableLiquidity: maxUsdc }, + tokenB: { address: WETH, decimals: 18n, maxAvailableLiquidity: 400n * 10n ** 18n }, + }) + + expect(() => + smallTarget.computeSingleSidedRange(spot, USDC, 1_000_000n * 10n ** 6n), + ).toThrow('token1 maxAvailableLiquidity should exceed the spot value of the deposit') + }) + + it('should throw when token0 maxAvailableLiquidity does not exceed the deposit spot value', () => { + /** 400 WETH at 2500 is worth exactly 1,000,000 USDC — no room for a range. */ + expect(() => calculator.computeSingleSidedRange(spot, WETH, 400n * 10n ** 18n)).toThrow( + 'token0 maxAvailableLiquidity should exceed the spot value of the deposit', + ) + }) }) }) }) diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts index 63bc8db..f92759b 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/concentrate-liquidity-calculator.ts @@ -9,9 +9,9 @@ import type { ConcentrateTokenInfo, PriceAllocationRange, PriceBounds, - SingleSidedRangeInfo, } from './types' -import type { Price } from '../price' +import { Price } from '../price' +import { ONE_E18 } from '../concentrate-grow-liquidity-2d-args' import { computeLiquidityAndPrice, computeLiquidityFromAmounts, @@ -87,26 +87,30 @@ export class ConcentrateLiquidityCalculator { } /** - * Build a single-sided range from a spot price, one price bound and a single reserve. + * Build a single-sided price range from a spot price and a single reserve. * - * The spot price itself becomes the second bound, so the position holds only the - * provided token and the opposite reserve is zero by default: - * - depositing token0: spot = min bound, so `priceBound` must be above the spot - * (in sqrt(token1/token0) terms) and becomes the max bound; - * - depositing token1: spot = max bound, so `priceBound` must be below the spot - * and becomes the min bound. + * The spot price sits exactly on one bound (the position holds only the provided + * token, the opposite reserve is zero): + * - depositing token0: spot = min bound (in sqrt(token1/token0) terms); + * - depositing token1: spot = max bound. + * + * The opposite bound is derived from the other token's `maxAvailableLiquidity`: + * it is the price at which the deposited reserve has fully converted into exactly + * that amount. This follows from the range-order relation + * `sqrtPmin * sqrtPmax / 1e18^2 = amountGt / amountLt` (the geometric mean of the + * bounds is the average execution price across the range), so the opposite token's + * `maxAvailableLiquidity` must exceed the spot-equivalent value of the deposit. * * @param spotPrice Current spot price; sits exactly on one bound of the range - * @param priceBound The other bound of the range * @param reserveForToken Token being deposited (must be one of the pair tokens) * @param reserve Raw amount of `reserveForToken` to deposit (must be positive) + * @returns Price range with the spot on one bound and the derived opposite bound */ computeSingleSidedRange( spotPrice: Price, - priceBound: Price, reserveForToken: Address, reserve: bigint, - ): SingleSidedRangeInfo { + ): PriceAllocationRange { assert(reserve > 0n, 'reserve must be positive') assert( spotPrice.token0.address.equal(this.token0.address) && @@ -119,21 +123,37 @@ export class ConcentrateLiquidityCalculator { ) const isReserveLt = reserveForToken.equal(this.token0.address) + const sqrtPspot = spotPrice.toSqrt() + const pair = { tokenA: spotPrice.token0, tokenB: spotPrice.token1 } if (isReserveLt) { - assert(priceBound.gt(spotPrice), 'price bound should be above spot for a token0 deposit') + const targetGt = this.token1.maxAvailableLiquidity + const sqrtPmax = (targetGt * ONE_E18 * ONE_E18) / (reserve * sqrtPspot) + assert( + sqrtPmax > sqrtPspot, + 'token1 maxAvailableLiquidity should exceed the spot value of the deposit', + ) return { - prices: { minPrice: spotPrice, spotPrice, maxPrice: priceBound }, - reserves: { token0Reserve: reserve, token1Reserve: 0n }, + minPrice: spotPrice, + spotPrice, + maxPrice: Price.fromSqrt(sqrtPmax, pair), } } - assert(priceBound.lt(spotPrice), 'price bound should be below spot for a token1 deposit') + const targetLt = this.token0.maxAvailableLiquidity + assert(targetLt > 0n, 'token0 maxAvailableLiquidity must be positive') + + const sqrtPmin = (reserve * ONE_E18 * ONE_E18) / (targetLt * sqrtPspot) + assert( + sqrtPmin > 0n && sqrtPmin < sqrtPspot, + 'token0 maxAvailableLiquidity should exceed the spot value of the deposit', + ) return { - prices: { minPrice: priceBound, spotPrice, maxPrice: spotPrice }, - reserves: { token0Reserve: 0n, token1Reserve: reserve }, + minPrice: Price.fromSqrt(sqrtPmin, pair), + spotPrice, + maxPrice: spotPrice, } } diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts index 0ea7764..0093076 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/concentrate-liquidity-calculator/types.ts @@ -29,15 +29,6 @@ export type ConcentratedLiquidityInfo = { token1Reserve: bigint } -/** - * Result of a single-sided range calculation: the price range with the spot sitting - * exactly on one bound, and the reserves with the opposite (depleted) side at zero. - */ -export type SingleSidedRangeInfo = { - prices: PriceAllocationRange - reserves: ConcentratedLiquidityInfo -} - /** * Constructor argument: the two tokens (tokenA, tokenB). Order is arbitrary; * token0/token1 are derived by address comparison. diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts index 6c16d45..31e8136 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/index.ts @@ -20,6 +20,5 @@ export type { PriceAllocationRange, PriceBounds, ConcentratedLiquidityInfo, - SingleSidedRangeInfo, } from './concentrate-liquidity-calculator/types' export { bigintSqrt } from '../utils/bigint-sqrt'