Skip to content
Open
40 changes: 29 additions & 11 deletions app/vibenet/demos/validity/ValidityDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ 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 +125,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 @@ -576,9 +578,14 @@ function ValidityDemoInner() {
submitMode === 'concurrent'
? clampNoncelessExpiry(expirySeconds)
: Math.min(MAX_EXPIRY_SECONDS, expirySeconds);
const maxBlock = maxBlockForExpiry(blockNumber, seconds);
return [...draft.predicates, blockExpiryPredicate(maxBlock)];
}, [blockNumber, draft, expirySeconds, submitMode]);
const cap = submitMode === 'concurrent' ? MAX_NONCELESS_SECONDS : MAX_EXPIRY_SECONDS;
const delay = Math.max(0, Math.min(delaySeconds, cap - seconds));
const delayBlock = delay > 0 ? minBlockForDelay(blockNumber, delay) : blockNumber;
const maxBlock = maxBlockForExpiry(delayBlock, seconds);
const predicates = [...draft.predicates, blockExpiryPredicate(maxBlock)];
if (delay > 0) predicates.push(blockDelayPredicate(delayBlock));
return predicates;
}, [blockNumber, delaySeconds, draft, expirySeconds, submitMode]);

const chartLevels = useMemo((): PriceLevel[] => {
const levels: PriceLevel[] = [];
Expand Down Expand Up @@ -738,8 +745,12 @@ function ValidityDemoInner() {
? clampNoncelessExpiry(expirySeconds)
: Math.min(MAX_EXPIRY_SECONDS, expirySeconds);
const block = blockNumber ?? (await publicClient.getBlockNumber({ cacheTime: 0 }));
const maxBlock = maxBlockForExpiry(block, seconds);
const cap = submitMode === 'concurrent' ? MAX_NONCELESS_SECONDS : MAX_EXPIRY_SECONDS;
const delay = Math.max(0, Math.min(delaySeconds, cap - seconds));
const minBlock = delay > 0 ? minBlockForDelay(block, delay) : undefined;
const maxBlock = maxBlockForExpiry(minBlock ?? block, seconds);
const validity = [...draft.predicates, blockExpiryPredicate(maxBlock)];
if (minBlock !== undefined) validity.push(blockDelayPredicate(minBlock));
const fromHead = headFeesRef.current;
const estimated =
fromHead ??
Expand Down Expand Up @@ -819,8 +830,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 All @@ -844,7 +857,7 @@ function ValidityDemoInner() {
engine.pushActivity({
kind: 'transact',
title: `Validity ${side} submitted`,
detail: submitMode === 'concurrent' ? '8130 concurrent' : '8130 replace',
detail: submitMode === 'concurrent' ? '8130 concurrent' : 'sequential replace',
account: acct.address,
txHash: hash,
network: engine.chain.name,
Expand Down Expand Up @@ -955,6 +968,7 @@ function ValidityDemoInner() {
side={side}
offsetBps={offsetBps}
expirySeconds={expirySeconds}
delaySeconds={delaySeconds}
submitMode={submitMode}
busy={busy}
vibeBalance={vibeBalance}
Expand All @@ -971,11 +985,13 @@ function ValidityDemoInner() {
}}
onPriceOverride={setPriceOverrideWad}
onExpiry={setExpirySeconds}
onDelay={setDelaySeconds}
onSubmitMode={(mode) => {
setSubmitMode(mode);
if (mode === 'concurrent' && expirySeconds > MAX_NONCELESS_SECONDS) {
setExpirySeconds(15);
}
if (mode !== 'concurrent') return;
const nextExpiry = expirySeconds > MAX_NONCELESS_SECONDS ? 15 : expirySeconds;
if (nextExpiry !== expirySeconds) setExpirySeconds(nextExpiry);
if (delaySeconds + nextExpiry > MAX_NONCELESS_SECONDS) setDelaySeconds(0);
}}
onSubmit={() => {
setError(null);
Expand Down Expand Up @@ -1030,8 +1046,10 @@ function ValidityDemoInner() {
{draft.side === 'buy' ? '≤' : '≥'} ${formatPrice(draft.priceWad)}
</Text>
<Text variant="footnote" tone="muted" className="mt-1">
{submitMode === 'concurrent' ? '8130 concurrent' : 'Replace resting nonce'} · expires in{' '}
{expirySeconds}s
{submitMode === 'concurrent' ? '8130 concurrent' : 'Sequential replace'} ·{' '}
{delaySeconds > 0
? `starts in ~${delaySeconds}s, expires ${expirySeconds}s after`
: `expires in ${expirySeconds}s`}
</Text>
</div>
<ul className="flex flex-col gap-2">
Expand Down
7 changes: 5 additions & 2 deletions app/vibenet/demos/validity/components/OrderList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) {
<div className="flex flex-col gap-2">
<Text variant="title3">Submitted</Text>
<Text variant="footnote" tone="muted">
Conditional swaps land here. Concurrent 8130 orders stack; replace
Conditional swaps land here. Concurrent orders stack; sequential
mode bumps the last nonce.
</Text>
</div>
Expand Down Expand Up @@ -128,7 +128,10 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) {
</div>
<Text variant="footnote" tone="muted" className="tabular-nums">
{formatClock(order.submittedAt)}
{order.submitMode === 'concurrent' ? ' · 8130' : order.submitMode === 'replace' ? ' · replace' : null}
{order.submitMode === 'concurrent' ? ' · concurrent' : order.submitMode === 'replace' ? ' · sequential' : 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
120 changes: 87 additions & 33 deletions app/vibenet/demos/validity/components/OrderTicket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
import { useState } from 'react';

import { Button } from '../../../../components/ui/Button';
import { InfoTooltip } from '../../../../components/ui/InfoTooltip';
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 { MAX_EXPIRY_SECONDS, MAX_NONCELESS_SECONDS, TRADE_VIBE } from '../lib/constants';
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 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 +25,7 @@ type Props = {
side: Side;
offsetBps: number;
expirySeconds: number;
delaySeconds: number;
submitMode: SubmitMode;
busy: boolean;
vibeBalance: bigint | null;
Expand All @@ -33,6 +36,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 +52,7 @@ export function OrderTicket({
side,
offsetBps,
expirySeconds,
delaySeconds,
submitMode,
busy,
vibeBalance,
Expand All @@ -56,6 +61,7 @@ export function OrderTicket({
onSide,
onOffset,
onExpiry,
onDelay,
onSubmitMode,
onSubmit,
onPriceOverride,
Expand Down Expand Up @@ -201,9 +207,15 @@ export function OrderTicket({
</Text>
</div>
<div className="flex flex-col gap-2">
<Text variant="caption" tone="muted">
Mempool
</Text>
<div className="flex items-center gap-1.5">
<Text variant="caption" tone="muted">
Mempool
</Text>
<InfoTooltip label="About mempool mode">
Sequential resubmits on the same nonce, so a new order replaces the resting one. Concurrent
uses nonceless (EIP-8130) transactions so several orders can be pending at once.
</InfoTooltip>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
Expand All @@ -214,7 +226,7 @@ export function OrderTicket({
: 'rounded-xl border border-bds-gray-10 px-3 py-2 text-[13px] dark:border-white/10'
}
>
Replace
Sequential
</button>
<button
type="button"
Expand All @@ -230,36 +242,78 @@ export function OrderTicket({
</div>
<Text variant="footnote" tone="muted">
{submitMode === 'replace'
? 'Same nonce, fee bump. The new swap takes the resting slot.'
: `8130 nonceless — stack several at once. Envelope max ${MAX_NONCELESS_SECONDS}s.`}
? 'One transaction at a time via sequential nonces.'
: `Submit multiple nonceless transactions simultaneously. Delay + expiry ≤ ${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">
<div className="flex items-center gap-1.5">
<Text variant="caption" tone="muted">
Delay
</Text>
<InfoTooltip label="About delay">
Holds the swap until this much time has passed.
</InfoTooltip>
</div>
<div className="flex flex-wrap gap-2">
{DELAYS.map((seconds) => {
const cap = submitMode === 'concurrent' ? MAX_NONCELESS_SECONDS : MAX_EXPIRY_SECONDS;
const blocked = seconds > 0 && seconds + expirySeconds > cap;
return (
<button
key={seconds}
type="button"
disabled={blocked}
title={blocked ? `Delay plus expiry cannot exceed ${cap}s` : 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 className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<Text variant="caption" tone="muted">
Expiry
</Text>
<InfoTooltip label="About expiry">
Keeps the swap eligible for this long once it starts.
</InfoTooltip>
</div>
<div className="flex flex-wrap gap-2">
{EXPIRIES.map((seconds) => {
const cap = submitMode === 'concurrent' ? MAX_NONCELESS_SECONDS : MAX_EXPIRY_SECONDS;
const blocked = delaySeconds + seconds > cap;
return (
<button
key={seconds}
type="button"
disabled={blocked}
title={blocked ? `Delay plus expiry cannot exceed ${cap}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>
<Button onClick={onSubmit} disabled={busy || !canAfford} className="w-full">
Expand Down
5 changes: 2 additions & 3 deletions app/vibenet/demos/validity/components/ValidityJson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,9 @@ export function ValidityJson({
<button
type="button"
onClick={handleCopy}
aria-label="Copy predicate JSON"
className="group inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 font-mono text-[12px] text-bds-gray-50 transition-colors hover:bg-bds-gray-5 hover:text-foreground dark:hover:bg-white/5"
aria-label={copied ? 'Copied predicate JSON' : 'Copy predicate JSON'}
className="group inline-flex items-center rounded-md p-1.5 text-bds-gray-50 transition-colors hover:bg-bds-gray-5 hover:text-foreground dark:hover:bg-white/5"
>
{copied ? 'copied' : 'copy json'}
<MorphIcon
icon={copied ? CHECK_MORPH_ICON : COPY_SQUARES_MORPH_ICON}
size={16}
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, storagePredicate } from './predicates';
import { blockDelayPredicate, blockExpiryPredicate, priceValidity, storagePredicate } from './predicates';

const PAIR = '0x1111111111111111111111111111111111111111';

Expand Down Expand Up @@ -42,6 +42,14 @@ describe('annotatedValidity', () => {
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);
});

it('uses neutral labels for a full-mask non-AMM storage slot', () => {
const predicate = storagePredicate(PAIR, 123n, (1n << 256n) - 1n, '=', 1n);
const notes = annotatedValidity([predicate]).map((row) => row.note).filter(Boolean);
Expand Down
5 changes: 3 additions & 2 deletions app/vibenet/demos/validity/lib/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ function storageNotes(predicate: StoragePredicate, vibeToken0: boolean): Record<
function notesFor(predicate: ValidityPredicate, vibeToken0: boolean): Record<string, string> {
if (predicate.type === 'storage') return storageNotes(predicate, vibeToken0);
const block = BigInt(predicate.params.value);
const lowerBound = predicate.params.op === '>=' || predicate.params.op === '>';
return {
type: 'Block-number expiry',
op: `Include only while the head is ${comparePhrase(predicate.params.op)}`,
type: lowerBound ? 'Block-number delay' : 'Block-number expiry',
op: `Include only ${lowerBound ? 'once' : 'while'} the head is ${comparePhrase(predicate.params.op)}`,
value: `L2 block ${block.toString()}`,
};
}
Expand Down
Loading
Loading