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
13 changes: 4 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,14 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org
# Only affects local runs — Vercel sets no value, so the public build is external.
# NEXT_PUBLIC_DEPLOY_TARGET=internal

# Internal Explorer: per-chain S3 + RPC, read server-side by
# Internal Explorer: per-chain RPC + audit endpoints, read server-side by
# /api/internal-explorer (chain via ?chain=).
# One deployment serves all chains; provide a set per chain
# (TIPS_MAINNET_, TIPS_SEPOLIA_, TIPS_ZERONET_). Secrets → Vercel env, not git.
# TIPS_MAINNET_S3_BUCKET=
# TIPS_MAINNET_S3_REGION=us-east-1
# TIPS_MAINNET_S3_ENDPOINT= # optional (e.g. MinIO); omit for AWS
# TIPS_MAINNET_S3_ACCESS_KEY_ID=
# TIPS_MAINNET_S3_SECRET_ACCESS_KEY=
# TIPS_MAINNET_RPC_URL=
# Audit events JSON-RPC endpoint (transaction observability). Opt-in per chain:
# when set, /api/internal-explorer prefers it over S3 and falls back to S3 when
# unset/empty.
# Audit events JSON-RPC endpoint (Postgres-backed transaction observability).
# Opt-in per chain: the only source for bundle and rejected views, and the
# enrichment source for block and transaction views.
# TIPS_MAINNET_AUDIT_RPC_URL=
# Public origin that serves a chain's Internal Explorer (runtime, not
# NEXT_PUBLIC_*). Unset locally so the chain toggle stays on this origin.
Expand Down
81 changes: 8 additions & 73 deletions app/api/internal-explorer/block/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,7 @@ import {
import { getAuditRpcUrl, getRpcUrl } from '../../config';
import { explorerDisabledResponse } from '../../guard';
import { getTransactionReceiptSummaries } from '../../receipts';
import {
cacheBlockData,
getBlockFromCache,
getBundleHistory,
getTransactionMetadataByHash,
} from '../../s3';
import type { BlockData, BlockTransaction, BundleEvent } from '../../transaction-data';
import type { BlockData, BlockTransaction } from '../../transaction-data';
import { publicClientFor } from '../../viem';

export const runtime = 'nodejs';
Expand Down Expand Up @@ -168,13 +162,6 @@ function timedAuditEvents(events: AuditTransactionEventRecord[]): TimedExplorerE
}));
}

function timedHistoryEvents(history: BundleEvent[]): TimedExplorerEvent[] {
return history.map((event) => ({
event: event.event,
timestamp: event.data.timestamp,
}));
}

