Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading