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
88 changes: 88 additions & 0 deletions app/components/ui/Slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { cn } from './cn';

type SliderMark = {
value: number;
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;
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 (
<div className={cn('flex flex-col gap-1', className)}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(event) => 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 ? (
<div className="relative h-4 select-none">
{marks.map((mark) => {
const pct = position(mark.value);
return (
<button
key={mark.value}
type="button"
disabled={disabled}
onClick={() => onChange(mark.value)}
style={{
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',
mark.value === value
? 'font-medium text-foreground'
: 'text-bds-gray-50 hover:text-foreground',
)}
>
{mark.label}
</button>
);
})}
</div>
) : null}
</div>
);
}
26 changes: 21 additions & 5 deletions app/vibenet/demos/validity/ValidityDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function ValidityDemoInner() {
const [txHash, setTxHash] = useState<Hex | null>(null);
const [side, setSide] = useState<Side>('buy');
const [offsetBps, setOffsetBps] = useState(100);
const [priceOverrideWad, setPriceOverrideWad] = useState<bigint | null>(null);
const [expirySeconds, setExpirySeconds] = useState(15);
const [submitMode, setSubmitMode] = useState<SubmitMode>('concurrent');
const [orders, setOrders] = useState<PlacedOrder[]>([]);
Expand Down Expand Up @@ -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,
Expand All @@ -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 [];
Expand Down Expand Up @@ -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);
Expand Down
110 changes: 82 additions & 28 deletions app/vibenet/demos/validity/components/OrderTicket.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
'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';

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;
Expand All @@ -22,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;
Expand All @@ -44,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<string | null>(null);

return (
<div className="flex flex-col gap-4 rounded-2xl border border-bds-gray-10 bg-background p-5 dark:border-white/10 dark:bg-white/5">
Expand Down Expand Up @@ -106,25 +131,33 @@ export function OrderTicket({
</button>
</div>
<div className="flex flex-col gap-2">
<Text variant="caption" tone="muted">
{offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'}
</Text>
<div className="flex flex-wrap gap-2">
{OFFSETS.map((bps) => (
<button
key={bps}
type="button"
onClick={() => onOffset(bps)}
className={
bps === offsetBps
? 'rounded-full bg-foreground px-3 py-1 text-[12px] text-background'
: 'rounded-full border border-bds-gray-10 px-3 py-1 text-[12px] dark:border-white/10'
}
>
{bps === 0 ? '±0%' : `${side === 'buy' ? '−' : '+'}${formatBps(bps)}`}
</button>
))}
<div className="flex items-baseline justify-between gap-3">
<Text variant="caption" tone="muted">
{target === spotWad ? 'At mid' : target < spotWad ? 'Below mid' : 'Above mid'}
</Text>
<Text variant="label.mono" className="tabular-nums text-bds-gray-60">
{signed}
</Text>
</div>
<div className={overrideActive ? 'opacity-50 transition-opacity' : 'transition-opacity'}>
<Slider
value={offsetBps}
min={0}
max={OFFSET_MAX_BPS}
step={OFFSET_STEP_BPS}
onChange={onOffset}
marks={OFFSET_MARKS.map((bps) => ({
value: bps,
label: bps === 0 ? '0%' : formatBps(bps),
}))}
aria-label="Offset from mid"
/>
</div>
{overrideActive ? (
<Text variant="footnote" tone="muted">
Custom price set — move the slider to clear it.
</Text>
) : null}
</div>
<div
className={
Expand All @@ -136,14 +169,35 @@ export function OrderTicket({
<Text variant="footnote" tone="muted">
Include when price is {side === 'buy' ? '≤' : '≥'}
</Text>
<Text
variant="title3"
className={`mt-1 tabular-nums ${side === 'buy' ? 'text-bds-green-70' : 'text-bds-red-70'}`}
<div
className={`mt-1 flex items-baseline text-[18px] font-[500] leading-[26px] tracking-tight md:text-[20px] md:leading-[28px] ${
side === 'buy' ? 'text-bds-green-70' : 'text-bds-red-70'
}`}
>
${formatPrice(target)}
</Text>
<span>$</span>
<input
type="text"
inputMode="decimal"
aria-label="Target price"
value={priceText ?? formatPrice(target)}
onFocus={() => 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)}
/>
</div>
<Text variant="footnote" className="mt-1 tabular-nums text-bds-gray-60">
mid {signed}
mid {signed} · {overrideActive ? 'custom price' : 'type to set a price'}
</Text>
</div>
<div className="flex flex-col gap-2">
Expand Down
60 changes: 60 additions & 0 deletions app/vibenet/demos/validity/lib/predicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
blockExpiryPredicate,
blockNumberPredicate,
formatPrice,
parsePriceWad,
prettyValidity,
priceValidity,
rectangleForTarget,
Expand All @@ -29,13 +30,72 @@ 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);
expect(applyOffsetBps(WAD, 'buy', 0)).toBe(WAD);
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;
Expand Down
Loading
Loading