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
17 changes: 13 additions & 4 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,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 {
Expand All @@ -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 [];
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 72 additions & 22 deletions app/vibenet/demos/validity/components/OrderTicket.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -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<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 @@ -111,24 +133,31 @@ export function OrderTicket({
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<Text variant="caption" tone="muted">
{offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'}
{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>
<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 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 @@ -140,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
22 changes: 22 additions & 0 deletions app/vibenet/demos/validity/lib/predicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants';
import {
applyOffsetBps,
formatPrice,
parsePriceWad,
prettyValidity,
priceValidity,
rectangleForTarget,
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions app/vibenet/demos/validity/lib/predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading