diff --git a/AGENTS.md b/AGENTS.md index 19daf88..d76aca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ This app uses Vercel Web Analytics. Two things must stay in place: | `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 attempts | + | `trackReflexRun(status)` | `app/vibenet/demos/reflex/ReflexGame.tsx` — reaction-game starts and outcomes | 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`. diff --git a/app/analytics/events.ts b/app/analytics/events.ts index b65d81d..ccbd7fc 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -91,3 +91,9 @@ export function trackValidityRace( ): void { track('validity_race', { attempt, status }); } + +// --- 200ms Reflex demo --- + +export function trackReflexRun(status: 'started' | 'human' | 'block' | 'tie' | 'error'): void { + track('reflex_run', { status }); +} diff --git a/app/vibenet/demos/_shared/InclusionBadge.tsx b/app/vibenet/demos/_shared/InclusionBadge.tsx index d9bf28e..f937cb8 100644 --- a/app/vibenet/demos/_shared/InclusionBadge.tsx +++ b/app/vibenet/demos/_shared/InclusionBadge.tsx @@ -5,22 +5,35 @@ // timestamp minus that of the newest block the page had seen at broadcast. // Vibenet runs Cobalt today; on chains without it the slot is omitted. +import Link from 'next/link'; + import { cn } from '../../../components/ui/cn'; import { Text } from '../../../components/ui/Text'; import { blocksAfterSendLabel, type Inclusion, latencyLabel, slotLabel } from './inclusion'; +/** Unlisted reaction game; this footnote is the only way in. */ +const REFLEX_PATH = '/vibenet/demos/reflex'; + /** - * The line under the badge in a transaction result: the claim, in words. Only - * for blocks that carry Cobalt's millisecond timestamp, so it never shows on a - * chain without 200 ms blocks. + * The line under the badge in a transaction result: the claim, in words, plus + * a footnote linking the Reflex easter egg. Only for blocks that carry Cobalt's + * millisecond timestamp, so it never shows on a chain without 200 ms blocks. */ export function InclusionTagline({ inclusion }: { inclusion: Inclusion }) { if (inclusion.blockTimestampMs === null) return null; const blocks = blocksAfterSendLabel(inclusion); return ( - - {blocks ? `Sealed ${blocks} after broadcast. ` : ''}Brought to you by 200 ms blocks on Base. - + <> + + {blocks ? `Sealed ${blocks} after broadcast. ` : ''}Brought to you by 200 ms blocks on Base. + + + Think you're faster?{' '} + + Race a block + + + ); } diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index 885a7bd..b5af250 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -287,8 +287,9 @@ function useAccountEngineCore() { return vibenetApi.account.balances(acct.address, 'vibenet').catch(() => null); }; - const requestFaucet = async () => { - if (!acct) return; + /** Drip ETH + USDV to the active account and wait for the ETH to land. Resolves true once credited. */ + const requestFaucet = async (): Promise => { + if (!acct) return false; setFaucetBusy('eth+usdv'); try { // Capture the pre-drip balance as the baseline for the "did it credit?" poll. @@ -310,8 +311,10 @@ function useAccountEngineCore() { } else { toast.error("Top up didn't go through — Vibenet may be down for maintenance. Please try again shortly."); } + return credited; } catch { toast.error('Top up failed'); + return false; } finally { setFaucetBusy(null); } @@ -397,7 +400,7 @@ function useAccountEngineCore() { // Wait for a broadcast tx to be included and check that it — and every 8130 // phase in it — succeeded. Throws TxPendingError if it is still not included // when the timeout runs out, a plain Error if anything reverted. - const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { + const awaitInclusion = async (txHash: Hex, timeout = 30_000, onReceipt?: () => void): Promise => { const client = makeRpcClient(); const w = ensureWatcher(); // The receipt arrives on the socket the moment its block is sealed; over @@ -409,6 +412,7 @@ function useAccountEngineCore() { if (err instanceof ReceiptTimeoutError) throw new TxPendingError(txHash); throw err; } + onReceipt?.(); // Status and 8130 phase results come from the account RPC, whose replica // can trail the socket's node by a block: a few short retries, then the // pushed receipt itself. @@ -453,8 +457,15 @@ function useAccountEngineCore() { // Broadcast a signed 8130 tx and wait for inclusion. Throws TxPendingError on // timeout (submitted but unconfirmed), a plain Error if any phase reverts. - const broadcast8130 = async (signedTx: Hex, onStatus?: (s: 'submitting' | 'confirming') => void): Promise => { + const broadcast8130 = async ( + signedTx: Hex, + onStatus?: (s: 'submitting' | 'confirming') => void, + onBroadcast?: (hash: Hex) => void, + beforeBroadcast?: () => void | Promise, + onReceipt?: () => void, + ): Promise => { const client = makeRpcClient(); + await beforeBroadcast?.(); onStatus?.('submitting'); // Read the anchor before the send leaves. const anchor = latestAnchor(); @@ -463,8 +474,9 @@ function useAccountEngineCore() { params: [signedTx], })) as Hex; anchorSend(txHash, anchor); + onBroadcast?.(txHash); onStatus?.('confirming'); - return awaitInclusion(txHash); + return awaitInclusion(txHash, 30_000, onReceipt); }; // Wait for a tx a hosted payer broadcast (`payer_sendTransaction`), measured @@ -592,11 +604,20 @@ function useAccountEngineCore() { calls, tokenGas, metadata, + onBroadcast, + beforeBroadcast, + onReceipt, }: { calls: { to: Address; data: Hex; value?: string }[]; tokenGas?: { token: Address; decimals: number; payer: Signer; fee: bigint }; /** Optional top-level signed app data attached to the transaction. */ metadata?: string; + /** Called as soon as the RPC accepts the signed transaction, before inclusion. */ + onBroadcast?: (hash: Hex) => void; + /** Runs after signing and immediately before the transaction is sent to the RPC. */ + beforeBroadcast?: () => void | Promise; + /** Called when the receipt first arrives, before follow-up receipt parsing. */ + onReceipt?: () => void; }): Promise<{ hash: Hex; serialized: Hex; mode: 'self' | 'token'; inclusion?: Inclusion }> => { if (!acct) throw new Error('Select an account before you continue.'); if (!calls.length) throw new Error('No calls to send.'); @@ -611,7 +632,7 @@ function useAccountEngineCore() { } : undefined, }); - const hash = await broadcast8130(serialized); + const hash = await broadcast8130(serialized, undefined, onBroadcast, beforeBroadcast, onReceipt); return { hash, serialized, mode: tokenGas ? 'token' : 'self', inclusion: inclusionFor(hash) }; }; @@ -643,6 +664,7 @@ function useAccountEngineCore() { // Faucet faucetBusy, requestFaucet, + refreshVibenetBalances, // Key + account-building primitives (used by CreateAccountModal) usedSignerIds, diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 70d5427..3652336 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -110,6 +110,20 @@ export const DEMOS: DemoEntry[] = [ available: true, listed: false, }, + { + // Easter egg: linked only from the inclusion line in transaction popups. + href: '/vibenet/demos/reflex', + title: 'Reflex', + summary: + 'Send a real transaction from your browser account, then race to hit the target before Vibenet seals it in a 200 ms block.', + points: [ + 'Starts on transaction broadcast', + 'Compares your reaction with real inclusion', + 'Uses your existing Vibenet account', + ], + available: true, + listed: false, + }, ]; /** `smart-wallet` -> `Smart Wallet`. Fallback for a route with no catalogue entry. */ diff --git a/app/vibenet/demos/reflex/ReflexGame.tsx b/app/vibenet/demos/reflex/ReflexGame.tsx new file mode 100644 index 0000000..cf18516 --- /dev/null +++ b/app/vibenet/demos/reflex/ReflexGame.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useEffect, useRef, useState, type MouseEvent } from 'react'; +import type { Hex } from 'viem'; + +import { trackReflexRun } from '../../../analytics/events'; +import { Button } from '../../../components/ui/Button'; +import { Card } from '../../../components/ui/Card'; +import { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; +import { VIBENET_EXPLORER_PATH } from '../../library/config'; +import { AccountDemoShell } from '../_components/AccountDemoShell'; +import { ViewTransactionButton } from '../_shared/ViewTransactionButton'; +import type { Inclusion } from '../_shared/inclusion'; +import { AccountEngineProvider, useAccountEngine } from '../account/useAccountEngine'; +import { MIN_RACE_BALANCE_WEI, outcomeCopy, raceErrorMessage, reflexOutcome, type ReflexOutcome } from './lib/game'; + +const TARGETS = Array.from({ length: 9 }, (_, index) => index); + +type Phase = 'idle' | 'preparing' | 'countdown' | 'racing' | 'result' | 'error'; + +type Result = { + outcome: ReflexOutcome; + reactionMs: number | null; + inclusionMs: number; + hash: Hex; + inclusion?: Inclusion; +}; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +function BaseTarget({ active }: { active: boolean }) { + return ( + + ); +} + +function ReflexGameInner() { + const engine = useAccountEngine(); + const [phase, setPhase] = useState('idle'); + const [countdown, setCountdown] = useState<3 | 2 | 1 | 'GO' | null>(null); + const [target, setTarget] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [hasReacted, setHasReacted] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [toppingUp, setToppingUp] = useState(false); + const startedAt = useRef(0); + const reactionMs = useRef(null); + const transactionHash = useRef(null); + + useEffect(() => { + if (phase !== 'racing') return; + let frame = 0; + const update = () => { + setElapsed(Math.round(performance.now() - startedAt.current)); + frame = window.requestAnimationFrame(update); + }; + frame = window.requestAnimationFrame(update); + return () => window.cancelAnimationFrame(frame); + }, [phase]); + + const reset = () => { + setPhase('idle'); + setCountdown(null); + setTarget(null); + setElapsed(0); + setHasReacted(false); + setResult(null); + setError(''); + startedAt.current = 0; + reactionMs.current = null; + transactionHash.current = null; + }; + + const finish = () => { + const inclusionMs = Math.max(0, Math.round(performance.now() - startedAt.current)); + const reaction = reactionMs.current; + const outcome = reaction === null ? 'block' : reflexOutcome(reaction, inclusionMs); + const hash = transactionHash.current; + if (!hash) return; + setElapsed(inclusionMs); + setTarget(null); + setResult({ outcome, reactionMs: reaction, inclusionMs, hash }); + setPhase('result'); + trackReflexRun(outcome); + }; + + const startRun = async () => { + if (!engine.acct || phase !== 'idle' && phase !== 'result' && phase !== 'error') return; + setPhase('preparing'); + setError(''); + setResult(null); + setElapsed(0); + setHasReacted(false); + reactionMs.current = null; + transactionHash.current = null; + trackReflexRun('started'); + + try { + const account = engine.acct; + // Accounts persist in the browser across Vibenet resets, but their ETH + // does not, and Reflex pays its own gas. Top up before signing instead of + // letting the node reject the send after the countdown. + const balances = await engine.refreshVibenetBalances(); + if (balances && BigInt(balances.eth_wei ?? '0') < MIN_RACE_BALANCE_WEI) { + setToppingUp(true); + const credited = await engine.requestFaucet().finally(() => setToppingUp(false)); + if (!credited) throw new Error('Could not top up this account from the faucet. Try again shortly.'); + } + const response = await engine.sendActiveCalls({ + calls: [{ to: account.address, data: '0x', value: '0' }], + metadata: '200ms Reflex', + beforeBroadcast: async () => { + setPhase('countdown'); + for (const value of [3, 2, 1] as const) { + setCountdown(value); + await sleep(1_000); + } + setCountdown('GO'); + }, + onBroadcast: (hash) => { + transactionHash.current = hash; + startedAt.current = performance.now(); + setTarget(Math.floor(Math.random() * TARGETS.length)); + setCountdown(null); + setPhase('racing'); + }, + onReceipt: finish, + }); + + setResult((current) => current ? { ...current, inclusion: response.inclusion } : current); + engine.pushActivity({ + kind: 'transact', + title: '200ms Reflex run', + detail: reactionMs.current === null ? 'The block won' : `Reaction: ${reactionMs.current} ms`, + txHash: response.hash, + serialized: response.serialized, + network: engine.chain.name, + mode: engine.chain.mode, + account: account.address, + }); + } catch (cause) { + setTarget(null); + setCountdown(null); + setError(raceErrorMessage(cause)); + setPhase('error'); + trackReflexRun('error'); + } + }; + + const hitTarget = (index: number, event: MouseEvent) => { + if (phase !== 'racing' || index !== target || reactionMs.current !== null) return; + const reaction = Math.max(0, Math.round(event.timeStamp - startedAt.current)); + reactionMs.current = reaction; + setElapsed(reaction); + setHasReacted(true); + setTarget(null); + }; + + const outcome = result ? outcomeCopy(result.outcome) : null; + const busy = phase === 'preparing' || phase === 'countdown' || phase === 'racing'; + const arenaStatus = + phase === 'racing' + ? hasReacted + ? 'Waiting for the block…' + : 'Hit the lit target' + : phase === 'result' + ? 'Race complete' + : phase === 'error' + ? 'Ready to try again' + : 'Move your cursor into the arena'; + + return ( + +
+ Vibenet · 200ms Blocks + Can you beat a block? + + After a 3–2–1 countdown, a real transaction is broadcast and one target lights up. Hit it before Vibenet + seals the transaction in a complete native block. + +
+ +
+ +
+
+ Reaction arena + {arenaStatus} +
+
+ {phase === 'racing' ? `${elapsed} ms` : phase === 'result' ? 'Done' : '— ms'} +
+
+ +
+ {TARGETS.map((index) => { + const active = phase === 'racing' && target === index; + return ( + + ); + })} + + {phase === 'preparing' || phase === 'countdown' ? ( +
+ {phase === 'countdown' ? 'Get ready' : toppingUp ? 'Topping up account from the faucet' : 'Preparing transaction'} +
+ {phase === 'preparing' ? '…' : countdown} +
+
+ ) : null} +
+
+ +
+ + The race + {result && outcome ? ( + <> + {outcome.title} + {outcome.detail} +
+
+ You + + {result.reactionMs === null ? 'Missed' : `${result.reactionMs} ms`} + +
+
+ Txn inclusion + {result.inclusionMs} ms +
+
+ + Measured from RPC acceptance until this page observed the receipt. + +
+ + +
+ + ) : phase === 'error' ? ( + <> + Run interrupted + {error} + + + ) : ( + <> +
+ 200 + ms +
+ + One full block. Signing happens first, then the countdown starts immediately before broadcast. + + + + )} +
+ + + How it works +
    +
  1. 01 Sign and prepare the transaction.
  2. +
  3. 02 Position your cursor during 3–2–1.
  4. +
  5. 03 Hit the target before the receipt arrives.
  6. +
