Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,13 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org
# /benchmark to load any data; the section throws a configuration error without
# it. No credentials belong here: this value is inlined into the client bundle.
# NEXT_PUBLIC_BENCHMARK_API_BASE_URL=

# Validity demo (/vibenet/demos/validity). Server-side RPC proxy for HTTP
# reads and `base_sendRawTransactionValidity` submits. WebSocket is for
# eth_subscribe (defaults to the read host + /ws). ETH comes from the
# Vibenet faucet — do not set a funder key.
# VALIDITY_DEMO_RPC_URL=https://rpc.vibes.base.org
# VALIDITY_DEMO_SUBMIT_RPC_URL=https://rpc.vibes.base.org
# VALIDITY_DEMO_WS_URL=wss://rpc.vibes.base.org/ws
# Local node with --enable-experimental-validity-transactions:
# VALIDITY_DEMO_RPC_URL=http://127.0.0.1:8545
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ This app uses Vercel Web Analytics. Two things must stay in place:
| `trackB20PromptCopy(module, prompt)` | `app/vibenet/demos/b20/components/CopyPromptButton.tsx` — copy AI prompt |
| `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 |

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 @@ -77,3 +77,10 @@ export function trackExplorerChainSelect(chain: string): void {
export function trackExplorerActiveBlockJump(chain: string, jump: 'latest' | 'previous'): void {
track('explorer_active_block_jump', { chain, jump });
}

export function trackValidityOrder(
side: string,
status: 'submitted' | 'filled' | 'expired' | 'replaced' | 'error',
): void {
track('validity_order', { side, status });
}
113 changes: 113 additions & 0 deletions app/api/vibenet/validity/candles/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import {
decodeFunctionResult,
encodeEventTopics,
encodeFunctionData,
parseAbi,
toHex,
type Address,
} from 'viem';

import { pairAbi } from '../../../../vibenet/demos/validity/lib/constants';
import { quoteWad } from '../../../../vibenet/demos/validity/lib/quote';
import {
isAddress,
lookbackBlocks,
needsLogBackfill,
parseTapeSamples,
readTape,
samplesFromSyncLogs,
writeTape,
type RpcLog,
type TapeSample,
} from '../../../../vibenet/demos/validity/lib/tape';
import { forwardJsonRpc } from '../forward';

const SYNC_TOPIC = encodeEventTopics({
abi: parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']),
eventName: 'Sync',
})[0];

type JsonRpcResponse = { result?: unknown; error?: { message?: string } };

async function rpc<T>(method: string, params: unknown[]): Promise<T | null> {
const body = (await forwardJsonRpc({ jsonrpc: '2.0', id: 1, method, params })) as JsonRpcResponse;
if (body.error?.message || body.result === undefined || body.result === null) return null;
return body.result as T;
}

async function backfillFromLogs(pair: Address, vibeToken0: boolean, now: number): Promise<TapeSample[]> {
const latestHex = await rpc<string>('eth_blockNumber', []);
if (!latestHex) return [];
let latest: bigint;
try {
latest = BigInt(latestHex);
} catch {
return [];
}
const lookback = lookbackBlocks();
const from = latest > lookback ? latest - lookback : 0n;
const logs = await rpc<RpcLog[]>('eth_getLogs', [
{
address: pair,
fromBlock: toHex(from),
toBlock: 'latest',
topics: [SYNC_TOPIC],
},
]);
if (!logs?.length) return [];
return samplesFromSyncLogs({ logs, pair, vibeToken0, latestBlock: latest, now });
}

async function currentMid(pair: Address, vibeToken0: boolean): Promise<number | null> {
const data = encodeFunctionData({ abi: pairAbi, functionName: 'getReserves' });
const raw = await rpc<`0x${string}`>('eth_call', [{ to: pair, data }, 'latest']);
if (!raw) return null;
try {
const decoded = decodeFunctionResult({
abi: pairAbi,
functionName: 'getReserves',
data: raw,
}) as [bigint, bigint, number];
const price = Number(quoteWad(decoded[0], decoded[1], vibeToken0)) / 1e18;
return Number.isFinite(price) && price > 0 ? price : null;
} catch {
return null;
}
}

export async function GET(request: Request) {
const url = new URL(request.url);
const pair = url.searchParams.get('pair');
if (!isAddress(pair)) {
return NextResponse.json({ error: 'pair required' }, { status: 400 });
}
const vibeToken0 = url.searchParams.get('vibeToken0') !== '0';
const now = Date.now();
let samples = readTape(pair);
if (needsLogBackfill(samples, now)) {
const fromLogs = await backfillFromLogs(pair, vibeToken0, now);
if (fromLogs.length > 0) samples = writeTape(pair, fromLogs);
}
const mid = await currentMid(pair, vibeToken0);
if (mid !== null) samples = writeTape(pair, [{ t: now, price: mid }]);
return NextResponse.json(
{ samples },
{ headers: { 'Cache-Control': 'no-store' } },
);
}

export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
const record = body && typeof body === 'object' ? (body as { pair?: unknown; samples?: unknown }) : {};
if (!isAddress(typeof record.pair === 'string' ? record.pair : null)) {
return NextResponse.json({ error: 'pair required' }, { status: 400 });
}
const samples = writeTape(record.pair as Address, parseTapeSamples(record.samples));
return NextResponse.json({ ok: true, count: samples.length });
}
48 changes: 48 additions & 0 deletions app/api/vibenet/validity/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from 'vitest';

import { VIBENET_RPC_URL } from '../../../vibenet/library/config';
import { getReadRpcUrl, getSubmitRpcUrl, getWsRpcUrl, wsUrlFromHttp } from './config';

const originalRead = process.env.VALIDITY_DEMO_RPC_URL;
const originalSubmit = process.env.VALIDITY_DEMO_SUBMIT_RPC_URL;
const originalWs = process.env.VALIDITY_DEMO_WS_URL;

afterEach(() => {
if (originalRead === undefined) delete process.env.VALIDITY_DEMO_RPC_URL;
else process.env.VALIDITY_DEMO_RPC_URL = originalRead;
if (originalSubmit === undefined) delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL;
else process.env.VALIDITY_DEMO_SUBMIT_RPC_URL = originalSubmit;
if (originalWs === undefined) delete process.env.VALIDITY_DEMO_WS_URL;
else process.env.VALIDITY_DEMO_WS_URL = originalWs;
});

describe('validity demo RPC config', () => {
it('defaults to the public Vibenet RPC for reads and submits', () => {
delete process.env.VALIDITY_DEMO_RPC_URL;
delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL;
expect(getReadRpcUrl()).toBe(VIBENET_RPC_URL);
expect(getSubmitRpcUrl()).toBe(VIBENET_RPC_URL);
});

it('uses a single custom RPC for both when submit is unset', () => {
process.env.VALIDITY_DEMO_RPC_URL = 'http://127.0.0.1:8545';
delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL;
expect(getReadRpcUrl()).toBe('http://127.0.0.1:8545');
expect(getSubmitRpcUrl()).toBe('http://127.0.0.1:8545');
delete process.env.VALIDITY_DEMO_RPC_URL;
});

it('derives the public Vibenet /ws URL from HTTPS RPC', () => {
delete process.env.VALIDITY_DEMO_WS_URL;
expect(wsUrlFromHttp('https://rpc.vibes.base.org')).toBe('wss://rpc.vibes.base.org/ws');
process.env.VALIDITY_DEMO_RPC_URL = 'https://rpc.vibes.base.org';
expect(getWsRpcUrl()).toBe('wss://rpc.vibes.base.org/ws');
delete process.env.VALIDITY_DEMO_RPC_URL;
});

it('lets VALIDITY_DEMO_WS_URL win', () => {
process.env.VALIDITY_DEMO_WS_URL = 'wss://example.test/ws';
expect(getWsRpcUrl()).toBe('wss://example.test/ws');
delete process.env.VALIDITY_DEMO_WS_URL;
});
});
69 changes: 69 additions & 0 deletions app/api/vibenet/validity/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Server-only config for the validity demo's RPC proxy.
// Defaults to the public Vibenet RPC; override with VALIDITY_DEMO_* in `.env.local`.

import { VIBENET_RPC_URL } from '../../../vibenet/library/config';

function trimEnv(name: string): string | undefined {
const value = process.env[name]?.trim();
return value && value.length > 0 ? value : undefined;
}

export function getReadRpcUrl(): string {
return trimEnv('VALIDITY_DEMO_RPC_URL') ?? VIBENET_RPC_URL;
}

export function getSubmitRpcUrl(): string {
return trimEnv('VALIDITY_DEMO_SUBMIT_RPC_URL') ?? getReadRpcUrl();
}

export function rpcHost(url: string): string {
try {
return new URL(url).host;
} catch {
return 'invalid-rpc-url';
}
}

/** Map an HTTP JSON-RPC URL to the usual `/ws` WebSocket path. */
export function wsUrlFromHttp(httpUrl: string): string | null {
try {
const url = new URL(httpUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
if (url.pathname === '/' || url.pathname === '') url.pathname = '/ws';
return url.toString();
} catch {
return null;
}
}

export function getWsRpcUrl(): string | null {
return trimEnv('VALIDITY_DEMO_WS_URL') ?? wsUrlFromHttp(getReadRpcUrl());
}

export const SUBMIT_METHODS = new Set([
'eth_sendRawTransaction',
'eth_sendRawTransactionSync',
'base_sendRawTransactionValidity',
]);

export const ALLOWED_METHODS = new Set([
...SUBMIT_METHODS,
'eth_chainId',
'eth_blockNumber',
'eth_getBlockByNumber',
'eth_getBlockByHash',
'eth_getCode',
'eth_call',
'eth_estimateGas',
'eth_gasPrice',
'eth_maxPriorityFeePerGas',
'eth_feeHistory',
'eth_getBalance',
'eth_getTransactionCount',
'eth_getTransactionReceipt',
'eth_getTransactionByHash',
'eth_getStorageAt',
'eth_getLogs',
'eth_blobBaseFee',
]);
56 changes: 56 additions & 0 deletions app/api/vibenet/validity/forward.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ALLOWED_METHODS, SUBMIT_METHODS, getReadRpcUrl, getSubmitRpcUrl } from './config';

type JsonRpcRequest = {
jsonrpc?: string;
id?: unknown;
method?: string;
params?: unknown;
};

type JsonRpcError = { code: number; message: string };

function methodNotAllowed(id: unknown, method: string) {
return {
jsonrpc: '2.0',
id: id ?? null,
error: { code: -32601, message: `Method not allowed: ${method}` } satisfies JsonRpcError,
};
}

async function forwardOne(request: JsonRpcRequest): Promise<unknown> {
const method = request.method ?? '';
if (!ALLOWED_METHODS.has(method)) {
return methodNotAllowed(request.id, method);
}
const url = SUBMIT_METHODS.has(method) ? getSubmitRpcUrl() : getReadRpcUrl();
const response = await fetch(url, {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: request.jsonrpc ?? '2.0',
id: request.id ?? 1,
method,
params: request.params ?? [],
}),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
return {
jsonrpc: '2.0',
id: request.id ?? null,
error: {
code: -32603,
message: `Upstream RPC HTTP ${response.status}`,
},
};
}
return body;
}

export async function forwardJsonRpc(payload: unknown): Promise<unknown> {
if (Array.isArray(payload)) {
return Promise.all(payload.map((item) => forwardOne(item as JsonRpcRequest)));
}
return forwardOne(payload as JsonRpcRequest);
}
25 changes: 25 additions & 0 deletions app/api/vibenet/validity/rpc/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';

import { forwardJsonRpc } from '../forward';

export async function POST(request: Request) {
let payload: unknown;
try {
payload = await request.json();
} catch {
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
{ status: 400 },
);
}
try {
const result = await forwardJsonRpc(payload);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'RPC proxy failed';
return NextResponse.json(
{ jsonrpc: '2.0', id: null, error: { code: -32603, message } },
{ status: 502 },
);
}
}
Loading
Loading