Skip to content
Draft
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 });
}
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 },
);
}
}
84 changes: 84 additions & 0 deletions app/api/vibenet/validity/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';

import { getReadRpcUrl, getSubmitRpcUrl, getWsRpcUrl, rpcHost } from '../config';
import { forwardJsonRpc } from '../forward';

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

async function rpcCall(method: string, params: unknown[]): Promise<JsonRpcResponse> {
const body = await forwardJsonRpc({ jsonrpc: '2.0', id: 1, method, params });
return (body ?? {}) as JsonRpcResponse;
}

function methodExists(response: JsonRpcResponse): boolean {
const code = response.error?.code;
const message = (response.error?.message ?? '').toLowerCase();
if (code === -32601) return false;
if (message.includes('method not found') || message.includes('method is not available')) {
return false;
}
if (message.includes('unsupported') && message.includes('method')) return false;
return true;
}

function typeAccepted(response: JsonRpcResponse): boolean {
const message = (response.error?.message ?? '').toLowerCase();
if (!response.error) return true;
if (message.includes('unknown variant') || message.includes('unknown type') || message.includes('invalid type')) {
return false;
}
if (message.includes('deny_unknown') || message.includes('did not match any variant')) return false;
return true;
}

const DUMMY_TX = '0x00';
const DUMMY_BALANCE = {
type: 'balance',
params: {
address: '0x0000000000000000000000000000000000000001',
op: '>=',
value: '0x0',
},
};
const DUMMY_BLOCK = {
type: 'block_number',
params: { op: '<=', value: '0x1' },
};

export async function GET() {
const readHost = rpcHost(getReadRpcUrl());
const submitHost = rpcHost(getSubmitRpcUrl());

const chain = await rpcCall('eth_chainId', []);
const genesis = await rpcCall('eth_getBlockByNumber', ['0x0', false]);
const validity = await rpcCall('base_sendRawTransactionValidity', [
{ tx: DUMMY_TX, validity: [DUMMY_BALANCE] },
]);
const validitySupported = methodExists(validity);
let blockNumberPredicate = false;
if (validitySupported) {
const blockProbe = await rpcCall('base_sendRawTransactionValidity', [
{ tx: DUMMY_TX, validity: [DUMMY_BLOCK] },
]);
blockNumberPredicate = typeAccepted(blockProbe);
}

const genesisHash =
genesis.result && typeof genesis.result === 'object' && genesis.result !== null && 'hash' in genesis.result
? String((genesis.result as { hash: unknown }).hash)
: null;

return NextResponse.json({
chainId: typeof chain.result === 'string' ? Number.parseInt(chain.result, 16) : null,
genesisHash,
readHost,
submitHost,
wsUrl: getWsRpcUrl(),
validitySupported,
blockNumberPredicate,
validityError: validity.error?.message ?? null,
});
}
1 change: 1 addition & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: '/vibenet/faucet', priority: 0.5, changeFrequency: 'monthly' },
{ 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' },
];

return routes.map(({ path, priority, changeFrequency }) => ({
Expand Down
Loading
Loading