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..74da656 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 @@ -383,6 +383,76 @@ describe('PriceRange', () => { ).toThrow('provided reserve for unknown token') }) + describe('single-sided compositions (DAPP-4462 repro: WBTC 8dp / USDT 6dp)', () => { + const WBTC = new Address('0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599') + const USDT = new Address('0xdAC17F958D2ee523a2206206994597C13D831ec7') + + /** WBTC has the lower address, so it is token0 and USDT is token1. */ + const WBTC_TOKEN: PriceToken = { address: WBTC, decimals: 8n } + const USDT_TOKEN: PriceToken = { address: USDT, decimals: 6n } + const pairUsdtQuoteWbtcBase = pricePair(USDT_TOKEN, WBTC_TOKEN) + + /** Range 58,946 -> 63,752 USDT per WBTC. */ + const wbtcUsdtBounds = (): PriceBounds => ({ + minPrice: Price.fromHuman('58946', pairUsdtQuoteWbtcBase), + maxPrice: Price.fromHuman('63752', pairUsdtQuoteWbtcBase), + }) + + it('should place spot exactly at the min bound for token0-only reserves', () => { + const { minPrice, maxPrice } = wbtcUsdtBounds() + + const range = PriceRange.fromPriceBounds( + { minPrice, maxPrice }, + { + /** 0.442142 WBTC */ + reserveA: TokenReserve.new({ token: WBTC, reserve: 44214200n }), + reserveB: TokenReserve.new({ token: USDT, reserve: 0n }), + }, + ) + + expect(range.spotPrice.equals(range.minPrice)).toBe(true) + expect(range.spotPrice.toHuman(USDT)).toBe('58946') + }) + + it('should place spot exactly at the max bound for token1-only reserves', () => { + const { minPrice, maxPrice } = wbtcUsdtBounds() + + /** + * Mirror of the min-side case above. In exact arithmetic the implied spot equals the + * max bound (amount0 = 0), but deriving it via `computeLiquidityAndPrice` truncates + * (`virtualLt = L / sqrtPmax` rounds down), landing the derived `sqrtSpot` ~3.4e-10 + * (relative) above `sqrtPmax` so the constructor assert used to reject this + * legitimate single-sided composition with 'maxPrice should be >= spotPrice'. + * Single-sided compositions now short-circuit to the corresponding bound. + */ + const range = PriceRange.fromPriceBounds( + { minPrice, maxPrice }, + { + reserveA: TokenReserve.new({ token: WBTC, reserve: 0n }), + /** 28,207 USDT */ + reserveB: TokenReserve.new({ token: USDT, reserve: 28_207_000_000n }), + }, + ) + + expect(range.spotPrice.equals(range.maxPrice)).toBe(true) + expect(range.spotPrice.toHuman(USDT)).toBe('63752') + }) + + it('should throw when both reserves are zero', () => { + const { minPrice, maxPrice } = wbtcUsdtBounds() + + expect(() => + PriceRange.fromPriceBounds( + { minPrice, maxPrice }, + { + reserveA: TokenReserve.new({ token: WBTC, reserve: 0n }), + reserveB: TokenReserve.new({ token: USDT, reserve: 0n }), + }, + ), + ).toThrow('at least one reserve must be positive') + }) + }) + describe('spot recovery after allocation', () => { const maxUsdc = 1_000_000n * 10n ** 6n const maxWeth = 400n * 10n ** 18n diff --git a/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.ts b/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.ts index 0dbea7e..78562cb 100644 --- a/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.ts +++ b/typescript/swap-vm/src/swap-vm/instructions/concentrate/price-range/price-range.ts @@ -71,6 +71,19 @@ export class PriceRange { 'provided reserve for unknown token', ) + assert(reserve0.reserve > 0n || reserve1.reserve > 0n, 'at least one reserve must be positive') + + // Single-sided composition: the spot sits exactly on a bound (reserve0 depletes at max, + // reserve1 at min). Deriving it via computeLiquidityAndPrice would reintroduce integer + // truncation that can land the implied sqrt spot just outside the bounds. + if (reserve0.reserve === 0n) { + return PriceRange.new({ minPrice, spotPrice: maxPrice, maxPrice }) + } + + if (reserve1.reserve === 0n) { + return PriceRange.new({ minPrice, spotPrice: minPrice, maxPrice }) + } + const { sqrtPriceSpot } = computeLiquidityAndPrice( reserve0.reserve, reserve1.reserve,