function auditEventsForHash(
txHash: string,
events: AuditTransactionEventRecord[],
Expand All @@ -183,43 +170,14 @@ function auditEventsForHash(
return events.filter((event) => event.tx_hash?.toLowerCase() === normalized);
}

async function enrichTransactionFromS3(
chain: ExplorerChain,
txHash: string,
): Promise<{
bundleId: string | null;
meterBundleResponse: Record<string, unknown> | null;
history: BundleEvent[];
}> {
const metadata = await getTransactionMetadataByHash(chain, txHash);
if (!metadata || metadata.bundle_ids.length === 0) {
return { bundleId: null, meterBundleResponse: null, history: [] };
}

const bundleId = metadata.bundle_ids[0];
const bundleHistory = await getBundleHistory(chain, bundleId);
if (!bundleHistory) {
return { bundleId, meterBundleResponse: null, history: [] };
}

const receivedEvent = bundleHistory.history.find((event) => event.event === 'Received');
return {
bundleId,
meterBundleResponse: receivedEvent?.data?.bundle?.meter_bundle_response
? (receivedEvent.data.bundle.meter_bundle_response as unknown as Record<string, unknown>)
: null,
history: bundleHistory.history,
};
}

type TransactionEnrichment = {
bundleId: string | null;
meterBundleResponse: Record<string, unknown> | null;
inclusionLatencyMs: number | null;
};

// Audit-first, S3-fallback per-transaction enrichment (bundle id, metering, and
// inclusion latency from the same event set the transaction page uses).
// Audit-backed per-transaction enrichment (bundle id, metering, and inclusion
// latency from the same event set the transaction page uses).
async function enrichTransactionWithBundleData(
chain: ExplorerChain,
txHash: string,
Expand All @@ -232,18 +190,18 @@ async function enrichTransactionWithBundleData(
try {
hashEvents = await getAuditEventsByTransactionHash(auditRpcUrl, txHash);
} catch {
// Audit is an optional read path; fall back to the S3-backed enrichment on errors.
// Audit is an optional read path; render the block without enrichment on errors.
}
}

const mergedAuditEvents = mergeAuditEvents([
hashEvents,
auditEventsForHash(txHash, blockAuditEvents),
]);
let inclusionLatency = inclusionLatencyMs(timedAuditEvents(mergedAuditEvents));
const inclusionLatency = inclusionLatencyMs(timedAuditEvents(mergedAuditEvents));

const metadata = transactionMetadataFromAuditEvents(hashEvents);
let bundleId = metadata?.bundle_ids[0] ?? null;
const bundleId = metadata?.bundle_ids[0] ?? null;
let meterBundleResponse: Record<string, unknown> | null = null;
if (bundleId !== null) {
const accepted = hashEvents.find((event) => event.event_type === 'SIMULATION_SUCCEEDED');
Expand All @@ -252,13 +210,6 @@ async function enrichTransactionWithBundleData(
: null;
}

if (bundleId === null) {
const s3 = await enrichTransactionFromS3(chain, txHash);
bundleId = s3.bundleId;
meterBundleResponse = s3.meterBundleResponse;
inclusionLatency = inclusionLatency ?? inclusionLatencyMs(timedHistoryEvents(s3.history));
}

return {
bundleId,
meterBundleResponse,
Expand Down Expand Up @@ -335,17 +286,6 @@ async function buildBlockData(chain: ExplorerChain, rpcBlock: ParsedFullBlock):
};
}

// Build live from RPC (audit-first enrichment), writing through to the block
// cache, which also serves as a fallback when the RPC is unavailable.
async function buildAndCacheBlockData(
chain: ExplorerChain,
rpcBlock: ParsedFullBlock,
): Promise<BlockData> {
const blockData = await buildBlockData(chain, rpcBlock);
await cacheBlockData(chain, blockData);
return blockData;
}

export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;
Expand All @@ -362,21 +302,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
return Response.json({ error: 'Block not found' }, { status: 404 });
}

const blockData = await buildAndCacheBlockData(chain, rpcBlock);
const blockData = await buildBlockData(chain, rpcBlock);
return Response.json(serializeBlockData(blockData));
}

const rpcBlock = await fetchBlockFromRpc(rpcUrl, identifier);
if (!rpcBlock) {
// RPC could not serve the block; fall back to the block cache.
const cachedBlock = await getBlockFromCache(chain, identifier);
if (cachedBlock) {
return Response.json(serializeBlockData(cachedBlock));
}
return Response.json({ error: 'Block not found' }, { status: 404 });
}

