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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ This app uses Vercel Web Analytics. Two things must stay in place:
| `trackExplorerChainSelect(chain)` | `app/internal-explorer/components/ChainToggle.tsx` — chain toggle |
| `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block |
| `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace |
| `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison and condition agent lifecycle |

Add a helper (and a row here) for a new key journey; remove the helper if you
remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`.
Expand Down
7 changes: 7 additions & 0 deletions app/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@ export function trackValidityOrder(
): void {
track('validity_order', { side, status });
}

export function trackValidityRace(
attempt: 'validity' | 'manual' | 'agent',
status: 'started' | 'submitted' | 'success' | 'reverted' | 'expired' | 'stopped' | 'error',
): void {
track('validity_race', { attempt, status });
}
9 changes: 5 additions & 4 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Toaster } from 'sonner';
import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation';
import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme';
import { getChangeBySlug } from '../upgrades/data/changes';
import { demoLabel } from '../vibenet/demos/catalogue';
import { demoBreadcrumb } from '../vibenet/demos/catalogue';
import { getUpgradeById } from '../upgrades/data/upgrades';

import { trackNavClick } from '../analytics/events';
Expand Down Expand Up @@ -869,15 +869,16 @@ export function AppShell({ children }: PropsWithChildren) {
let childLabel = title;
let middle: { label: string; href: string } | undefined;
const explorerDetailMatch = pathname.match(/^\/vibenet\/explorer\/(tx|block|address)\/(.+)$/);
const demoMatch = pathname.match(/^\/vibenet\/demos\/(.+)$/);
const demo = demoBreadcrumb(pathname);
if (explorerDetailMatch) {
middle = { label: 'Explorer', href: '/vibenet/explorer' };
const raw = explorerDetailMatch[2];
childLabel = raw.startsWith('0x') && raw.length > 12
? `${raw.slice(0, 6)}…${raw.slice(-4)}`
: raw;
} else if (demoMatch) {
childLabel = demoLabel(demoMatch[1].split('/')[0]);
} else if (demo) {
childLabel = demo.childLabel;
middle = demo.middle;
}
return (
<Breadcrumb
Expand Down
6 changes: 6 additions & 0 deletions app/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,10 @@ describe('titleForPath', () => {
expect(titleForPath('/vibenet/faucet')).toBe('Faucet');
expect(titleForPath('/vibenet')).toBe('Overview');
});

it('uses catalogue labels for grouped and nested demos', () => {
expect(titleForPath('/vibenet/demos/validity')).toBe('Validity Transactions');
expect(titleForPath('/vibenet/demos/validity/conditional-swaps')).toBe('Conditional Swaps');
expect(titleForPath('/vibenet/demos/validity/race-the-agent')).toBe('Race the Agent');
});
});
3 changes: 3 additions & 0 deletions app/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BENCHMARK_ENABLED } from './benchmark/flag';
import { EXPLORER_ENABLED, EXPLORER_LABEL } from './internal-explorer/flag';
import { demoBreadcrumb } from './vibenet/demos/catalogue';

export type NavIcon = 'home' | 'snapshots' | 'upgrades' | 'changelog' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'internal-explorer' | 'benchmark' | 'runs' | 'loadtest';

Expand Down Expand Up @@ -66,6 +67,8 @@ export function pathMatches(href: string, pathname: string, exact = false): bool

