Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
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
7 changes: 1 addition & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://base.blockscout.com
TIPS_UI_RPC_URL=http://localhost:8545
TIPS_UI_AWS_REGION=us-east-1
TIPS_UI_S3_BUCKET_NAME=tips
TIPS_UI_S3_CONFIG_TYPE=manual
TIPS_UI_S3_ENDPOINT=http://localhost:7000
TIPS_UI_S3_ACCESS_KEY_ID=minioadmin
TIPS_UI_S3_SECRET_ACCESS_KEY=minioadmin
TIPS_UI_AUDIT_RPC_URL=http://localhost:8080
198 changes: 40 additions & 158 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingRoot: __dirname,
turbopack: {
root: __dirname,
},
};

export default nextConfig;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"format": "biome format --write"
},
"dependencies": {
"@aws-sdk/client-s3": "3.940.0",
"@aws-sdk/client-s3": "^3.1071.0",
"next": "16.0.7",
"react": "19.2.1",
"react-dom": "19.2.1",
Expand Down
191 changes: 112 additions & 79 deletions src/app/api/block/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,32 @@ import { type NextRequest, NextResponse } from "next/server";
import { type Block, createPublicClient, type Hash, http } from "viem";
import { mainnet } from "viem/chains";
import {
type BlockData,
type BlockTransaction,
cacheBlockData,
bundleHistoryFromAuditEvents,
getAuditEventsByBlockNumber,
getAuditEventsByTransactionHash,
isAuditConfigured,
meterBundleResponseFromAuditEvent,
transactionMetadataFromAuditEvents,
} from "@/lib/audit-events";
import { calculateTransactionFee } from "@/lib/explorer-format";
import { getTransactionReceiptSummaries } from "@/lib/receipts";
import {
getBlockFromCache,
getBundleHistory,
getTransactionMetadataByHash,
} from "@/lib/s3";
import type {
BlockData,
BlockTransaction,
BundleEvent,
} from "@/lib/transaction-data";

const BLOCK_EVENT_TYPES = new Set([
"BUILDER_FLASHBLOCK_STARTED",
"BUILDER_FLASHBLOCK_PUBLISHED",
"BUILDER_FLASHBLOCK_BUILD_STOPPED",
"BUILDER_PAYLOAD_FINALIZED",
]);

function serializeBlockData(block: BlockData) {
return {
Expand All @@ -17,6 +36,7 @@ function serializeBlockData(block: BlockData) {
timestamp: block.timestamp.toString(),
gasUsed: block.gasUsed.toString(),
gasLimit: block.gasLimit.toString(),
baseFeePerGas: block.baseFeePerGas?.toString() ?? null,
transactions: block.transactions.map((tx) => {
let metering: { transaction: unknown; bundle: unknown } | null = null;
if (tx.meterBundleResponse) {
Expand All @@ -26,14 +46,23 @@ function serializeBlockData(block: BlockData) {
}
return {
hash: tx.hash,
blockHash: tx.blockHash,
blockNumber: tx.blockNumber.toString(),
blockTimestamp: tx.blockTimestamp.toString(),
from: tx.from,
to: tx.to,
input: tx.input,
value: tx.value.toString(),
gasLimit: tx.gasLimit.toString(),
gasUsed: tx.gasUsed?.toString() ?? null,
effectiveGasPrice: tx.effectiveGasPrice?.toString() ?? null,
transactionFee: tx.transactionFee?.toString() ?? null,
bundleId: tx.bundleId,
index: tx.index,
metering,
};
}),
eventHistory: block.eventHistory ?? [],
};
}

Expand Down Expand Up @@ -78,7 +107,7 @@ async function fetchBlockFromRpcByNumber(
}
}

async function enrichTransactionWithBundleData(txHash: string): Promise<{
async function enrichTransactionFromS3(txHash: string): Promise<{
bundleId: string | null;
meterBundleResponse: Record<string, unknown> | null;
}> {
Expand All @@ -94,7 +123,7 @@ async function enrichTransactionWithBundleData(txHash: string): Promise<{
}

const receivedEvent = bundleHistory.history.find(
(e) => e.event === "Received",
(event) => event.event === "Received",
);
if (!receivedEvent?.data?.bundle?.meter_bundle_response) {
return { bundleId, meterBundleResponse: null };
Expand All @@ -107,19 +136,91 @@ async function enrichTransactionWithBundleData(txHash: string): Promise<{
};
}

async function enrichTransactionWithBundleData(txHash: string): Promise<{
bundleId: string | null;
meterBundleResponse: Record<string, unknown> | null;
}> {
if (!isAuditConfigured()) {
return enrichTransactionFromS3(txHash);
}

try {
const events = await getAuditEventsByTransactionHash(txHash);
const metadata = transactionMetadataFromAuditEvents(events);
const bundleId = metadata?.bundle_ids[0] ?? null;
if (bundleId !== null) {
const accepted = events.find(
(event) => event.event_type === "SIMULATION_SUCCEEDED",
);
return {
bundleId,
meterBundleResponse: accepted
? (meterBundleResponseFromAuditEvent(accepted) as unknown as Record<
string,
unknown
>)
: null,
};
}
} catch {
// Audit is an optional read path; retain the S3-backed behavior on errors.
}

return enrichTransactionFromS3(txHash);
}

async function getBlockEventHistory(
hash: Hash,
number: bigint,
): Promise<BundleEvent[]> {
if (!isAuditConfigured()) {
return [];
}

try {
return (
bundleHistoryFromAuditEvents(
hash,
(await getAuditEventsByBlockNumber(Number(number))).filter((event) =>
BLOCK_EVENT_TYPES.has(event.event_type),
),
)?.history ?? []
);
} catch (error) {
console.error("Failed to fetch block event history from audit RPC:", error);
return [];
}
}

async function buildAndCacheBlockData(
rpcBlock: Block<bigint, true>,
hash: Hash,
number: bigint,
): Promise<BlockData> {
const receiptByHash = await getTransactionReceiptSummaries(
rpcBlock.transactions.map((tx) => tx.hash),
);
const transactions: BlockTransaction[] = await Promise.all(
rpcBlock.transactions.map(async (tx, index) => {
const enriched = await enrichTransactionWithBundleData(tx.hash);
const receipt = receiptByHash.get(tx.hash);
const effectiveGasPrice = receipt?.effectiveGasPrice ?? null;
return {
hash: tx.hash,
blockHash: hash,
blockNumber: number,
blockTimestamp: rpcBlock.timestamp,
from: tx.from,
to: tx.to,
input: tx.input,
value: tx.value,
gasLimit: tx.gas,
gasUsed: receipt?.gasUsed ?? null,
effectiveGasPrice,
transactionFee: calculateTransactionFee(
receipt?.gasUsed ?? null,
effectiveGasPrice,
),
bundleId: enriched.bundleId,
index,
meterBundleResponse: enriched.meterBundleResponse,
Expand All @@ -132,65 +233,16 @@ async function buildAndCacheBlockData(
number,
timestamp: rpcBlock.timestamp,
transactions,
eventHistory: await getBlockEventHistory(hash, number),
gasUsed: rpcBlock.gasUsed,
gasLimit: rpcBlock.gasLimit,
baseFeePerGas: rpcBlock.baseFeePerGas ?? null,
cachedAt: Date.now(),
};

await cacheBlockData(blockData);

return blockData;
}

// On OP Stack, the first transaction (index 0) is the L1 attributes deposit transaction.
// This is not a perfect check (ideally we'd check tx.type === 'deposit' or type 0x7e),
// but sufficient for filtering out system transactions that don't need simulation data.
function isSystemTransaction(tx: BlockTransaction): boolean {
return tx.index === 0;
}

async function refetchMissingTransactionSimulations(
block: BlockData,
): Promise<{ updatedBlock: BlockData; hasUpdates: boolean }> {
const transactionsToRefetch = block.transactions.filter(
(tx) => tx.bundleId === null && !isSystemTransaction(tx),
);

if (transactionsToRefetch.length === 0) {
return { updatedBlock: block, hasUpdates: false };
}

const refetchResults = await Promise.all(
transactionsToRefetch.map(async (tx) => {
const enriched = await enrichTransactionWithBundleData(tx.hash);
return { hash: tx.hash, ...enriched };
}),
);

let hasUpdates = false;
const updatedTransactions = block.transactions.map((tx) => {
const refetchResult = refetchResults.find((r) => r.hash === tx.hash);
if (refetchResult && refetchResult.bundleId !== null) {
hasUpdates = true;
return {
...tx,
bundleId: refetchResult.bundleId,
meterBundleResponse: refetchResult.meterBundleResponse,
};
}
return tx;
});

return {
updatedBlock: {
...block,
transactions: updatedTransactions,
cachedAt: hasUpdates ? Date.now() : block.cachedAt,
},
hasUpdates,
};
}

export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ hash: string }> },
Expand All @@ -205,17 +257,6 @@ export async function GET(
return NextResponse.json({ error: "Block not found" }, { status: 404 });
}

// Check cache by resolved hash
const cachedBlock = await getBlockFromCache(rpcBlock.hash);
if (cachedBlock) {
const { updatedBlock, hasUpdates } =
await refetchMissingTransactionSimulations(cachedBlock);
if (hasUpdates) {
await cacheBlockData(updatedBlock);
}
return NextResponse.json(serializeBlockData(updatedBlock));
}

const blockData = await buildAndCacheBlockData(
rpcBlock,
rpcBlock.hash,
Expand All @@ -224,20 +265,12 @@ export async function GET(
return NextResponse.json(serializeBlockData(blockData));
}

const cachedBlock = await getBlockFromCache(identifier);
if (cachedBlock) {
const { updatedBlock, hasUpdates } =
await refetchMissingTransactionSimulations(cachedBlock);

if (hasUpdates) {
await cacheBlockData(updatedBlock);
}

return NextResponse.json(serializeBlockData(updatedBlock));
}

const rpcBlock = await fetchBlockFromRpc(identifier);
if (!rpcBlock || !rpcBlock.hash || !rpcBlock.number) {
const cachedBlock = await getBlockFromCache(identifier);
if (cachedBlock) {
return NextResponse.json(serializeBlockData(cachedBlock));
}
return NextResponse.json({ error: "Block not found" }, { status: 404 });
}

Expand Down
Loading
Loading