diff --git a/app/api/block/[height]/route.ts b/app/api/block/[height]/route.ts new file mode 100644 index 00000000..ebb9032d --- /dev/null +++ b/app/api/block/[height]/route.ts @@ -0,0 +1 @@ +export { GET } from '@/shared/api/server/block'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db516271..4966c845 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9623,7 +9623,7 @@ snapshots: safe-regex-test: 1.0.3 string.prototype.includes: 2.0.0 - eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.10.0(jiti@2.4.2)))(eslint@9.10.0(jiti@2.4.2))(prettier@3.4.2): + eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@9.10.0(jiti@2.4.2))(prettier@3.4.2): dependencies: eslint: 9.10.0(jiti@2.4.2) prettier: 3.4.2 @@ -10866,7 +10866,7 @@ snapshots: eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0(jiti@2.4.2)) eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0) eslint-plugin-import-x: 0.5.3(eslint@9.10.0(jiti@2.4.2))(typescript@5.7.2) - eslint-plugin-prettier: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.10.0(jiti@2.4.2)))(eslint@9.10.0(jiti@2.4.2))(prettier@3.4.2) + eslint-plugin-prettier: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@9.10.0(jiti@2.4.2))(prettier@3.4.2) eslint-plugin-react: 7.37.2(eslint@9.10.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.1.0-rc-c3cdbec0a7-20240708(eslint@9.10.0(jiti@2.4.2)) eslint-plugin-react-refresh: 0.4.11(eslint@9.10.0(jiti@2.4.2)) diff --git a/scripts/generate-pindexer-schema.ts b/scripts/generate-pindexer-schema.ts index 30ef3d76..8885bbde 100644 --- a/scripts/generate-pindexer-schema.ts +++ b/scripts/generate-pindexer-schema.ts @@ -14,6 +14,7 @@ const pindexerTableWhitelist = [ 'dex_ex_position_withdrawals', 'dex_ex_batch_swap_traces', 'dex_ex_metadata', + 'dex_ex_block_summary', ]; const envFileReady = (): boolean => { diff --git a/src/pages/inspect/block/api/block.ts b/src/pages/inspect/block/api/block.ts new file mode 100644 index 00000000..f0709866 --- /dev/null +++ b/src/pages/inspect/block/api/block.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { BlockSummaryApiResponse } from '@/shared/api/server/block/types'; +import { apiFetch } from '@/shared/utils/api-fetch'; + +export const useBlockSummary = (height: string) => { + return useQuery({ + queryKey: ['block', height], + retry: 1, + queryFn: async (): Promise => { + if (!height) { + throw new Error('Invalid block height'); + } + return apiFetch(`/api/block/${height}`); + }, + }); +}; diff --git a/src/pages/inspect/block/index.tsx b/src/pages/inspect/block/index.tsx index 1624500c..3b6c41c1 100644 --- a/src/pages/inspect/block/index.tsx +++ b/src/pages/inspect/block/index.tsx @@ -1,9 +1 @@ -import { Text } from '@penumbra-zone/ui/Text'; - -export function InspectBlock() { - return ( -
- Coming soon... -
- ); -} +export { InspectBlock } from './ui'; diff --git a/src/pages/inspect/block/ui/block-summary.tsx b/src/pages/inspect/block/ui/block-summary.tsx new file mode 100644 index 00000000..725eb80c --- /dev/null +++ b/src/pages/inspect/block/ui/block-summary.tsx @@ -0,0 +1,97 @@ +import { InfoCard } from '@/pages/explore/ui/info-card'; +import { Text } from '@penumbra-zone/ui/Text'; +import { TableCell } from '@penumbra-zone/ui/TableCell'; +import { BlockSummaryApiResponse } from '@/shared/api/server/block/types'; +import { ValueViewComponent } from '@penumbra-zone/ui/ValueView'; +import { pnum } from '@penumbra-zone/types/pnum'; + +export function BlockSummary({ blockSummary }: { blockSummary: BlockSummaryApiResponse }) { + if ('error' in blockSummary) { + return
Error: {blockSummary.error}
; + } + + return ( +
+
+ + + {blockSummary.numTxs} + + + + + {blockSummary.numSwaps} + + + + + {blockSummary.numSwapClaims} + + + + + {blockSummary.numOpenLps} + + + + + {blockSummary.numClosedLps} + + + + + {blockSummary.numWithdrawnLps} + + +
+
+
+ + Swaps + +
+
+
+ From + To + Price + Number of Hops +
+ {blockSummary.batchSwaps.length ? ( + blockSummary.batchSwaps.map(swap => ( +
+ + + + + + + + + {swap.endPrice} {swap.endAsset.symbol} + + + + {swap.numSwaps} + +
+ )) + ) : ( +
+ -- + -- + -- + -- +
+ )} +
+
+
+ ); +} diff --git a/src/pages/inspect/block/ui/index.tsx b/src/pages/inspect/block/ui/index.tsx new file mode 100644 index 00000000..6a586821 --- /dev/null +++ b/src/pages/inspect/block/ui/index.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useBlockSummary } from '../api/block'; +import { Card } from '@penumbra-zone/ui/Card'; +import { Skeleton } from '@/shared/ui/skeleton'; +import { BlockSummary } from './block-summary'; + +export function InspectBlock() { + const params = useParams<{ height: string }>(); + const blockheight = params?.height; + const { data: blockSummary, isError } = useBlockSummary(blockheight ?? ''); + + return ( +
+
+ {isError ? ( + +
+ Something went wrong while fetching the transaction. +
+
+ ) : ( + +
+ {blockSummary ? ( + + ) : ( +
+
+ +
+
+ +
+
+ +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/src/shared/api/server/block/index.ts b/src/shared/api/server/block/index.ts new file mode 100644 index 00000000..30492330 --- /dev/null +++ b/src/shared/api/server/block/index.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { pindexer } from '@/shared/database'; +import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { ChainRegistryClient, Registry } from '@penumbra-labs/registry'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; +import { pnum } from '@penumbra-zone/types/pnum'; +import { hexToUint8Array, base64ToHex } from '@penumbra-zone/types/hex'; +import { joinLoHi, LoHi } from '@penumbra-zone/types/lo-hi'; +import { serialize, Serialized } from '@/shared/utils/serializer'; +import { BatchSwapSummary as PindexerBatchSwapSummary } from '@/shared/database/schema'; +import { BatchSwapSummary, BlockSummaryApiResponse } from './types'; + +function isLoHi(value: unknown): value is LoHi { + return typeof value === 'object' && value !== null && 'lo' in value; +} + +interface AssetWithInner { + inner: string; +} + +function isAsset(value: unknown): value is AssetWithInner { + return ( + typeof value === 'object' && + value !== null && + 'inner' in value && + value.inner !== null && + typeof (value as AssetWithInner).inner === 'string' + ); +} + +export const getBatchSwapDisplayData = + (registry: Registry) => + (batchSwapSummary: PindexerBatchSwapSummary): BatchSwapSummary => { + if (!isLoHi(batchSwapSummary.input) || !isLoHi(batchSwapSummary.output)) { + throw new Error('Invalid input or output: expected LoHi type'); + } + + if (!isAsset(batchSwapSummary.asset_start) || !isAsset(batchSwapSummary.asset_end)) { + throw new Error('Invalid asset_start or asset_end'); + } + + const startAssetId = new AssetId({ + inner: hexToUint8Array(base64ToHex(batchSwapSummary.asset_start.inner)), + }); + const startMetadata = registry.getMetadata(startAssetId); + const startExponent = getDisplayDenomExponent.optional(startMetadata) ?? 0; + + const endAssetId = new AssetId({ + inner: hexToUint8Array(base64ToHex(batchSwapSummary.asset_end.inner)), + }); + const endMetadata = registry.getMetadata(endAssetId); + const endExponent = getDisplayDenomExponent.optional(endMetadata) ?? 0; + + const inputBigInt = joinLoHi(batchSwapSummary.input.lo, batchSwapSummary.input.hi); + const outputBigInt = joinLoHi(batchSwapSummary.output.lo, batchSwapSummary.output.hi); + + return { + startAsset: startMetadata, + endAsset: endMetadata, + startInput: pnum(inputBigInt, startExponent).toString(), + endOutput: pnum(outputBigInt, endExponent).toString(), + endPrice: pnum(outputBigInt / inputBigInt, endExponent).toFormattedString(), + numSwaps: batchSwapSummary.num_swaps, + }; + }; + +export async function GET( + _req: NextRequest, + { params }: { params: { height: string } }, +): Promise>> { + const chainId = process.env['PENUMBRA_CHAIN_ID']; + if (!chainId) { + return NextResponse.json({ error: 'PENUMBRA_CHAIN_ID is not set' }, { status: 500 }); + } + + const height = params.height; + if (!height) { + return NextResponse.json({ error: 'height is required' }, { status: 400 }); + } + + const registryClient = new ChainRegistryClient(); + const registry = await registryClient.remote.get(chainId); + + const blockSummary = await pindexer.getBlockSummary(Number(height)); + + if (!blockSummary) { + return NextResponse.json({ error: 'Block summary not found' }, { status: 404 }); + } + + return NextResponse.json( + serialize({ + height: blockSummary.height, + time: blockSummary.time, + batchSwaps: blockSummary.batch_swaps.map(getBatchSwapDisplayData(registry)), + numOpenLps: blockSummary.num_open_lps, + numClosedLps: blockSummary.num_closed_lps, + numWithdrawnLps: blockSummary.num_withdrawn_lps, + numSwaps: blockSummary.num_swaps, + numSwapClaims: blockSummary.num_swap_claims, + numTxs: blockSummary.num_txs, + }), + ); +} diff --git a/src/shared/api/server/block/types.ts b/src/shared/api/server/block/types.ts new file mode 100644 index 00000000..36d33e26 --- /dev/null +++ b/src/shared/api/server/block/types.ts @@ -0,0 +1,24 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +export interface BatchSwapSummary { + startAsset: Metadata; + endAsset: Metadata; + startInput: string; + endOutput: string; + endPrice: string; + numSwaps: number; +} + +export type BlockSummaryApiResponse = + | { + height: number; + time: Date; + batchSwaps: BatchSwapSummary[]; + numOpenLps: number; + numClosedLps: number; + numWithdrawnLps: number; + numSwaps: number; + numSwapClaims: number; + numTxs: number; + } + | { error: string }; diff --git a/src/shared/database/index.ts b/src/shared/database/index.ts index 692f309d..30cfa4aa 100644 --- a/src/shared/database/index.ts +++ b/src/shared/database/index.ts @@ -7,6 +7,7 @@ import { DexExPositionExecutions, DexExPositionReserves, DexExPositionWithdrawals, + DexExBlockSummary, DexExTransactions, } from '@/shared/database/schema.ts'; import { AssetId } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; @@ -466,6 +467,14 @@ class Pindexer { })); } + async getBlockSummary(height: number): Promise | undefined> { + return this.db + .selectFrom('dex_ex_block_summary') + .selectAll() + .where('height', '=', height) + .executeTakeFirst(); + } + async getTransaction(txHash: string): Promise | undefined> { return this.db .selectFrom('dex_ex_transactions') diff --git a/src/shared/database/schema.ts b/src/shared/database/schema.ts index 91cfbe56..ee1313bd 100644 --- a/src/shared/database/schema.ts +++ b/src/shared/database/schema.ts @@ -197,6 +197,28 @@ export interface DexExPriceCharts { the_window: DurationWindow; } +export interface BatchSwapSummary { + asset_start: Buffer; + asset_end: Buffer; + input: string; + output: string; + num_swaps: number; + price_float: number; +} + +export interface DexExBlockSummary { + rowid: number; + height: number; + time: Date; + batch_swaps: BatchSwapSummary[]; + num_open_lps: number; + num_closed_lps: number; + num_withdrawn_lps: number; + num_swaps: number; + num_swap_claims: number; + num_txs: number; +} + export interface DexExTransactions { transaction_id: Buffer; transaction: Buffer; @@ -349,6 +371,7 @@ interface RawDB { dex_ex_position_state: DexExPositionState; dex_ex_position_withdrawals: DexExPositionWithdrawals; dex_ex_price_charts: DexExPriceCharts; + dex_ex_block_summary: DexExBlockSummary; dex_ex_transactions: DexExTransactions; governance_delegator_votes: GovernanceDelegatorVotes; governance_proposals: GovernanceProposals; @@ -379,5 +402,6 @@ export type DB = Pick< | 'dex_ex_position_withdrawals' | 'dex_ex_batch_swap_traces' | 'dex_ex_metadata' + | 'dex_ex_block_summary' | 'dex_ex_transactions' >;