export function titleForPath(pathname: string): string {
if (pathname === '/') return 'Home';
const demo = demoBreadcrumb(pathname);
if (demo) return demo.childLabel;
for (const item of NAV_ITEMS) {
if (item.children) {
for (const child of item.children) {
Expand Down
13 changes: 13 additions & 0 deletions app/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';

import sitemap from './sitemap';

describe('sitemap', () => {
it('indexes the Validity Transactions group and both nested demos', () => {
const urls = sitemap().map((entry) => entry.url);

expect(urls).toContain('https://chain.base.org/vibenet/demos/validity');
expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/conditional-swaps');
expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/race-the-agent');
});
});
2 changes: 2 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: '/vibenet/demos/account', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/b20', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/validity', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/validity/conditional-swaps', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/validity/race-the-agent', priority: 0.5, changeFrequency: 'weekly' },
];

return routes.map(({ path, priority, changeFrequency }) => ({
Expand Down
14 changes: 14 additions & 0 deletions app/vibenet/demos/account/library/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';

import { aaReceiptSucceeded } from './receipt';

describe('aaReceiptSucceeded', () => {
it('requires both the outer transaction and every AA phase to succeed', () => {
expect(aaReceiptSucceeded({ status: 'success', eip8130: { phaseStatuses: ['0x1'] } })).toBe(true);
expect(aaReceiptSucceeded({ status: 'success' })).toBe(true);
expect(aaReceiptSucceeded({ status: '0x1', eip8130: { phaseStatuses: ['0x1', '0x1'] } })).toBe(true);
expect(aaReceiptSucceeded({ status: 'reverted', eip8130: { phaseStatuses: ['0x1'] } })).toBe(false);
expect(aaReceiptSucceeded({ status: '0x0', eip8130: { phaseStatuses: ['0x1'] } })).toBe(false);
expect(aaReceiptSucceeded({ status: 'success', eip8130: { phaseStatuses: ['0x1', '0x0'] } })).toBe(false);
});
});
12 changes: 12 additions & 0 deletions app/vibenet/demos/account/library/receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { allPhasesSucceeded, type Hex } from '@aa';

export type AaReceiptLike = {
status?: 'success' | 'reverted' | Hex;
eip8130?: { phaseStatuses?: readonly Hex[] };
};

/** An EIP-8130 transaction succeeds only when its outer tx and every call phase succeed. */
export function aaReceiptSucceeded(receipt: AaReceiptLike): boolean {
if (receipt.status === 'reverted' || receipt.status === '0x0') return false;
return allPhasesSucceeded(receipt.eip8130 ?? {});
}
25 changes: 16 additions & 9 deletions app/vibenet/demos/account/useAccountEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { vibenetApi } from '../../library/client';
import { ACCOUNT_RPC_URL } from '../../library/config';
import { type DemoChain, deploymentFromContracts, estimateTxGas, getDemoChain } from './library/chains';
import { buildPhases, type CallRow, newCallRow, safeGasLimit, valueBearingCallCount } from './library/calls';
import { aaReceiptSucceeded } from './library/receipt';
import {
type AppPolicy,
type AppSessionKey,
Expand Down Expand Up @@ -803,10 +804,7 @@ function useAccountEngineCore() {
const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise<Hex> => {
try {
const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout });
if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`);
const phases = receipt.eip8130?.phaseStatuses ?? [];
const failedPhase = phases.findIndex((s: Hex) => s === '0x0');
if (failedPhase !== -1) throw new Error(`Phase ${failedPhase} reverted (tx ${txHash}).`);
if (!aaReceiptSucceeded(receipt)) throw new Error(`Transaction reverted onchain (${txHash}).`);
} catch (err) {
if ((err as Error)?.message?.includes('timed out')) throw new TxPendingError(txHash);
throw err;
Expand Down Expand Up @@ -1174,16 +1172,14 @@ function useAccountEngineCore() {
return signer;
};

const sendAccountCalls = async ({
const signAccountCalls = async ({
account,
calls,
wait = true,
seqOpt,
metadata,
}: {
account: StoredAccount;
calls: { to: Address; data: Hex; value?: string }[];
wait?: boolean;
seqOpt?: {
nonceSequence?: bigint;
nonceKey?: bigint;
Expand All @@ -1193,10 +1189,10 @@ function useAccountEngineCore() {
maxPriorityFeePerGas?: bigint;
};
metadata?: string;
}): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => {
}): Promise<{ serialized: Hex; nextSeq: number }> => {
if (!calls.length) throw new Error('No calls to send.');
const signer = signerForAccount(account);
const { serialized, nextSeq } = await signComposed(
return signComposed(
account,
signer,
calls.map((call) => newCallRow({ to: call.to, data: call.data, value: call.value ?? '0' })),
Expand All @@ -1207,6 +1203,16 @@ function useAccountEngineCore() {
undefined,
seqOpt,
);
};

const sendAccountCalls = async ({
wait = true,
...signArgs
}: Parameters<typeof signAccountCalls>[0] & {
wait?: boolean;
}): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => {
const { account } = signArgs;
const { serialized, nextSeq } = await signAccountCalls(signArgs);
if (wait) {
const hash = await broadcast8130(serialized);
applyLandedBundle(account, nextSeq, []);
Expand Down Expand Up @@ -2079,6 +2085,7 @@ function useAccountEngineCore() {
// Signing engine (also used by each surface's own Transact flow)
broadcast8130,
signComposed,
signAccountCalls,
sendActiveCalls,
sendAccountCalls,
sendActiveCallsBatches,
Expand Down
62 changes: 54 additions & 8 deletions app/vibenet/demos/catalogue.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';

import { DEMOS, demoLabel, listedDemos } from './catalogue';
import { DEMOS, demoBreadcrumb, demoForPath, demoLabel, listedDemos } from './catalogue';

describe('demoLabel', () => {
it('prefers shortTitle for the validity demo', () => {
expect(demoLabel('validity')).toBe('Validity');
it('uses the group title for the validity demo', () => {
expect(demoLabel('validity')).toBe('Validity Transactions');
});

it('prefers shortTitle over title when both are set', () => {
Expand All @@ -31,19 +31,65 @@ describe('demoLabel', () => {
});

describe('DEMOS', () => {
const allDemos = DEMOS.flatMap((demo) => [demo, ...(demo.children ?? [])]);

it('gives every entry a /vibenet/demos/ href, so demoLabel can resolve it', () => {
for (const demo of DEMOS) {
for (const demo of allDemos) {
expect(demo.href.startsWith('/vibenet/demos/')).toBe(true);
}
});

it('has no duplicate hrefs', () => {
const hrefs = DEMOS.map((d) => d.href);
const hrefs = allDemos.map((demo) => demo.href);
expect(new Set(hrefs).size).toBe(hrefs.length);
});

it('keeps Validity off the Vibenet demos grid while the route still resolves', () => {
expect(listedDemos().some((demo) => demo.href === '/vibenet/demos/validity')).toBe(false);
expect(demoLabel('validity')).toBe('Validity');
it('lists Validity Transactions as a top-level group', () => {
const validity = listedDemos().find((demo) => demo.href === '/vibenet/demos/validity');
expect(validity?.title).toBe('Validity Transactions');
expect(validity?.children?.map((demo) => demo.title)).toEqual(['Conditional Swaps', 'Race the Agent']);
});
});

describe('demoForPath', () => {
it('finds nested demos without flattening them onto the Vibenet grid', () => {
expect(demoForPath('/vibenet/demos/validity/conditional-swaps')?.title).toBe('Conditional Swaps');
expect(demoForPath('/vibenet/demos/validity/race-the-agent')?.title).toBe('Race the Agent');
expect(listedDemos().some((demo) => demo.title === 'Conditional Swaps')).toBe(false);
});
});

describe('demoBreadcrumb', () => {
it('resolves a top-level group breadcrumb', () => {
expect(demoBreadcrumb('/vibenet/demos/validity')).toEqual({
childLabel: 'Validity Transactions',
});
});

it('resolves a nested demo breadcrumb through its group', () => {
expect(demoBreadcrumb('/vibenet/demos/validity/conditional-swaps')).toEqual({
middle: {
label: 'Validity Transactions',
href: '/vibenet/demos/validity',
},
childLabel: 'Conditional Swaps',
});
});

it('resolves the second nested validity demo', () => {
expect(demoBreadcrumb('/vibenet/demos/validity/race-the-agent')).toEqual({
middle: {
label: 'Validity Transactions',
href: '/vibenet/demos/validity',
},
childLabel: 'Race the Agent',
});
});

it('falls back to readable labels for unregistered nested routes', () => {
expect(demoBreadcrumb('/vibenet/demos/trading/stop-loss')).toEqual({
middle: { label: 'Trading', href: '/vibenet/demos/trading' },
childLabel: 'Stop Loss',
});
});
});
80 changes: 73 additions & 7 deletions app/vibenet/demos/catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type DemoEntry = {
available: boolean;
/** When false, the route stays live but is omitted from the Vibenet demos grid. */
listed?: boolean;
/** Nested demos shown from a group landing page. */
children?: DemoEntry[];
};

/** Demos shown on the Vibenet index. Unlisted entries stay reachable by URL. */
Expand Down Expand Up @@ -55,17 +57,42 @@ export const DEMOS: DemoEntry[] = [
},
{
href: '/vibenet/demos/validity',
title: 'Validity',
shortTitle: 'Validity',
title: 'Validity Transactions',
shortTitle: 'Validity Transactions',
summary:
'Attach conditions to a transaction so the sequencer includes it only while they hold. A simulated pool shows a swap waiting on price, then landing or expiring.',
'Explore transactions that remain pending until their onchain conditions are satisfied, then execute without a keeper or a custom settlement contract.',
points: [
'Add storage and block-number conditions to an ordinary swap',
'A simulated AMM makes those conditions visible on a moving mid',
'Stack several 8130 conditions at once, or replace the resting one',
'Attach storage and block-number conditions to signed transactions',
'Let the sequencer evaluate validity before inclusion',
'Build intent-like flows from ordinary account transactions',
],
available: true,
listed: false,
children: [
{
href: '/vibenet/demos/validity/conditional-swaps',
title: 'Conditional Swaps',
summary:
'Place a swap that waits for a target price, then lands or expires as a shared simulated market moves through its validity window.',
points: [
'Set a buy or sell price against a live VIBE/USDV pool',
'Inspect the EIP-8130 predicates attached to the swap',
'Watch pending orders fill, expire, or get replaced',
],
available: true,
},
{
href: '/vibenet/demos/validity/race-the-agent',
title: 'Race the Agent',
summary:
'Submit a withdrawal before it is valid, then race a randomized onchain condition with an ordinary transaction sent by hand.',
points: [
'Compare the same permissionless withdrawal call two ways',
'Watch a dedicated agent subaccount flip shared chain state',
'Judge the result by inclusion blocks, not browser timing',
],
available: true,
},
],
},
];

Expand All @@ -87,3 +114,42 @@ export function demoLabel(slug: string): string {
const demo = DEMOS.find((entry) => entry.href === `/vibenet/demos/${slug}`);
return demo?.shortTitle ?? demo?.title ?? prettifySlug(slug);
}

function entryLabel(entry: DemoEntry | undefined, fallbackSlug: string): string {
return entry?.shortTitle ?? entry?.title ?? prettifySlug(fallbackSlug);
}

/** Finds a registered top-level or nested demo by its full route. */
export function demoForPath(pathname: string): DemoEntry | undefined {
for (const demo of DEMOS) {
if (demo.href === pathname) return demo;
const child = demo.children?.find((entry) => entry.href === pathname);
if (child) return child;
}
return undefined;
}

export type DemoBreadcrumb = {
childLabel: string;
middle?: { label: string; href: string };
};

/** Resolves catalogue-backed labels for any route below `/vibenet/demos`. */
export function demoBreadcrumb(pathname: string): DemoBreadcrumb | null {
const prefix = '/vibenet/demos/';
if (!pathname.startsWith(prefix)) return null;

const segments = pathname.slice(prefix.length).split('/').filter(Boolean);
if (segments.length === 0) return null;

const parentHref = `${prefix}${segments[0]}`;
const parent = DEMOS.find((entry) => entry.href === parentHref);
const parentLabel = entryLabel(parent, segments[0]);
if (segments.length === 1) return { childLabel: parentLabel };

const child = parent?.children?.find((entry) => entry.href === pathname);
return {
middle: { label: parentLabel, href: parentHref },
childLabel: entryLabel(child, segments.at(-1) ?? ''),
};
}
4 changes: 2 additions & 2 deletions app/vibenet/demos/validity/ValidityDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,8 @@ function ValidityDemoInner() {
<div className="flex min-w-0 flex-1 flex-col gap-6 pb-16 text-foreground">
<DemoHeader
compact
eyebrow="Validity · experimental"
title="Send now. Land later."
eyebrow="Validity Transactions · experimental"
title="Conditional Swaps"
description="Place conditional swaps against a shared constant-product AMM. Each order carries predicates the sequencer checks before inclusion — it lands while they hold, or expires."
/>

Expand Down
Loading
Loading