const blockData = await buildAndCacheBlockData(chain, rpcBlock);
const blockData = await buildBlockData(chain, rpcBlock);
return Response.json(serializeBlockData(blockData));
} catch (error) {
console.error('Error fetching block data:', error);
Expand Down
42 changes: 18 additions & 24 deletions app/api/internal-explorer/bundle/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { type Hash } from 'viem';

import { type ExplorerChain } from '../../../../internal-explorer/chains';
import { resolveExplorerChainFromRequest } from '../../chain';
import {
bundleHistoryFromAuditEvents,
getJoinedAuditEventsByBundle,
} from '../../audit-events';
import { getAuditRpcUrl, getRpcUrl } from '../../config';
import { explorerDisabledResponse } from '../../guard';
import { getBundleHistory } from '../../s3';
import type { BundleEvent, BundleHistory, BundleTransaction } from '../../transaction-data';
import type { BundleEvent, BundleTransaction } from '../../transaction-data';
import { publicClientFor, type ExplorerPublicClient } from '../../viem';

export const runtime = 'nodejs';
Expand Down Expand Up @@ -51,7 +49,7 @@ function bundleTransactionFromViem(
}

// Fills in full transaction fields for each bundle tx hash from the execution RPC.
// Audit/S3 bundle events only carry the tx hashes (and metering), so the on-chain
// Audit bundle events only carry the tx hashes (and metering), so the on-chain
// signer/value/gas come from getTransaction.
async function enrichBundleTransactionsFromRpc(
rpcUrl: string,
Expand Down Expand Up @@ -98,10 +96,24 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
if (disabled) return disabled;
const chain = resolveExplorerChainFromRequest(request);

// Bundle history comes only from the audit events RPC (Postgres-backed).
const auditRpcUrl = getAuditRpcUrl(chain);
if (!auditRpcUrl) {
return Response.json({ error: 'Bundle not found' }, { status: 404 });
}

const { hash } = await params;

let events;
try {
const { hash } = await params;
events = await getJoinedAuditEventsByBundle(auditRpcUrl, hash);
} catch (error) {
console.error('Error fetching bundle history from audit RPC:', error);
return Response.json({ error: 'Audit RPC unavailable' }, { status: 502 });
}

const bundle = await getAuditBundleHistory(chain, hash);
try {
const bundle = bundleHistoryFromAuditEvents(hash, events);
if (!bundle) {
return Response.json({ error: 'Bundle not found' }, { status: 404 });
}
Expand All @@ -120,21 +132,3 @@ export async function GET(request: Request, { params }: { params: Promise<{ hash
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}

async function getAuditBundleHistory(
chain: ExplorerChain,
hash: string,
): Promise<BundleHistory | null> {
const auditRpcUrl = getAuditRpcUrl(chain);
if (!auditRpcUrl) {
return getBundleHistory(chain, hash);
}

try {
const events = await getJoinedAuditEventsByBundle(auditRpcUrl, hash);
return bundleHistoryFromAuditEvents(hash, events) ?? (await getBundleHistory(chain, hash));
} catch (error) {
console.error('Falling back to S3 bundle history:', error);
return getBundleHistory(chain, hash);
}
}
69 changes: 6 additions & 63 deletions app/api/internal-explorer/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Per-chain server config for the Internal Explorer API. One deployment serves all chains, so
// the S3 client, bucket, and RPC/audit URLs are resolved per ExplorerChain from
// TIPS_<CHAIN>_* env vars. Server-only: never import from client.
import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3';

// the RPC/audit URLs are resolved per ExplorerChain from TIPS_<CHAIN>_* env vars.
// Server-only: never import from client.
import type { ExplorerChain } from '../../internal-explorer/chains';
import type { ExplorerHostMap } from '../../internal-explorer/hosts';

Expand All @@ -13,73 +11,18 @@ const ENV_PREFIX: Record<ExplorerChain, string> = {
zeronet: 'ZERONET',
};

type ChainS3Config = {
bucket: string;
region: string;
endpoint?: string;
accessKeyId?: string;
secretAccessKey?: string;
};

function envValue(names: string[]): string | undefined {
return names.map((name) => process.env[name]).find(Boolean);
}

function getS3Config(chain: ExplorerChain): ChainS3Config {
const prefix = ENV_PREFIX[chain];
return {
bucket: envValue([`TIPS_${prefix}_S3_BUCKET`]) ?? 'tips',
region: envValue([`TIPS_${prefix}_S3_REGION`]) ?? 'us-east-1',
endpoint: envValue([`TIPS_${prefix}_S3_ENDPOINT`]),
accessKeyId: envValue([`TIPS_${prefix}_S3_ACCESS_KEY_ID`]),
secretAccessKey: envValue([`TIPS_${prefix}_S3_SECRET_ACCESS_KEY`]),
};
}

const s3Clients = new Map<ExplorerChain, S3Client>();

// Path-style S3 client (optional endpoint + optional static credentials),
// memoized per chain. With no endpoint or credentials this behaves like the
// default AWS SDK config.
export function getS3Client(chain: ExplorerChain): S3Client {
const existing = s3Clients.get(chain);
if (existing) {
return existing;
}

const s3Config = getS3Config(chain);
const config: S3ClientConfig = {
region: s3Config.region,
forcePathStyle: true,
};

if (s3Config.endpoint) {
config.endpoint = s3Config.endpoint;
}

if (s3Config.accessKeyId && s3Config.secretAccessKey) {
config.credentials = {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
};
}

const client = new S3Client(config);
s3Clients.set(chain, client);
return client;
}

export function getBucketName(chain: ExplorerChain): string {
return getS3Config(chain).bucket;
}

export function getRpcUrl(chain: ExplorerChain): string {
return envValue([`TIPS_${ENV_PREFIX[chain]}_RPC_URL`]) ?? 'http://localhost:8545';
}

// Audit events JSON-RPC endpoint for a chain. Unlike S3/RPC there is no default:
// audit is opt-in per chain via TIPS_<CHAIN>_AUDIT_RPC_URL. When unset, the audit
// source is treated as disabled and the routes fall back to the S3 archive.
// Audit events JSON-RPC endpoint (Postgres-backed) for a chain. Unlike the
// execution RPC there is no default: audit is opt-in per chain via
// TIPS_<CHAIN>_AUDIT_RPC_URL. When unset, the audit source is treated as
// disabled: bundle/rejected views are empty and block/txn views show chain data only.
export function getAuditRpcUrl(chain: ExplorerChain): string | undefined {
return envValue([`TIPS_${ENV_PREFIX[chain]}_AUDIT_RPC_URL`]);
}
Expand Down
Loading
Loading