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
32 changes: 27 additions & 5 deletions app/vibenet/demos/validity/ValidityDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,21 @@ import { VIBENET_WS_URL } from '../../library/config';
import {
ageRestoredOrders,
maxBlockForExpiry,
minBlockForDelay,
occupyingOrder,
orderBlockExpired,
orderWallClockExpired,
restingOrderToReplace,
} from './lib/orders';
import { bumpReplacementFees, feesFromHead, isReplacementUnderpriced, padFees } from './lib/fees';
import { reviewClauses } from './lib/annotate';
import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity } from './lib/predicates';
import {
applyOffsetBps,
blockDelayPredicate,
blockExpiryPredicate,
formatPrice,
priceValidity,
} from './lib/predicates';
import {
ammPriceFromQuote,
ammSide,
Expand Down Expand Up @@ -124,6 +131,7 @@ function ValidityDemoInner() {
const [offsetBps, setOffsetBps] = useState(100);
const [priceOverrideWad, setPriceOverrideWad] = useState<bigint | null>(null);
const [expirySeconds, setExpirySeconds] = useState(15);
const [delaySeconds, setDelaySeconds] = useState(0);
const [submitMode, setSubmitMode] = useState<SubmitMode>('concurrent');
const [orders, setOrders] = useState<PlacedOrder[]>([]);
const [hoveredOrderId, setHoveredOrderId] = useState<string | null>(null);
Expand Down Expand Up @@ -577,8 +585,11 @@ function ValidityDemoInner() {
? clampNoncelessExpiry(expirySeconds)
: Math.min(MAX_EXPIRY_SECONDS, expirySeconds);
const maxBlock = maxBlockForExpiry(blockNumber, seconds);
return [...draft.predicates, blockExpiryPredicate(maxBlock)];
}, [blockNumber, draft, expirySeconds, submitMode]);
const predicates = [...draft.predicates, blockExpiryPredicate(maxBlock)];
const delay = Math.min(delaySeconds, Math.max(0, seconds - 1));
if (delay > 0) predicates.push(blockDelayPredicate(minBlockForDelay(blockNumber, delay)));
return predicates;
}, [blockNumber, delaySeconds, draft, expirySeconds, submitMode]);

