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
119 changes: 48 additions & 71 deletions src/app/api/block/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
getBlockFromCache,
getBundleHistory,
getTransactionMetadataByHash,
type MeterBundleResult,
} from "@/lib/s3";

function serializeBlockData(block: BlockData) {
Expand All @@ -18,11 +17,23 @@ function serializeBlockData(block: BlockData) {
timestamp: block.timestamp.toString(),
gasUsed: block.gasUsed.toString(),
gasLimit: block.gasLimit.toString(),
transactions: block.transactions.map((tx) => ({
...tx,
gasLimit: tx.gasLimit.toString(),
gasUsed: tx.gasUsed?.toString() ?? null,
})),
transactions: block.transactions.map((tx) => {
let metering: { transaction: unknown; bundle: unknown } | null = null;
if (tx.meterBundleResponse) {
const { results, ...bundle } = tx.meterBundleResponse;
const txResults = results as unknown[] | undefined;
metering = { transaction: txResults?.[0] ?? null, bundle };
}
return {
hash: tx.hash,
from: tx.from,
to: tx.to,
gasLimit: tx.gasLimit.toString(),
bundleId: tx.bundleId,
index: tx.index,
metering,
};
}),
};
}

Expand Down Expand Up @@ -67,6 +78,35 @@ async function fetchBlockFromRpcByNumber(
}
}

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

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

const receivedEvent = bundleHistory.history.find(
(e) => e.event === "Received",
);
if (!receivedEvent?.data?.bundle?.meter_bundle_response) {
return { bundleId, meterBundleResponse: null };
}

return {
bundleId,
meterBundleResponse: receivedEvent.data.bundle
.meter_bundle_response as unknown as Record<string, unknown>,
};
}

async function buildAndCacheBlockData(
rpcBlock: Block<bigint, true>,
hash: Hash,
Expand All @@ -80,11 +120,9 @@ async function buildAndCacheBlockData(
from: tx.from,
to: tx.to,
gasLimit: tx.gas,
gasUsed: enriched.gasUsed != null ? BigInt(enriched.gasUsed) : null,
executionTimeUs: enriched.executionTimeUs,
stateRootTimeUs: enriched.stateRootTimeUs,
bundleId: enriched.bundleId,
index,
meterBundleResponse: enriched.meterBundleResponse,
};
}),
);
Expand All @@ -111,63 +149,6 @@ function isSystemTransaction(tx: BlockTransaction): boolean {
return tx.index === 0;
}

async function enrichTransactionWithBundleData(txHash: string): Promise<{
bundleId: string | null;
executionTimeUs: number | null;
stateRootTimeUs: number | null;
gasUsed: number | null;
}> {
const metadata = await getTransactionMetadataByHash(txHash);
if (!metadata || metadata.bundle_ids.length === 0) {
return {
bundleId: null,
executionTimeUs: null,
stateRootTimeUs: null,
gasUsed: null,
};
}

const bundleId = metadata.bundle_ids[0];
const bundleHistory = await getBundleHistory(bundleId);
if (!bundleHistory) {
return {
bundleId,
executionTimeUs: null,
stateRootTimeUs: null,
gasUsed: null,
};
}

const receivedEvent = bundleHistory.history.find(
(e) => e.event === "Received",
);
if (!receivedEvent?.data?.bundle?.meter_bundle_response?.results) {
return {
bundleId,
executionTimeUs: null,
stateRootTimeUs: null,
gasUsed: null,
};
}

const meterResponse = receivedEvent.data.bundle.meter_bundle_response;

// TODO: Switch to meterResponse.totalExecutionTimeUs once 0.7 is deployed.
// On 0.6, totalExecutionTimeUs is the wall-clock total_time_us which includes
// setup, teardown, and state root (double-counting stateRootTimeUs). PR #1111
// fixes this on main to be the sum of per-tx execution times.
const txResult = meterResponse.results.find(
(r: MeterBundleResult) => r.txHash.toLowerCase() === txHash.toLowerCase(),
);

return {
bundleId,
executionTimeUs: txResult?.executionTimeUs ?? null,
stateRootTimeUs: meterResponse.stateRootTimeUs ?? null,
gasUsed: txResult?.gasUsed ?? null,
};
}

