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
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
458 changes: 170 additions & 288 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
139 changes: 38 additions & 101 deletions src/app/api/block/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ 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,
getBlockFromCache,
getBundleHistory,
getTransactionMetadataByHash,
} from "@/lib/s3";
bundleHistoryFromAuditEvents,
getAuditEventsByBlockNumber,
getAuditEventsByTransactionHash,
meterBundleResponseFromAuditEvent,
transactionMetadataFromAuditEvents,
} from "@/lib/audit-events";
import type { BlockData, BlockTransaction } from "@/lib/transaction-data";
import { getBlockFromCache } from "@/lib/s3";

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 @@ -34,6 +42,7 @@ function serializeBlockData(block: BlockData) {
metering,
};
}),
eventHistory: block.eventHistory ?? [],
};
}

Expand Down Expand Up @@ -82,28 +91,19 @@ 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",
const events = await getAuditEventsByTransactionHash(txHash);
const metadata = transactionMetadataFromAuditEvents(events);
const accepted = events.find(
(event) => event.event_type === "SIMULATION_SUCCEEDED",
);
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>,
bundleId: metadata?.bundle_ids[0] ?? null,
meterBundleResponse: accepted
? (meterBundleResponseFromAuditEvent(accepted) as unknown as Record<
string,
unknown
>)
: null,
};
}

Expand Down Expand Up @@ -132,65 +132,21 @@ async function buildAndCacheBlockData(
number,
timestamp: rpcBlock.timestamp,
transactions,
eventHistory:
bundleHistoryFromAuditEvents(
hash,
(await getAuditEventsByBlockNumber(Number(number))).filter((event) =>
BLOCK_EVENT_TYPES.has(event.event_type),
),
)?.history ?? [],
gasUsed: rpcBlock.gasUsed,
gasLimit: rpcBlock.gasLimit,
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 +161,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 +169,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
95 changes: 91 additions & 4 deletions src/app/api/bundle/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,108 @@
import { type NextRequest, NextResponse } from "next/server";
import { type BundleEvent, getBundleHistory } from "@/lib/s3";
import { createPublicClient, type Hash, http } from "viem";
import { mainnet } from "viem/chains";
import {
bundleHistoryFromAuditEvents,
getJoinedAuditEventsByBundle,
} from "@/lib/audit-events";
import type { BundleEvent, BundleTransaction } from "@/lib/transaction-data";
import { getBundleHistory } from "@/lib/s3";

const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545";

const client = createPublicClient({
chain: mainnet,
transport: http(RPC_URL),
});

export interface BundleHistoryResponse {
hash: string;
history: BundleEvent[];
}

function bigintToHex(value: bigint | null | undefined): string {
return value === null || value === undefined ? "0x0" : `0x${value.toString(16)}`;
}

function numberToHex(value: number | null | undefined): string {
return value === null || value === undefined ? "0x0" : `0x${value.toString(16)}`;
}

async function enrichBundleTransactionsFromRpc(
history: BundleEvent[],
): Promise<BundleEvent[]> {
const hashes = Array.from(
new Set(
history.flatMap((event) =>
event.data.bundle?.txs.map((tx) => tx.hash).filter(Boolean) ?? [],
),
),
);
const transactions = new Map<string, BundleTransaction>();

await Promise.all(
hashes.map(async (hash) => {
try {
const tx = await client.getTransaction({ hash: hash as Hash });
transactions.set(hash, {
signer: tx.from,
type: tx.typeHex ?? "",
chainId: numberToHex(tx.chainId),
nonce: numberToHex(tx.nonce),
gas: bigintToHex(tx.gas),
maxFeePerGas: bigintToHex(tx.maxFeePerGas ?? tx.gasPrice),
maxPriorityFeePerGas: bigintToHex(tx.maxPriorityFeePerGas),
to: tx.to,
value: bigintToHex(tx.value),
accessList: [...(tx.accessList ?? [])],
input: tx.input,
r: tx.r,
s: tx.s,
yParity: numberToHex(tx.yParity),
v: bigintToHex(tx.v),
hash: tx.hash,
});
} catch (error) {
console.error(`Failed to fetch transaction ${hash} from RPC:`, error);
}
}),
);

return history.map((event) => {
if (!event.data.bundle) return event;
return {
...event,
data: {
...event.data,
bundle: {
...event.data.bundle,
txs: event.data.bundle.txs.map((tx) => transactions.get(tx.hash) ?? tx),
},
},
};
});
}

export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ hash: string }> },
) {
try {
const { hash } = await params;

const bundle = await getBundleHistory(hash);
const bundle = await getAuditBundleHistory(hash);
if (!bundle) {
return NextResponse.json({ error: "Bundle not found" }, { status: 404 });
}

const history = bundle.history;
const history = await enrichBundleTransactionsFromRpc(bundle.history);
history.sort((lhs, rhs) =>
lhs.data.timestamp < rhs.data.timestamp ? -1 : 1,
);

const response: BundleHistoryResponse = {
hash,
history: bundle.history,
history,
};

return NextResponse.json(response);
Expand All @@ -37,3 +114,13 @@ export async function GET(
);
}
}

async function getAuditBundleHistory(hash: string) {
try {
const events = await getJoinedAuditEventsByBundle(hash);
return bundleHistoryFromAuditEvents(hash, events) ?? getBundleHistory(hash);
} catch (error) {
console.error("Falling back to S3 bundle history:", error);
return getBundleHistory(hash);
}
}
Loading
Loading