diff --git a/schema.graphql b/schema.graphql index 28187a1..3e236e1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -46,6 +46,11 @@ type BlockInfo { distanceFromMaxBlockHeight: Int! } +type MaxBlockHeightInfo { + canonicalMaxBlockHeight: Int! + pendingMaxBlockHeight: Int! +} + type TransactionInfo { status: String! hash: String! @@ -75,7 +80,12 @@ type ActionOutput { actionState: ActionStates! } +type NetworkStateOutput { + maxBlockHeight: MaxBlockHeightInfo +} + type Query { events(input: EventFilterOptionsInput!): [EventOutput]! actions(input: ActionFilterOptionsInput!): [ActionOutput]! + networkState: NetworkStateOutput! } diff --git a/src/blockchain/types.ts b/src/blockchain/types.ts index 6f3baa0..911a3c7 100644 --- a/src/blockchain/types.ts +++ b/src/blockchain/types.ts @@ -24,6 +24,15 @@ export type Action = { data: string[]; }; +export type NetworkState = { + maxBlockHeight: MaxBlockHeightInfo; +}; + +export type MaxBlockHeightInfo = { + canonicalMaxBlockHeight: number; + pendingMaxBlockHeight: number; +}; + export type BlockInfo = { height: number; stateHash: 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 78b11bf..785d8dc 100644 --- a/src/db/archive-node-adapter/archive-node-adapter.interface.ts +++ b/src/db/archive-node-adapter/archive-node-adapter.interface.ts @@ -1,5 +1,9 @@ import type { EventFilterOptionsInput } from '../../resolvers-types.js'; -import type { Actions, Events } from '../../blockchain/types.js'; +import type { + Actions, + Events, + NetworkState, +} from '../../blockchain/types.js'; export interface DatabaseAdapter { getEvents(input: EventFilterOptionsInput, options?: unknown): Promise; @@ -7,4 +11,5 @@ export interface DatabaseAdapter { input: EventFilterOptionsInput, options?: unknown ): Promise; + getNetworkState(options?: unknown): Promise; } diff --git a/src/db/archive-node-adapter/archive-node-adapter.ts b/src/db/archive-node-adapter/archive-node-adapter.ts index 6de9011..8dfb029 100644 --- a/src/db/archive-node-adapter/archive-node-adapter.ts +++ b/src/db/archive-node-adapter/archive-node-adapter.ts @@ -1,5 +1,9 @@ import postgres from 'postgres'; -import type { Actions, Events } from '../../blockchain/types.js'; +import type { + Actions, + Events, + NetworkState, +} from '../../blockchain/types.js'; import type { DatabaseAdapter } from './archive-node-adapter.interface.js'; import type { ActionFilterOptionsInput, @@ -10,6 +14,8 @@ 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 { NetworkService } from '../../services/network-service/network-service.js'; +import { INetworkService } from '../../services/network-service/network-service.interface.js'; export class ArchiveNodeAdapter implements DatabaseAdapter { /** @@ -21,6 +27,7 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { private client: postgres.Sql; private eventsService: IEventsService; private actionsService: IActionsService; + private networkService: INetworkService; constructor(connectionString: string | undefined) { if (!connectionString) @@ -30,6 +37,7 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { this.client = postgres(connectionString); this.eventsService = new EventsService(this.client); this.actionsService = new ActionsService(this.client); + this.networkService = new NetworkService(this.client); } async getEvents( @@ -46,6 +54,10 @@ export class ArchiveNodeAdapter implements DatabaseAdapter { return this.actionsService.getActions(input, options); } + async getNetworkState(options: unknown): Promise { + return this.networkService.getNetworkState(options); + } + async checkSQLSchema() { let tables; try { diff --git a/src/db/sql/events-actions/queries.ts b/src/db/sql/events-actions/queries.ts index 0de23c4..9483f65 100644 --- a/src/db/sql/events-actions/queries.ts +++ b/src/db/sql/events-actions/queries.ts @@ -371,6 +371,24 @@ export function getActionsQuery( `; } +export function getNetworkStateQuery(db_client: postgres.Sql) { + return db_client` +WITH max_heights AS ( + SELECT + chain_status, + MAX(height) AS max_height + FROM blocks + WHERE chain_status IN ('canonical', 'pending') + GROUP BY chain_status +) +SELECT b.* +FROM blocks b +JOIN max_heights mh + ON b.chain_status = mh.chain_status + AND b.height = mh.max_height; + `; +} + export function checkActionState(db_client: postgres.Sql, actionState: string) { return db_client` SELECT field FROM zkapp_field WHERE field = ${actionState} diff --git a/src/resolvers-types.ts b/src/resolvers-types.ts index 5c8f266..e1c452f 100644 --- a/src/resolvers-types.ts +++ b/src/resolvers-types.ts @@ -83,6 +83,17 @@ export type BlockInfo = { timestamp: Scalars['String']['output']; }; +export type NetworkStateOutput = { + __typename?: 'NetworkStateOutput'; + maxBlockHeight: MaxBlockHeightInfo +}; + +export type MaxBlockHeightInfo = { + __typename?: 'MaxBlockHeightInfo'; + canonicalMaxBlockHeight: Scalars['Int']['output']; + pendingMaxBlockHeight: Scalars['Int']['output']; +}; + export { BlockStatusFilter }; export type EventData = { @@ -110,6 +121,7 @@ export type Query = { __typename?: 'Query'; actions: Array>; events: Array>; + networkState: Maybe; }; export type QueryActionsArgs = { @@ -242,6 +254,8 @@ export type ResolversTypes = { ActionOutput: ResolverTypeWrapper; ActionStates: ResolverTypeWrapper; BlockInfo: ResolverTypeWrapper; + NetworkStateOutput: ResolverTypeWrapper; + MaxBlockHeightInfo: ResolverTypeWrapper; BlockStatusFilter: BlockStatusFilter; Boolean: ResolverTypeWrapper; EventData: ResolverTypeWrapper; @@ -438,6 +452,11 @@ export type QueryResolvers< ContextType, RequireFields >; + networkState?: Resolver< + Maybe, + ParentType, + ContextType + >; }; export type TransactionInfoResolvers< diff --git a/src/resolvers.ts b/src/resolvers.ts index e0fb300..6052eef 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -32,6 +32,16 @@ const resolvers: Resolvers = { tracingState: new TracingState(graphQLSpan), }); }, + + networkState: async (_, __, context) => { + const graphQLSpan = setSpanNameFromGraphQLContext( + context, + 'networkState.graphql' + ); + return context.db_client.getNetworkState({ + tracingState: new TracingState(graphQLSpan), + }); + }, }, }; diff --git a/src/services/network-service/network-service.interface.ts b/src/services/network-service/network-service.interface.ts new file mode 100644 index 0000000..59023a0 --- /dev/null +++ b/src/services/network-service/network-service.interface.ts @@ -0,0 +1,5 @@ +import { NetworkState } from '../../blockchain/types.js'; + +export interface INetworkService { + getNetworkState(options: unknown): Promise; +} diff --git a/src/services/network-service/network-service.ts b/src/services/network-service/network-service.ts new file mode 100644 index 0000000..f95c623 --- /dev/null +++ b/src/services/network-service/network-service.ts @@ -0,0 +1,53 @@ +import type postgres from 'postgres'; +import { NetworkState } from '../../blockchain/types.js'; +import { getNetworkStateQuery } from '../../db/sql/events-actions/queries.js'; + +import { INetworkService } from './network-service.interface.js'; +import { + TracingState, + extractTraceStateFromOptions, +} from '../../tracing/tracer.js'; + +export { NetworkService }; + +class NetworkService implements INetworkService { + private readonly client: postgres.Sql; + + constructor(client: postgres.Sql) { + this.client = client; + } + + async getNetworkState(options: unknown): Promise { + const tracingState = extractTraceStateFromOptions(options); + return (await this.getNetworkStateData({ tracingState })) ?? []; + } + + async getNetworkStateData({ + tracingState, + }: { + tracingState: TracingState; + }): Promise { + const sqlSpan = tracingState.startSpan('networkState.SQL'); + const rows = await this.executeNetworkStateQuery(); + sqlSpan.end(); + + const processingSpan = tracingState.startSpan('networkState.processing'); + const maxBlockHeightInfo = { + canonicalMaxBlockHeight: Number( + rows.filter((row) => row.chain_status === 'canonical')[0].height + ), + pendingMaxBlockHeight: Number( + rows.filter((row) => row.chain_status === 'pending')[0].height + ), + }; + const networkState = { + maxBlockHeight: maxBlockHeightInfo + } + processingSpan.end(); + return networkState; + } + + private async executeNetworkStateQuery() { + return getNetworkStateQuery(this.client); + } +} diff --git a/tests/resolvers.test.ts b/tests/resolvers.test.ts index dd64749..9344675 100644 --- a/tests/resolvers.test.ts +++ b/tests/resolvers.test.ts @@ -29,6 +29,7 @@ import { Keypair, emitActionsFromMultipleSenders, emitMultipleFieldsEvents, + fetchNetworkState, randomStruct, } from '../zkapp/utils.js'; import { HelloWorld, TestStruct } from '../zkapp/contract.js'; @@ -37,17 +38,22 @@ import { ActionOutput, EventData, EventOutput, + NetworkStateOutput, + MaxBlockHeightInfo, Maybe, } from 'src/resolvers-types.js'; interface ExecutorResult { data: - | { - events: Array; - } - | { - actions: Array; - }; + | { + events: Array; + } + | { + actions: Array; + } + | { + networkState: NetworkStateOutput; + }; } interface EventQueryResult extends ExecutorResult { @@ -62,6 +68,12 @@ interface ActionQueryResult extends ExecutorResult { }; } +interface NetworkQueryResult extends ExecutorResult { + data: { + networkState: NetworkStateOutput; + }; +} + const eventsQuery = ` query getEvents($input: EventFilterOptionsInput!) { events(input: $input) { @@ -121,17 +133,31 @@ query getActions($input: ActionFilterOptionsInput!) { } `; +const networkQuery = ` +query maxBlockHeightInfo { + networkState { + maxBlockHeight { + canonicalMaxBlockHeight + pendingMaxBlockHeight + } + } +} +`; + // This is the default connection string provided by the lightnet postgres container const PG_CONN = 'postgresql://postgres:postgres@localhost:5432/archive '; interface ExecutorResult { data: - | { - events: Array; - } - | { - actions: Array; - }; + | { + events: Array; + } + | { + actions: Array; + } + | { + networkState: NetworkStateOutput; + }; } interface EventQueryResult extends ExecutorResult { @@ -146,6 +172,12 @@ interface ActionQueryResult extends ExecutorResult { }; } +interface NetworkQueryResult extends ExecutorResult { + data: { + networkState: NetworkStateOutput; + }; +} + describe('Query Resolvers', async () => { let executor: AsyncExecutor; let senderKeypair: Keypair; @@ -174,6 +206,12 @@ describe('Query Resolvers', async () => { })) as EventQueryResult; } + async function executeNetworkStateQuery(): Promise { + return (await executor({ + document: parse(`${networkQuery}`), + })) as NetworkQueryResult; + } + before(async () => { try { setNetworkConfig(); @@ -216,6 +254,48 @@ describe('Query Resolvers', async () => { process.exit(0); }); + describe("NetworkState", async () => { + let blockResponse: NetworkStateOutput; + let results: NetworkQueryResult; + let fetchedBlockchainLength: number; + + before(async () => { + results = await executeNetworkStateQuery(); + blockResponse = results.data.networkState; + fetchedBlockchainLength = await fetchNetworkState(zkApp, senderKeypair); + }); + + test("Fetching the max block height should not throw", async () => { + assert.doesNotThrow(async () => { + await executeNetworkStateQuery(); + }); + }); + + test("Fetching the max block height should return the max block height", async () => { + blockResponse = results.data.networkState; + assert.ok(blockResponse.maxBlockHeight.canonicalMaxBlockHeight > 0); + assert.ok(blockResponse.maxBlockHeight.pendingMaxBlockHeight > 0); + assert.ok(blockResponse.maxBlockHeight.pendingMaxBlockHeight > blockResponse.maxBlockHeight.canonicalMaxBlockHeight); + }); + + test("Fetched max block height from archive node should match with the one from mina node", async () => { + assert.deepStrictEqual(blockResponse.maxBlockHeight.pendingMaxBlockHeight, fetchedBlockchainLength); + }); + + describe("Advance a block", async () => { + before(async () => { + await new Promise((resolve) => setTimeout(resolve, 25000)); // wait for new lightnet block + results = await executeNetworkStateQuery(); + blockResponse = results.data.networkState; + fetchedBlockchainLength = await fetchNetworkState(zkApp, senderKeypair); + }); + test("Fetched max block height from archive node should match the one from mina node after one block", () => { + assert.deepStrictEqual(blockResponse.maxBlockHeight.pendingMaxBlockHeight, fetchedBlockchainLength); + }); + }); + + }); + describe('Events', async () => { let eventsResponse: EventOutput[]; let lastBlockEvents: Maybe[]; @@ -508,8 +588,10 @@ describe('Query Resolvers', async () => { }); }); }); + }); + function structToAction(s: TestStruct) { return [ s.x.toString(), diff --git a/zkapp/utils.ts b/zkapp/utils.ts index 4c48afd..3f6bfe0 100644 --- a/zkapp/utils.ts +++ b/zkapp/utils.ts @@ -7,6 +7,7 @@ import { fetchAccount, UInt64, Bool, + UInt32, } from 'o1js'; import { HelloWorld, TestStruct, type TestStructArray } from './contract.js'; @@ -21,6 +22,7 @@ export { emitAction, emitActionsFromMultipleSenders, reduceAction, + fetchNetworkState, Keypair, randomStruct, }; @@ -97,7 +99,7 @@ async function updateContractState( await zkApp.update(Field(4)); } ); - transaction.sign([senderKey]).prove(); + await transaction.sign([senderKey]).prove(); await sendTransaction(transaction); } @@ -238,6 +240,24 @@ async function reduceAction( await sendTransaction(transaction); } +async function fetchNetworkState( + zkApp: HelloWorld, + { publicKey: sender, privateKey: senderKey }: Keypair +): Promise { + console.log('Fetching network state.'); + let blockchainLength: UInt32 = UInt32.zero; + let transaction = await Mina.transaction( + { sender, fee: transactionFee }, + async () => { + blockchainLength = Mina.getNetworkState().blockchainLength; + } + ); + transaction.sign([senderKey]); + await transaction.prove(); + await sendTransaction(transaction); + return Number(blockchainLength.toString()); +} + async function sendTransaction(transaction: Mina.Transaction) { let pendingTx = await transaction.send(); if (pendingTx.status === 'pending') {