Skip to content

Commit be37779

Browse files
authored
feat: add block view and per block execution info (#91)
* feat: block listing & overview * fixes * downgrade viem * fix lints
1 parent bd47768 commit be37779

File tree

10 files changed

+2175
-1062
lines changed

10 files changed

+2175
-1062
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ TIPS_AUDIT_S3_SECRET_ACCESS_KEY=minioadmin
3333

3434
# TIPS UI
3535
NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://base.blockscout.com
36+
TIPS_UI_RPC_URL=http://localhost:8549
3637
TIPS_UI_AWS_REGION=us-east-1
3738
TIPS_UI_S3_BUCKET_NAME=tips
3839
TIPS_UI_S3_CONFIG_TYPE=manual

ui/biome.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",
@@ -9,6 +9,12 @@
99
"ignoreUnknown": true,
1010
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
1111
},
12+
"css": {
13+
"parser": {
14+
"cssModules": true,
15+
"tailwindDirectives": true
16+
}
17+
},
1218
"formatter": {
1319
"enabled": true,
1420
"indentStyle": "space",

ui/package.json

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@
1010
"format": "biome format --write"
1111
},
1212
"dependencies": {
13-
"@aws-sdk/client-s3": "^3.888.0",
14-
"next": "15.5.3",
13+
"@aws-sdk/client-s3": "3.940.0",
14+
"next": "15.5.6",
1515
"react": "19.1.0",
16-
"react-dom": "19.1.0"
16+
"react-dom": "19.1.0",
17+
"viem": "2.40.3"
1718
},
1819
"devDependencies": {
19-
"@biomejs/biome": "2.2.0",
20-
"@tailwindcss/postcss": "^4",
21-
"@types/node": "^20",
22-
"@types/react": "^19",
23-
"@types/react-dom": "^19",
24-
"tailwindcss": "^4",
25-
"typescript": "^5"
20+
"@biomejs/biome": "2.3.8",
21+
"@tailwindcss/postcss": "4.1.17",
22+
"@types/node": "20.19.25",
23+
"@types/react": "19.1.2",
24+
"@types/react-dom": "19.1.2",
25+
"tailwindcss": "4.1.17",
26+
"typescript": "5.9.3"
2627
}
2728
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { type NextRequest, NextResponse } from "next/server";
2+
import { type Block, createPublicClient, type Hash, http } from "viem";
3+
import { mainnet } from "viem/chains";
4+
import {
5+
type BlockData,
6+
type BlockTransaction,
7+
cacheBlockData,
8+
getBlockFromCache,
9+
getBundleHistory,
10+
getTransactionMetadataByHash,
11+
type MeterBundleResult,
12+
} from "@/lib/s3";
13+
14+
function serializeBlockData(block: BlockData) {
15+
return {
16+
...block,
17+
number: block.number.toString(),
18+
timestamp: block.timestamp.toString(),
19+
gasUsed: block.gasUsed.toString(),
20+
gasLimit: block.gasLimit.toString(),
21+
transactions: block.transactions.map((tx) => ({
22+
...tx,
23+
gasUsed: tx.gasUsed.toString(),
24+
})),
25+
};
26+
}
27+
28+
const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545";
29+
30+
const client = createPublicClient({
31+
chain: mainnet,
32+
transport: http(RPC_URL),
33+
});
34+
35+
async function fetchBlockFromRpc(
36+
blockHash: string,
37+
): Promise<Block<bigint, true> | null> {
38+
try {
39+
const block = await client.getBlock({
40+
blockHash: blockHash as Hash,
41+
includeTransactions: true,
42+
});
43+
return block;
44+
} catch (error) {
45+
console.error("Failed to fetch block from RPC:", error);
46+
return null;
47+
}
48+
}
49+
50+
async function enrichTransactionWithBundleData(
51+
txHash: string,
52+
): Promise<{ bundleId: string | null; executionTimeUs: number | null }> {
53+
const metadata = await getTransactionMetadataByHash(txHash);
54+
if (!metadata || metadata.bundle_ids.length === 0) {
55+
return { bundleId: null, executionTimeUs: null };
56+
}
57+
58+
const bundleId = metadata.bundle_ids[0];
59+
const bundleHistory = await getBundleHistory(bundleId);
60+
if (!bundleHistory) {
61+
return { bundleId, executionTimeUs: null };
62+
}
63+
64+
const receivedEvent = bundleHistory.history.find(
65+
(e) => e.event === "Received",
66+
);
67+
if (!receivedEvent?.data?.bundle?.meter_bundle_response?.results) {
68+
return { bundleId, executionTimeUs: null };
69+
}
70+
71+
const txResult = receivedEvent.data.bundle.meter_bundle_response.results.find(
72+
(r: MeterBundleResult) => r.txHash.toLowerCase() === txHash.toLowerCase(),
73+
);
74+
75+
return {
76+
bundleId,
77+
executionTimeUs: txResult?.executionTimeUs ?? null,
78+
};
79+
}
80+
81+
export async function GET(
82+
_request: NextRequest,
83+
{ params }: { params: Promise<{ hash: string }> },
84+
) {
85+
try {
86+
const { hash } = await params;
87+
88+
const cachedBlock = await getBlockFromCache(hash);
89+
if (cachedBlock) {
90+
return NextResponse.json(serializeBlockData(cachedBlock));
91+
}
92+
93+
const rpcBlock = await fetchBlockFromRpc(hash);
94+
if (!rpcBlock || !rpcBlock.hash || !rpcBlock.number) {
95+
return NextResponse.json({ error: "Block not found" }, { status: 404 });
96+
}
97+
98+
const transactions: BlockTransaction[] = await Promise.all(
99+
rpcBlock.transactions.map(async (tx, index) => {
100+
const { bundleId, executionTimeUs } =
101+
await enrichTransactionWithBundleData(tx.hash);
102+
return {
103+
hash: tx.hash,
104+
from: tx.from,
105+
to: tx.to,
106+
gasUsed: tx.gas,
107+
executionTimeUs,
108+
bundleId,
109+
index,
110+
};
111+
}),
112+
);
113+
114+
const blockData: BlockData = {
115+
hash: rpcBlock.hash,
116+
number: rpcBlock.number,
117+
timestamp: rpcBlock.timestamp,
118+
transactions,
119+
gasUsed: rpcBlock.gasUsed,
120+
gasLimit: rpcBlock.gasLimit,
121+
cachedAt: Date.now(),
122+
};
123+
124+
await cacheBlockData(blockData);
125+
126+
return NextResponse.json(serializeBlockData(blockData));
127+
} catch (error) {
128+
console.error("Error fetching block data:", error);
129+
return NextResponse.json(
130+
{ error: "Internal server error" },
131+
{ status: 500 },
132+
);
133+
}
134+
}

