diff --git a/.changeset/early-shrimps-guess.md b/.changeset/early-shrimps-guess.md new file mode 100644 index 000000000..71bc6a15d --- /dev/null +++ b/.changeset/early-shrimps-guess.md @@ -0,0 +1,5 @@ +--- +"@blobscan/api": minor +--- + +Added support for fetching blocks by slot diff --git a/packages/api/src/routers/block/common/index.ts b/packages/api/src/routers/block/common/index.ts index 15a3a2996..e37b6566b 100644 --- a/packages/api/src/routers/block/common/index.ts +++ b/packages/api/src/routers/block/common/index.ts @@ -1,2 +1,3 @@ +export * from "./queries"; export * from "./selects"; export * from "./serializers"; diff --git a/packages/api/src/routers/block/common/queries.ts b/packages/api/src/routers/block/common/queries.ts new file mode 100644 index 000000000..d0d5fe277 --- /dev/null +++ b/packages/api/src/routers/block/common/queries.ts @@ -0,0 +1,100 @@ +import type { BlobStorageManager } from "@blobscan/blob-storage-manager"; +import type { BlobscanPrismaClient, Prisma } from "@blobscan/db"; + +import type { Expands } from "../../../middlewares/withExpands"; +import type { Filters } from "../../../middlewares/withFilters"; +import { + calculateDerivedTxBlobGasFields, + retrieveBlobData, +} from "../../../utils"; +import { createBlockSelect } from "./selects"; +import type { QueriedBlock } from "./serializers"; + +export type BlockId = "hash" | "number" | "slot"; +export type BlockIdField = + | { type: "hash"; value: string } + | { type: "number"; value: number } + | { type: "slot"; value: number }; + +function buildBlockWhereClause( + { type, value }: BlockIdField, + filters: Filters +): Prisma.BlockWhereInput { + switch (type) { + case "hash": { + return { hash: value }; + } + case "number": { + return { number: value, transactionForks: filters.blockType }; + } + case "slot": { + return { slot: value, transactionForks: filters.blockType }; + } + } +} + +export async function fetchBlock( + blockId: BlockIdField, + { + blobStorageManager, + prisma, + filters, + expands, + }: { + blobStorageManager: BlobStorageManager; + prisma: BlobscanPrismaClient; + filters: Filters; + expands: Expands; + } +) { + const where = buildBlockWhereClause(blockId, filters); + + const queriedBlock = await prisma.block.findFirst({ + select: createBlockSelect(expands), + where, + }); + + if (!queriedBlock) { + return; + } + + const block: QueriedBlock = queriedBlock; + + if (expands.transaction) { + block.transactions = block.transactions.map((tx) => { + const { blobAsCalldataGasUsed, blobGasUsed, gasPrice, maxFeePerBlobGas } = + tx; + const derivedFields = + maxFeePerBlobGas && blobAsCalldataGasUsed && blobGasUsed && gasPrice + ? calculateDerivedTxBlobGasFields({ + blobAsCalldataGasUsed, + blobGasUsed, + gasPrice, + blobGasPrice: block.blobGasPrice, + maxFeePerBlobGas, + }) + : {}; + + return { + ...tx, + ...derivedFields, + }; + }); + } + + if (expands.blobData) { + const txsBlobs = block.transactions.flatMap((tx) => tx.blobs); + + await Promise.all( + txsBlobs.map(async ({ blob }) => { + if (blob.dataStorageReferences?.length) { + const data = await retrieveBlobData(blobStorageManager, blob); + + blob.data = data; + } + }) + ); + } + + return block; +} diff --git a/packages/api/src/routers/block/getByBlockId.ts b/packages/api/src/routers/block/getByBlockId.ts index 157f33584..a7daf09b2 100644 --- a/packages/api/src/routers/block/getByBlockId.ts +++ b/packages/api/src/routers/block/getByBlockId.ts @@ -1,6 +1,6 @@ import { TRPCError } from "@trpc/server"; -import { z } from "@blobscan/zod"; +import { hashSchema, z } from "@blobscan/zod"; import { createExpandsSchema, @@ -11,35 +11,16 @@ import { withTypeFilterSchema, } from "../../middlewares/withFilters"; import { publicProcedure } from "../../procedures"; -import { calculateDerivedTxBlobGasFields, retrieveBlobData } from "../../utils"; -import { - createBlockSelect, - serializeBlock, - serializedBlockSchema, -} from "./common"; -import type { QueriedBlock } from "./common"; +import type { BlockIdField } from "./common"; +import { fetchBlock, serializeBlock, serializedBlockSchema } from "./common"; -const blockIdSchema = z - .string() - .refine( - (id) => { - const isHash = id.startsWith("0x") && id.length === 66; - const s_ = Number(id); - const isNumber = !isNaN(s_) && s_ > 0; +const blockHashSchema = hashSchema.refine((value) => value.length === 66, { + message: "Block hashes must be 66 characters long", +}); - return isHash || isNumber; - }, - { - message: "Invalid block id", - } - ) - .transform((id) => { - if (id.startsWith("0x")) { - return id; - } +const blockNumberSchema = z.coerce.number().int().positive(); - return Number(id); - }); +const blockIdSchema = z.union([blockHashSchema, blockNumberSchema]); const inputSchema = z .object({ @@ -68,64 +49,36 @@ export const getByBlockId = publicProcedure ctx: { blobStorageManager, prisma, expands, filters }, input: { id }, }) => { - const isNumber = typeof id === "number"; + let blockIdField: BlockIdField | undefined; - const queriedBlock = await prisma.block.findFirst({ - select: createBlockSelect(expands), - where: { - [isNumber ? "number" : "hash"]: id, - // Hash is unique, so we don't need to filter by transaction forks if we're querying by it - transactionForks: isNumber ? filters.blockType : undefined, - }, - }); + const parsedHash = blockHashSchema.safeParse(id); + const parsedBlockNumber = blockNumberSchema.safeParse(id); - if (!queriedBlock) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `No block with id '${id}'.`, - }); + if (parsedHash.success) { + blockIdField = { type: "hash", value: parsedHash.data }; + } else if (parsedBlockNumber.success) { + blockIdField = { type: "number", value: parsedBlockNumber.data }; } - const block: QueriedBlock = queriedBlock; - - if (expands.transaction) { - block.transactions = block.transactions.map((tx) => { - const { - blobAsCalldataGasUsed, - blobGasUsed, - gasPrice, - maxFeePerBlobGas, - } = tx; - const derivedFields = - maxFeePerBlobGas && blobAsCalldataGasUsed && blobGasUsed && gasPrice - ? calculateDerivedTxBlobGasFields({ - blobAsCalldataGasUsed, - blobGasUsed, - gasPrice, - blobGasPrice: block.blobGasPrice, - maxFeePerBlobGas, - }) - : {}; - - return { - ...tx, - ...derivedFields, - }; + if (!blockIdField) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid block id "${id}"`, }); } - if (expands.blobData) { - const txsBlobs = block.transactions.flatMap((tx) => tx.blobs); - - await Promise.all( - txsBlobs.map(async ({ blob }) => { - if (blob.dataStorageReferences?.length) { - const data = await retrieveBlobData(blobStorageManager, blob); + const block = await fetchBlock(blockIdField, { + blobStorageManager, + prisma, + filters, + expands, + }); - blob.data = data; - } - }) - ); + if (!block) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Block with id "${id}" not found`, + }); } return serializeBlock(block); diff --git a/packages/api/src/routers/block/getBySlot.ts b/packages/api/src/routers/block/getBySlot.ts new file mode 100644 index 000000000..86aded6f0 --- /dev/null +++ b/packages/api/src/routers/block/getBySlot.ts @@ -0,0 +1,59 @@ +import { TRPCError } from "@trpc/server"; + +import { z } from "@blobscan/zod"; + +import { + createExpandsSchema, + withExpands, +} from "../../middlewares/withExpands"; +import { + withFilters, + withSortFilterSchema, +} from "../../middlewares/withFilters"; +import { publicProcedure } from "../../procedures"; +import type { BlockIdField } from "./common"; +import { fetchBlock, serializeBlock } from "./common"; + +const inputSchema = z + .object({ + slot: z.coerce.number().int().positive(), + }) + .merge(withSortFilterSchema) + .merge(createExpandsSchema(["transaction", "blob", "blob_data"])); + +export const getBySlot = publicProcedure + .meta({ + openapi: { + method: "GET", + path: `/slots/{slot}`, + tags: ["slots"], + summary: "retrieves block details for given slot.", + }, + }) + .input(inputSchema) + .use(withExpands) + .use(withFilters) + .query( + async ({ + ctx: { blobStorageManager, prisma, filters, expands }, + input: { slot }, + }) => { + const blockIdField: BlockIdField = { type: "slot", value: slot }; + + const block = await fetchBlock(blockIdField, { + blobStorageManager, + prisma, + filters, + expands, + }); + + if (!block) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Block with slot ${slot} not found`, + }); + } + + return serializeBlock(block); + } + ); diff --git a/packages/api/src/routers/block/index.ts b/packages/api/src/routers/block/index.ts index 466c73898..0e5e1959b 100644 --- a/packages/api/src/routers/block/index.ts +++ b/packages/api/src/routers/block/index.ts @@ -1,12 +1,14 @@ import { t } from "../../trpc-client"; import { getAll } from "./getAll"; import { getByBlockId } from "./getByBlockId"; +import { getBySlot } from "./getBySlot"; import { getCount } from "./getCount"; import { getLatestBlock } from "./getGasPrice"; export const blockRouter = t.router({ getAll, getByBlockId, + getBySlot, getCount, getLatestBlock, }); diff --git a/packages/api/test/__snapshots__/block.test.ts.snap b/packages/api/test/__snapshots__/block.test.ts.snap index 78ad4efba..668631e3d 100644 --- a/packages/api/test/__snapshots__/block.test.ts.snap +++ b/packages/api/test/__snapshots__/block.test.ts.snap @@ -1920,3 +1920,89 @@ exports[`Block router > getByBlockId > when getting expanded block results > sho ], } `; + +exports[`Block router > getBySlot > should fail when trying to get a block with a negative slot 1`] = ` +"[ + { + \\"code\\": \\"too_small\\", + \\"minimum\\": 0, + \\"type\\": \\"number\\", + \\"inclusive\\": false, + \\"exact\\": false, + \\"message\\": \\"Number must be greater than 0\\", + \\"path\\": [ + \\"slot\\" + ] + } +]" +`; + +exports[`Block router > getBySlot > should fail when trying to get a block with a negative slot 2`] = ` +[ZodError: [ + { + "code": "too_small", + "minimum": 0, + "type": "number", + "inclusive": false, + "exact": false, + "message": "Number must be greater than 0", + "path": [ + "slot" + ] + } +]] +`; + +exports[`Block router > getBySlot > should fail when trying to get a reorged block by slot 1`] = `"Block with slot 110 not found"`; + +exports[`Block router > getBySlot > should get a block by slot 1`] = ` +{ + "blobAsCalldataGasUsed": "2042780", + "blobGasPrice": "22", + "blobGasUsed": "786432", + "excessBlobGas": "15000", + "hash": "blockHash001", + "number": 1001, + "slot": 101, + "timestamp": "2022-10-16T12:00:00.000Z", + "transactions": [ + { + "blobs": [ + { + "versionedHash": "blobHash001", + }, + { + "versionedHash": "blobHash002", + }, + { + "versionedHash": "blobHash003", + }, + ], + "category": "other", + "hash": "txHash001", + }, + { + "blobs": [ + { + "versionedHash": "blobHash001", + }, + ], + "category": "other", + "hash": "txHash002", + }, + { + "blobs": [ + { + "versionedHash": "blobHash001", + }, + { + "versionedHash": "blobHash002", + }, + ], + "category": "rollup", + "hash": "txHash003", + "rollup": "optimism", + }, + ], +} +`; diff --git a/packages/api/test/block.test.ts b/packages/api/test/block.test.ts index 11f83cc86..ec19a613a 100644 --- a/packages/api/test/block.test.ts +++ b/packages/api/test/block.test.ts @@ -2,7 +2,7 @@ import type { inferProcedureInput } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { fixtures } from "@blobscan/test"; +import { fixtures, testValidError } from "@blobscan/test"; import type { TRPCContext } from "../src"; import type { AppRouter } from "../src/app-router"; @@ -69,6 +69,7 @@ describe("Block router", async () => { }; const result = await caller.block.getByBlockId(input); + expect(result).toMatchSnapshot(); }); @@ -113,7 +114,7 @@ describe("Block router", async () => { ).rejects.toThrow( new TRPCError({ code: "NOT_FOUND", - message: `No block with id '${invalidHash}'.`, + message: `Block with id "${invalidHash}" not found`, }) ); }); @@ -126,12 +127,45 @@ describe("Block router", async () => { ).rejects.toThrow( new TRPCError({ code: "NOT_FOUND", - message: `No block with id '9999'.`, + message: 'Block with id "9999" not found', }) ); }); }); + describe("getBySlot", () => { + it("should get a block by slot", async () => { + const result = await caller.block.getBySlot({ + slot: 101, + }); + + expect(result).toMatchSnapshot(); + }); + + testValidError( + "should fail when trying to get a reorged block by slot", + async () => { + await caller.block.getBySlot({ + slot: 110, + }); + }, + TRPCError + ); + + testValidError( + "should fail when trying to get a block with a negative slot", + async () => { + await caller.block.getBySlot({ + slot: -1, + }); + }, + TRPCError, + { + checkCause: true, + } + ); + }); + describe("getCount", () => { it("should return the overall total blocks stat when no filters are provided", async () => { await ctx.prisma.overallStats.aggregate(); diff --git a/packages/zod/src/schemas.ts b/packages/zod/src/schemas.ts index 658524d64..5b0113fb9 100644 --- a/packages/zod/src/schemas.ts +++ b/packages/zod/src/schemas.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +export const hashSchema = z + .string() + .regex(/^0x[a-fA-F0-9]+$/, "Invalid hex string"); + // We use this workaround instead of z.coerce.boolean.default(false) // because it considers as "true" any value different than "false" // (including the empty string).