diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index 1769ded..3f5bcd9 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -122,6 +122,7 @@ function ValidityDemoInner() { const [txHash, setTxHash] = useState(null); const [side, setSide] = useState('buy'); const [offsetBps, setOffsetBps] = useState(100); + const [priceOverrideWad, setPriceOverrideWad] = useState(null); const [expirySeconds, setExpirySeconds] = useState(15); const [submitMode, setSubmitMode] = useState('concurrent'); const [orders, setOrders] = useState([]); @@ -546,9 +547,16 @@ function ValidityDemoInner() { const draft = useMemo(() => { if (!state?.deployment || k === 0n || spot === 0n) return null; try { - const price = applyOffsetBps(spot, side, offsetBps); + const price = priceOverrideWad ?? applyOffsetBps(spot, side, offsetBps); const ammPrice = ammPriceFromQuote(price, vibeToken0); - const built = priceValidity(state.deployment.pair, k, ammPrice, ammSide(side, vibeToken0)); + const ammSpot = ammPriceFromQuote(spot, vibeToken0); + const built = priceValidity( + state.deployment.pair, + k, + ammPrice, + ammSide(side, vibeToken0), + ammSpot, + ); return { priceWad: price, side, @@ -559,7 +567,7 @@ function ValidityDemoInner() { } catch { return null; } - }, [k, offsetBps, side, spot, state?.deployment, vibeToken0]); + }, [k, offsetBps, priceOverrideWad, side, spot, state?.deployment, vibeToken0]); const reviewPredicates = useMemo(() => { if (!draft) return []; @@ -935,9 +943,17 @@ function ValidityDemoInner() { busy={busy} vibeBalance={vibeBalance} costHint={costHint} + priceOverrideWad={priceOverrideWad} canAfford={canAffordTrade} - onSide={setSide} - onOffset={setOffsetBps} + onSide={(next) => { + setSide(next); + setPriceOverrideWad(null); + }} + onOffset={(bps) => { + setOffsetBps(bps); + setPriceOverrideWad(null); + }} + onPriceOverride={setPriceOverrideWad} onExpiry={setExpirySeconds} onSubmitMode={(mode) => { setSubmitMode(mode); diff --git a/app/vibenet/demos/validity/components/OrderTicket.tsx b/app/vibenet/demos/validity/components/OrderTicket.tsx index 8469437..fe106a9 100644 --- a/app/vibenet/demos/validity/components/OrderTicket.tsx +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -1,11 +1,13 @@ 'use client'; +import { useState } from 'react'; + import { Button } from '../../../../components/ui/Button'; import { Slider } from '../../../../components/ui/Slider'; import { Text } from '../../../../components/ui/Text'; import { AnimatedAmount } from '../../_components/AnimatedAmount'; import { MAX_NONCELESS_SECONDS, TRADE_VIBE } from '../lib/constants'; -import { applyOffsetBps, formatPrice } from '../lib/predicates'; +import { applyOffsetBps, formatPrice, parsePriceWad } from '../lib/predicates'; import { formatTokenAmount, VIBE_SYMBOL } from '../lib/quote'; import type { Side, SubmitMode } from '../lib/types'; @@ -25,8 +27,11 @@ type Props = { busy: boolean; vibeBalance: bigint | null; costHint: string | null; + /** Manual target price; set from the price input, cleared when the slider moves. */ + priceOverrideWad: bigint | null; onSide: (side: Side) => void; onOffset: (bps: number) => void; + onPriceOverride: (wad: bigint | null) => void; onExpiry: (seconds: number) => void; onSubmitMode: (mode: SubmitMode) => void; onSubmit: () => void; @@ -47,15 +52,32 @@ export function OrderTicket({ busy, vibeBalance, costHint, + priceOverrideWad, onSide, onOffset, onExpiry, onSubmitMode, onSubmit, + onPriceOverride, canAfford, }: Props) { - const target = applyOffsetBps(spotWad, side, offsetBps); - const signed = offsetBps === 0 ? '±0%' : side === 'buy' ? `−${formatBps(offsetBps)}` : `+${formatBps(offsetBps)}`; + const overrideActive = priceOverrideWad !== null; + const target = priceOverrideWad ?? applyOffsetBps(spotWad, side, offsetBps); + const effectiveBps = + spotWad > 0n ? Math.round((Number(target - spotWad) / Number(spotWad)) * 10_000) : 0; + const signed = overrideActive + ? effectiveBps === 0 + ? '±0%' + : `${effectiveBps > 0 ? '+' : '−'}${formatBps(Math.abs(effectiveBps))}` + : offsetBps === 0 + ? '±0%' + : side === 'buy' + ? `−${formatBps(offsetBps)}` + : `+${formatBps(offsetBps)}`; + // Raw text while the price input is being edited; null shows the computed target. + // Blur clears it, and anything that resets the override (slider, side flip) + // first steals focus from the input, so no sync with the override is needed. + const [priceText, setPriceText] = useState(null); return (
@@ -111,24 +133,31 @@ export function OrderTicket({
- {offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'} + {target === spotWad ? 'At mid' : target < spotWad ? 'Below mid' : 'Above mid'} {signed}
- ({ - value: bps, - label: bps === 0 ? '0%' : formatBps(bps), - }))} - aria-label="Offset from mid" - /> +
+ ({ + value: bps, + label: bps === 0 ? '0%' : formatBps(bps), + }))} + aria-label="Offset from mid" + /> +
+ {overrideActive ? ( + + Custom price set — move the slider to clear it. + + ) : null}
Include when price is {side === 'buy' ? '≤' : '≥'} - - ${formatPrice(target)} - + $ + setPriceText(formatPrice(target))} + onChange={(event) => { + const text = event.target.value; + setPriceText(text); + const wad = parsePriceWad(text); + if (wad !== null) onPriceOverride(wad); + }} + onBlur={() => setPriceText(null)} + className={`w-fit min-w-0 max-w-full cursor-text border-b border-dashed bg-transparent tabular-nums outline-none transition-colors focus:border-solid ${ + side === 'buy' + ? 'border-bds-green-70/40 hover:border-bds-green-70 focus:border-bds-green-70' + : 'border-bds-red-70/40 hover:border-bds-red-70 focus:border-bds-red-70' + }`} + size={Math.max((priceText ?? formatPrice(target)).length, 4)} + /> +
- mid {signed} + mid {signed} · {overrideActive ? 'custom price' : 'type to set a price'}
diff --git a/app/vibenet/demos/validity/lib/predicates.test.ts b/app/vibenet/demos/validity/lib/predicates.test.ts index d91d0a1..ba312f3 100644 --- a/app/vibenet/demos/validity/lib/predicates.test.ts +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -4,6 +4,7 @@ import { RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; import { applyOffsetBps, formatPrice, + parsePriceWad, prettyValidity, priceValidity, rectangleForTarget, @@ -27,6 +28,27 @@ describe('predicates', () => { expect(formatPrice(99n * 10n ** 16n)).toBe('0.9900'); }); + it('parses typed prices into wad', () => { + expect(parsePriceWad('1')).toBe(WAD); + expect(parsePriceWad('1.0000')).toBe(WAD); + expect(parsePriceWad('$0.99')).toBe(99n * 10n ** 16n); + expect(parsePriceWad('.5')).toBe(WAD / 2n); + expect(parsePriceWad('1,024.5')).toBe(1_024n * WAD + WAD / 2n); + expect(parsePriceWad(' 2.5 ')).toBe((5n * WAD) / 2n); + expect(parsePriceWad(formatPrice((3n * WAD) / 2n))).toBe((3n * WAD) / 2n); + }); + + it('rejects non-prices', () => { + expect(parsePriceWad('')).toBeNull(); + expect(parsePriceWad('0')).toBeNull(); + expect(parsePriceWad('0.000')).toBeNull(); + expect(parsePriceWad('-1')).toBeNull(); + expect(parsePriceWad('1e3')).toBeNull(); + expect(parsePriceWad('1.2.3')).toBeNull(); + expect(parsePriceWad('abc')).toBeNull(); + expect(parsePriceWad('1.0000000000000000001')).toBeNull(); + }); + it('offsets spot in basis points for buy and sell', () => { expect(applyOffsetBps(WAD, 'buy', 100)).toBe((99n * WAD) / 100n); expect(applyOffsetBps(WAD, 'sell', 100)).toBe((101n * WAD) / 100n); @@ -34,6 +56,44 @@ describe('predicates', () => { expect(applyOffsetBps(WAD, 'sell', 0)).toBe(WAD); }); + it('stretches a satisfied buy box to the current point', () => { + const k = 1_000n * WAD * (1_000n * WAD); // mid 1.0 + const current = WAD; + const target = 2n * WAD; // buy at ≤ 2 with mid at 1: already satisfied + const box = rectangleForTarget(k, target, 'buy', current); + const r0Now = sqrt((k * WAD) / current); + const r1Now = k / r0Now; + expect(box.r0Min <= r0Now && r0Now <= box.r0Max).toBe(true); + expect(box.r1Min <= r1Now && r1Now <= box.r1Max).toBe(true); + // Corners still respect price ≤ P. + expect((box.r1Max * WAD) / box.r0Min <= target).toBe(true); + }); + + it('stretches a satisfied sell box to the current point', () => { + const k = 1_000n * WAD * (1_000n * WAD); // mid 1.0 + const current = WAD; + const target = WAD / 2n; // sell at ≥ 0.5 with mid at 1: already satisfied + const box = rectangleForTarget(k, target, 'sell', current); + const r0Now = sqrt((k * WAD) / current); + const r1Now = k / r0Now; + expect(box.r0Min <= r0Now && r0Now <= box.r0Max).toBe(true); + expect(box.r1Min <= r1Now && r1Now <= box.r1Max).toBe(true); + // Corners still respect price ≥ P. + expect((box.r1Min * WAD) / box.r0Max >= target).toBe(true); + }); + + it('keeps the resting box when the condition is not yet met', () => { + const k = 1_000n * WAD * (1_000n * WAD); // mid 1.0 + const buyTarget = (99n * WAD) / 100n; // buy below mid rests as before + expect(rectangleForTarget(k, buyTarget, 'buy', WAD)).toEqual( + rectangleForTarget(k, buyTarget, 'buy'), + ); + const sellTarget = (101n * WAD) / 100n; // sell above mid rests as before + expect(rectangleForTarget(k, sellTarget, 'sell', WAD)).toEqual( + rectangleForTarget(k, sellTarget, 'sell'), + ); + }); + it('buy box implies every corner has price ≤ P', () => { const k = 1_000n * WAD * (1_000n * WAD); const target = (99n * WAD) / 100n; diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts index 24c9299..7446bac 100644 --- a/app/vibenet/demos/validity/lib/predicates.ts +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -44,6 +44,16 @@ export function formatPrice(wad: bigint, digits = 4): string { return `${negative ? '-' : ''}${int.toString()}.${frac}`; } +/** Parse a typed price like "1.0421", "$0.98", or "1,024.5" into wad. Null if not a positive price. */ +export function parsePriceWad(input: string): bigint | null { + const cleaned = input.trim().replace(/^\$/, '').replace(/,/g, ''); + if (!/^(\d+(\.\d*)?|\.\d+)$/.test(cleaned)) return null; + const [intPart = '0', fracPart = ''] = cleaned.split('.'); + if (fracPart.length > 18) return null; + const wad = BigInt(intPart || '0') * WAD + BigInt(fracPart.padEnd(18, '0') || '0'); + return wad > 0n ? wad : null; +} + /** Apply a basis-point offset to spot. Buy is below (`-bps`), sell is above (`+bps`). 0 is at mid. */ export function applyOffsetBps(spotWad: bigint, side: Side, offsetBps: number): bigint { if (spotWad <= 0n) throw new Error('Need a live mid price.'); @@ -83,17 +93,33 @@ export function prettyValidity(predicates: ValidityPredicate[]): string { * sell (price ≥ P): A/s ≤ r0 ≤ A ∧ B ≤ r1 ≤ B·s with B/A ≥ P * * Four storage predicates, so a drained or wildly expanded pool cannot fill. + * + * When `currentPriceWad` already satisfies the condition (a buy priced above + * the mid, a sell priced below it), the box stretches along the hyperbola to + * the current point so the order fills without the mid retracing to target. */ -export function rectangleForTarget(k: bigint, targetPriceWad: bigint, side: Side): Rectangle { +export function rectangleForTarget( + k: bigint, + targetPriceWad: bigint, + side: Side, + currentPriceWad?: bigint, +): Rectangle { if (k === 0n || targetPriceWad <= 0n) { throw new Error('Need a live pool and a positive target price.'); } const a = sqrt((k * WAD) / targetPriceWad); if (a === 0n) throw new Error('Degenerate reserve bound.'); + const aCur = + currentPriceWad !== undefined && currentPriceWad > 0n + ? sqrt((k * WAD) / currentPriceWad) + : a; if (side === 'buy') { const b = (a * targetPriceWad) / WAD || 1n; - const r0Max = (a * BOX_SPAN_NUM) / BOX_SPAN_DEN; - const r1Min = (b * BOX_SPAN_DEN) / BOX_SPAN_NUM; + // Larger r0 means lower price; a satisfied buy sits at aOut > a. + const aOut = aCur > a ? aCur : a; + const bOut = k / aOut || 1n; + const r0Max = (aOut * BOX_SPAN_NUM) / BOX_SPAN_DEN; + const r1Min = (bOut * BOX_SPAN_DEN) / BOX_SPAN_NUM; return { r0Min: a, r0Max: r0Max > a ? r0Max : a + 1n, @@ -103,8 +129,11 @@ export function rectangleForTarget(k: bigint, targetPriceWad: bigint, side: Side }; } const b = (a * targetPriceWad + WAD - 1n) / WAD; - const r0Min = (a * BOX_SPAN_DEN) / BOX_SPAN_NUM; - const r1Max = (b * BOX_SPAN_NUM) / BOX_SPAN_DEN; + // Smaller r0 means higher price; a satisfied sell sits at aOut < a. + const aOut = aCur < a && aCur > 0n ? aCur : a; + const bOut = (k + aOut - 1n) / aOut; + const r0Min = (aOut * BOX_SPAN_DEN) / BOX_SPAN_NUM; + const r1Max = (bOut * BOX_SPAN_NUM) / BOX_SPAN_DEN; return { r0Min: r0Min < a ? r0Min : 1n, r0Max: a, @@ -141,8 +170,9 @@ export function priceValidity( k: bigint, targetPriceWad: bigint, side: Side, + currentPriceWad?: bigint, ): { rectangle: Rectangle; predicates: ValidityPredicate[] } { - const rectangle = rectangleForTarget(k, targetPriceWad, side); + const rectangle = rectangleForTarget(k, targetPriceWad, side, currentPriceWad); const r1MinValue = rectangle.r1Min << RESERVE_BITS; const r1MaxValue = rectangle.r1Max << RESERVE_BITS; const predicates: ValidityPredicate[] = [