+
+
+
+
+ ); +} + +export function ReflexGame() { + return ( + + + + ); +} diff --git a/app/vibenet/demos/reflex/layout.tsx b/app/vibenet/demos/reflex/layout.tsx new file mode 100644 index 0000000..f1c6a64 --- /dev/null +++ b/app/vibenet/demos/reflex/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +// Unlisted: reached from the inclusion line in transaction popups, not the +// catalogue grid, nav, or sitemap. +export const metadata: Metadata = { + title: 'Reflex · Vibenet', + description: 'Race your reaction time against a real transaction landing in a native 200 ms Vibenet block.', + robots: { index: false, follow: false }, +}; + +export default function ReflexLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/vibenet/demos/reflex/lib/game.test.ts b/app/vibenet/demos/reflex/lib/game.test.ts new file mode 100644 index 0000000..24c257c --- /dev/null +++ b/app/vibenet/demos/reflex/lib/game.test.ts @@ -0,0 +1,33 @@ +import { TransactionRejectedRpcError } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { outcomeCopy, raceErrorMessage, reflexOutcome } from './game'; + +describe('reflexOutcome', () => { + it('awards the run to the human only when the click is earlier', () => { + expect(reflexOutcome(199, 200)).toBe('human'); + expect(reflexOutcome(201, 200)).toBe('block'); + }); + + it('calls an exact same-millisecond finish a tie', () => { + expect(reflexOutcome(200, 200)).toBe('tie'); + expect(outcomeCopy('tie').title).toBe('Dead heat.'); + }); +}); + +describe('raceErrorMessage', () => { + // What the node returns when a stored account has no ETH, e.g. after a reset. + const rejected = (reason: string) => new TransactionRejectedRpcError(new Error(reason)); + + it('explains an unfunded account instead of the generic -32003 text', () => { + const message = raceErrorMessage(rejected('insufficient funds for gas * price + value: have 0 want 67000000000000')); + expect(message).not.toBe('Transaction creation failed.'); + expect(message).toMatch(/no ETH/); + }); + + it("passes through the node's reason for other rejections", () => { + expect(raceErrorMessage(rejected('nonce too low: next nonce 4, tx nonce 3'))).toBe( + 'nonce too low: next nonce 4, tx nonce 3', + ); + }); +}); diff --git a/app/vibenet/demos/reflex/lib/game.ts b/app/vibenet/demos/reflex/lib/game.ts new file mode 100644 index 0000000..21e9d42 --- /dev/null +++ b/app/vibenet/demos/reflex/lib/game.ts @@ -0,0 +1,47 @@ +export type ReflexOutcome = 'human' | 'block' | 'tie'; + +/** Compare two durations measured from the same broadcast callback. */ +export function reflexOutcome(reactionMs: number, inclusionMs: number): ReflexOutcome { + if (reactionMs < inclusionMs) return 'human'; + if (reactionMs > inclusionMs) return 'block'; + return 'tie'; +} + +export function outcomeCopy(outcome: ReflexOutcome): { title: string; detail: string } { + if (outcome === 'human') { + return { + title: 'You beat the block.', + detail: 'You hit the target before this page observed the transaction receipt.', + }; + } + if (outcome === 'tie') { + return { + title: 'Dead heat.', + detail: 'Your click and the transaction receipt arrived in the same millisecond.', + }; + } + return { + title: 'The block beat you.', + detail: 'Vibenet included the transaction before you hit the target.', + }; +} + +/** + * Below this, top the account up before a race. A Reflex transaction costs + * about 67k gas at 1 gwei (~0.000067 ETH); the faucet drips 0.1 ETH. + */ +export const MIN_RACE_BALANCE_WEI = 1_000_000_000_000_000n; // 0.001 ETH + +/** + * The node reports every txpool rejection as JSON-RPC -32003, which viem + * renders as the generic "Transaction creation failed." The node's reason is + * in `details`; translate the one a player can act on and surface the rest. + */ +export function raceErrorMessage(cause: unknown): string { + const e = cause as { shortMessage?: string; details?: string; message?: string }; + const reason = e?.details || e?.shortMessage || e?.message || String(cause); + if (/insufficient funds/i.test(reason)) { + return 'This account has no ETH on Vibenet to pay for gas (the network may have been reset). Try again to top it up from the faucet.'; + } + return reason; +} diff --git a/app/vibenet/demos/reflex/page.tsx b/app/vibenet/demos/reflex/page.tsx new file mode 100644 index 0000000..86e7902 --- /dev/null +++ b/app/vibenet/demos/reflex/page.tsx @@ -0,0 +1,5 @@ +import { ReflexGame } from './ReflexGame'; + +export default function ReflexPage() { + return ; +} diff --git a/llms.config.mjs b/llms.config.mjs index c7e6cc5..268690f 100644 --- a/llms.config.mjs +++ b/llms.config.mjs @@ -8,5 +8,6 @@ import { disabledRouteGlobs } from './deploy.config.mjs'; export default { - exclude: disabledRouteGlobs(), + // Reflex is an easter egg reached from transaction popups, so keep it out of the indexes. + exclude: [...disabledRouteGlobs(), '/vibenet/demos/reflex'], };