ui/src/app/api/blocks/route.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { NextResponse } from "next/server";
2+
3+
const RPC_URL = process.env.TIPS_UI_RPC_URL || "http://localhost:8545";
4+
5+
export interface BlockSummary {
6+
hash: string;
7+
number: number;
8+
timestamp: number;
9+
transactionCount: number;
10+
}
11+
12+
export interface BlocksResponse {
13+
blocks: BlockSummary[];
14+
}
15+
16+
async function fetchLatestBlockNumber(): Promise<number | null> {
17+
try {
18+
const response = await fetch(RPC_URL, {
19+
method: "POST",
20+
headers: { "Content-Type": "application/json" },
21+
body: JSON.stringify({
22+
jsonrpc: "2.0",
23+
method: "eth_blockNumber",
24+
params: [],
25+
id: 1,
26+
}),
27+
});
28+
29+
const data = await response.json();
30+
if (data.error || !data.result) {
31+
return null;
32+
}
33+
34+
return parseInt(data.result, 16);
35+
} catch (error) {
36+
console.error("Failed to fetch latest block number:", error);
37+
return null;
38+
}
39+
}
40+
41+
async function fetchBlockByNumber(
42+
blockNumber: number,
43+
): Promise<BlockSummary | null> {
44+
try {
45+
const response = await fetch(RPC_URL, {
46+
method: "POST",
47+
headers: { "Content-Type": "application/json" },
48+
body: JSON.stringify({
49+
jsonrpc: "2.0",
50+
method: "eth_getBlockByNumber",
51+
params: [`0x${blockNumber.toString(16)}`, false],
52+
id: 1,
53+
}),
54+
});
55+
56+
const data = await response.json();
57+
if (data.error || !data.result) {
58+
return null;
59+
}
60+
61+
const block = data.result;
62+
return {
63+
hash: block.hash,
64+
number: parseInt(block.number, 16),
65+
timestamp: parseInt(block.timestamp, 16),
66+
transactionCount: block.transactions?.length ?? 0,
67+
};
68+
} catch (error) {
69+
console.error(`Failed to fetch block ${blockNumber}:`, error);
70+
return null;
71+
}
72+
}
73+
74+
export async function GET() {
75+
try {
76+
const latestBlockNumber = await fetchLatestBlockNumber();
77+
if (latestBlockNumber === null) {
78+
return NextResponse.json(
79+
{ error: "Failed to fetch latest block" },
80+
{ status: 500 },
81+
);
82+
}
83+
84+
const blockNumbers = Array.from(
85+
{ length: 10 },
86+
(_, i) => latestBlockNumber - i,
87+
).filter((n) => n >= 0);
88+
89+
const blocks = await Promise.all(blockNumbers.map(fetchBlockByNumber));
90+
91+
const validBlocks = blocks.filter(
92+
(block): block is BlockSummary => block !== null,
93+
);
94+
95+
const response: BlocksResponse = { blocks: validBlocks };
96+
97+
return NextResponse.json(response);
98+
} catch (error) {
99+
console.error("Error fetching blocks:", error);
100+
return NextResponse.json(
101+
{ error: "Internal server error" },
102+
{ status: 500 },
103+
);
104+
}
105+
}

0 commit comments

Comments
 (0)