async function refetchMissingTransactionSimulations(
block: BlockData,
): Promise<{ updatedBlock: BlockData; hasUpdates: boolean }> {
Expand All @@ -194,11 +175,7 @@ async function refetchMissingTransactionSimulations(
return {
...tx,
bundleId: refetchResult.bundleId,
executionTimeUs: refetchResult.executionTimeUs,
stateRootTimeUs: refetchResult.stateRootTimeUs,
...(refetchResult.gasUsed != null && {
gasUsed: BigInt(refetchResult.gasUsed),
}),
meterBundleResponse: refetchResult.meterBundleResponse,
};
}
return tx;
Expand Down
64 changes: 40 additions & 24 deletions src/app/block/[hash]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import type { BlockData, BlockTransaction } from "@/lib/s3";

const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL;

// The API serializes metering as { transaction, bundle } from the raw
// meterBundleResponse. The page receives this shape via JSON.
// biome-ignore lint/suspicious/noExplicitAny: opaque metering JSON
function metering(tx: BlockTransaction): any {
return (tx as any).metering;
}

function executionTimeUs(tx: BlockTransaction): number | null {
return metering(tx)?.transaction?.executionTimeUs ?? null;
}

function stateRootTimeUs(tx: BlockTransaction): number | null {
return metering(tx)?.bundle?.stateRootTimeUs ?? null;
}

function gasUsed(tx: BlockTransaction): number | null {
return metering(tx)?.transaction?.gasUsed ?? null;
}

interface PageProps {
params: Promise<{ hash: string }>;
}
Expand Down Expand Up @@ -78,11 +97,11 @@ function Card({
}

function getHeatmapStyle(
executionTimeUs: number,
timeUs: number,
maxTime: number,
): { bg: string; text: string } {
if (maxTime === 0) return { bg: "bg-amber-50", text: "text-amber-700" };
const ratio = Math.min(executionTimeUs / maxTime, 1);
const ratio = Math.min(timeUs / maxTime, 1);
if (ratio < 0.2) return { bg: "bg-amber-100", text: "text-amber-800" };
if (ratio < 0.4) return { bg: "bg-amber-200", text: "text-amber-900" };
if (ratio < 0.6) return { bg: "bg-orange-200", text: "text-orange-900" };
Expand All @@ -98,13 +117,14 @@ function TransactionRow({
maxTotalTime: number;
}) {
const hasBundle = tx.bundleId !== null;
const hasExecutionTime = tx.executionTimeUs !== null;
const executionTime = tx.executionTimeUs ?? 0;
const stateRootTime = tx.stateRootTimeUs ?? 0;
const totalTime = executionTime + stateRootTime;
const heatmapStyle = hasExecutionTime
const execTime = executionTimeUs(tx);
const srTime = stateRootTimeUs(tx);
const hasMetering = execTime !== null;
const totalTime = (execTime ?? 0) + (srTime ?? 0);
const heatmapStyle = hasMetering
? getHeatmapStyle(totalTime, maxTotalTime)
: null;
const txGasUsed = gasUsed(tx);

const content = (
<div
Expand Down Expand Up @@ -142,7 +162,7 @@ function TransactionRow({
</div>
</div>
<div className="text-right">
{hasExecutionTime && heatmapStyle ? (
{hasMetering && heatmapStyle ? (
<span
className={`inline-block px-2 py-0.5 rounded text-sm font-medium ${heatmapStyle.bg} ${heatmapStyle.text}`}
>
Expand All @@ -152,8 +172,8 @@ function TransactionRow({
<div className="text-sm font-medium text-gray-400">—</div>
)}
<div className="text-xs text-gray-500 mt-0.5">
{tx.gasUsed != null
? `${tx.gasUsed.toLocaleString()} / ${tx.gasLimit.toLocaleString()} gas`
{txGasUsed != null
? `${txGasUsed.toLocaleString()} / ${tx.gasLimit.toLocaleString()} gas`
: `${tx.gasLimit.toLocaleString()} gas limit`}
</div>
</div>
Expand All @@ -168,15 +188,15 @@ function TransactionRow({
}

function BlockStats({ block }: { block: BlockData }) {
const txsWithTime = block.transactions.filter(
(tx) => tx.executionTimeUs !== null,
const meteredTxs = block.transactions.filter(
(tx) => executionTimeUs(tx) !== null,
);
const totalExecutionTime = txsWithTime.reduce(
(sum, tx) => sum + (tx.executionTimeUs ?? 0),
const totalExecTime = meteredTxs.reduce(
(sum, tx) => sum + (executionTimeUs(tx) ?? 0),
0,
);
const totalStateRootTime = txsWithTime.reduce(
(sum, tx) => sum + (tx.stateRootTimeUs ?? 0),
const totalSrTime = meteredTxs.reduce(
(sum, tx) => sum + (stateRootTimeUs(tx) ?? 0),
0,
);
const bundleCount = block.transactions.filter(
Expand Down Expand Up @@ -208,17 +228,13 @@ function BlockStats({ block }: { block: BlockData }) {
<div>
<div className="text-xs text-gray-500 mb-1">Total Exec Time</div>
<div className="text-xl font-semibold text-gray-900">
{totalExecutionTime > 0
? `${totalExecutionTime.toLocaleString()}μs`
: "—"}
{totalExecTime > 0 ? `${totalExecTime.toLocaleString()}μs` : "—"}
</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Total State Root</div>
<div className="text-xl font-semibold text-gray-900">
{totalStateRootTime > 0
? `${totalStateRootTime.toLocaleString()}μs`
: "—"}
{totalSrTime > 0 ? `${totalSrTime.toLocaleString()}μs` : "—"}
</div>
</div>
</div>
Expand Down Expand Up @@ -303,8 +319,8 @@ export default function BlockPage({ params }: PageProps) {
const maxTotalTime = data
? Math.max(
...data.transactions
.filter((tx) => tx.executionTimeUs !== null)
.map((tx) => (tx.executionTimeUs ?? 0) + (tx.stateRootTimeUs ?? 0)),
.filter((tx) => executionTimeUs(tx) !== null)
.map((tx) => (executionTimeUs(tx) ?? 0) + (stateRootTimeUs(tx) ?? 0)),
0,
)
: 0;
Expand Down
14 changes: 4 additions & 10 deletions src/lib/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,9 @@ export interface BlockTransaction {
from: string;
to: string | null;
gasLimit: bigint;
gasUsed: bigint | null;
executionTimeUs: number | null;
stateRootTimeUs: number | null;
bundleId: string | null;
index: number;
meterBundleResponse: Record<string, unknown> | null;
}

export interface BlockData {
Expand Down Expand Up @@ -223,14 +221,10 @@ export async function getBlockFromCache(
gasUsed: BigInt(parsed.gasUsed),
gasLimit: BigInt(parsed.gasLimit),
transactions: parsed.transactions.map(
(tx: {
gasLimit?: string;
gasUsed?: string | null;
[key: string]: unknown;
}) => ({
(tx: { gasLimit?: string; [key: string]: unknown }) => ({
...tx,
gasLimit: BigInt(tx.gasLimit ?? tx.gasUsed ?? "0"),
gasUsed: tx.gasUsed != null ? BigInt(tx.gasUsed) : null,
gasLimit: BigInt(tx.gasLimit ?? "0"),
meterBundleResponse: tx.meterBundleResponse ?? null,
}),
),
} as BlockData;
Expand Down
Loading