From d6cbc148417abcbd1f4ef4282e494fc408f05c82 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Wed, 2 Sep 2026 13:11:30 -0700 Subject: [PATCH 1/4] feat(vibenet): drive the Validity offset with a slider Add a ui/Slider primitive (styled native range input with clickable labeled marks) and replace the fixed offset chips in the order ticket with a 0-5% slider in 0.1% steps, with the signed offset shown beside the section label. Generated with Claude Code Co-Authored-By: Claude --- app/components/ui/Slider.tsx | 83 +++++++++++++++++++ .../demos/validity/components/OrderTicket.tsx | 42 +++++----- 2 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 app/components/ui/Slider.tsx diff --git a/app/components/ui/Slider.tsx b/app/components/ui/Slider.tsx new file mode 100644 index 00000000..b9509871 --- /dev/null +++ b/app/components/ui/Slider.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { cn } from './cn'; + +type SliderMark = { + value: number; + label: string; +}; + +type SliderProps = { + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + disabled?: boolean; + /** Labeled stops rendered under the track; clicking one jumps to its value. */ + marks?: SliderMark[]; + 'aria-label'?: string; + className?: string; +}; + +export function Slider({ + value, + min, + max, + step = 1, + onChange, + disabled = false, + marks, + 'aria-label': ariaLabel, + className, +}: SliderProps) { + const span = max - min; + const position = (markValue: number) => (span > 0 ? ((markValue - min) / span) * 100 : 0); + return ( +
+ onChange(Number(event.target.value))} + className={cn( + 'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-bds-gray-10 outline-none dark:bg-white/10', + '[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground', + '[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-foreground', + 'disabled:cursor-not-allowed disabled:opacity-40', + )} + /> + {marks && marks.length > 0 ? ( +
+ {marks.map((mark) => { + const pct = position(mark.value); + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/OrderTicket.tsx b/app/vibenet/demos/validity/components/OrderTicket.tsx index 8ab14871..8469437b 100644 --- a/app/vibenet/demos/validity/components/OrderTicket.tsx +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -1,6 +1,7 @@ 'use client'; 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'; @@ -11,7 +12,9 @@ import type { Side, SubmitMode } from '../lib/types'; const TRADE_LABEL = formatTokenAmount(TRADE_VIBE); const EXPIRIES = [5, 15, 60] as const; -const OFFSETS = [0, 50, 100, 200, 500] as const; +const OFFSET_MAX_BPS = 500; +const OFFSET_STEP_BPS = 10; +const OFFSET_MARKS = [0, 100, 200, 300, 400, 500] as const; type Props = { spotWad: bigint; @@ -106,25 +109,26 @@ export function OrderTicket({
- - {offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'} - -
- {OFFSETS.map((bps) => ( - - ))} +
+ + {offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'} + + + {signed} +
+ ({ + value: bps, + label: bps === 0 ? '0%' : formatBps(bps), + }))} + aria-label="Offset from mid" + />
Date: Wed, 2 Sep 2026 13:48:08 -0700 Subject: [PATCH 2/4] fix(ui): align Slider marks with the thumb's travel The thumb center travels from half its width to width minus half, while marks were placed at the raw track percentage, so they drifted apart toward the ends. Position marks with the same thumb-center geometry and center every label on its stop. Generated with Claude Code Co-Authored-By: Claude --- app/components/ui/Slider.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/components/ui/Slider.tsx b/app/components/ui/Slider.tsx index b9509871..3c127e8e 100644 --- a/app/components/ui/Slider.tsx +++ b/app/components/ui/Slider.tsx @@ -7,6 +7,11 @@ type SliderMark = { label: string; }; +// Matches the h-4/w-4 thumb below. The thumb's center travels from +// THUMB_PX/2 to width - THUMB_PX/2, so marks must follow that geometry +// instead of the raw track percentage or they drift near the ends. +const THUMB_PX = 16; + type SliderProps = { value: number; min: number; @@ -62,8 +67,8 @@ export function Slider({ disabled={disabled} onClick={() => onChange(mark.value)} style={{ - left: `${pct}%`, - transform: pct === 0 ? 'none' : pct === 100 ? 'translateX(-100%)' : 'translateX(-50%)', + left: `calc(${pct}% + ${((50 - pct) / 100) * THUMB_PX}px)`, + transform: 'translateX(-50%)', }} className={cn( 'absolute top-0 font-mono text-[10px] leading-4 transition-colors disabled:cursor-not-allowed disabled:opacity-40', From 5fe3abded4ba01d329d1074759b51f9eb4820175 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 3 Sep 2026 09:31:47 -0700 Subject: [PATCH 3/4] feat(vibenet): let Validity orders name a manual target price (#135) Co-authored-by: Claude --- app/vibenet/demos/validity/ValidityDemo.tsx | 17 +++- .../demos/validity/components/OrderTicket.tsx | 94 ++++++++++++++----- .../demos/validity/lib/predicates.test.ts | 22 +++++ app/vibenet/demos/validity/lib/predicates.ts | 10 ++ 4 files changed, 117 insertions(+), 26 deletions(-) diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index 9acbb5b6..edc7254f 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,7 +547,7 @@ 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)); return { @@ -559,7 +560,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 +936,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 8469437b..fe106a9e 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 6229aeac..d1823ce9 100644 --- a/app/vibenet/demos/validity/lib/predicates.test.ts +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -6,6 +6,7 @@ import { blockExpiryPredicate, blockNumberPredicate, formatPrice, + parsePriceWad, prettyValidity, priceValidity, rectangleForTarget, @@ -29,6 +30,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); diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts index 70492362..78c3c320 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.'); From 8bb0e48d0d63f8508cfe26f91c3e64b15d02cd3d Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 3 Sep 2026 09:33:16 -0700 Subject: [PATCH 4/4] fix(vibenet): fill Validity orders already inside their price condition (#136) Co-authored-by: Claude --- app/vibenet/demos/validity/ValidityDemo.tsx | 9 ++++- .../demos/validity/lib/predicates.test.ts | 38 +++++++++++++++++++ app/vibenet/demos/validity/lib/predicates.ts | 32 +++++++++++++--- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index edc7254f..5b6f5546 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -549,7 +549,14 @@ function ValidityDemoInner() { try { 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, diff --git a/app/vibenet/demos/validity/lib/predicates.test.ts b/app/vibenet/demos/validity/lib/predicates.test.ts index d1823ce9..0e0e6d4c 100644 --- a/app/vibenet/demos/validity/lib/predicates.test.ts +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -58,6 +58,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 78c3c320..98c8db89 100644 --- a/app/vibenet/demos/validity/lib/predicates.ts +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -93,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, @@ -113,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, @@ -151,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[] = [