const chartLevels = useMemo((): PriceLevel[] => {
const levels: PriceLevel[] = [];
Expand Down Expand Up @@ -723,7 +734,10 @@ function ValidityDemoInner() {
: Math.min(MAX_EXPIRY_SECONDS, expirySeconds);
const block = blockNumber ?? (await publicClient.getBlockNumber({ cacheTime: 0 }));
const maxBlock = maxBlockForExpiry(block, seconds);
const delay = Math.min(delaySeconds, Math.max(0, seconds - 1));
const minBlock = delay > 0 ? minBlockForDelay(block, delay) : undefined;
const validity = [...draft.predicates, blockExpiryPredicate(maxBlock)];
if (minBlock !== undefined) validity.push(blockDelayPredicate(minBlock));
const fromHead = headFeesRef.current;
const estimated =
fromHead ??
Expand Down Expand Up @@ -803,8 +817,10 @@ function ValidityDemoInner() {
targetPriceWad: draft.priceWad,
size: TRADE_VIBE,
expirySeconds: seconds,
delaySeconds: delay > 0 ? delay : undefined,
submitMode,
maxBlock,
minBlock,
submittedAt: Date.now(),
txHash: hash,
nonce,
Expand Down Expand Up @@ -939,6 +955,7 @@ function ValidityDemoInner() {
side={side}
offsetBps={offsetBps}
expirySeconds={expirySeconds}
delaySeconds={delaySeconds}
submitMode={submitMode}
busy={busy}
vibeBalance={vibeBalance}
Expand All @@ -954,11 +971,16 @@ function ValidityDemoInner() {
setPriceOverrideWad(null);
}}
onPriceOverride={setPriceOverrideWad}
onExpiry={setExpirySeconds}
onExpiry={(seconds) => {
setExpirySeconds(seconds);
if (delaySeconds >= seconds) setDelaySeconds(0);
}}
onDelay={setDelaySeconds}
onSubmitMode={(mode) => {
setSubmitMode(mode);
if (mode === 'concurrent' && expirySeconds > MAX_NONCELESS_SECONDS) {
setExpirySeconds(15);
if (delaySeconds >= 15) setDelaySeconds(0);
}
}}
onSubmit={() => {
Expand Down Expand Up @@ -1015,7 +1037,7 @@ function ValidityDemoInner() {
</Text>
<Text variant="footnote" tone="muted" className="mt-1">
{submitMode === 'concurrent' ? '8130 concurrent' : 'Replace resting nonce'} · expires in{' '}
{expirySeconds}s
{expirySeconds}s{delaySeconds > 0 ? ` · starts in ~${delaySeconds}s` : ''}
</Text>
</div>
<ul className="flex flex-col gap-2">
Expand Down
3 changes: 3 additions & 0 deletions app/vibenet/demos/validity/components/OrderList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) {
<Text variant="footnote" tone="muted" className="tabular-nums">
{formatClock(order.submittedAt)}
{order.submitMode === 'concurrent' ? ' · 8130' : order.submitMode === 'replace' ? ' · replace' : null}
{order.status === 'pending' && order.delaySeconds
? ` · starts ${formatClock(order.submittedAt + order.delaySeconds * 1000)}`
: null}
{order.filledAt ? ` → ${formatClock(order.filledAt)}` : null}
{filled && order.fillPriceWad !== undefined
? ` · ${formatPrice(order.fillPriceWad)}`
Expand Down
87 changes: 61 additions & 26 deletions app/vibenet/demos/validity/components/OrderTicket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { Side, SubmitMode } from '../lib/types';
const TRADE_LABEL = formatTokenAmount(TRADE_VIBE);

const EXPIRIES = [5, 15, 60] as const;
const DELAYS = [0, 5, 15] as const;
const OFFSET_MAX_BPS = 500;
const OFFSET_STEP_BPS = 10;
const OFFSET_MARKS = [0, 100, 200, 300, 400, 500] as const;
Expand All @@ -23,6 +24,7 @@ type Props = {
side: Side;
offsetBps: number;
expirySeconds: number;
delaySeconds: number;
submitMode: SubmitMode;
busy: boolean;
vibeBalance: bigint | null;
Expand All @@ -33,6 +35,7 @@ type Props = {
onOffset: (bps: number) => void;
onPriceOverride: (wad: bigint | null) => void;
onExpiry: (seconds: number) => void;
onDelay: (seconds: number) => void;
onSubmitMode: (mode: SubmitMode) => void;
onSubmit: () => void;
canAfford: boolean;
Expand All @@ -48,6 +51,7 @@ export function OrderTicket({
side,
offsetBps,
expirySeconds,
delaySeconds,
submitMode,
busy,
vibeBalance,
Expand All @@ -56,6 +60,7 @@ export function OrderTicket({
onSide,
onOffset,
onExpiry,
onDelay,
onSubmitMode,
onSubmit,
onPriceOverride,
Expand Down Expand Up @@ -234,32 +239,62 @@ export function OrderTicket({
: `8130 nonceless — stack several at once. Envelope max ${MAX_NONCELESS_SECONDS}s.`}
</Text>
</div>
<div className="flex flex-col gap-2">
<Text variant="caption" tone="muted">
Expiry
</Text>
<div className="flex gap-2">
{EXPIRIES.map((seconds) => {
const blocked = submitMode === 'concurrent' && seconds > MAX_NONCELESS_SECONDS;
return (
<button
key={seconds}
type="button"
disabled={blocked}
title={blocked ? `8130 nonceless max is ${MAX_NONCELESS_SECONDS}s` : undefined}
onClick={() => onExpiry(seconds)}
className={
blocked
? 'rounded-full border border-bds-gray-10 px-3 py-1 text-[12px] text-bds-gray-40 dark:border-white/10'
: seconds === expirySeconds
? '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'
}
>
{seconds}s
</button>
);
})}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Text variant="caption" tone="muted">
Expiry
</Text>
<div className="flex flex-wrap gap-2">
{EXPIRIES.map((seconds) => {
const blocked = submitMode === 'concurrent' && seconds > MAX_NONCELESS_SECONDS;
return (
<button
key={seconds}
type="button"
disabled={blocked}
title={blocked ? `8130 nonceless max is ${MAX_NONCELESS_SECONDS}s` : undefined}
onClick={() => onExpiry(seconds)}
className={
blocked
? 'rounded-full border border-bds-gray-10 px-3 py-1 text-[12px] text-bds-gray-40 dark:border-white/10'
: seconds === expirySeconds
? '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'
}
>
{seconds}s
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-2">
<Text variant="caption" tone="muted">
Delay
</Text>
<div className="flex flex-wrap gap-2">
{DELAYS.map((seconds) => {
const blocked = seconds > 0 && seconds >= expirySeconds;
return (
<button
key={seconds}
type="button"
disabled={blocked}
title={blocked ? 'Delay must be shorter than expiry' : undefined}
onClick={() => onDelay(seconds)}
className={
blocked
? 'rounded-full border border-bds-gray-10 px-3 py-1 text-[12px] text-bds-gray-40 dark:border-white/10'
: seconds === delaySeconds
? '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'
}
>
{seconds === 0 ? 'Off' : `${seconds}s`}
</button>
);
})}
</div>
</div>
</div>
<Button onClick={onSubmit} disabled={busy || !canAfford} className="w-full">
Expand Down
29 changes: 28 additions & 1 deletion app/vibenet/demos/validity/components/PriceCandles.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';

import { CANDLE_BUCKET_MS } from '../lib/constants';
import { isUpCandle, toCandles, type PriceSample } from './PriceCandles';
import { domainBounds, isUpCandle, toCandles, type PriceSample } from './PriceCandles';

const BUCKET = CANDLE_BUCKET_MS;

Expand Down Expand Up @@ -73,3 +73,30 @@ describe('isUpCandle', () => {
expect(isUpCandle(lower, flat)).toBe(false);
});
});

describe('domainBounds', () => {
const candle = (t: number) => ({ t, o: 0.07, h: 0.072, l: 0.069, c: 0.07 });
const tape = [candle(0), candle(5_000), candle(10_000)];

it('ignores a far fill target unless the order is hovered', () => {
const fill = { id: 'a', t: 6_000, price: 0.07, target: 1, side: 'buy' as const };
const resting = domainBounds(tape, [], [fill], 0);
expect(resting?.hi).toBeLessThan(0.1);
const hovered = domainBounds(tape, [], [{ ...fill, highlighted: true }], 0);
expect(hovered?.hi).toBe(1);
});

it('ignores fills that scrolled out of the window', () => {
const fill = { id: 'a', t: 1_000, price: 1, target: 1, side: 'buy' as const, highlighted: true };
expect(domainBounds(tape, [], [fill], 5_000)?.hi).toBeLessThan(0.1);
});

it('shows near levels but zooms to far ones only on hover', () => {
const near = { id: 'n', price: 0.075, side: 'sell' as const, kind: 'resting' as const };
const far = { id: 'f', price: 1, side: 'sell' as const, kind: 'resting' as const };
const bounds = domainBounds(tape, [near, far], [], 0);
expect(bounds?.hi).toBe(0.075);
const hovered = domainBounds(tape, [near, { ...far, highlighted: true }], [], 0);
expect(hovered?.hi).toBe(1);
});
});
64 changes: 48 additions & 16 deletions app/vibenet/demos/validity/components/PriceCandles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,49 @@ export function isUpCandle(candle: Candle, prev?: Candle): boolean {
return candle.c >= prev.c;
}

/** Levels/fills beyond this fraction of the last price only zoom the chart while hovered. */
const NEAR_BAND = 0.15;

/**
* Price bounds for the y-domain. The tape always fits; order levels and fill
* targets far from the mid expand the view only while their order is hovered,
* so one outlier order doesn't keep the whole chart zoomed out.
*/
export function domainBounds(
candles: Candle[],
levels: PriceLevel[],
fills: FillMark[],
windowStart: number,
): { lo: number; hi: number } | null {
if (candles.length === 0) return null;
let lo = candles[0].l;
let hi = candles[0].h;
for (const candle of candles) {
lo = Math.min(lo, candle.l);
hi = Math.max(hi, candle.h);
}
const last = candles[candles.length - 1].c;
const nearLo = Math.min(lo, last * (1 - NEAR_BAND));
const nearHi = Math.max(hi, last * (1 + NEAR_BAND));
for (const level of levels) {
if (level.highlighted || (level.price >= nearLo && level.price <= nearHi)) {
lo = Math.min(lo, level.price);
hi = Math.max(hi, level.price);
}
}
for (const fill of fills) {
if (fill.t < windowStart) continue;
if (fill.highlighted) {
lo = Math.min(lo, fill.price, fill.target);
hi = Math.max(hi, fill.price, fill.target);
} else if (fill.price >= nearLo && fill.price <= nearHi) {
lo = Math.min(lo, fill.price);
hi = Math.max(hi, fill.price);
}
}
return { lo, hi };
}

function formatAxisPrice(price: number): string {
if (price >= 1) return `$${price.toFixed(2)}`;
if (price >= 0.1) return `$${price.toFixed(3)}`;
Expand Down Expand Up @@ -148,20 +191,11 @@ export function PriceCandles({ samples, levels = [], fills = [] }: Props) {

const layout = useMemo(() => {
if (candles.length === 0 || innerW <= 0 || innerH <= 0) return null;
let lo = candles[0].l;
let hi = candles[0].h;
for (const candle of candles) {
lo = Math.min(lo, candle.l);
hi = Math.max(hi, candle.h);
}
for (const level of visibleLevels) {
lo = Math.min(lo, level.price);
hi = Math.max(hi, level.price);
}
for (const fill of visibleFills) {
lo = Math.min(lo, fill.price, fill.target);
hi = Math.max(hi, fill.price, fill.target);
}
const t1 = candles[candles.length - 1].t + BUCKET_MS;
const t0 = t1 - WINDOW_MS;
const bounds = domainBounds(candles, visibleLevels, visibleFills, t0);
if (!bounds) return null;
let { lo, hi } = bounds;
const last = candles[candles.length - 1].c;
const minSpan = Math.max(last * 0.06, 0.002);
if (hi - lo < minSpan) {
Expand All @@ -172,8 +206,6 @@ export function PriceCandles({ samples, levels = [], fills = [] }: Props) {
const pad = (hi - lo) * 0.08;
const yMin = Math.max(lo - pad, 0);
const yMax = hi + pad;
const t1 = candles[candles.length - 1].t + BUCKET_MS;
const t0 = t1 - WINDOW_MS;
const x = scaleLinear().domain([t0, t1]).range([0, innerW]);
const y = scaleLinear().domain([yMin, yMax]).range([innerH, 0]);
const yTicks = y.ticks(6);
Expand Down
10 changes: 9 additions & 1 deletion app/vibenet/demos/validity/lib/annotate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';

import { annotatedValidity, reviewClauses } from './annotate';
import { WAD } from './constants';
import { blockExpiryPredicate, priceValidity } from './predicates';
import { blockDelayPredicate, blockExpiryPredicate, priceValidity } from './predicates';

const PAIR = '0x1111111111111111111111111111111111111111';

Expand Down Expand Up @@ -41,6 +41,14 @@ describe('annotatedValidity', () => {
expect(notes).toContain('L2 block 18422105');
expect(notes.some((note) => note?.includes('at most'))).toBe(true);
});

it('decodes a block-number delay as a not-before bound', () => {
const rows = annotatedValidity([blockDelayPredicate(18_422_105n)]);
const notes = rows.map((row) => row.note).filter(Boolean);
expect(notes).toContain('Block-number delay');
expect(notes).toContain('L2 block 18422105');
expect(notes.some((note) => note?.includes('once') && note.includes('at least'))).toBe(true);
});
});

describe('reviewClauses', () => {
Expand Down
Loading
Loading