From 3d391d974cc3ca2335ba59046a54c64fc27f12a0 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Tue, 26 May 2026 02:30:25 +0700 Subject: [PATCH] feat: add zkapp command range query --- .env.example.compose | 2 + README.md | 3 + docs/getting-started.md | 2 + schema.graphql | 77 ++++ src/blockchain/types.ts | 51 +++ .../archive-node-adapter.interface.ts | 18 +- .../archive-node-adapter.ts | 13 + src/db/sql/events-actions/queries.ts | 8 + src/db/sql/zkapp-commands/queries.ts | 359 ++++++++++++++++++ src/db/sql/zkapp-commands/types.ts | 43 +++ src/envionment.d.ts | 4 + src/resolvers-types.ts | 222 +++++++++++ src/resolvers.ts | 76 ++-- src/server/server.ts | 12 +- .../zkapp-commands-service.interface.ts | 9 + .../zkapp-commands-service.ts | 181 +++++++++ tests/integration/integration.test.ts | 33 +- tests/integration/setup.ts | 57 +++ .../zkapp-commands-service.test.ts | 171 +++++++++ 19 files changed, 1302 insertions(+), 39 deletions(-) create mode 100644 src/db/sql/zkapp-commands/queries.ts create mode 100644 src/db/sql/zkapp-commands/types.ts create mode 100644 src/services/zkapp-commands-service/zkapp-commands-service.interface.ts create mode 100644 src/services/zkapp-commands-service/zkapp-commands-service.ts create mode 100644 tests/services/zkapp-commands-service/zkapp-commands-service.test.ts diff --git a/.env.example.compose b/.env.example.compose index 12bb81d..a6f413b 100644 --- a/.env.example.compose +++ b/.env.example.compose @@ -41,3 +41,5 @@ JAEGER_FRONTEND=16686 JAEGER_LOG_PORT=14268 BLOCK_RANGE_SIZE=10000 +ZKAPP_COMMAND_RANGE_SIZE=1000 +ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT=5000 diff --git a/README.md b/README.md index 2a413d3..a86d462 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \ | `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` | | `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection | | `ENABLE_LOGGING` | `false` | Enable request logging | +| `BLOCK_RANGE_SIZE` | `10000` | Max block range a single query may span | +| `ZKAPP_COMMAND_RANGE_SIZE` | `1000` | Max block range for `zkappCommands` | +| `ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT` | `5000` | Max expanded account updates for one `zkappCommands` query | | `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | | `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` | diff --git a/docs/getting-started.md b/docs/getting-started.md index 67a52be..f28ba72 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -182,6 +182,8 @@ The server reads config from environment variables. `PG_CONN` is the only requir | `ENABLE_INTROSPECTION` | `false` | If `true`, allows GraphQL schema introspection | | `ENABLE_LOGGING` | `false` | Enable request logging | | `BLOCK_RANGE_SIZE` | `10000` | Max block range a single query may span | +| `ZKAPP_COMMAND_RANGE_SIZE` | `1000` | Max block range for `zkappCommands` | +| `ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT` | `5000` | Max expanded account updates for one `zkappCommands` query | | `ENABLE_BLOCK_TRANSACTION_DETAILS` | `false` | Include `userCommands` / `zkappCommands` / `feeTransfers` | | `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector | | `JAEGER_SERVICE_NAME` | `archive-api` | Service name reported to Jaeger | diff --git a/schema.graphql b/schema.graphql index ec58a06..84b9e9e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -66,6 +66,31 @@ input ActionFilterOptionsInput { endActionState: String } +""" +Filter successful zkApp commands by block range. + +Both `from` and `to` are required. The range is bounded by `ZKAPP_COMMAND_RANGE_SIZE`, and the server may reject dense ranges that expand to too many account updates. +""" +input ZkappCommandFilterOptionsInput { + blockStatus: BlockStatusFilter + """ + Mina block height to filter zkApp commands from, inclusive + """ + from: Int! + """ + Mina block height to filter zkApp commands to, exclusive + """ + to: Int! + """ + Optional account update public key filter. + """ + accountPublicKey: String + """ + Optional account update token id filter. + """ + tokenId: String +} + type EventData { accountUpdateId: String! transactionInfo: TransactionInfo @@ -124,6 +149,57 @@ type ActionOutput { actionState: ActionStates! } +""" +Raw field array from the archive database. + +An empty `fields` array means the archive row exists but contains no field values. +Nullable entries mean the corresponding nullable archive state slot has no field value. +""" +type ZkappFieldArray { + fields: [String]! +} + +type ZkappAccountPrecondition { + state: ZkappFieldArray + actionState: ZkappFieldArray + provedState: Boolean + isNew: Boolean +} + +type ZkappGlobalSlotBounds { + lowerBound: Int + upperBound: Int +} + +type ZkappNetworkPrecondition { + globalSlotSinceGenesis: ZkappGlobalSlotBounds +} + +type ZkappAccountUpdateOutput { + id: String! + publicKey: String! + tokenId: String! + authorizationKind: String! + balanceChange: String! + incrementNonce: Boolean! + callDepth: Int! + actions: [ZkappFieldArray!]! + events: [ZkappFieldArray!]! + appState: ZkappFieldArray + accountPrecondition: ZkappAccountPrecondition + networkPrecondition: ZkappNetworkPrecondition +} + +type ZkappCommandOutput { + blockInfo: BlockInfo! + hash: String! + feePayer: String! + fee: String! + memo: String! + sequenceNumber: Int! + accountUpdates: [ZkappAccountUpdateOutput!]! +} + """ Metadata about the network """ @@ -216,6 +292,7 @@ type Block { type Query { events(input: EventFilterOptionsInput!): [EventOutput]! actions(input: ActionFilterOptionsInput!): [ActionOutput]! + zkappCommands(input: ZkappCommandFilterOptionsInput!): [ZkappCommandOutput!]! networkState: NetworkStateOutput! blocks(query: BlockQueryInput, limit: Int, sortBy: BlockSortByInput): [Block]! } diff --git a/src/blockchain/types.ts b/src/blockchain/types.ts index 9b441cd..a69818b 100644 --- a/src/blockchain/types.ts +++ b/src/blockchain/types.ts @@ -98,6 +98,57 @@ export type ZkAppCommand = { failureReason: string | null; }; +export type ZkappAccountPrecondition = { + state: ZkappFieldArray | null; + actionState: ZkappFieldArray | null; + provedState: boolean | null; + isNew: boolean | null; +}; + +export type ZkappFieldArray = { + fields: (string | null)[]; +}; + +export type ZkappNestedFieldArray = { + fields: string[]; +}; + +export type ZkappGlobalSlotBounds = { + lowerBound: number | null; + upperBound: number | null; +}; + +export type ZkappNetworkPrecondition = { + globalSlotSinceGenesis: ZkappGlobalSlotBounds; +}; + +export type ZkappAccountUpdate = { + id: string; + publicKey: string; + tokenId: string; + authorizationKind: string; + balanceChange: string; + incrementNonce: boolean; + callDepth: number; + actions: ZkappNestedFieldArray[]; + events: ZkappNestedFieldArray[]; + appState: ZkappFieldArray | null; + accountPrecondition: ZkappAccountPrecondition; + networkPrecondition: ZkappNetworkPrecondition; +}; + +export type ZkappCommand = { + blockInfo: BlockInfo; + hash: string; + feePayer: string; + fee: string; + memo: string; + sequenceNumber: number; + accountUpdates: ZkappAccountUpdate[]; +}; + +export type ZkappCommands = ZkappCommand[]; + export type FeeTransfer = { recipient: string; fee: string; diff --git a/src/db/archive-node-adapter/archive-node-adapter.interface.ts b/src/db/archive-node-adapter/archive-node-adapter.interface.ts index 43225d8..70f32cb 100644 --- a/src/db/archive-node-adapter/archive-node-adapter.interface.ts +++ b/src/db/archive-node-adapter/archive-node-adapter.interface.ts @@ -1,18 +1,30 @@ -import type { EventFilterOptionsInput } from '../../resolvers-types.js'; +import type { + ActionFilterOptionsInput, + EventFilterOptionsInput, + ZkappCommandFilterOptionsInput, +} from '../../resolvers-types.js'; import type { Actions, Events, NetworkState, Blocks, + ZkappCommands, } from '../../blockchain/types.js'; -import type { BlockQueryInput, BlockSortByInput } from '../../resolvers-types.js'; +import type { + BlockQueryInput, + BlockSortByInput, +} from '../../resolvers-types.js'; export interface DatabaseAdapter { getEvents(input: EventFilterOptionsInput, options?: unknown): Promise; getActions( - input: EventFilterOptionsInput, + input: ActionFilterOptionsInput, options?: unknown ): Promise; + getZkappCommands( + input: ZkappCommandFilterOptionsInput, + options?: unknown + ): Promise; getNetworkState(options?: unknown): Promise; getBlocks( query: BlockQueryInput | null | undefined, diff --git a/src/db/archive-node-adapter/archive-node-adapter.ts b/src/db/archive-node-adapter/archive-node-adapter.ts index c6a21d8..5484a90 100644 --- a/src/db/archive-node-adapter/archive-node-adapter.ts +++ b/src/db/archive-node-adapter/archive-node-adapter.ts @@ -4,6 +4,7 @@ import type { Events, NetworkState, Blocks, + ZkappCommands, } from '../../blockchain/types.js'; import type { DatabaseAdapter } from './archive-node-adapter.interface.js'; import type { @@ -11,12 +12,15 @@ import type { EventFilterOptionsInput, BlockQueryInput, BlockSortByInput, + ZkappCommandFilterOptionsInput, } from '../../resolvers-types.js'; import { getTables, USED_TABLES } from '../../db/sql/events-actions/queries.js'; import { EventsService } from '../../services/events-service/events-service.js'; import { IEventsService } from '../../services/events-service/events-service.interface.js'; import { ActionsService } from '../../services/actions-service/actions-service.js'; import { IActionsService } from '../../services/actions-service/actions-service.interface.js'; +import { ZkappCommandsService } from '../../services/zkapp-commands-service/zkapp-commands-service.js'; +import { IZkappCommandsService } from '../../services/zkapp-commands-service/zkapp-commands-service.interface.js'; import { NetworkService } from '../../services/network-service/network-service.js'; import { INetworkService } from '../../services/network-service/network-service.interface.js'; import { BlocksService } from '../../services/blocks-service/blocks-service.js'; @@ -32,6 +36,7 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { private client: postgres.Sql; private eventsService: IEventsService; private actionsService: IActionsService; + private zkappCommandsService: IZkappCommandsService; private networkService: INetworkService; private blocksService: IBlocksService; @@ -43,6 +48,7 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { this.client = postgres(connectionString); this.eventsService = new EventsService(this.client); this.actionsService = new ActionsService(this.client); + this.zkappCommandsService = new ZkappCommandsService(this.client); this.networkService = new NetworkService(this.client); this.blocksService = new BlocksService(this.client); } @@ -61,6 +67,13 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { return this.actionsService.getActions(input, options); } + async getZkappCommands( + input: ZkappCommandFilterOptionsInput, + options: unknown + ): Promise { + return this.zkappCommandsService.getZkappCommands(input, options); + } + async getNetworkState(options: unknown): Promise { return this.networkService.getNetworkState(options); } diff --git a/src/db/sql/events-actions/queries.ts b/src/db/sql/events-actions/queries.ts index 35de3ad..7178599 100644 --- a/src/db/sql/events-actions/queries.ts +++ b/src/db/sql/events-actions/queries.ts @@ -429,12 +429,20 @@ export const USED_TABLES = [ 'account_identifiers', 'accounts_accessed', 'blocks_zkapp_commands', + 'public_keys', + 'tokens', 'zkapp_commands', + 'zkapp_fee_payer_body', 'zkapp_account_update', 'zkapp_account_update_body', + 'zkapp_updates', 'zkapp_events', 'zkapp_field_array', 'zkapp_field', + 'zkapp_account_precondition', + 'zkapp_network_precondition', + 'zkapp_global_slot_bounds', + 'zkapp_states_nullable', 'zkapp_verification_key_hashes', 'zkapp_verification_keys', 'zkapp_accounts', diff --git a/src/db/sql/zkapp-commands/queries.ts b/src/db/sql/zkapp-commands/queries.ts new file mode 100644 index 0000000..72a8c08 --- /dev/null +++ b/src/db/sql/zkapp-commands/queries.ts @@ -0,0 +1,359 @@ +import type postgres from 'postgres'; +import { BlockStatusFilter } from '../../../blockchain/types.js'; +import type { ZkappCommandDatabaseRow } from './types.js'; + +function blockRangeCte( + dbClient: postgres.Sql, + status: BlockStatusFilter, + to: number, + from: number +) { + if (status === BlockStatusFilter.canonical) { + return dbClient` + max_height AS ( + SELECT MAX(height) AS value FROM blocks + ), + block_range AS ( + SELECT + b.id, + b.state_hash, + b.parent_hash, + b.height, + b.global_slot_since_genesis, + b.global_slot_since_hard_fork, + b.timestamp, + b.chain_status, + b.ledger_hash, + b.last_vrf_output + FROM blocks b + WHERE b.chain_status = 'canonical' + AND b.height >= ${from} + AND b.height < ${to} + ) + `; + } + + return dbClient` + RECURSIVE max_height AS ( + SELECT MAX(height) AS value FROM blocks + ), + pending_chain AS ( + ( + SELECT + id, + state_hash, + parent_hash, + parent_id, + height, + global_slot_since_genesis, + global_slot_since_hard_fork, + timestamp, + chain_status, + ledger_hash, + last_vrf_output + FROM blocks + WHERE height = (SELECT value FROM max_height) + ) + UNION ALL + SELECT + b.id, + b.state_hash, + b.parent_hash, + b.parent_id, + b.height, + b.global_slot_since_genesis, + b.global_slot_since_hard_fork, + b.timestamp, + b.chain_status, + b.ledger_hash, + b.last_vrf_output + FROM blocks b + INNER JOIN pending_chain + ON b.id = pending_chain.parent_id + AND pending_chain.id <> pending_chain.parent_id + AND pending_chain.chain_status <> 'canonical' + ), + full_chain AS ( + SELECT DISTINCT + id, + state_hash, + parent_id, + parent_hash, + height, + global_slot_since_genesis, + global_slot_since_hard_fork, + timestamp, + chain_status, + ledger_hash, + last_vrf_output + FROM ( + SELECT + id, + state_hash, + parent_id, + parent_hash, + height, + global_slot_since_genesis, + global_slot_since_hard_fork, + timestamp, + chain_status, + ledger_hash, + last_vrf_output + FROM pending_chain + WHERE height >= ${from} + AND height < ${to} + UNION ALL + SELECT + id, + state_hash, + parent_id, + parent_hash, + height, + global_slot_since_genesis, + global_slot_since_hard_fork, + timestamp, + chain_status, + ledger_hash, + last_vrf_output + FROM blocks b + WHERE chain_status = 'canonical' + AND b.height >= ${from} + AND b.height < ${to} + ) AS resolved_chain + ), + block_range AS ( + SELECT + b.id, + b.state_hash, + b.parent_hash, + b.height, + b.global_slot_since_genesis, + b.global_slot_since_hard_fork, + b.timestamp, + b.chain_status, + b.ledger_hash, + b.last_vrf_output + FROM full_chain b + WHERE 1 = 1 + ${ + status === BlockStatusFilter.all + ? dbClient`` + : dbClient`AND b.chain_status = ${status.toLowerCase()}` + } + ) + `; +} + +export function getZkappCommandsQuery( + dbClient: postgres.Sql, + status: BlockStatusFilter, + to: number, + from: number, + accountPublicKey?: string, + tokenId?: string +) { + return dbClient` + WITH ${blockRangeCte(dbClient, status, to, from)} + SELECT + b.id AS block_id, + b.state_hash, + b.parent_hash, + b.height, + b.global_slot_since_genesis, + b.global_slot_since_hard_fork, + b.timestamp, + b.chain_status, + b.ledger_hash, + (SELECT value FROM max_height) - b.height AS distance_from_max_block_height, + b.last_vrf_output, + zkc.hash, + zkc.memo, + bzkc.sequence_no AS sequence_number, + fee_payer_pk.value AS fee_payer, + fpb.fee, + zkau.id AS account_update_id, + account_update_ids.account_update_order, + account_update_pk.value AS public_key, + t.value AS token_id, + zkub.authorization_kind, + zkub.balance_change, + zkub.increment_nonce, + zkub.call_depth, + COALESCE(actions.fields, '[]'::jsonb) AS actions, + COALESCE(events.fields, '[]'::jsonb) AS events, + app_state.fields AS app_state, + account_precondition_state.fields AS account_precondition_state, + account_precondition_action_state.fields AS account_precondition_action_state, + account_precondition.proved_state AS account_precondition_proved_state, + account_precondition.is_new AS account_precondition_is_new, + network_global_slot_bounds.global_slot_lower_bound AS network_precondition_global_slot_lower_bound, + network_global_slot_bounds.global_slot_upper_bound AS network_precondition_global_slot_upper_bound + FROM block_range b + JOIN blocks_zkapp_commands bzkc + ON b.id = bzkc.block_id + AND bzkc.status <> 'failed' + JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id + JOIN zkapp_fee_payer_body fpb ON zkc.zkapp_fee_payer_body_id = fpb.id + JOIN public_keys fee_payer_pk ON fpb.public_key_id = fee_payer_pk.id + JOIN LATERAL unnest(zkc.zkapp_account_updates_ids) + WITH ORDINALITY AS account_update_ids(account_update_id, account_update_order) ON true + JOIN zkapp_account_update zkau ON zkau.id = account_update_ids.account_update_id + JOIN zkapp_account_update_body zkub ON zkau.body_id = zkub.id + JOIN account_identifiers ai ON zkub.account_identifier_id = ai.id + JOIN public_keys account_update_pk + ON ai.public_key_id = account_update_pk.id + ${ + accountPublicKey + ? dbClient`AND account_update_pk.value = ${accountPublicKey}` + : dbClient`` + } + JOIN tokens t + ON ai.token_id = t.id + ${tokenId ? dbClient`AND t.value = ${tokenId}` : dbClient``} + LEFT JOIN zkapp_updates update_body ON update_body.id = zkub.update_id + LEFT JOIN zkapp_states_nullable app_state_rows ON app_state_rows.id = update_body.app_state_id + LEFT JOIN zkapp_account_precondition account_precondition + ON account_precondition.id = zkub.zkapp_account_precondition_id + LEFT JOIN zkapp_states_nullable account_precondition_state_rows + ON account_precondition_state_rows.id = account_precondition.state_id + LEFT JOIN zkapp_action_states account_precondition_action_state_rows + ON account_precondition_action_state_rows.id = account_precondition.action_state_id + LEFT JOIN zkapp_network_precondition network_precondition + ON network_precondition.id = zkub.zkapp_network_precondition_id + LEFT JOIN zkapp_global_slot_bounds network_global_slot_bounds + ON network_global_slot_bounds.id = network_precondition.global_slot_since_genesis + LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('fields', field_array.fields) ORDER BY event_ids.event_order), '[]'::jsonb) AS fields + FROM zkapp_events action_events + JOIN LATERAL unnest(action_events.element_ids) + WITH ORDINALITY AS event_ids(field_array_id, event_order) ON true + JOIN zkapp_field_array event_field_array ON event_field_array.id = event_ids.field_array_id + JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(zkf.field ORDER BY field_ids.field_order), '[]'::jsonb) AS fields + FROM unnest(event_field_array.element_ids) + WITH ORDINALITY AS field_ids(field_id, field_order) + JOIN zkapp_field zkf ON zkf.id = field_ids.field_id + ) field_array ON true + WHERE action_events.id = zkub.actions_id + ) actions ON true + LEFT JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('fields', field_array.fields) ORDER BY event_ids.event_order), '[]'::jsonb) AS fields + FROM zkapp_events account_update_events + JOIN LATERAL unnest(account_update_events.element_ids) + WITH ORDINALITY AS event_ids(field_array_id, event_order) ON true + JOIN zkapp_field_array event_field_array ON event_field_array.id = event_ids.field_array_id + JOIN LATERAL ( + SELECT COALESCE(jsonb_agg(zkf.field ORDER BY field_ids.field_order), '[]'::jsonb) AS fields + FROM unnest(event_field_array.element_ids) + WITH ORDINALITY AS field_ids(field_id, field_order) + JOIN zkapp_field zkf ON zkf.id = field_ids.field_id + ) field_array ON true + WHERE account_update_events.id = zkub.events_id + ) events ON true + LEFT JOIN LATERAL ( + SELECT jsonb_build_object('fields', jsonb_agg(zkf.field ORDER BY state_fields.field_order)) AS fields + FROM (VALUES + (0, app_state_rows.element0), (1, app_state_rows.element1), + (2, app_state_rows.element2), (3, app_state_rows.element3), + (4, app_state_rows.element4), (5, app_state_rows.element5), + (6, app_state_rows.element6), (7, app_state_rows.element7), + (8, app_state_rows.element8), (9, app_state_rows.element9), + (10, app_state_rows.element10), (11, app_state_rows.element11), + (12, app_state_rows.element12), (13, app_state_rows.element13), + (14, app_state_rows.element14), (15, app_state_rows.element15), + (16, app_state_rows.element16), (17, app_state_rows.element17), + (18, app_state_rows.element18), (19, app_state_rows.element19), + (20, app_state_rows.element20), (21, app_state_rows.element21), + (22, app_state_rows.element22), (23, app_state_rows.element23), + (24, app_state_rows.element24), (25, app_state_rows.element25), + (26, app_state_rows.element26), (27, app_state_rows.element27), + (28, app_state_rows.element28), (29, app_state_rows.element29), + (30, app_state_rows.element30), (31, app_state_rows.element31) + ) AS state_fields(field_order, field_id) + LEFT JOIN zkapp_field zkf ON zkf.id = state_fields.field_id + WHERE app_state_rows.id IS NOT NULL + ) app_state ON true + LEFT JOIN LATERAL ( + SELECT jsonb_build_object('fields', jsonb_agg(zkf.field ORDER BY state_fields.field_order)) AS fields + FROM (VALUES + (0, account_precondition_state_rows.element0), (1, account_precondition_state_rows.element1), + (2, account_precondition_state_rows.element2), (3, account_precondition_state_rows.element3), + (4, account_precondition_state_rows.element4), (5, account_precondition_state_rows.element5), + (6, account_precondition_state_rows.element6), (7, account_precondition_state_rows.element7), + (8, account_precondition_state_rows.element8), (9, account_precondition_state_rows.element9), + (10, account_precondition_state_rows.element10), (11, account_precondition_state_rows.element11), + (12, account_precondition_state_rows.element12), (13, account_precondition_state_rows.element13), + (14, account_precondition_state_rows.element14), (15, account_precondition_state_rows.element15), + (16, account_precondition_state_rows.element16), (17, account_precondition_state_rows.element17), + (18, account_precondition_state_rows.element18), (19, account_precondition_state_rows.element19), + (20, account_precondition_state_rows.element20), (21, account_precondition_state_rows.element21), + (22, account_precondition_state_rows.element22), (23, account_precondition_state_rows.element23), + (24, account_precondition_state_rows.element24), (25, account_precondition_state_rows.element25), + (26, account_precondition_state_rows.element26), (27, account_precondition_state_rows.element27), + (28, account_precondition_state_rows.element28), (29, account_precondition_state_rows.element29), + (30, account_precondition_state_rows.element30), (31, account_precondition_state_rows.element31) + ) AS state_fields(field_order, field_id) + LEFT JOIN zkapp_field zkf ON zkf.id = state_fields.field_id + WHERE account_precondition_state_rows.id IS NOT NULL + ) account_precondition_state ON true + LEFT JOIN LATERAL ( + SELECT jsonb_build_object('fields', jsonb_agg(zkf.field ORDER BY action_state_fields.field_order)) AS fields + FROM (VALUES + (0, account_precondition_action_state_rows.element0), + (1, account_precondition_action_state_rows.element1), + (2, account_precondition_action_state_rows.element2), + (3, account_precondition_action_state_rows.element3), + (4, account_precondition_action_state_rows.element4) + ) AS action_state_fields(field_order, field_id) + LEFT JOIN zkapp_field zkf ON zkf.id = action_state_fields.field_id + WHERE account_precondition_action_state_rows.id IS NOT NULL + ) account_precondition_action_state ON true + ORDER BY b.height, bzkc.sequence_no, account_update_ids.account_update_order; + `; +} + +export function getZkappCommandAccountUpdateCountQuery( + dbClient: postgres.Sql, + status: BlockStatusFilter, + to: number, + from: number, + accountPublicKey?: string, + tokenId?: string +) { + if (!accountPublicKey && !tokenId) { + return dbClient<{ count: string }[]>` + WITH ${blockRangeCte(dbClient, status, to, from)} + SELECT COALESCE(SUM(cardinality(zkc.zkapp_account_updates_ids)), 0)::text AS count + FROM block_range b + JOIN blocks_zkapp_commands bzkc + ON b.id = bzkc.block_id + AND bzkc.status <> 'failed' + JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id; + `; + } + + return dbClient<{ count: string }[]>` + WITH ${blockRangeCte(dbClient, status, to, from)} + SELECT COUNT(*) AS count + FROM block_range b + JOIN blocks_zkapp_commands bzkc + ON b.id = bzkc.block_id + AND bzkc.status <> 'failed' + JOIN zkapp_commands zkc ON bzkc.zkapp_command_id = zkc.id + JOIN LATERAL unnest(zkc.zkapp_account_updates_ids) + WITH ORDINALITY AS account_update_ids(account_update_id, account_update_order) ON true + JOIN zkapp_account_update zkau ON zkau.id = account_update_ids.account_update_id + JOIN zkapp_account_update_body zkub ON zkau.body_id = zkub.id + JOIN account_identifiers ai ON zkub.account_identifier_id = ai.id + JOIN public_keys account_update_pk + ON ai.public_key_id = account_update_pk.id + ${ + accountPublicKey + ? dbClient`AND account_update_pk.value = ${accountPublicKey}` + : dbClient`` + } + JOIN tokens t + ON ai.token_id = t.id + ${tokenId ? dbClient`AND t.value = ${tokenId}` : dbClient``} + `; +} diff --git a/src/db/sql/zkapp-commands/types.ts b/src/db/sql/zkapp-commands/types.ts new file mode 100644 index 0000000..ee9eb17 --- /dev/null +++ b/src/db/sql/zkapp-commands/types.ts @@ -0,0 +1,43 @@ +export type RawFieldArray = { + fields: (string | null)[]; +}; + +export type RawNestedFieldArray = { + fields: string[]; +}[]; + +export type ZkappCommandDatabaseRow = { + block_id: number; + state_hash: string; + parent_hash: string; + height: string; + global_slot_since_genesis: string; + global_slot_since_hard_fork: string; + timestamp: string; + chain_status: string; + ledger_hash: string; + distance_from_max_block_height: string; + last_vrf_output: string; + hash: string; + memo: string; + sequence_number: number; + fee_payer: string; + fee: string; + account_update_id: number; + account_update_order: string; + public_key: string; + token_id: string; + authorization_kind: string; + balance_change: string; + increment_nonce: boolean; + call_depth: number; + actions: RawNestedFieldArray; + events: RawNestedFieldArray; + app_state: RawFieldArray | null; + account_precondition_state: RawFieldArray | null; + account_precondition_action_state: RawFieldArray | null; + account_precondition_proved_state: boolean | null; + account_precondition_is_new: boolean | null; + network_precondition_global_slot_lower_bound: number | null; + network_precondition_global_slot_upper_bound: number | null; +}; diff --git a/src/envionment.d.ts b/src/envionment.d.ts index 95cf7a6..9d0844c 100644 --- a/src/envionment.d.ts +++ b/src/envionment.d.ts @@ -5,6 +5,10 @@ declare global { PORT?: string; PG_CONN: string; CORS_ORIGIN?: string; + BLOCK_RANGE_SIZE?: string; + ZKAPP_COMMAND_RANGE_SIZE?: string; + ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT?: string; + ENABLE_BLOCK_TRANSACTION_DETAILS?: string; ENABLE_LOGGING?: bool; ENABLE_INTROSPECTION?: bool; ENABLE_GRAPHIQL?: bool; diff --git a/src/resolvers-types.ts b/src/resolvers-types.ts index 37e2764..404b522 100644 --- a/src/resolvers-types.ts +++ b/src/resolvers-types.ts @@ -194,6 +194,7 @@ export type Query = { blocks: Array>; events: Array>; networkState: NetworkStateOutput; + zkappCommands: Array; }; export type QueryActionsArgs = { @@ -210,6 +211,10 @@ export type QueryEventsArgs = { input: EventFilterOptionsInput; }; +export type QueryZkappCommandsArgs = { + input: ZkappCommandFilterOptionsInput; +}; + export type TransactionInfo = { __typename?: 'TransactionInfo'; authorizationKind: Scalars['String']['output']; @@ -244,6 +249,80 @@ export type ZkAppCommand = { status: Scalars['String']['output']; }; +export type ZkappAccountPrecondition = { + __typename?: 'ZkappAccountPrecondition'; + actionState?: Maybe; + isNew?: Maybe; + provedState?: Maybe; + state?: Maybe; +}; + +export type ZkappAccountUpdateOutput = { + __typename?: 'ZkappAccountUpdateOutput'; + accountPrecondition?: Maybe; + actions: Array; + appState?: Maybe; + authorizationKind: Scalars['String']['output']; + balanceChange: Scalars['String']['output']; + callDepth: Scalars['Int']['output']; + events: Array; + id: Scalars['String']['output']; + incrementNonce: Scalars['Boolean']['output']; + networkPrecondition?: Maybe; + publicKey: Scalars['String']['output']; + tokenId: Scalars['String']['output']; +}; + +/** + * Filter successful zkApp commands by block range. + * + * Both `from` and `to` are required. The range is bounded by `ZKAPP_COMMAND_RANGE_SIZE`, and the server may reject dense ranges that expand to too many account updates. + */ +export type ZkappCommandFilterOptionsInput = { + /** Optional account update public key filter. */ + accountPublicKey?: InputMaybe; + blockStatus?: InputMaybe; + /** Mina block height to filter zkApp commands from, inclusive */ + from: Scalars['Int']['input']; + /** Mina block height to filter zkApp commands to, exclusive */ + to: Scalars['Int']['input']; + /** Optional account update token id filter. */ + tokenId?: InputMaybe; +}; + +export type ZkappCommandOutput = { + __typename?: 'ZkappCommandOutput'; + accountUpdates: Array; + blockInfo: BlockInfo; + fee: Scalars['String']['output']; + feePayer: Scalars['String']['output']; + hash: Scalars['String']['output']; + memo: Scalars['String']['output']; + sequenceNumber: Scalars['Int']['output']; +}; + +/** + * Raw field array from the archive database. + * + * An empty `fields` array means the archive row exists but contains no field values. + * Nullable entries mean the corresponding nullable archive state slot has no field value. + */ +export type ZkappFieldArray = { + __typename?: 'ZkappFieldArray'; + fields: Array>; +}; + +export type ZkappGlobalSlotBounds = { + __typename?: 'ZkappGlobalSlotBounds'; + lowerBound?: Maybe; + upperBound?: Maybe; +}; + +export type ZkappNetworkPrecondition = { + __typename?: 'ZkappNetworkPrecondition'; + globalSlotSinceGenesis?: Maybe; +}; + export type ResolverTypeWrapper = Promise | T; export type ResolverWithResolve = { @@ -387,6 +466,13 @@ export type ResolversTypes = { TransactionInfo: ResolverTypeWrapper; UserCommand: ResolverTypeWrapper; ZkAppCommand: ResolverTypeWrapper; + ZkappAccountPrecondition: ResolverTypeWrapper; + ZkappAccountUpdateOutput: ResolverTypeWrapper; + ZkappCommandFilterOptionsInput: ZkappCommandFilterOptionsInput; + ZkappCommandOutput: ResolverTypeWrapper; + ZkappFieldArray: ResolverTypeWrapper; + ZkappGlobalSlotBounds: ResolverTypeWrapper; + ZkappNetworkPrecondition: ResolverTypeWrapper; }; /** Mapping between all available schema types and the resolvers parents */ @@ -413,6 +499,13 @@ export type ResolversParentTypes = { TransactionInfo: TransactionInfo; UserCommand: UserCommand; ZkAppCommand: ZkAppCommand; + ZkappAccountPrecondition: ZkappAccountPrecondition; + ZkappAccountUpdateOutput: ZkappAccountUpdateOutput; + ZkappCommandFilterOptionsInput: ZkappCommandFilterOptionsInput; + ZkappCommandOutput: ZkappCommandOutput; + ZkappFieldArray: ZkappFieldArray; + ZkappGlobalSlotBounds: ZkappGlobalSlotBounds; + ZkappNetworkPrecondition: ZkappNetworkPrecondition; }; export type ActionDataResolvers< @@ -672,6 +765,12 @@ export type QueryResolvers< ParentType, ContextType >; + zkappCommands?: Resolver< + Array, + ParentType, + ContextType, + RequireFields + >; }; export type TransactionInfoResolvers< @@ -733,6 +832,123 @@ export type ZkAppCommandResolvers< status?: Resolver; }; +export type ZkappAccountPreconditionResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappAccountPrecondition'] = ResolversParentTypes['ZkappAccountPrecondition'], +> = { + actionState?: Resolver< + Maybe, + ParentType, + ContextType + >; + isNew?: Resolver, ParentType, ContextType>; + provedState?: Resolver< + Maybe, + ParentType, + ContextType + >; + state?: Resolver< + Maybe, + ParentType, + ContextType + >; +}; + +export type ZkappAccountUpdateOutputResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappAccountUpdateOutput'] = ResolversParentTypes['ZkappAccountUpdateOutput'], +> = { + accountPrecondition?: Resolver< + Maybe, + ParentType, + ContextType + >; + actions?: Resolver< + Array, + ParentType, + ContextType + >; + appState?: Resolver< + Maybe, + ParentType, + ContextType + >; + authorizationKind?: Resolver< + ResolversTypes['String'], + ParentType, + ContextType + >; + balanceChange?: Resolver; + callDepth?: Resolver; + events?: Resolver< + Array, + ParentType, + ContextType + >; + id?: Resolver; + incrementNonce?: Resolver; + networkPrecondition?: Resolver< + Maybe, + ParentType, + ContextType + >; + publicKey?: Resolver; + tokenId?: Resolver; +}; + +export type ZkappCommandOutputResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappCommandOutput'] = ResolversParentTypes['ZkappCommandOutput'], +> = { + accountUpdates?: Resolver< + Array, + ParentType, + ContextType + >; + blockInfo?: Resolver; + fee?: Resolver; + feePayer?: Resolver; + hash?: Resolver; + memo?: Resolver; + sequenceNumber?: Resolver; +}; + +export type ZkappFieldArrayResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappFieldArray'] = ResolversParentTypes['ZkappFieldArray'], +> = { + fields?: Resolver< + Array>, + ParentType, + ContextType + >; +}; + +export type ZkappGlobalSlotBoundsResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappGlobalSlotBounds'] = ResolversParentTypes['ZkappGlobalSlotBounds'], +> = { + lowerBound?: Resolver, ParentType, ContextType>; + upperBound?: Resolver, ParentType, ContextType>; +}; + +export type ZkappNetworkPreconditionResolvers< + ContextType = GraphQLContext, + ParentType extends + ResolversParentTypes['ZkappNetworkPrecondition'] = ResolversParentTypes['ZkappNetworkPrecondition'], +> = { + globalSlotSinceGenesis?: Resolver< + Maybe, + ParentType, + ContextType + >; +}; + export type Resolvers = { ActionData?: ActionDataResolvers; ActionOutput?: ActionOutputResolvers; @@ -751,4 +967,10 @@ export type Resolvers = { TransactionInfo?: TransactionInfoResolvers; UserCommand?: UserCommandResolvers; ZkAppCommand?: ZkAppCommandResolvers; + ZkappAccountPrecondition?: ZkappAccountPreconditionResolvers; + ZkappAccountUpdateOutput?: ZkappAccountUpdateOutputResolvers; + ZkappCommandOutput?: ZkappCommandOutputResolvers; + ZkappFieldArray?: ZkappFieldArrayResolvers; + ZkappGlobalSlotBounds?: ZkappGlobalSlotBoundsResolvers; + ZkappNetworkPrecondition?: ZkappNetworkPreconditionResolvers; }; diff --git a/src/resolvers.ts b/src/resolvers.ts index 19f23af..958ad21 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -12,6 +12,8 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const schemaPath = path.resolve(__dirname, '../../schema.graphql'); +const ENABLE_BLOCK_TRANSACTION_DETAILS = + process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true'; const fullResolvers: Resolvers = { Query: { @@ -33,6 +35,15 @@ const fullResolvers: Resolvers = { tracingState: new TracingState(graphQLSpan), }); }, + zkappCommands: async (_, { input }, context) => { + const graphQLSpan = setSpanNameFromGraphQLContext( + context, + 'zkappCommands.graphql' + ); + return context.db_client.getZkappCommands(input, { + tracingState: new TracingState(graphQLSpan), + }); + }, networkState: async (_, __, context) => { const graphQLSpan = setSpanNameFromGraphQLContext( context, @@ -54,49 +65,46 @@ const fullResolvers: Resolvers = { }, }; -let resolvers: Resolvers = fullResolvers; -let typeDefs: string | undefined = undefined; +let enabledQueries = Object.keys(fullResolvers.Query || {}); -// If the ENABLED_QUERIES environment variable is set, filter the schema and resolvers. if (process.env.ENABLED_QUERIES !== undefined) { - const enabledQueries = process.env.ENABLED_QUERIES.split(',').map((q) => - q.trim() + enabledQueries = process.env.ENABLED_QUERIES.split(',').map((q) => q.trim()); +} + +if (!ENABLE_BLOCK_TRANSACTION_DETAILS) { + enabledQueries = enabledQueries.filter( + (queryName) => queryName !== 'zkappCommands' ); +} - // If the list is not empty, filter the resolvers. - if (enabledQueries.length > 0) { - resolvers = { - Query: Object.fromEntries( - Object.entries(fullResolvers.Query || {}).filter(([queryName]) => - enabledQueries.includes(queryName) - ) - ), - }; +const resolvers: Resolvers = { + Query: Object.fromEntries( + Object.entries(fullResolvers.Query || {}).filter(([queryName]) => + enabledQueries.includes(queryName) + ) + ), +}; - // Filter the schema AST. - const typeDefsString = fs.readFileSync(schemaPath, 'utf-8'); - const typeDefsAst = parse(typeDefsString); - const modifiedAst = visit(typeDefsAst, { - ObjectTypeDefinition(node) { - if (node.name.value === 'Query') { - return { - ...node, - fields: node.fields?.filter((field) => - enabledQueries.includes(field.name.value) - ), - }; - } - return node; - }, - }); - typeDefs = print(modifiedAst); - } -} +const typeDefsString = fs.readFileSync(schemaPath, 'utf-8'); +const typeDefsAst = parse(typeDefsString); +const modifiedAst = visit(typeDefsAst, { + ObjectTypeDefinition(node) { + if (node.name.value === 'Query') { + return { + ...node, + fields: node.fields?.filter((field) => + enabledQueries.includes(field.name.value) + ), + }; + } + return node; + }, +}); // Create the executable schema. const schema = makeExecutableSchema({ resolvers: [resolvers], - typeDefs: typeDefs || fs.readFileSync(schemaPath, 'utf-8'), + typeDefs: print(modifiedAst), }); export { resolvers, schema }; diff --git a/src/server/server.ts b/src/server/server.ts index 0e53666..353b672 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -4,10 +4,20 @@ import { Plugin } from '@envelop/core'; import { schema } from '../resolvers.js'; import type { GraphQLContext } from '../context.js'; -export { BLOCK_RANGE_SIZE, ENABLE_BLOCK_TRANSACTION_DETAILS, buildServer }; +export { + BLOCK_RANGE_SIZE, + ZKAPP_COMMAND_RANGE_SIZE, + ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT, + ENABLE_BLOCK_TRANSACTION_DETAILS, + buildServer, +}; const LOG_LEVEL = (process.env.LOG_LEVEL as LogLevel) || 'info'; const BLOCK_RANGE_SIZE = Number(process.env.BLOCK_RANGE_SIZE) || 10000; +const ZKAPP_COMMAND_RANGE_SIZE = + Number(process.env.ZKAPP_COMMAND_RANGE_SIZE) || 1000; +const ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT = + Number(process.env.ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT) || 5000; const ENABLE_BLOCK_TRANSACTION_DETAILS = process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true'; diff --git a/src/services/zkapp-commands-service/zkapp-commands-service.interface.ts b/src/services/zkapp-commands-service/zkapp-commands-service.interface.ts new file mode 100644 index 0000000..7782024 --- /dev/null +++ b/src/services/zkapp-commands-service/zkapp-commands-service.interface.ts @@ -0,0 +1,9 @@ +import type { ZkappCommands } from '../../blockchain/types.js'; +import type { ZkappCommandFilterOptionsInput } from '../../resolvers-types.js'; + +export interface IZkappCommandsService { + getZkappCommands( + input: ZkappCommandFilterOptionsInput, + options: unknown + ): Promise; +} diff --git a/src/services/zkapp-commands-service/zkapp-commands-service.ts b/src/services/zkapp-commands-service/zkapp-commands-service.ts new file mode 100644 index 0000000..69f23de --- /dev/null +++ b/src/services/zkapp-commands-service/zkapp-commands-service.ts @@ -0,0 +1,181 @@ +import type postgres from 'postgres'; +import { + BlockStatusFilter, + type BlockInfo, + type ZkappCommand, + type ZkappCommands, +} from '../../blockchain/types.js'; +import type { ZkappCommandFilterOptionsInput } from '../../resolvers-types.js'; +import { + getZkappCommandAccountUpdateCountQuery, + getZkappCommandsQuery, +} from '../../db/sql/zkapp-commands/queries.js'; +import type { ZkappCommandDatabaseRow } from '../../db/sql/zkapp-commands/types.js'; +import { + TracingState, + extractTraceStateFromOptions, +} from '../../tracing/tracer.js'; +import { + ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT, + ZKAPP_COMMAND_RANGE_SIZE, +} from '../../server/server.js'; +import { throwBlockRangeError } from '../../errors/error.js'; +import type { IZkappCommandsService } from './zkapp-commands-service.interface.js'; + +export { ZkappCommandsService }; +export { normalizeZkappCommandRange }; +export { assertZkappCommandAccountUpdateLimit }; + +function normalizeZkappCommandRange(input: ZkappCommandFilterOptionsInput): { + from: number; + to: number; +} { + const { from, to } = input; + + if (from === null || from === undefined || to === null || to === undefined) { + throwBlockRangeError('from and to are required'); + } + if (to <= from) { + throwBlockRangeError('to must be greater than from'); + } + if (to - from > ZKAPP_COMMAND_RANGE_SIZE) { + throwBlockRangeError( + `The zkApp command block range is too large. The maximum range is ${ZKAPP_COMMAND_RANGE_SIZE}` + ); + } + + return { from, to }; +} + +function assertZkappCommandAccountUpdateLimit(accountUpdateCount: number) { + if (accountUpdateCount > ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT) { + throwBlockRangeError( + `The zkApp command range expands to ${accountUpdateCount} account updates. The maximum is ${ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT}; use a smaller block range.` + ); + } +} + +class ZkappCommandsService implements IZkappCommandsService { + private readonly client: postgres.Sql; + + constructor(client: postgres.Sql) { + this.client = client; + } + + async getZkappCommands( + input: ZkappCommandFilterOptionsInput, + options: unknown + ): Promise { + const tracingState = extractTraceStateFromOptions(options); + return (await this.getZkappCommandData(input, { tracingState })) ?? []; + } + + async getZkappCommandData( + input: ZkappCommandFilterOptionsInput, + { tracingState }: { tracingState: TracingState } + ): Promise { + const sqlSpan = tracingState.startSpan('zkappCommands.SQL'); + const rows = await this.executeZkappCommandsQuery(input); + sqlSpan.end(); + + const processingSpan = tracingState.startSpan('zkappCommands.processing'); + const commands = this.rowsToZkappCommands(rows); + processingSpan.end(); + return commands; + } + + private async executeZkappCommandsQuery( + input: ZkappCommandFilterOptionsInput + ) { + const { accountPublicKey, tokenId } = input; + let { blockStatus } = input; + const range = normalizeZkappCommandRange(input); + + blockStatus ||= BlockStatusFilter.all; + const [{ count }] = await getZkappCommandAccountUpdateCountQuery( + this.client, + blockStatus, + range.to, + range.from, + accountPublicKey?.toString(), + tokenId?.toString() + ); + assertZkappCommandAccountUpdateLimit(Number(count)); + + return getZkappCommandsQuery( + this.client, + blockStatus, + range.to, + range.from, + accountPublicKey?.toString(), + tokenId?.toString() + ); + } + + rowsToZkappCommands(rows: ZkappCommandDatabaseRow[]): ZkappCommands { + const commandsByKey = new Map(); + + for (const row of rows) { + const commandKey = `${row.state_hash}:${row.sequence_number}:${row.hash}`; + let command = commandsByKey.get(commandKey); + if (!command) { + command = this.createZkappCommand(row); + commandsByKey.set(commandKey, command); + } + + command.accountUpdates.push({ + id: row.account_update_id.toString(), + publicKey: row.public_key, + tokenId: row.token_id, + authorizationKind: row.authorization_kind, + balanceChange: row.balance_change, + incrementNonce: row.increment_nonce, + callDepth: row.call_depth, + actions: row.actions, + events: row.events, + appState: row.app_state, + accountPrecondition: { + state: row.account_precondition_state, + actionState: row.account_precondition_action_state, + provedState: row.account_precondition_proved_state, + isNew: row.account_precondition_is_new, + }, + networkPrecondition: { + globalSlotSinceGenesis: { + lowerBound: row.network_precondition_global_slot_lower_bound, + upperBound: row.network_precondition_global_slot_upper_bound, + }, + }, + }); + } + + return Array.from(commandsByKey.values()); + } + + private createZkappCommand(row: ZkappCommandDatabaseRow): ZkappCommand { + return { + blockInfo: this.createBlockInfo(row), + hash: row.hash, + feePayer: row.fee_payer, + fee: row.fee, + memo: row.memo, + sequenceNumber: row.sequence_number, + accountUpdates: [], + }; + } + + private createBlockInfo(row: ZkappCommandDatabaseRow): BlockInfo { + return { + height: Number(row.height), + stateHash: row.state_hash, + parentHash: row.parent_hash, + ledgerHash: row.ledger_hash, + chainStatus: row.chain_status, + timestamp: row.timestamp, + globalSlotSinceHardfork: Number(row.global_slot_since_hard_fork), + globalSlotSinceGenesis: Number(row.global_slot_since_genesis), + distanceFromMaxBlockHeight: Number(row.distance_from_max_block_height), + lastVrfOutput: row.last_vrf_output, + }; + } +} diff --git a/tests/integration/integration.test.ts b/tests/integration/integration.test.ts index 2e3f1dc..f864c63 100644 --- a/tests/integration/integration.test.ts +++ b/tests/integration/integration.test.ts @@ -7,7 +7,7 @@ * The sample dump contains: * - 24 canonical blocks (heights 1-25), 15 orphaned blocks * - 1 pending block (inserted by test setup at height 26) - * - 227 failed zkapp commands (no successful ones, so events/actions return empty) + * - 227 failed zkapp commands plus 1 applied command inserted by test setup * - Coinbase internal commands * - 240 public keys, default token only */ @@ -18,6 +18,7 @@ import { EventsService } from '../../src/services/events-service/events-service. import { ActionsService } from '../../src/services/actions-service/actions-service.js'; import { NetworkService } from '../../src/services/network-service/network-service.js'; import { BlocksService } from '../../src/services/blocks-service/blocks-service.js'; +import { ZkappCommandsService } from '../../src/services/zkapp-commands-service/zkapp-commands-service.js'; import { BlockStatusFilter } from '../../src/blockchain/types.js'; import { DEFAULT_TOKEN_ID } from '../../src/blockchain/constants.js'; import { TracingState } from '../../src/tracing/tracer.js'; @@ -399,6 +400,35 @@ describe('ActionsService (integration)', () => { }); }); +// ─── Zkapp Commands Service ────────────────────────────────────────── + +describe('ZkappCommandsService (integration)', () => { + let zkappCommandsService: ZkappCommandsService; + + before(() => { + zkappCommandsService = new ZkappCommandsService(client); + }); + + test('returns successful zkApp commands with account updates', async () => { + const commands = await zkappCommandsService.getZkappCommands( + { + blockStatus: BlockStatusFilter.canonical, + from: 25, + to: 26, + }, + nullOptions + ); + + assert.strictEqual(commands.length, 1); + assert.strictEqual(commands[0].hash, 'integration-test-zkapp-command-hash'); + assert.strictEqual(commands[0].blockInfo.height, 25); + assert.strictEqual(commands[0].blockInfo.chainStatus, 'canonical'); + assert.strictEqual(commands[0].accountUpdates.length, 1); + assert.ok(commands[0].accountUpdates[0].publicKey.length > 0); + assert.deepStrictEqual(commands[0].accountUpdates[0].events, []); + }); +}); + // ─── SQL Schema Validation ─────────────────────────────────────────── describe('Schema validation (integration)', () => { @@ -414,6 +444,7 @@ describe('Schema validation (integration)', () => { 'accounts_accessed', 'blocks_zkapp_commands', 'zkapp_commands', + 'zkapp_fee_payer_body', 'zkapp_account_update', 'zkapp_account_update_body', 'zkapp_events', diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 9e21154..9096b20 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -86,6 +86,63 @@ export async function setupTestDatabase(): Promise { WHERE height = (SELECT max(height) FROM blocks) LIMIT 1 `); + + await db.unsafe(` + WITH inserted_fee_payer AS ( + INSERT INTO zkapp_fee_payer_body ( + public_key_id, + fee, + valid_until, + nonce + ) + SELECT + (SELECT id FROM public_keys ORDER BY id LIMIT 1), + '100000000', + NULL, + 0 + RETURNING id + ), + inserted_command AS ( + INSERT INTO zkapp_commands ( + zkapp_fee_payer_body_id, + zkapp_account_updates_ids, + memo, + hash + ) + SELECT + inserted_fee_payer.id, + ARRAY[(SELECT id FROM zkapp_account_update ORDER BY id LIMIT 1)], + 'integration-test-memo', + 'integration-test-zkapp-command-hash' + FROM inserted_fee_payer + RETURNING id + ) + INSERT INTO blocks_zkapp_commands ( + block_id, + zkapp_command_id, + sequence_no, + status, + failure_reasons_ids + ) + SELECT + canonical_tip.id, + inserted_command.id, + COALESCE(( + SELECT max(sequence_no) + 1 + FROM blocks_zkapp_commands + WHERE block_id = canonical_tip.id + ), 0), + 'applied', + NULL + FROM inserted_command + CROSS JOIN ( + SELECT id + FROM blocks + WHERE chain_status = 'canonical' + ORDER BY height DESC + LIMIT 1 + ) canonical_tip + `); } finally { await db.end(); } diff --git a/tests/services/zkapp-commands-service/zkapp-commands-service.test.ts b/tests/services/zkapp-commands-service/zkapp-commands-service.test.ts new file mode 100644 index 0000000..0d77aff --- /dev/null +++ b/tests/services/zkapp-commands-service/zkapp-commands-service.test.ts @@ -0,0 +1,171 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { + ZkappCommandsService, + assertZkappCommandAccountUpdateLimit, + normalizeZkappCommandRange, +} from '../../../src/services/zkapp-commands-service/zkapp-commands-service.js'; +import type { ZkappCommandDatabaseRow } from '../../../src/db/sql/zkapp-commands/types.js'; +import { + ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT, + ZKAPP_COMMAND_RANGE_SIZE, +} from '../../../src/server/server.js'; +import { makeClient } from '../../test-helpers.js'; + +function makeRow( + overrides: Partial = {} +): ZkappCommandDatabaseRow { + return { + block_id: 1, + state_hash: 'state_hash_1', + parent_hash: 'parent_hash_1', + height: '100', + global_slot_since_genesis: '200', + global_slot_since_hard_fork: '200', + timestamp: '1700000000000', + chain_status: 'canonical', + ledger_hash: 'ledger_hash_1', + distance_from_max_block_height: '5', + last_vrf_output: 'vrf_1', + hash: 'tx_hash_1', + memo: 'memo_1', + sequence_number: 0, + fee_payer: 'B62feePayer', + fee: '100000000', + account_update_id: 10, + account_update_order: '1', + public_key: 'B62accountUpdate', + token_id: '1', + authorization_kind: 'Proof', + balance_change: '0', + increment_nonce: false, + call_depth: 0, + actions: [{ fields: ['1', '2'] }], + events: [{ fields: ['3', '4'] }], + app_state: { fields: ['5', null] }, + account_precondition_state: { fields: ['6', null] }, + account_precondition_action_state: { fields: ['7', '8'] }, + account_precondition_proved_state: true, + account_precondition_is_new: false, + network_precondition_global_slot_lower_bound: 200, + network_precondition_global_slot_upper_bound: 300, + ...overrides, + }; +} + +describe('ZkappCommandsService', () => { + describe('normalizeZkappCommandRange', () => { + test('keeps explicit bounded ranges unchanged', () => { + assert.deepStrictEqual(normalizeZkappCommandRange({ from: 0, to: 10 }), { + from: 0, + to: 10, + }); + }); + + test('rejects ranges over the configured maximum', () => { + assert.throws(() => + normalizeZkappCommandRange({ + from: 0, + to: ZKAPP_COMMAND_RANGE_SIZE + 1, + }) + ); + }); + + test('rejects empty ranges', () => { + assert.throws(() => normalizeZkappCommandRange({ from: 10, to: 10 })); + }); + + test('allows a range exactly at the configured maximum', () => { + assert.deepStrictEqual( + normalizeZkappCommandRange({ from: 0, to: ZKAPP_COMMAND_RANGE_SIZE }), + { + from: 0, + to: ZKAPP_COMMAND_RANGE_SIZE, + } + ); + }); + }); + + describe('assertZkappCommandAccountUpdateLimit', () => { + test('allows account update counts at the configured maximum', () => { + assert.doesNotThrow(() => + assertZkappCommandAccountUpdateLimit(ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT) + ); + }); + + test('rejects account update counts over the configured maximum', () => { + assert.throws(() => + assertZkappCommandAccountUpdateLimit( + ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT + 1 + ) + ); + }); + }); + + describe('rowsToZkappCommands', () => { + test('groups ordered account updates under their zkApp command', () => { + const service = new ZkappCommandsService(makeClient()); + const rows = [ + makeRow({ account_update_id: 10, public_key: 'B62first' }), + makeRow({ + account_update_id: 11, + account_update_order: '2', + public_key: 'B62second', + }), + ]; + + const result = service.rowsToZkappCommands(rows); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].hash, 'tx_hash_1'); + assert.strictEqual(result[0].blockInfo.height, 100); + assert.strictEqual(result[0].accountUpdates.length, 2); + assert.strictEqual(result[0].accountUpdates[0].id, '10'); + assert.strictEqual(result[0].accountUpdates[1].id, '11'); + assert.deepStrictEqual(result[0].accountUpdates[0].actions, [ + { fields: ['1', '2'] }, + ]); + assert.deepStrictEqual(result[0].accountUpdates[0].events, [ + { fields: ['3', '4'] }, + ]); + assert.deepStrictEqual(result[0].accountUpdates[0].appState, { + fields: ['5', null], + }); + assert.deepStrictEqual( + result[0].accountUpdates[0].networkPrecondition.globalSlotSinceGenesis, + { + lowerBound: 200, + upperBound: 300, + } + ); + }); + + test('keeps separate commands in block and sequence order', () => { + const service = new ZkappCommandsService(makeClient()); + const rows = [ + makeRow({ height: '100', sequence_number: 0, hash: 'tx_1' }), + makeRow({ height: '100', sequence_number: 1, hash: 'tx_2' }), + ]; + + const result = service.rowsToZkappCommands(rows); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].hash, 'tx_1'); + assert.strictEqual(result[1].hash, 'tx_2'); + }); + + test('does not collapse identical transaction hashes across blocks', () => { + const service = new ZkappCommandsService(makeClient()); + const rows = [ + makeRow({ state_hash: 'block_1', height: '100', hash: 'tx_hash' }), + makeRow({ state_hash: 'block_2', height: '101', hash: 'tx_hash' }), + ]; + + const result = service.rowsToZkappCommands(rows); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].blockInfo.stateHash, 'block_1'); + assert.strictEqual(result[1].blockInfo.stateHash, 'block_2'); + }); + }); +});