diff --git a/.eslintrc.json b/.eslintrc.json index 77a3dad..20654df 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,9 @@ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" ], + "rules": { + "@typescript-eslint/no-namespace": "off" + }, "overrides": [ { "files": ["src/schema.ts"], diff --git a/schema.graphql b/schema.graphql index be8d78e..28187a1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -23,6 +23,7 @@ input ActionFilterOptionsInput { } type EventData { + accountUpdateId: String! transactionInfo: TransactionInfo data: [String]! } @@ -50,6 +51,8 @@ type TransactionInfo { hash: String! memo: String! authorizationKind: String! + sequenceNumber: Int! # TODO: Is it ok to make this required? + zkappAccountUpdateIds: [Int]! } type ActionStates { diff --git a/src/blockchain/types.ts b/src/blockchain/types.ts index 7d3d358..6f3baa0 100644 --- a/src/blockchain/types.ts +++ b/src/blockchain/types.ts @@ -13,6 +13,7 @@ export enum BlockStatusFilter { } export type Event = { + accountUpdateId: string; transactionInfo: TransactionInfo; data: string[]; }; @@ -41,6 +42,8 @@ export type TransactionInfo = { hash: string; memo: string; authorizationKind: string; + sequenceNumber: number; + zkappAccountUpdateIds: number[]; }; export type Events = { diff --git a/src/blockchain/utils.ts b/src/blockchain/utils.ts index 6010725..2a99b30 100644 --- a/src/blockchain/utils.ts +++ b/src/blockchain/utils.ts @@ -24,14 +24,18 @@ export function createTransactionInfo( hash: row.hash, memo: row.memo, authorizationKind: row.authorization_kind, + sequenceNumber: row.sequence_number, + zkappAccountUpdateIds: row.zkapp_account_updates_ids, }; } export function createEvent( + accountUpdateId: string, data: string[], transactionInfo: TransactionInfo ): Event { return { + accountUpdateId, data, transactionInfo, }; diff --git a/src/db/sql/events-actions/queries.ts b/src/db/sql/events-actions/queries.ts index af71173..0de23c4 100644 --- a/src/db/sql/events-actions/queries.ts +++ b/src/db/sql/events-actions/queries.ts @@ -106,8 +106,23 @@ function emittedZkAppCommandsCTE(db_client: postgres.Sql) { return db_client` emitted_zkapp_commands AS ( SELECT - blocks_accessed.*, + blocks_accessed.requesting_zkapp_account_identifier_id, + blocks_accessed.block_id, + blocks_accessed.account_identifier_id, + blocks_accessed.zkapp_id, + blocks_accessed.account_access_id, + blocks_accessed.state_hash, + blocks_accessed.parent_hash, + blocks_accessed.height, + blocks_accessed.global_slot_since_genesis, + blocks_accessed.global_slot_since_hard_fork, + blocks_accessed.timestamp, + blocks_accessed.chain_status, + blocks_accessed.ledger_hash, + blocks_accessed.distance_from_max_block_height, + blocks_accessed.last_vrf_output, zkcu.id AS zkapp_account_update_id, + bzkc.sequence_no AS sequence_number, zkapp_fee_payer_body_id, zkapp_account_updates_ids, authorization_kind, @@ -133,13 +148,41 @@ function emittedEventsCTE(db_client: postgres.Sql) { return db_client` emitted_events AS ( SELECT - *, - zke.id AS zkapp_event_id, - zke.element_ids AS zkapp_event_element_ids, - zkfa.id AS zkapp_event_array_id + emitted_zkapp_commands.requesting_zkapp_account_identifier_id, + emitted_zkapp_commands.block_id, + emitted_zkapp_commands.account_identifier_id, + emitted_zkapp_commands.zkapp_id, + emitted_zkapp_commands.account_access_id, + emitted_zkapp_commands.state_hash, + emitted_zkapp_commands.parent_hash, + emitted_zkapp_commands.height, + emitted_zkapp_commands.global_slot_since_genesis, + emitted_zkapp_commands.global_slot_since_hard_fork, + emitted_zkapp_commands.timestamp, + emitted_zkapp_commands.chain_status, + emitted_zkapp_commands.ledger_hash, + emitted_zkapp_commands.distance_from_max_block_height, + emitted_zkapp_commands.last_vrf_output, + emitted_zkapp_commands.zkapp_account_update_id, + emitted_zkapp_commands.sequence_number, + emitted_zkapp_commands.zkapp_fee_payer_body_id, + emitted_zkapp_commands.zkapp_account_updates_ids, + emitted_zkapp_commands.authorization_kind, + emitted_zkapp_commands.status, + emitted_zkapp_commands.memo, + emitted_zkapp_commands.hash, + emitted_zkapp_commands.body_id, + emitted_zkapp_commands.events_id, + emitted_zkapp_commands.actions_id, + zke.id AS account_update_event_id, + zke.element_ids AS event_element_ids, + zkfa.element_ids AS event_field_element_ids, + zkfa.id AS event_field_elements_id, + zkf.id AS field_id, + zkf.field AS field_value FROM emitted_zkapp_commands - INNER JOIN zkapp_events zke ON zke.id = events_id + INNER JOIN zkapp_events zke ON zke.id = emitted_zkapp_commands.events_id INNER JOIN zkapp_field_array zkfa ON zkfa.id = ANY(zke.element_ids) INNER JOIN zkapp_field zkf ON zkf.id = ANY(zkfa.element_ids) ) @@ -150,13 +193,38 @@ function emittedActionsCTE(db_client: postgres.Sql) { return db_client` emitted_actions AS ( SELECT - *, - zke.id AS zkapp_event_id, - zke.element_ids AS zkapp_event_element_ids, - zkfa.id AS zkapp_event_array_id + emitted_zkapp_commands.block_id, + emitted_zkapp_commands.zkapp_id, + emitted_zkapp_commands.state_hash, + emitted_zkapp_commands.parent_hash, + emitted_zkapp_commands.height, + emitted_zkapp_commands.global_slot_since_genesis, + emitted_zkapp_commands.global_slot_since_hard_fork, + emitted_zkapp_commands.timestamp, + emitted_zkapp_commands.chain_status, + emitted_zkapp_commands.ledger_hash, + emitted_zkapp_commands.distance_from_max_block_height, + emitted_zkapp_commands.last_vrf_output, + emitted_zkapp_commands.zkapp_account_update_id, + emitted_zkapp_commands.sequence_number, + emitted_zkapp_commands.zkapp_fee_payer_body_id, + emitted_zkapp_commands.zkapp_account_updates_ids, + emitted_zkapp_commands.authorization_kind, + emitted_zkapp_commands.status, + emitted_zkapp_commands.memo, + emitted_zkapp_commands.hash, + emitted_zkapp_commands.body_id, + emitted_zkapp_commands.events_id, + emitted_zkapp_commands.actions_id, + zke.id AS account_update_event_id, + zke.element_ids AS event_element_ids, + zkfa.element_ids AS event_field_element_ids, + zkfa.id AS event_field_elements_id, + zkf.id AS field_id, + zkf.field AS field_value FROM emitted_zkapp_commands - INNER JOIN zkapp_events zke ON zke.id = actions_id + INNER JOIN zkapp_events zke ON zke.id = emitted_zkapp_commands.actions_id INNER JOIN zkapp_field_array zkfa ON zkfa.id = ANY(zke.element_ids) INNER JOIN zkapp_field zkf ON zkf.id = ANY(zkfa.element_ids) ) @@ -176,7 +244,36 @@ function emittedActionStateCTE( zkf2.field AS action_state_value3, zkf3.field AS action_state_value4, zkf4.field AS action_state_value5, - emitted_actions.* + emitted_actions.last_vrf_output, + emitted_actions.block_id, + emitted_actions.zkapp_id, + emitted_actions.state_hash, + emitted_actions.parent_hash, + emitted_actions.height, + emitted_actions.global_slot_since_genesis, + emitted_actions.global_slot_since_hard_fork, + emitted_actions.timestamp, + emitted_actions.chain_status, + emitted_actions.ledger_hash, + emitted_actions.distance_from_max_block_height, + emitted_actions.last_vrf_output, + emitted_actions.zkapp_account_update_id, + emitted_actions.sequence_number, + emitted_actions.zkapp_fee_payer_body_id, + emitted_actions.zkapp_account_updates_ids, + emitted_actions.authorization_kind, + emitted_actions.status, + emitted_actions.memo, + emitted_actions.hash, + emitted_actions.body_id, + emitted_actions.events_id, + emitted_actions.actions_id, + emitted_actions.account_update_event_id, + emitted_actions.event_element_ids, + emitted_actions.event_field_element_ids, + emitted_actions.event_field_elements_id, + emitted_actions.field_id, + emitted_actions.field_value FROM emitted_actions INNER JOIN zkapp_accounts zkacc ON zkacc.id = emitted_actions.zkapp_id @@ -216,7 +313,37 @@ export function getEventsQuery( ${blocksAccessedCTE(db_client, status, to, from)}, ${emittedZkAppCommandsCTE(db_client)}, ${emittedEventsCTE(db_client)} - SELECT * + SELECT + last_vrf_output, + block_id, + zkapp_id, + state_hash, + parent_hash, + height, + global_slot_since_genesis, + global_slot_since_hard_fork, + timestamp, + chain_status, + ledger_hash, + distance_from_max_block_height, + last_vrf_output, + zkapp_account_update_id, + sequence_number, + zkapp_fee_payer_body_id, + zkapp_account_updates_ids, + authorization_kind, + status, + memo, + hash, + body_id, + events_id, + actions_id, + account_update_event_id, + event_element_ids, + event_field_element_ids, + event_field_elements_id, + field_id, + field_value FROM emitted_events `; } diff --git a/src/db/sql/events-actions/types.ts b/src/db/sql/events-actions/types.ts index 1bc7bfe..09c241d 100644 --- a/src/db/sql/events-actions/types.ts +++ b/src/db/sql/events-actions/types.ts @@ -1,90 +1,208 @@ +// Namespaces are used here as an alias to the simple number and number[] types in order to model +// the relationships between the different types of data in the system. + +/** + * A Field is the smallest unit of raw data in an Event + */ +namespace Field { + /** + * The id of the Field in the archive node postres DB + */ + export type Id = number; + + /** + * The raw value of the Field + */ + export type Value = string; +} + +/** + * An EventFieldArray is an Array of {@link Field.Id} which represents a multi-Field datum within an event + * For instance, a public key is 2 fields, so it will generate an EventFieldArray with 2 Field.Ids + */ +namespace EventFieldArray { + /** + * The id of the EventFieldArray in the archive node postres DB + */ + export type Id = number; + + /** + * An array of {@link Field.Id} + */ + export type FieldIds = Field.Id[]; +} + +/** + * An Event is a collection of {@link EventFieldArray.Id} which represents a single event in the system + * Because an event may contain multiple data, it is represented as an array of arrays + */ +namespace Event { + /** + * The id of the Event in the archive node postres DB + */ + export type Id = number; + + /** + * An array of {@link EventFieldArray.Id} + */ + export type EventFieldArrayIds = EventFieldArray.Id[]; +} + /** - * Type representing a database row with detailed information related to the archive node. - * This includes fields such as block-related hashes, account information, action states, and more. + * Represents a complete row from the Archive Node database. + * This structure gathers information related to blocks, transactions, events, and associated zkApp updates. */ export type ArchiveNodeDatabaseRow = { - // Unique block identifier. + /** + * Unique block identifier. + */ block_id: number; - // zkapp identifier. + /** + * zkApp identifier. + */ zkapp_id: number; - // Hash representing the state of the block. + /** + * Hash representing the state of the block. + */ state_hash: string; - // Hash representing the parent of the block. + /** + * Hash representing the parent of the block. + */ parent_hash: string; - // Numeric representation of block's height in the chain. + /** + * Numeric representation of block's height in the chain. + */ height: string; - // Slot count since genesis. + /** + * Slot count since genesis. + */ global_slot_since_genesis: string; - // Slot count since the last hard fork. + /** + * Slot count since the last hard fork. + * @type {string} + */ global_slot_since_hard_fork: string; - // Type of authorization used for the block. + /** + * Type of authorization used for the block. + */ authorization_kind: string; - // Timestamp when the block was created. + /** + * Timestamp when the block was created. + */ timestamp: string; - // Current status of the block within the chain. + /** + * Current status of the block within the chain (e.g., canonical, orphaned). + */ chain_status: string; - // Hash representing the ledger state. + /** + * Sequence number of the transaction within a block. + */ + sequence_number: number; + + /** + * Hash representing the ledger state. + */ ledger_hash: string; - // Distance from the block with the maximum height. + /** + * Distance from the block with the maximum height. + */ distance_from_max_block_height: string; - // Unique identifier for the zkapp account update. + /** + * Unique identifier for the zkApp account update. + */ zkapp_account_update_id: number; - // List of identifiers inside a zkapp account update. + /** + * List of Account Update IDs associated with the transaction + */ zkapp_account_updates_ids: number[]; - // Status of the transaction. + /** + * Status of the transaction. + */ status: string; - // Optional memo field for additional details. + /** + * Optional memo field for additional details. + */ memo: string; - // Unique hash identifier. + /** + * Unique hash identifier for this row's data. + */ hash: string; - // The unique identifier that maps events/actions to a specific zkApp. - zkapp_event_array_id: number; - - // List of `element_ids` that are used to construct the zkApp event. - zkapp_event_element_ids: number[]; - - // `element_ids` represent a list of identifiers that map to specific field values. - // These are used to identify which field values are used in a zkApp transaction and construct the data returned to the user. - element_ids: number[]; - - // Unique id for a `field` value. Each field value in the Archive Node has it's own unique id. - id: number; - - // Field value information. - field: string; - - // Output of the last VRF (Verifiable Random Function). + /** + * ID of a single event in an account update. + */ + account_update_event_id: Event.Id; + + /** + * List of IDs of the field arrays used to construct the event array. + */ + event_element_ids: Event.EventFieldArrayIds; + + /** + * ID referencing an array of field elements associated with an event. + */ + event_field_elements_id: EventFieldArray.Id; + + /** + * List of `element_ids` that are used to construct the field array. + * Each entry corresponds to a `Field.Id`, linking to a `Field`. + */ + event_field_element_ids: EventFieldArray.FieldIds; + + /** + * Unique ID for a `field`. + * Each `field_id` corresponds to one `Field`. + */ + field_id: Field.Id; + + /** + * Value of the field corresponding to `field_id`. + */ + field_value: Field.Value; + + /** + * Output of the last VRF (Verifiable Random Function). + */ last_vrf_output: string; - // (Optional) Action state value 1. + /** + * (Optional) Action state value 1. + */ action_state_value1?: string; - // (Optional) Action state value 2. + /** + * (Optional) Action state value 2. + */ action_state_value2?: string; - // (Optional) Action state value 3. + /** + * (Optional) Action state value 3. + */ action_state_value3?: string; - // (Optional) Action state value 4. + /** + * (Optional) Action state value 4. + */ action_state_value4?: string; - // (Optional) Action state value 5. + /** + * (Optional) Action state value 5. + */ action_state_value5?: string; }; diff --git a/src/resolvers-types.ts b/src/resolvers-types.ts index a17c136..5c8f266 100644 --- a/src/resolvers-types.ts +++ b/src/resolvers-types.ts @@ -87,6 +87,7 @@ export { BlockStatusFilter }; export type EventData = { __typename?: 'EventData'; + accountUpdateId: Scalars['String']['output']; data: Array>; transactionInfo?: Maybe; }; @@ -124,7 +125,9 @@ export type TransactionInfo = { authorizationKind: Scalars['String']['output']; hash: Scalars['String']['output']; memo: Scalars['String']['output']; + sequenceNumber: Scalars['Int']['output']; status: Scalars['String']['output']; + zkappAccountUpdateIds: Array>; }; export type ResolverTypeWrapper = Promise | T; @@ -386,6 +389,7 @@ export type EventDataResolvers< ParentType extends ResolversParentTypes['EventData'] = ResolversParentTypes['EventData'], > = { + accountUpdateId?: Resolver; data?: Resolver< Array>, ParentType, @@ -448,7 +452,13 @@ export type TransactionInfoResolvers< >; hash?: Resolver; memo?: Resolver; + sequenceNumber?: Resolver; status?: Resolver; + zkappAccountUpdateIds?: Resolver< + Array>, + ParentType, + ContextType + >; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/src/services/actions-service/actions-service.ts b/src/services/actions-service/actions-service.ts index a672190..03652bf 100644 --- a/src/services/actions-service/actions-service.ts +++ b/src/services/actions-service/actions-service.ts @@ -51,6 +51,7 @@ class ActionsService implements IActionsService { ): Promise { const sqlSpan = tracingState.startSpan('actions.SQL'); const rows = await this.executeActionsQuery(input); + sqlSpan.end(); const processingSpan = tracingState.startSpan('actions.processing'); @@ -121,7 +122,7 @@ class ActionsService implements IActionsService { ); for (let i = 0; i < blockTransactionEntries.length; i++) { const transactions = blockTransactionEntries[i][1]; - const transaction = transactions.values().next().value[0]; + const transaction = transactions.values().next().value![0]; const blockInfo = createBlockInfo(transaction); const { action_state_value1, @@ -143,7 +144,7 @@ class ActionsService implements IActionsService { } actions.push({ blockInfo, - actionData: actionsData.flat(), + actionData: this.sortActions(actionsData.flat()), actionState: { /* eslint-disable */ actionStateOne: action_state_value1!, @@ -157,4 +158,29 @@ class ActionsService implements IActionsService { } return actions; } + + sortActions(actions: Action[]): Action[] { + return actions.sort((a, b) => { + // Sort by sequence number + if ( + a.transactionInfo.sequenceNumber !== b.transactionInfo.sequenceNumber + ) { + return ( + a.transactionInfo.sequenceNumber - b.transactionInfo.sequenceNumber + ); + } + + // Sort by account update index within the transaction + const aAccountUpdateIndex = + a.transactionInfo.zkappAccountUpdateIds.indexOf( + Number(a.accountUpdateId) + ); + const bAccountUpdateIndex = + b.transactionInfo.zkappAccountUpdateIds.indexOf( + Number(b.accountUpdateId) + ); + + return aAccountUpdateIndex - bAccountUpdateIndex; + }); + } } diff --git a/src/services/data-adapters/database-row-adapters.ts b/src/services/data-adapters/database-row-adapters.ts index e8100a1..0516cd7 100644 --- a/src/services/data-adapters/database-row-adapters.ts +++ b/src/services/data-adapters/database-row-adapters.ts @@ -72,8 +72,8 @@ function partitionBlocks(rows: ArchiveNodeDatabaseRow[]) { function getElementIdFieldValues(rows: ArchiveNodeDatabaseRow[]) { const elementIdValues: FieldElementIdWithValueMap = new Map(); for (let i = 0; i < rows.length; i++) { - const { id, field } = rows[i]; - elementIdValues.set(id.toString(), field); + const { field_id, field_value } = rows[i]; + elementIdValues.set(field_id.toString(), field_value); } return elementIdValues; } @@ -123,8 +123,8 @@ function removeRedundantEmittedFields( for (let i = 0; i < archiveNodeRow.length; i++) { const currentRow = archiveNodeRow[i]; const { - zkapp_event_array_id, // The unique id for the event/action emitted - zkapp_event_element_ids, // The list of field ids that make up the event/action + event_field_elements_id, // The unique id for the field array in the current row + event_element_ids, // The list of element ids in the event (list of event_field_elements_id) zkapp_account_update_id, // The unique id for the account update that emitted the event/action zkapp_account_updates_ids, // List of all account update ids inside the transaction } = currentRow; @@ -133,15 +133,15 @@ function removeRedundantEmittedFields( // This is used to check if we have already seen this event/action before. const uniqueEventId = createUniqueEventId( zkapp_account_update_id, - zkapp_event_array_id + event_field_elements_id ); if (!seenEventOrActionIds.has(uniqueEventId)) { // Since multiple events/actions can be emitted in a single account update, we want to put back the event/action // in the correct place. To do this, we need to know the index of the event array id in the list of event array ids (these stored in order by the Archive Node) const emittedEventOrActionIndexes = findAllIndexes( - zkapp_event_element_ids, - zkapp_event_array_id + event_element_ids, + event_field_elements_id ); // Since multiple account updates can be emitted in a single transaction, we need to know the index of the account update id in the list of account update ids @@ -152,7 +152,7 @@ function removeRedundantEmittedFields( if (accountUpdateIndexes.length === 0) { throw new Error( - `No matching account update found for the given account update ID (${zkapp_account_update_id}) and event array ID (${zkapp_event_array_id}).` + `No matching account update found for the given account update ID (${zkapp_account_update_id}) and event array ID (${event_field_elements_id}).` ); } @@ -199,15 +199,19 @@ function mapActionOrEvent( ) { const data: (Event | Action)[] = []; for (let i = 0; i < rows.length; i++) { - const { element_ids } = rows[i]; + const { zkapp_account_update_id, event_field_element_ids } = rows[i]; const transactionInfo = createTransactionInfo(rows[i]); const elementIdToFieldValues = getFieldValuesFromElementIds( - element_ids, + event_field_element_ids, elementIdFieldValues ); if (kind === 'event') { - const event = createEvent(elementIdToFieldValues, transactionInfo); + const event = createEvent( + zkapp_account_update_id.toString(), + elementIdToFieldValues, + transactionInfo + ); data.push(event); } else { const { zkapp_account_update_id } = rows[i]; diff --git a/src/services/events-service/events-service.ts b/src/services/events-service/events-service.ts index 940ff6e..eeff1b4 100644 --- a/src/services/events-service/events-service.ts +++ b/src/services/events-service/events-service.ts @@ -88,7 +88,7 @@ class EventsService implements IEventsService { const blockMapEntries = Array.from(blocksWithTransactions.entries()); for (let i = 0; i < blockMapEntries.length; i++) { const transactions = blockMapEntries[i][1]; - const transaction = transactions.values().next().value[0]; + const transaction = transactions.values().next().value![0]; const blockInfo = createBlockInfo(transaction); const eventsData: Event[][] = []; diff --git a/tests/resolvers.test.ts b/tests/resolvers.test.ts index 41b42a4..dd64749 100644 --- a/tests/resolvers.test.ts +++ b/tests/resolvers.test.ts @@ -9,7 +9,15 @@ import { } from '@graphql-tools/executor-http'; import { AsyncExecutor } from '@graphql-tools/utils'; import { parse } from 'graphql'; -import { PrivateKey, Lightnet } from 'o1js'; +import { + Bool, + Field, + Lightnet, + Mina, + Poseidon, + PrivateKey, + UInt64, +} from 'o1js'; import { resolvers } from '../src/resolvers.js'; import { buildContext, GraphQLContext } from '../src/context.js'; import { @@ -19,8 +27,11 @@ import { emitSingleEvent, setNetworkConfig, Keypair, + emitActionsFromMultipleSenders, + emitMultipleFieldsEvents, + randomStruct, } from '../zkapp/utils.js'; -import { HelloWorld } from '../zkapp/contract.js'; +import { HelloWorld, TestStruct } from '../zkapp/contract.js'; import { ActionData, ActionOutput, @@ -102,6 +113,8 @@ query getActions($input: ActionFilterOptionsInput!) { status hash memo + sequenceNumber + zkappAccountUpdateIds } } } @@ -111,6 +124,28 @@ query getActions($input: ActionFilterOptionsInput!) { // 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; + }; +} + +interface EventQueryResult extends ExecutorResult { + data: { + events: Array; + }; +} + +interface ActionQueryResult extends ExecutorResult { + data: { + actions: Array; + }; +} + describe('Query Resolvers', async () => { let executor: AsyncExecutor; let senderKeypair: Keypair; @@ -167,7 +202,7 @@ describe('Query Resolvers', async () => { } }); - after(async () => { + after(() => { process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); @@ -243,8 +278,14 @@ describe('Query Resolvers', async () => { describe('After emitting an event with multiple fields once', async () => { let results: EventQueryResult; + const baseStruct = randomStruct(); before(async () => { - await emitMultipleFieldsEvent(zkApp, senderKeypair); + await emitMultipleFieldsEvent( + zkApp, + senderKeypair, + undefined, + baseStruct + ); results = await executeEventsQuery({ address: zkApp.address.toBase58(), }); @@ -258,22 +299,23 @@ describe('Query Resolvers', async () => { test('The event has the correct data', async () => { const eventData = lastBlockEvents[0]!; - // The event type is 1 and the event data is 2, 1 (Bool(true)), 1 and the zkapp address - assert.deepStrictEqual(eventData.data, [ - '1', - '2', - '1', - '1', - ...zkApp.address.toFields().map((f) => f.toString()), - ]); + const expectedStructData = structToAction(baseStruct); + // The event type is 1 and the event data comes from the base struct + assert.deepStrictEqual(eventData.data, ['1', ...expectedStructData]); }); }); describe('After emitting an event with multiple fields multiple times', async () => { let results: EventQueryResult; const numberOfEmits = 3; + const baseStruct = randomStruct(); before(async () => { - await emitMultipleFieldsEvent(zkApp, senderKeypair, { numberOfEmits }); + await emitMultipleFieldsEvent( + zkApp, + senderKeypair, + { numberOfEmits }, + baseStruct + ); results = await executeEventsQuery({ address: zkApp.address.toBase58(), }); @@ -285,15 +327,62 @@ describe('Query Resolvers', async () => { }); test('the events have the correct data', async () => { for (let i = 0; i < numberOfEmits; i++) { + const expectedStruct = new TestStruct(baseStruct); + expectedStruct.x = expectedStruct.x.add(Field(i)); + const expectedStructData = structToAction(expectedStruct); const eventData = lastBlockEvents[i]!; - // The event type is 1 and the event data is 2, 1 (Bool(true)), 1, and the zkapp address - assert.deepStrictEqual(eventData.data, [ - '1', - '2', - '1', - '1', - ...zkApp.address.toFields().map((f) => f.toString()), - ]); + // The event type is 1 and the event data comes from the base struct + assert.deepStrictEqual(eventData.data, ['1', ...expectedStructData]); + } + }); + }); + + describe('After emitting multiple events with multiple fields', async () => { + let results: EventQueryResult; + const numberOfEmits = 3; + const baseStruct = randomStruct(); + before(async () => { + await emitMultipleFieldsEvents( + zkApp, + senderKeypair, + { numberOfEmits }, + baseStruct + ); + results = await executeEventsQuery({ + address: zkApp.address.toBase58(), + }); + eventsResponse = results.data.events; + lastBlockEvents = eventsResponse[eventsResponse.length - 1].eventData!; + }); + test('GQL response contains multiple events in the latest block', async () => { + assert.strictEqual(lastBlockEvents.length, numberOfEmits); + }); + test('the events have the correct data', async () => { + for (let i = 0; i < numberOfEmits; i++) { + const expectedStruct = new TestStruct(baseStruct); + expectedStruct.x = expectedStruct.x.add(Field(i)); + const expectedS1 = new TestStruct(expectedStruct); + const expectedS2 = new TestStruct(expectedStruct); + const expectedS3 = new TestStruct(expectedStruct); + expectedS1.z = expectedS1.z.add(UInt64.from(i)); + expectedS2.z = expectedS2.z.add(UInt64.from(i + 1)); + expectedS3.z = expectedS3.z.add(UInt64.from(i + 2)); + const eventData = lastBlockEvents[i]!; + const structData = eventData.data; + assert.strictEqual(structData.length, 16); + assert.strictEqual(structData[0], '2'); + assert.deepStrictEqual( + structData.slice(1, 6), + structToAction(expectedS1) + ); + assert.deepStrictEqual( + structData.slice(6, 11), + structToAction(expectedS2) + ); + assert.deepStrictEqual( + structData.slice(11, 16), + structToAction(expectedS3) + ); } }); }); @@ -311,7 +400,6 @@ describe('Query Resolvers', async () => { }); }); }); - test('Fetching actions with a empty address should return empty list', async () => { results = await executeActionsQuery({ address: '', @@ -320,8 +408,12 @@ describe('Query Resolvers', async () => { }); describe('After emitting an action', async () => { + const [s1, s2, s3] = [randomStruct(), randomStruct(), randomStruct()]; + const testStructArray = { + structs: [s1, s2, s3], + }; before(async () => { - await emitAction(zkApp, senderKeypair); + await emitAction(zkApp, senderKeypair, undefined, testStructArray); results = await executeActionsQuery({ address: zkApp.address.toBase58(), }); @@ -333,20 +425,30 @@ describe('Query Resolvers', async () => { assert.strictEqual(lastBlockActions.length, 1); }); test('The action has the correct data', async () => { - const actionData = lastBlockActions[0]!; - assert.deepStrictEqual(actionData.data, [ - '2', - '1', - '1', - ...zkApp.address.toFields().map((f) => f.toString()), - ]); + const actionFieldData = lastBlockActions[0]!.data; + assert.strictEqual(actionFieldData.length, 15); + assert.deepStrictEqual(actionFieldData.slice(0, 5), structToAction(s1)); + assert.deepStrictEqual( + actionFieldData.slice(5, 10), + structToAction(s2) + ); + assert.deepStrictEqual( + actionFieldData.slice(10, 15), + structToAction(s3) + ); }); }); - - describe('After emitting multiple actions', async () => { + describe('After emitting multiple actions', () => { const numberOfEmits = 3; + let results: ActionQueryResult; + const s1 = randomStruct(); + const s2 = randomStruct(); + const s3 = randomStruct(); + const testStructs = { + structs: [s1, s2, s3], + }; before(async () => { - await emitAction(zkApp, senderKeypair, { numberOfEmits }); + await emitAction(zkApp, senderKeypair, { numberOfEmits }, testStructs); results = await executeActionsQuery({ address: zkApp.address.toBase58(), }); @@ -354,21 +456,65 @@ describe('Query Resolvers', async () => { lastBlockActions = actionsResponse[actionsResponse.length - 1].actionData!; }); - test('GQL response contains multiple actions', async () => { assert.strictEqual(lastBlockActions.length, numberOfEmits); }); - test('The actions have the correct data', async () => { - for (let i = 0; i < numberOfEmits; i++) { - const actionData = lastBlockActions[i]!; - assert.deepStrictEqual(actionData.data, [ - '2', - '1', - '1', - ...zkApp.address.toFields().map((f) => f.toString()), - ]); + test('Fetched actions have correct data', async () => { + const lastAction = lastBlockActions[lastBlockActions.length - 1]!; + const actionFieldData = lastAction.data; + assert.strictEqual(actionFieldData.length, 15); + assert.deepStrictEqual(actionFieldData.slice(0, 5), structToAction(s1)); + assert.deepStrictEqual( + actionFieldData.slice(5, 10), + structToAction(s2) + ); + assert.deepStrictEqual( + actionFieldData.slice(10, 15), + structToAction(s3) + ); + }); + test('Fetched actions have order metadata', async () => { + for (const block of actionsResponse) { + const actionData = block.actionData; + for (const action of actionData!) { + assert(typeof action!.transactionInfo!.sequenceNumber === 'number'); + assert(action!.transactionInfo!.zkappAccountUpdateIds.length > 0); + } + } + }); + test('Fetched actions have correct order', async () => { + let testedAccountUpdateOrder = false; + for (const block of actionsResponse) { + const actionData = block.actionData; + for (let i = 1; i < actionData!.length; i++) { + const previousAction = actionData![i - 1]!; + const currentAction = actionData![i]!; + assert.ok( + previousAction.transactionInfo!.sequenceNumber <= + currentAction.transactionInfo!.sequenceNumber + ); + if ( + previousAction.transactionInfo!.sequenceNumber === + currentAction.transactionInfo!.sequenceNumber + ) { + testedAccountUpdateOrder = true; + assert.ok( + previousAction.accountUpdateId < currentAction.accountUpdateId + ); + } + } } + assert.ok(testedAccountUpdateOrder); }); }); }); }); + +function structToAction(s: TestStruct) { + return [ + s.x.toString(), + s.y.toField().toString(), + s.z.toString(), + ...s.address.toFields().map((f) => f.toString()), + ]; +} diff --git a/tests/runZkNoidScriptLocal.ts b/tests/runZkNoidScriptLocal.ts deleted file mode 100644 index bcbfe34..0000000 --- a/tests/runZkNoidScriptLocal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Mina, PublicKey } from 'o1js'; - -const network = Mina.Network({ - mina: 'https://api.minascan.io/node/devnet/v1/graphql', - archive: 'http://localhost:3000', -}); -Mina.setActiveInstance(network); - -const actions = await Mina.activeInstance.fetchActions( - PublicKey.fromBase58( - 'B62qrHzs8Sn6rJW3Jkd8xvUkPKb4RBC4xsTgKnBd7KvVWnjhkXV7YKJ' - // 'B62qmASMoUYbRA2TwbB6gDTcdaS9QxEQYghV67i8oMeqA5tsbfvkJ6P' - // 'B62qio1AyjVw6tBqYCuEJqDz3ej3qVCyJjcQfTTbt3VgE6MZmtruiMJ' - ) -); - -console.log(actions); diff --git a/tests/services/actions-service/actions-service.test.ts b/tests/services/actions-service/actions-service.test.ts new file mode 100644 index 0000000..610329c --- /dev/null +++ b/tests/services/actions-service/actions-service.test.ts @@ -0,0 +1,114 @@ +import { test, before, describe, after } from 'node:test'; +import assert from 'node:assert'; +import { ActionsService } from '../../../src/services/actions-service/actions-service.js'; +import { Sql } from 'postgres'; +import { Action } from '../../../src/blockchain/types.js'; + +describe('ActionsService', () => { + let actionsService: ActionsService; + + before(() => { + const client = { + query: () => {}, + CLOSE: () => {}, + END: () => {}, + PostgresError: class {}, + options: {}, + } as unknown as Sql<{}>; + actionsService = new ActionsService(client); + }); + + describe('sortActions', () => { + let actions: Action[]; + describe('with actions with different sequence numbers', () => { + before(() => { + actions = [ + dummyAction({ sequenceNumber: 2 }), + dummyAction({ sequenceNumber: 1 }), + ]; + }); + test('it sorts actions by their sequence number', () => { + const sortedActions = actionsService.sortActions(actions); + assert.strictEqual(sortedActions[0].transactionInfo.sequenceNumber, 1); + assert.strictEqual(sortedActions[1].transactionInfo.sequenceNumber, 2); + }); + }); + describe('with actions with the same sequence number', () => { + const sequenceNumber = 1; + describe('with actions with different account update ids', () => { + const zkappAccountUpdateIds = [1, 2]; + before(() => { + actions = [ + dummyAction({ + sequenceNumber, + zkappAccountUpdateIds, + accountUpdateId: '2', + }), + dummyAction({ + sequenceNumber, + zkappAccountUpdateIds, + accountUpdateId: '1', + }), + ]; + }); + test('it sorts actions by their account update index', () => { + const sortedActions = actionsService.sortActions(actions); + assert.strictEqual(sortedActions[0].accountUpdateId, '1'); + assert.strictEqual(sortedActions[1].accountUpdateId, '2'); + }); + }); + describe('with account update ids that are ordered in non-ascending or descending order', () => { + const zkappAccountUpdateIds = [1, 3, 2]; + before(() => { + actions = [ + dummyAction({ + sequenceNumber, + zkappAccountUpdateIds, + accountUpdateId: '2', + }), + dummyAction({ + sequenceNumber, + zkappAccountUpdateIds, + accountUpdateId: '1', + }), + dummyAction({ + sequenceNumber, + zkappAccountUpdateIds, + accountUpdateId: '3', + }), + ]; + }); + test('it sorts actions by their account update index', () => { + const sortedActions = actionsService.sortActions(actions); + assert.strictEqual(sortedActions[0].accountUpdateId, '1'); + assert.strictEqual(sortedActions[1].accountUpdateId, '3'); + assert.strictEqual(sortedActions[2].accountUpdateId, '2'); + }); + }); + }); + }); +}); + +function dummyAction({ + sequenceNumber = 1, + accountUpdateId = '1', + zkappAccountUpdateIds = [1], +}: { + sequenceNumber?: number; + accountUpdateId?: string; + eventElementId?: string; + zkappAccountUpdateIds?: number[]; +}): Action { + return { + accountUpdateId, + data: ['dummy'], + transactionInfo: { + sequenceNumber, + zkappAccountUpdateIds, + authorizationKind: 'dummy', + hash: 'dummy', + memo: 'dummy', + status: 'dummy', + }, + }; +} diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 4b0d93b..f482528 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -12,7 +12,7 @@ import { ArchiveNodeDatabaseRow } from '../src/db/sql/events-actions/types.js'; describe('utils', () => { describe('partitionBlocks', () => { test('should partition rows by block hash and transaction hash', () => { - const rows: any[] = [ + const rows: Partial[] = [ { state_hash: 'state_hash_1', hash: 'hash_1' }, { state_hash: 'state_hash_1', hash: 'hash_2' }, { state_hash: 'state_hash_2', hash: 'hash_3' }, @@ -32,7 +32,7 @@ describe('utils', () => { }); test('should return empty array if no rows', () => { - const rows: any[] = []; + const rows: ArchiveNodeDatabaseRow[] = []; const result = partitionBlocks(rows); assert.strictEqual(result.size, 0); }); @@ -40,10 +40,10 @@ describe('utils', () => { describe('getElementIdFieldValues', () => { test('should map id to field for each row', () => { - const rows: any[] = [ - { id: 1, field: 'field_1' }, - { id: 2, field: 'field_2' }, - { id: 3, field: 'field_3' }, + const rows: Partial[] = [ + { field_id: 1, field_value: 'field_1' }, + { field_id: 2, field_value: 'field_2' }, + { field_id: 3, field_value: 'field_3' }, ]; const result = getElementIdFieldValues(rows as ArchiveNodeDatabaseRow[]); @@ -54,7 +54,7 @@ describe('utils', () => { }); test('should handle empty rows', () => { - const rows: any[] = []; + const rows: ArchiveNodeDatabaseRow[] = []; const result = getElementIdFieldValues(rows); assert(result.size === 0); }); @@ -62,23 +62,23 @@ describe('utils', () => { describe('removeRedundantEmittedFields', () => { test('should remove duplicate rows based on unique event ID', () => { - const rows: any[] = [ + const rows: Partial[] = [ { - zkapp_event_array_id: 1, - zkapp_event_element_ids: [1, 2], + event_field_elements_id: 1, + event_element_ids: [1, 2], zkapp_account_update_id: 10, zkapp_account_updates_ids: [10, 12], }, { // Duplicate row (refers to the same event/action) - zkapp_event_array_id: 1, - zkapp_event_element_ids: [1, 2], + event_field_elements_id: 1, + event_element_ids: [1, 2], zkapp_account_update_id: 10, zkapp_account_updates_ids: [10, 12], }, { - zkapp_event_array_id: 2, - zkapp_event_element_ids: [1, 2], + event_field_elements_id: 2, + event_element_ids: [1, 2], zkapp_account_update_id: 12, zkapp_account_updates_ids: [10, 12], }, @@ -91,10 +91,10 @@ describe('utils', () => { }); test('should throw an error for a missing matching account update', () => { - const rows: any[] = [ + const rows: Partial[] = [ { - zkapp_event_array_id: 1, - zkapp_event_element_ids: [1, 2], + event_field_elements_id: 1, + event_field_element_ids: [1, 2], zkapp_account_update_id: 99, // No matching account update id in the list zkapp_account_updates_ids: [10, 11], }, @@ -113,9 +113,10 @@ describe('utils', () => { describe('when kind is "event"', () => { test('map rows to an array of events', () => { - const rows: any[] = [ + const rows: Partial[] = [ { - element_ids: [1, 2], + zkapp_account_update_id: 1, + event_field_element_ids: [1, 2], }, ]; @@ -132,10 +133,11 @@ describe('utils', () => { describe('when kind is "action"', () => { test('should map rows to an array of actions', () => { - const rows: any[] = [ + const rows: Partial[] = [ { - element_ids: [1, 2], + event_field_element_ids: [1, 2], zkapp_account_update_id: 123, + account_update_event_id: 456, }, ]; diff --git a/zkapp/contract.ts b/zkapp/contract.ts index ba35c9e..d559720 100644 --- a/zkapp/contract.ts +++ b/zkapp/contract.ts @@ -9,21 +9,27 @@ import { state, Reducer, PublicKey, + Provable, } from 'o1js'; -class TestStruct extends Struct({ +export class TestStruct extends Struct({ x: Field, y: Bool, z: UInt64, address: PublicKey, }) {} +export class TestStructArray extends Struct({ + structs: Provable.Array(TestStruct, 3), +}) {} + export class HelloWorld extends SmartContract { - reducer = Reducer({ actionType: TestStruct }); + reducer = Reducer({ actionType: TestStructArray }); events = { singleField: Field, struct: TestStruct, + structs: TestStructArray, }; @state(Field) x = State(); @@ -51,21 +57,29 @@ export class HelloWorld extends SmartContract { this.emitEvent('singleField', x); } - @method async emitStructEvent() { - const x = this.x.getAndRequireEquals(); - const y = this.y.getAndRequireEquals(); - const z = this.z.getAndRequireEquals(); - this.emitEvent( - 'struct', - new TestStruct({ x, y, z, address: this.address }) - ); + @method async emitStructEvent(struct: TestStruct) { + this.emitEvent('struct', struct); + } + + @method async emitStructsEvent(structs: TestStructArray) { + this.emitEvent('structs', structs); } - @method async emitStructAction() { + /** + * This method always emits the same action. + * It has limited utility in generating realistic test cases. + * We should use more dynamic methods to generate actions. + */ + @method async emitStaticStructAction() { const x = this.x.getAndRequireEquals(); const y = this.y.getAndRequireEquals(); const z = this.z.getAndRequireEquals(); - this.reducer.dispatch(new TestStruct({ x, y, z, address: this.address })); + const struct = new TestStruct({ x, y, z, address: this.address }); + this.reducer.dispatch({ structs: [struct, struct, struct] }); + } + + @method async emitAction(structs: TestStructArray) { + this.reducer.dispatch(structs); } @method async reduceStructAction() { @@ -78,8 +92,8 @@ export class HelloWorld extends SmartContract { let newCounter = this.reducer.reduce( pendingActions, Field, - (state: Field, action: TestStruct) => { - return state.add(action.x); + (state: Field, action: TestStructArray) => { + return state.add(action.structs[0].x); }, counter ); diff --git a/zkapp/utils.ts b/zkapp/utils.ts index 86ff021..4c48afd 100644 --- a/zkapp/utils.ts +++ b/zkapp/utils.ts @@ -5,8 +5,10 @@ import { Mina, PrivateKey, fetchAccount, + UInt64, + Bool, } from 'o1js'; -import { HelloWorld } from './contract.js'; +import { HelloWorld, TestStruct, type TestStructArray } from './contract.js'; export { setNetworkConfig, @@ -15,9 +17,12 @@ export { updateContractState, emitSingleEvent, emitMultipleFieldsEvent, + emitMultipleFieldsEvents, emitAction, + emitActionsFromMultipleSenders, reduceAction, Keypair, + randomStruct, }; const transactionFee = 100_000_000; @@ -118,14 +123,47 @@ async function emitSingleEvent( async function emitMultipleFieldsEvent( zkApp: HelloWorld, { publicKey: sender, privateKey: senderKey }: Keypair, - options: Options = { numberOfEmits: 1 } + options: Options = { numberOfEmits: 1 }, + baseStruct: TestStruct = randomStruct() +) { + console.log('Emitting multiple fields event.'); + let transaction = await Mina.transaction( + { sender, fee: transactionFee }, + async () => { + for (let i = 0; i < options.numberOfEmits; i++) { + const struct = new TestStruct(baseStruct); + struct.x = struct.x.add(Field(i)); + await zkApp.emitStructEvent(struct); + } + } + ); + transaction.sign([senderKey]); + await transaction.prove(); + await sendTransaction(transaction); +} + +async function emitMultipleFieldsEvents( + zkApp: HelloWorld, + { publicKey: sender, privateKey: senderKey }: Keypair, + options: Options = { numberOfEmits: 1 }, + baseStruct: TestStruct = randomStruct() ) { console.log('Emitting multiple fields event.'); let transaction = await Mina.transaction( { sender, fee: transactionFee }, async () => { for (let i = 0; i < options.numberOfEmits; i++) { - await zkApp.emitStructEvent(); + const struct = new TestStruct(baseStruct); + struct.x = struct.x.add(Field(i)); + const s1 = new TestStruct(struct); + const s2 = new TestStruct(struct); + const s3 = new TestStruct(struct); + s1.z = s1.z.add(UInt64.from(i)); + s2.z = s2.z.add(UInt64.from(i + 1)); + s3.z = s3.z.add(UInt64.from(i + 2)); + await zkApp.emitStructsEvent({ + structs: [s1, s2, s3], + }); } } ); @@ -137,14 +175,17 @@ async function emitMultipleFieldsEvent( async function emitAction( zkApp: HelloWorld, { publicKey: sender, privateKey: senderKey }: Keypair, - options: Options = { numberOfEmits: 1 } + options: Options = { numberOfEmits: 1 }, + testStructs: TestStructArray = { + structs: [randomStruct(), randomStruct(), randomStruct()], + } ) { console.log('Emitting action.'); let transaction = await Mina.transaction( { sender, fee: transactionFee }, async () => { for (let i = 0; i < options.numberOfEmits; i++) { - await zkApp.emitStructAction(); + await zkApp.emitAction(testStructs); } } ); @@ -153,6 +194,34 @@ async function emitAction( await sendTransaction(transaction); } +async function emitActionsFromMultipleSenders( + zkApp: HelloWorld, + callers: Keypair[], + options: Options = { numberOfEmits: 2 } +) { + const txs = []; + for (const caller of callers) { + console.log('Compiling transaction for ', caller.publicKey.toBase58()); + const testStruct = randomStruct(); + let transaction = await Mina.transaction( + { sender: caller.publicKey, fee: transactionFee }, + async () => { + for (let i = 0; i < options.numberOfEmits; i++) { + testStruct.x = testStruct.x.add(Field(i)); + await zkApp.emitAction({ + structs: [testStruct, testStruct, testStruct], + }); + } + } + ); + transaction.sign([caller.privateKey]); + await transaction.prove(); + txs.push(transaction); + } + + await sendTransactions(txs); +} + async function reduceAction( zkApp: HelloWorld, { publicKey: sender, privateKey: senderKey }: Keypair @@ -182,3 +251,27 @@ async function sendTransaction(transaction: Mina.Transaction) { console.error('Transaction rejected or failed to finalize:', error); } } + +async function sendTransactions(transactions: Mina.Transaction[]) { + const pendingTxs = transactions.map((tx) => tx.send()); + console.log('Waiting for transactions to be included in a block.\n'); + + for (const pendingTx of pendingTxs) { + let tx = await pendingTx; + try { + await tx.wait({ maxAttempts: 90 }); + console.log(`Success! Transaction sent. Txn hash: ${tx.hash}`); + } catch (error) { + console.error('Transaction rejected or failed to finalize:', error); + } + } +} + +function randomStruct() { + return new TestStruct({ + x: Field(Math.floor(Math.random() * 100_000)), + y: Bool(true), + z: UInt64.from(Math.floor(Math.random() * 100_000)), + address: PrivateKey.random().toPublicKey(), + }); +}