diff --git a/packages/build-utils/paima-build-utils/src/standalone-esbuildconfig.template.cts b/packages/build-utils/paima-build-utils/src/standalone-esbuildconfig.template.cts index 43d6eb27..18618d1b 100644 --- a/packages/build-utils/paima-build-utils/src/standalone-esbuildconfig.template.cts +++ b/packages/build-utils/paima-build-utils/src/standalone-esbuildconfig.template.cts @@ -4,7 +4,8 @@ import type esbuild from 'esbuild'; export function generateConfig( apiFolder: string, stfFolder: string, - precompilesFolder: string + precompilesFolder: string, + eventsFolder: string ): { config: esbuild.BuildOptions; outFiles: Record; @@ -19,6 +20,7 @@ export function generateConfig( [apiFolder]: 'endpoints.cjs', [stfFolder]: 'gameCode.cjs', [precompilesFolder]: 'precompiles.cjs', + [eventsFolder]: 'events.cjs', }; const config: esbuild.BuildOptions = { diff --git a/packages/engine/paima-rest/src/controllers/BasicControllers.ts b/packages/engine/paima-rest/src/controllers/BasicControllers.ts index 5f112325..740281d8 100644 --- a/packages/engine/paima-rest/src/controllers/BasicControllers.ts +++ b/packages/engine/paima-rest/src/controllers/BasicControllers.ts @@ -1,4 +1,4 @@ -import { Controller, Response, Query, Get, Route } from 'tsoa'; +import { Controller, Response, Query, Get, Route, Body, Post } from 'tsoa'; import { doLog, logError, ENV } from '@paima/utils'; import type { InternalServerErrorResult, @@ -7,6 +7,7 @@ import type { ValidateErrorResult, } from '@paima/utils'; import { EngineService } from '../EngineService.js'; +import type { IGetEventsResult } from '@paima/db'; import { deploymentChainBlockheightToEmulated, emulatedSelectLatestPrior, @@ -397,3 +398,154 @@ export class TransactionContentController extends Controller { } } } + +type GetLogsResponse = Result< + { + topic: string; + address: string; + blockNumber: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { [fieldName: string]: any }; + tx: number; + logIndex: number; + }[] +>; +type GetLogsParams = { + fromBlock: number; + toBlock?: number; + address: string; + filters?: { [fieldName: string]: string }; + topic: string; +}; + +@Route('get_logs') +export class GetLogsController extends Controller { + @Response(StatusCodes.NOT_FOUND) + @Response(StatusCodes.BAD_REQUEST) + @Response(StatusCodes.INTERNAL_SERVER_ERROR) + @Post() + public async post(@Body() params: GetLogsParams): Promise { + const gameStateMachine = EngineService.INSTANCE.getSM(); + + params.toBlock = params.toBlock ?? (await gameStateMachine.latestProcessedBlockHeight()); + + if ( + params.toBlock < params.fromBlock || + params.toBlock - params.fromBlock > ENV.GET_LOGS_MAX_BLOCK_RANGE + ) { + this.setStatus(StatusCodes.BAD_REQUEST); + return { + success: false, + errorMessage: 'Invalid block range', + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventDefinition = ((): any => { + const appEvents = gameStateMachine.getAppEvents(); + + for (const defs of Object.values(appEvents)) { + for (const def of defs) { + if (def.topicHash === params.topic) { + return def; + } + } + } + + return undefined; + })(); + + if (!eventDefinition) { + this.setStatus(StatusCodes.NOT_FOUND); + return { + success: false, + errorMessage: 'Topic not found', + }; + } + + if (params.filters) { + const indexedFields = new Set(); + for (const field of eventDefinition.definition.fields) { + if (field.indexed) { + indexedFields.add(field.name); + } + } + + for (const fieldName of Object.keys(params.filters)) { + if (!indexedFields.has(fieldName)) { + this.setStatus(StatusCodes.NOT_FOUND); + return { + success: false, + errorMessage: `Field is not indexed: ${fieldName}`, + }; + } + } + } + + try { + const DBConn = gameStateMachine.getReadonlyDbConn(); + + let dynamicPart = ''; + const dynamicFilters = []; + + // it does not seem to be possible to build this dynamic filter with + // pgtyped, so we instead build a dynamic parametrized query. + if (params.filters) { + const keys = Object.keys(params.filters); + + for (let i = 0; i < keys.length; i++) { + dynamicPart = dynamicPart.concat( + `COALESCE(data->>$${5 + i * 2} = $${5 + i * 2 + 1}, 1=1) AND\n` + ); + + dynamicFilters.push(keys[i]); + dynamicFilters.push(params.filters[keys[i]]); + } + } + + const query = ` + SELECT * FROM event WHERE + COALESCE(block_height >= $1, 1=1) AND + COALESCE(block_height <= $2, 1=1) AND + COALESCE(address = $3, 1=1) AND + ${dynamicPart} + topic = $4 + ORDER BY id; + `; + + // casting to IGetEventsResult is sound, since both are a select from the + // same table with the same rows, and at least if the table changes there + // is a chance that this will not typecheck anymore. + const rows = ( + await DBConn.query(query, [ + params.fromBlock, + params.toBlock, + params.address, + params.topic, + ...dynamicFilters, + ]) + ).rows as IGetEventsResult[]; + + return { + success: true, + result: rows.map(row => ({ + topic: row.topic, + blockNumber: row.block_height, + address: row.address, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: row.data as { [fieldName: string]: any }, + tx: row.tx, + logIndex: row.log_index, + })), + }; + } catch (err) { + doLog(`Unexpected webserver error:`); + logError(err); + this.setStatus(StatusCodes.INTERNAL_SERVER_ERROR); + return { + success: false, + errorMessage: 'Unknown error, please contact game node operator', + }; + } + } +} diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index 5238de57..2d69da44 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -19,6 +19,8 @@ import { TransactionCountController } from './../controllers/BasicControllers'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { TransactionContentController } from './../controllers/BasicControllers'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { GetLogsController } from './../controllers/BasicControllers'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { AchievementsController } from './../controllers/AchievementsController'; import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; @@ -168,6 +170,30 @@ const models: TsoaRoute.Models = { "type": {"dataType":"union","subSchemas":[{"ref":"SuccessfulResult_TransactionContentResponse_"},{"ref":"FailedResult"}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": { + "dataType": "refObject", + "properties": { + "success": {"dataType":"enum","enums":[true],"required":true}, + "result": {"dataType":"array","array":{"dataType":"nestedObjectLiteral","nestedProperties":{"logIndex":{"dataType":"double","required":true},"tx":{"dataType":"double","required":true},"data":{"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"any"},"required":true},"blockNumber":{"dataType":"double","required":true},"address":{"dataType":"string","required":true},"topic":{"dataType":"string","required":true}}},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": { + "dataType": "refAlias", + "type": {"dataType":"union","subSchemas":[{"ref":"SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_"},{"ref":"FailedResult"}],"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "GetLogsResponse": { + "dataType": "refAlias", + "type": {"ref":"Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_","validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "GetLogsParams": { + "dataType": "refAlias", + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"topic":{"dataType":"string","required":true},"filters":{"dataType":"nestedObjectLiteral","nestedProperties":{},"additionalProperties":{"dataType":"string"}},"address":{"dataType":"string","required":true},"toBlock":{"dataType":"double"},"fromBlock":{"dataType":"double","required":true}},"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "Achievement": { "dataType": "refObject", "properties": { @@ -517,6 +543,36 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/get_logs', + ...(fetchMiddlewares(GetLogsController)), + ...(fetchMiddlewares(GetLogsController.prototype.post)), + + async function GetLogsController_post(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + params: {"in":"body","name":"params","required":true,"ref":"GetLogsParams"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new GetLogsController(); + + await templateService.apiHandler({ + methodName: 'post', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/achievements/public/list', ...(fetchMiddlewares(AchievementsController)), ...(fetchMiddlewares(AchievementsController.prototype.public_list)), diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index e1247fb9..7a1a6540 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -307,6 +307,106 @@ } ] }, + "SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": { + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ], + "nullable": false + }, + "result": { + "items": { + "properties": { + "logIndex": { + "type": "number", + "format": "double" + }, + "tx": { + "type": "number", + "format": "double" + }, + "data": { + "properties": {}, + "additionalProperties": {}, + "type": "object" + }, + "blockNumber": { + "type": "number", + "format": "double" + }, + "address": { + "type": "string" + }, + "topic": { + "type": "string" + } + }, + "required": [ + "logIndex", + "tx", + "data", + "blockNumber", + "address", + "topic" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "result" + ], + "type": "object", + "additionalProperties": false + }, + "Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": { + "anyOf": [ + { + "$ref": "#/components/schemas/SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_" + }, + { + "$ref": "#/components/schemas/FailedResult" + } + ] + }, + "GetLogsResponse": { + "$ref": "#/components/schemas/Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_" + }, + "GetLogsParams": { + "properties": { + "topic": { + "type": "string" + }, + "filters": { + "properties": {}, + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "address": { + "type": "string" + }, + "toBlock": { + "type": "number", + "format": "double" + }, + "fromBlock": { + "type": "number", + "format": "double" + } + }, + "required": [ + "topic", + "address", + "fromBlock" + ], + "type": "object" + }, "Achievement": { "properties": { "name": { @@ -1083,6 +1183,74 @@ ] } }, + "/get_logs": { + "post": { + "operationId": "GetLogsPost", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetLogsResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FailedResult" + }, + "examples": { + "Example 1": {} + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FailedResult" + }, + "examples": { + "Example 1": {} + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResult" + }, + "examples": { + "Example 1": {} + } + } + } + } + }, + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetLogsParams" + } + } + } + } + } + }, "/achievements/public/list": { "get": { "operationId": "AchievementsPublic_list", diff --git a/packages/engine/paima-runtime/src/index.ts b/packages/engine/paima-runtime/src/index.ts index e9b18ad5..220d9df4 100644 --- a/packages/engine/paima-runtime/src/index.ts +++ b/packages/engine/paima-runtime/src/index.ts @@ -19,6 +19,7 @@ export { registerDocsPrecompiles, registerDocsOpenAPI, registerValidationErrorHandler, + registerDocsAppEvents, } from './server.js'; export { TimeoutError } from './utils.js'; @@ -97,6 +98,18 @@ async function runInitializationProcedures( return false; } + const eventValidationResult = await gameStateMachine.initializeAndValidateRegisteredEvents(); + if (!eventValidationResult) { + doLog('[paima-runtime] Failed to validate pre-existing events! Shutting down...'); + return false; + } + + const eventIndexesResult = await gameStateMachine.initializeEventIndexes(); + if (!eventIndexesResult) { + doLog('[paima-runtime] Unable to initialize indexes for events! Shutting down...'); + return false; + } + // CDE config validation / storing: if (!funnelFactory.extensionsAreValid()) { doLog( diff --git a/packages/engine/paima-runtime/src/server.ts b/packages/engine/paima-runtime/src/server.ts index 1ec468f2..9b611a9e 100644 --- a/packages/engine/paima-runtime/src/server.ts +++ b/packages/engine/paima-runtime/src/server.ts @@ -7,6 +7,7 @@ import { merge, isErrorResult } from 'openapi-merge'; import { doLog, ENV, logError } from '@paima/utils'; import path from 'path'; import { ValidateError } from 'tsoa'; +import type { EventPathAndDef } from '@paima/events'; import { BuiltinEvents, toAsyncApi } from '@paima/events'; import YAML from 'yaml'; import { evmRpcEngine } from './evm-rpc/eip1193.js'; @@ -141,17 +142,27 @@ function registerDocsOpenAPI(userStateMachineApi: object | undefined): void { ); } -server.get(`/${DocPaths.Root}/${DocPaths.AsyncApi.Root}/${DocPaths.AsyncApi.Spec}`, (_, res) => { - const asyncApi = toAsyncApi( - { - backendUri: ENV.MQTT_ENGINE_BROKER_URL, - // TODO: batcher docs theoretically should be hosted separately in some batcher-managed server - batcherUri: ENV.MQTT_BATCHER_BROKER_URL, - }, - BuiltinEvents +function registerDocsAppEvents(events: Record): void { + const appEvents: [string, EventPathAndDef][] = Object.entries(events).flatMap( + ([name, definitions]) => + definitions.map((definition: EventPathAndDef, index: number): [string, EventPathAndDef] => [ + name + index, + definition, + ]) ); - res.send(YAML.stringify(asyncApi, null, 2)); -}); + + server.get(`/${DocPaths.Root}/${DocPaths.AsyncApi.Root}/${DocPaths.AsyncApi.Spec}`, (_, res) => { + const asyncApi = toAsyncApi( + { + backendUri: ENV.MQTT_ENGINE_BROKER_URL, + // TODO: batcher docs theoretically should be hosted separately in some batcher-managed server + batcherUri: ENV.MQTT_BATCHER_BROKER_URL, + }, + (Object.entries(BuiltinEvents) as [string, EventPathAndDef][]).concat(appEvents) + ); + res.send(YAML.stringify(asyncApi, null, 2)); + }); +} server.get(`/${DocPaths.Root}/${DocPaths.AsyncApi.Root}/${DocPaths.AsyncApi.Ui}`, (_, res) => { res.sendFile(path.join(__dirname, 'public', 'asyncapi.html')); @@ -174,4 +185,5 @@ export { registerDocsPrecompiles, registerDocsOpenAPI, registerValidationErrorHandler, + registerDocsAppEvents, }; diff --git a/packages/engine/paima-runtime/tsconfig.json b/packages/engine/paima-runtime/tsconfig.json index 9dc3f0c5..781ba9b4 100644 --- a/packages/engine/paima-runtime/tsconfig.json +++ b/packages/engine/paima-runtime/tsconfig.json @@ -11,7 +11,7 @@ { "path": "../../node-sdk/paima-db" }, { "path": "../paima-rest/tsconfig.build.json" }, { "path": "../paima-sm" }, - { "path": "../../node-sdk/paima-broker/tsconfig.build.json" }, { "path": "../../paima-sdk/paima-mw-core" }, + { "path": "../../node-sdk/paima-broker/tsconfig.build.json" }, ] } diff --git a/packages/engine/paima-sm/src/index.ts b/packages/engine/paima-sm/src/index.ts index cb423f1c..fa94b06d 100644 --- a/packages/engine/paima-sm/src/index.ts +++ b/packages/engine/paima-sm/src/index.ts @@ -1,6 +1,5 @@ import { Pool } from 'pg'; import type { PoolClient, Client } from 'pg'; - import type { STFSubmittedData } from '@paima/utils'; import { caip2PrefixFor, @@ -32,10 +31,12 @@ import { updateCardanoEpoch, updatePaginationCursor, updateMinaCheckpoint, + insertEvent, + createIndexesForEvents, + registerEventTypes, } from '@paima/db'; import type { SQLUpdate } from '@paima/db'; import Prando from '@paima/prando'; - import { randomnessRouter } from './randomness.js'; import { cdeTransitionFunction } from './cde-processing.js'; import { DelegateWallet } from './delegate-wallet.js'; @@ -51,22 +52,32 @@ import type { import { ConfigNetworkType } from '@paima/utils'; import assertNever from 'assert-never'; import { keccak_256 } from 'js-sha3'; +import type { AppEvents, EventPathAndDef, generateAppEvents, ResolvedPath } from '@paima/events'; +import { PaimaEventManager } from '@paima/events'; +import { PaimaEventBroker } from '@paima/broker'; export * from './types.js'; export type * from './types.js'; +type ValueOf = T[keyof T]; + const SM: GameStateMachineInitializer = { initialize: ( databaseInfo, randomnessProtocolEnum, gameStateTransitionRouter, startBlockHeight, - precompiles + precompiles, + events ) => { const DBConn: Pool = getConnection(databaseInfo); const persistentReadonlyDBConn: Client = getPersistentConnection(databaseInfo); const readonlyDBConn: Pool = getConnection(databaseInfo, true); + if (ENV.MQTT_BROKER) { + new PaimaEventBroker('Paima-Engine').getServer(); + } + return { latestProcessedBlockHeight: async ( dbTx: PoolClient | Pool = readonlyDBConn @@ -98,6 +109,35 @@ const SM: GameStateMachineInitializer = { initializeDatabase: async (force: boolean = false): Promise => { return await tx(DBConn, dbTx => initializePaimaTables(dbTx, force)); }, + initializeEventIndexes: async (): Promise => { + return await tx(DBConn, dbTx => + createIndexesForEvents( + dbTx, + Object.values(events).flatMap(eventsByName => + eventsByName.flatMap(event => + event.definition.fields.map(f => ({ + topic: event.topicHash, + fieldName: f.name, + indexed: f.indexed, + })) + ) + ) + ) + ); + }, + initializeAndValidateRegisteredEvents: async (): Promise => { + return await tx(DBConn, dbTx => + registerEventTypes( + dbTx, + Object.values(events).flatMap(eventByName => + eventByName.flatMap(event => ({ + name: event.definition.name, + topic: event.topicHash, + })) + ) + ) + ); + }, getReadonlyDbConn: (): Pool => { return readonlyDBConn; }, @@ -186,7 +226,7 @@ const SM: GameStateMachineInitializer = { new Prando('1234567890'), dbTx ); - return data && data.length > 0; + return data && data.stateTransitions.length > 0; }; if (dbTxOrPool instanceof Pool) { return await tx(dbTxOrPool, dbTx => internal(dbTx)); @@ -259,7 +299,8 @@ const SM: GameStateMachineInitializer = { gameStateTransition, randomnessGenerator, precompiles.precompiles, - indexForEventByTx + indexForEventByTx, + events ); // Execute user submitted input data @@ -268,7 +309,9 @@ const SM: GameStateMachineInitializer = { dbTx, gameStateTransition, randomnessGenerator, - indexForEventByTx + indexForEventByTx, + scheduledInputsLength, + events ); const processedCount = cdeDataLength + userInputsLength + scheduledInputsLength; @@ -282,6 +325,9 @@ const SM: GameStateMachineInitializer = { await blockHeightDone.run({ block_height: latestChainData.blockNumber }, dbTx); return processedCount; }, + getAppEvents(): ReturnType { + return events; + }, }; }, }; @@ -387,13 +433,14 @@ async function processPaginatedCdeData( // Process all of the scheduled data inputs by running each of them through the game STF, // saving the results to the DB, and deleting the schedule data all together in one postgres tx. // Function returns number of scheduled inputs that were processed. -async function processScheduledData( +async function processScheduledData( latestChainData: ChainData, DBConn: PoolClient, - gameStateTransition: GameStateTransitionFunction, + gameStateTransition: GameStateTransitionFunction, randomnessGenerator: Prando, precompiles: Precompiles['precompiles'], - indexForEvent: (txHash: string) => number + indexForEvent: (txHash: string) => number, + eventDefinitions: ReturnType ): Promise { const scheduledData = await getScheduledDataByBlockHeight.run( { block_height: latestChainData.blockNumber }, @@ -405,6 +452,12 @@ async function processScheduledData( const networks = await GlobalConfig.getInstance(); + // Note: this is not related to `indexForEvent`, since that one is used to + // keep a counter of the original tx that scheduled the input. This is just a + // global counter for the current block, so we can just increase it by one + // with each event. + let txIndexInBlock = 0; + for (const data of scheduledData) { try { const userAddress = data.precompile ? precompiles[data.precompile] : SCHEDULED_DATA_ADDRESS; @@ -462,24 +515,42 @@ async function processScheduledData( // Trigger STF let sqlQueries: SQLUpdate[] = []; + let eventsToEmit: EventsToEmit = []; try { - sqlQueries = await gameStateTransition( + const { stateTransitions, events } = await gameStateTransition( inputData, { blockHeight: data.block_height, timestamp: latestChainData.timestamp }, randomnessGenerator, DBConn ); + + sqlQueries = stateTransitions; + + handleEvents( + events, + sqlQueries, + txIndexInBlock, + latestChainData, + eventDefinitions, + eventsToEmit + ); } catch (err) { // skip scheduled data where the STF fails doLog(`[paima-sm] Error on scheduled data STF call. Skipping`, err); continue; } if (sqlQueries.length !== 0) { - await tryOrRollback(DBConn, async () => { + const success = await tryOrRollback(DBConn, async () => { for (const [query, params] of sqlQueries) { await query.run(params, DBConn); } + + return true; }); + + if (success) { + await sendEventsToBroker(eventsToEmit); + } } } catch (e) { logError(e); @@ -487,6 +558,7 @@ async function processScheduledData( } finally { // guarantee we run this no matter if there is an error or a continue await deleteScheduled.run({ id: data.id }, DBConn); + txIndexInBlock += 1; } } return scheduledData.length; @@ -495,12 +567,14 @@ async function processScheduledData( // Process all of the user inputs data inputs by running each of them through the game STF, // saving the results to the DB with the nonces, all together in one postgres tx. // Function returns number of user inputs that were processed. -async function processUserInputs( +async function processUserInputs( latestChainData: ChainData, DBConn: PoolClient, - gameStateTransition: GameStateTransitionFunction, + gameStateTransition: GameStateTransitionFunction, randomnessGenerator: Prando, - indexForEvent: (txHash: string) => number + indexForEvent: (txHash: string) => number, + txIndexInBlock: number, + eventDefinitions: ReturnType ): Promise { for (const submittedData of latestChainData.submittedData) { // Check nonce is valid @@ -548,26 +622,45 @@ async function processUserInputs( // Trigger STF let sqlQueries: SQLUpdate[] = []; + let eventsToEmit: EventsToEmit = []; try { - sqlQueries = await gameStateTransition( + const { stateTransitions, events } = await gameStateTransition( inputData, { blockHeight: latestChainData.blockNumber, timestamp: latestChainData.timestamp }, randomnessGenerator, DBConn ); + + sqlQueries = stateTransitions; + + handleEvents( + events, + sqlQueries, + txIndexInBlock, + latestChainData, + eventDefinitions, + eventsToEmit + ); } catch (err) { // skip inputs where the STF fails doLog(`[paima-sm] Error on user input STF call. Skipping`, err); continue; } if (sqlQueries.length !== 0) { - await tryOrRollback(DBConn, async () => { + const success = await tryOrRollback(DBConn, async () => { for (const [query, params] of sqlQueries) { await query.run(params, DBConn); } + + return true; }); + + if (success) { + await sendEventsToBroker(eventsToEmit); + } } } finally { + txIndexInBlock += 1; // guarantee we run this no matter if there is an error or a continue await insertNonce.run( { @@ -591,6 +684,17 @@ async function processUserInputs( return latestChainData.submittedData.length; } +async function sendEventsToBroker( + eventsToEmit: EventsToEmit +): Promise { + if (ENV.MQTT_BROKER) { + // we probably don't want to use Promise.all since it will change the order. + for (const [eventDefinition, fields] of eventsToEmit) { + await PaimaEventManager.Instance.sendMessage(eventDefinition, fields); + } + } +} + async function processInternalEvents( events: InternalEvent[] | undefined, dbTx: PoolClient @@ -629,4 +733,46 @@ async function processInternalEvents( } } +type EventsToEmit = [ + ValueOf>[0], + ResolvedPath & Event['type'], +][]; + +function handleEvents( + events: { + address: `0x${string}`; + data: { name: string; fields: ResolvedPath & Event['type']; topic: string }; + }[], + sqlQueries: SQLUpdate[], + txIndexInBlock: number, + latestChainData: ChainData, + eventDefinitions: ReturnType, + eventsToEmit: EventsToEmit +): void { + for (let log_index = 0; log_index < events.length; log_index++) { + const event = events[log_index]; + sqlQueries.push([ + insertEvent, + { + topic: event.data.topic, + address: event.address, + data: event.data.fields, + tx: txIndexInBlock, + log_index, + block_height: latestChainData.blockNumber, + }, + ]); + + const eventDefinition = eventDefinitions[event.data.name].find( + eventDefinition => eventDefinition.topicHash === event.data.topic + ); + + if (!eventDefinition) { + throw new Error('Event definition not found'); + } + + eventsToEmit.push([eventDefinition, event.data.fields]); + } +} + export default SM; diff --git a/packages/engine/paima-sm/src/types.ts b/packages/engine/paima-sm/src/types.ts index f6495e29..8438f415 100644 --- a/packages/engine/paima-sm/src/types.ts +++ b/packages/engine/paima-sm/src/types.ts @@ -21,6 +21,7 @@ import type { import { Type } from '@sinclair/typebox'; import type { Static } from '@sinclair/typebox'; import type { ProjectedNftStatus } from '@dcspark/carp-client'; +import type { AppEvents, generateAppEvents, ResolvedPath } from '@paima/events'; export { SubmittedChainData, SubmittedData }; @@ -645,16 +646,26 @@ export type ChainDataExtension = ( | ChainDataExtensionDynamicEvmPrimitive ) & { network: string }; -export type GameStateTransitionFunctionRouter = ( +export type GameStateTransitionFunctionRouter = ( blockHeight: number -) => GameStateTransitionFunction; +) => GameStateTransitionFunction; -export type GameStateTransitionFunction = ( +export type GameStateTransitionFunction = ( inputData: STFSubmittedData, blockHeader: BlockHeader, randomnessGenerator: any, DBConn: PoolClient -) => Promise; +) => Promise<{ + stateTransitions: SQLUpdate[]; + events: { + address: `0x${string}`; + data: { + name: string; + fields: ResolvedPath & Events[string][number]['type']; + topic: string; + }; + }[]; +}>; export type Precompiles = { precompiles: { [name: string]: `0x${string}` } }; @@ -662,14 +673,17 @@ export interface GameStateMachineInitializer { initialize: ( databaseInfo: PoolConfig, randomnessProtocolEnum: number, - gameStateTransitionRouter: GameStateTransitionFunctionRouter, + gameStateTransitionRouter: GameStateTransitionFunctionRouter, startBlockHeight: number, - precompiles: Precompiles + precompiles: Precompiles, + events: ReturnType ) => GameStateMachine; } export interface GameStateMachine { initializeDatabase: (force: boolean) => Promise; + initializeAndValidateRegisteredEvents: () => Promise; + initializeEventIndexes: () => Promise; presyncStarted: (network: string) => Promise; syncStarted: () => Promise; latestProcessedBlockHeight: (dbTx?: PoolClient | Pool) => Promise; @@ -681,4 +695,5 @@ export interface GameStateMachine { presyncProcess: (dbTx: PoolClient, latestCdeData: PresyncChainData) => Promise; markPresyncMilestone: (blockHeight: number, network: string) => Promise; dryRun: (gameInput: string, userAddress: string) => Promise; + getAppEvents: () => ReturnType; } diff --git a/packages/engine/paima-sm/tsconfig.json b/packages/engine/paima-sm/tsconfig.json index df54ae17..51776177 100644 --- a/packages/engine/paima-sm/tsconfig.json +++ b/packages/engine/paima-sm/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../paima-sdk/paima-prando" }, { "path": "../../paima-sdk/paima-concise/tsconfig.build.json" }, { "path": "../../node-sdk/paima-utils-backend" }, - { "path": "../../paima-sdk/paima-crypto/tsconfig.build.json" } + { "path": "../../paima-sdk/paima-crypto/tsconfig.build.json" }, + { "path": "../../paima-sdk/paima-events/tsconfig.build.json" }, ] } diff --git a/packages/engine/paima-standalone/src/sm.ts b/packages/engine/paima-standalone/src/sm.ts index d6025b80..eef41925 100644 --- a/packages/engine/paima-standalone/src/sm.ts +++ b/packages/engine/paima-standalone/src/sm.ts @@ -1,17 +1,22 @@ import type { GameStateMachine } from '@paima/sm'; import PaimaSM from '@paima/sm'; +import type { AppEventsImport, PreCompilesImport } from './utils/import.js'; import { importGameStateTransitionRouter } from './utils/import.js'; -import type { PreCompilesImport } from './utils/import.js'; import { poolConfig } from './utils/index.js'; import { ENV } from '@paima/utils'; -export const gameSM = (precompiles: PreCompilesImport): GameStateMachine => { +export const gameSM = ( + precompiles: PreCompilesImport, + gameEvents: AppEventsImport +): GameStateMachine => { const gameStateTransitionRouter = importGameStateTransitionRouter(); + return PaimaSM.initialize( poolConfig, 4, // https://xkcd.com/221/ gameStateTransitionRouter, ENV.START_BLOCKHEIGHT, - precompiles + precompiles, + gameEvents.events ); }; diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index a6533b36..a498f7f0 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; import type { AchievementMetadata } from '@paima/utils-backend'; +import type { generateAppEvents } from '@paima/events'; /** * Checks that the user packed their game code and it is available for Paima Engine to use to run @@ -24,13 +25,13 @@ function importFile(path: string): T { } export interface GameCodeImport { - default: GameStateTransitionFunctionRouter; + default: GameStateTransitionFunctionRouter; } const ROUTER_FILENAME = 'packaged/gameCode.cjs'; /** * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file */ -export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { +export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { return importFile(ROUTER_FILENAME).default; } @@ -65,5 +66,23 @@ const PRECOMPILES_FILENAME = 'packaged/precompiles.cjs'; * Reads repackaged user's code placed next to the executable in `precompiles.cjs` file */ export function importPrecompiles(): PreCompilesImport { - return importFile(PRECOMPILES_FILENAME); + try { + return importFile(PRECOMPILES_FILENAME); + } catch (error) { + return { precompiles: {} }; + } +} + +const EVENTS_FILENAME = 'packaged/events.cjs'; +export type AppEventsImport = { events: ReturnType }; + +/** + * Reads repackaged user's code placed next to the executable in `events.cjs` file + */ +export function importEvents(): AppEventsImport { + try { + return importFile(EVENTS_FILENAME); + } catch (error) { + return { events: {} }; + } } diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index b49acfa1..740cec19 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -1,5 +1,6 @@ import { FunnelFactory } from '@paima/funnel'; import paimaRuntime, { + registerDocsAppEvents, registerDocsOpenAPI, registerDocsPrecompiles, registerValidationErrorHandler, @@ -28,6 +29,7 @@ import { importOpenApiJson, importPrecompiles, importEndpoints, + importEvents, } from './import.js'; import type { Template } from './types.js'; import RegisterRoutes, { EngineService } from '@paima/rest'; @@ -141,7 +143,8 @@ export const runPaimaEngine = async (): Promise => { // Import & initialize state machine const precompilesImport = importPrecompiles(); - const stateMachine = gameSM(precompilesImport); + const eventsImport = importEvents(); + const stateMachine = gameSM(precompilesImport, eventsImport); console.log(`Connecting to database at ${poolConfig.host}:${poolConfig.port}`); const dbConn = await stateMachine.getReadonlyDbConn().connect(); const funnelFactory = await FunnelFactory.initialize(dbConn); @@ -170,6 +173,7 @@ export const runPaimaEngine = async (): Promise => { registerDocsOpenAPI(importOpenApiJson()); registerDocsPrecompiles(precompilesImport.precompiles); registerValidationErrorHandler(); + registerDocsAppEvents(eventsImport.events); void engine.run(ENV.STOP_BLOCKHEIGHT, ENV.SERVER_ONLY_MODE); } else { diff --git a/packages/engine/paima-standalone/tsconfig.json b/packages/engine/paima-standalone/tsconfig.json index e6b436fd..39dc6b80 100644 --- a/packages/engine/paima-standalone/tsconfig.json +++ b/packages/engine/paima-standalone/tsconfig.json @@ -14,5 +14,6 @@ { "path": "../paima-funnel" }, { "path": "../paima-rest/tsconfig.build.json" }, { "path": "../../node-sdk/paima-utils-backend" }, + { "path": "../../paima-sdk/paima-events/tsconfig.build.json" }, ] } diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index 62e8f638..aabd0b83 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -280,3 +280,19 @@ CREATE TABLE cde_dynamic_primitive_config ( config JSONB NOT NULL, PRIMARY KEY(cde_name) ); + +CREATE TABLE event ( + id SERIAL PRIMARY KEY, + topic TEXT NOT NULL, + address TEXT NOT NULL, + data JSONB NOT NULL, + block_height INTEGER NOT NULL, + tx INTEGER NOT NULL, + log_index INTEGER NOT NULL +); + +CREATE TABLE registered_event ( + name TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY(name, topic) +); diff --git a/packages/node-sdk/paima-db/src/event-indexing.ts b/packages/node-sdk/paima-db/src/event-indexing.ts new file mode 100644 index 00000000..7e131074 --- /dev/null +++ b/packages/node-sdk/paima-db/src/event-indexing.ts @@ -0,0 +1,27 @@ +import type { PoolClient } from 'pg'; + +export async function createIndexesForEvents( + pool: PoolClient, + eventDescriptions: { topic: string; fieldName: string; indexed: boolean }[] +): Promise { + for (const event of eventDescriptions) { + const indexName = `index_${event.topic.slice(0, 20)}_${event.fieldName.toLowerCase()}`; + + const checkQuery = `SELECT * FROM pg_indexes WHERE indexname = '${indexName}';`; + + const result = await pool.query(checkQuery); + + const createQuery = `CREATE INDEX ${indexName} ON event(topic, (data->>'${event.fieldName}'));`; + const deleteQuery = `DROP INDEX ${indexName};`; + + if (result.rowCount === 0 && event.indexed) { + await pool.query(createQuery); + } + + if (result.rowCount !== 0 && !event.indexed) { + await pool.query(deleteQuery); + } + } + + return true; +} diff --git a/packages/node-sdk/paima-db/src/index.ts b/packages/node-sdk/paima-db/src/index.ts index b9df958e..4249fc6c 100644 --- a/packages/node-sdk/paima-db/src/index.ts +++ b/packages/node-sdk/paima-db/src/index.ts @@ -64,6 +64,10 @@ export type * from './sql/mina-checkpoints.queries.js'; export * from './sql/mina-checkpoints.queries.js'; export type * from './sql/dynamic-primitives.queries.js'; export * from './sql/dynamic-primitives.queries.js'; +export type * from './sql/events.queries.js'; +export * from './sql/events.queries.js'; +export * from './event-indexing.js'; +export * from './register-events.js'; export { tx, diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index c6dc2c9f..025d246c 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -753,6 +753,61 @@ const TABLE_DATA_CDE_DYNAMIC_PRIMITIVE_CONFIG: TableData = { creationQuery: QUERY_CREATE_TABLE_CDE_DYNAMIC_PRIMITIVE_CONFIG, }; +const QUERY_CREATE_TABLE_EVENT = ` +CREATE TABLE event ( + id SERIAL PRIMARY KEY, + topic TEXT NOT NULL, + address TEXT NOT NULL, + data JSONB NOT NULL, + block_height INTEGER NOT NULL, + tx INTEGER NOT NULL, + log_index INTEGER NOT NULL +); +`; + +const QUERY_CREATE_INDEX_EVENT_TOPIC = ` +CREATE INDEX EVENT_TOPIC_INDEX ON "event" (topic); +`; + +const TABLE_DATA_EVENT: TableData = { + tableName: 'event', + primaryKeyColumns: ['id'], + columnData: packTuples([ + ['id', 'integer', 'NO', ''], + ['topic', 'text', 'NO', ''], + ['address', 'text', 'NO', ''], + ['data', 'jsonb', 'NO', ''], + ['block_height', 'integer', 'NO', ''], + ['tx', 'integer', 'NO', ''], + ['log_index', 'integer', 'NO', ''], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_EVENT, + index: { + name: 'EVENT_TOPIC_INDEX', + creationQuery: QUERY_CREATE_INDEX_EVENT_TOPIC, + }, +}; + +const QUERY_CREATE_TABLE_REGISTERED_EVENT = ` +CREATE TABLE registered_event ( + name TEXT NOT NULL, + topic TEXT NOT NULL, + PRIMARY KEY(name, topic) +); +`; + +const TABLE_DATA_REGISTERED_EVENT: TableData = { + tableName: 'registered_event', + primaryKeyColumns: ['name', 'topic'], + columnData: packTuples([ + ['name', 'text', 'NO', ''], + ['topic', 'text', 'NO', ''], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_REGISTERED_EVENT, +}; + export const FUNCTIONS: string[] = [ FUNCTION_NOTIFY_WALLET_CONNECT, FUNCTION_TRIGGER_ADDRESSES, @@ -790,4 +845,6 @@ export const TABLES: TableData[] = [ TABLE_DATA_MINA_CHECKPOINT, TABLE_DATA_ACHIEVEMENT_PROGRESS, TABLE_DATA_CDE_DYNAMIC_PRIMITIVE_CONFIG, + TABLE_DATA_EVENT, + TABLE_DATA_REGISTERED_EVENT, ]; diff --git a/packages/node-sdk/paima-db/src/register-events.ts b/packages/node-sdk/paima-db/src/register-events.ts new file mode 100644 index 00000000..fefb0414 --- /dev/null +++ b/packages/node-sdk/paima-db/src/register-events.ts @@ -0,0 +1,28 @@ +import type { PoolClient } from 'pg'; +import { getEventByTopic, getTopics, registerEventType } from './sql/events.queries.js'; + +export async function registerEventTypes( + pool: PoolClient, + eventDescriptions: { topic: string; name: string }[] +): Promise { + const allStoredTopics = await getTopics.run(undefined, pool); + + // check that no events were deleted. + for (const storedTopic of allStoredTopics) { + const found = eventDescriptions.find(ed => ed.topic === storedTopic.topic); + + if (found === undefined) { + return false; + } + } + + for (const event of eventDescriptions) { + const registeredTopic = await getEventByTopic.run({ topic: event.topic }, pool); + + if (registeredTopic.length === 0) { + await registerEventType.run({ name: event.name, topic: event.topic }, pool); + } + } + + return true; +} diff --git a/packages/node-sdk/paima-db/src/sql/events.queries.ts b/packages/node-sdk/paima-db/src/sql/events.queries.ts new file mode 100644 index 00000000..8ad48e83 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/events.queries.ts @@ -0,0 +1,201 @@ +/** Types generated for queries found in "src/sql/events.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +/** 'GetEvents' parameters type */ +export interface IGetEventsParams { + address?: string | null | void; + from?: number | null | void; + to?: number | null | void; + topic: string; +} + +/** 'GetEvents' return type */ +export interface IGetEventsResult { + address: string; + block_height: number; + data: Json; + id: number; + log_index: number; + topic: string; + tx: number; +} + +/** 'GetEvents' query type */ +export interface IGetEventsQuery { + params: IGetEventsParams; + result: IGetEventsResult; +} + +const getEventsIR: any = {"usedParamSet":{"from":true,"to":true,"address":true,"topic":true},"params":[{"name":"from","required":false,"transform":{"type":"scalar"},"locs":[{"a":53,"b":57}]},{"name":"to","required":false,"transform":{"type":"scalar"},"locs":[{"a":96,"b":98}]},{"name":"address","required":false,"transform":{"type":"scalar"},"locs":[{"a":131,"b":138}]},{"name":"topic","required":true,"transform":{"type":"scalar"},"locs":[{"a":160,"b":166}]}],"statement":"SELECT * FROM event WHERE\n COALESCE(block_height >= :from, 1=1) AND\n COALESCE(block_height <= :to, 1=1) AND\n COALESCE(address = :address, 1=1) AND\n topic = :topic!"}; + +/** + * Query generated from SQL: + * ``` + * SELECT * FROM event WHERE + * COALESCE(block_height >= :from, 1=1) AND + * COALESCE(block_height <= :to, 1=1) AND + * COALESCE(address = :address, 1=1) AND + * topic = :topic! + * ``` + */ +export const getEvents = new PreparedQuery(getEventsIR); + + +/** 'InsertEvent' parameters type */ +export interface IInsertEventParams { + address: string; + block_height: number; + data: Json; + log_index: number; + topic: string; + tx: number; +} + +/** 'InsertEvent' return type */ +export type IInsertEventResult = void; + +/** 'InsertEvent' query type */ +export interface IInsertEventQuery { + params: IInsertEventParams; + result: IInsertEventResult; +} + +const insertEventIR: any = {"usedParamSet":{"topic":true,"address":true,"data":true,"block_height":true,"tx":true,"log_index":true},"params":[{"name":"topic","required":true,"transform":{"type":"scalar"},"locs":[{"a":95,"b":101}]},{"name":"address","required":true,"transform":{"type":"scalar"},"locs":[{"a":106,"b":114}]},{"name":"data","required":true,"transform":{"type":"scalar"},"locs":[{"a":119,"b":124}]},{"name":"block_height","required":true,"transform":{"type":"scalar"},"locs":[{"a":129,"b":142}]},{"name":"tx","required":true,"transform":{"type":"scalar"},"locs":[{"a":147,"b":150}]},{"name":"log_index","required":true,"transform":{"type":"scalar"},"locs":[{"a":155,"b":165}]}],"statement":"INSERT INTO event (\n topic,\n address,\n data,\n block_height,\n tx,\n log_index\n) VALUES (\n :topic!,\n :address!,\n :data!,\n :block_height!,\n :tx!,\n :log_index!\n)"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO event ( + * topic, + * address, + * data, + * block_height, + * tx, + * log_index + * ) VALUES ( + * :topic!, + * :address!, + * :data!, + * :block_height!, + * :tx!, + * :log_index! + * ) + * ``` + */ +export const insertEvent = new PreparedQuery(insertEventIR); + + +/** 'RegisterEventType' parameters type */ +export interface IRegisterEventTypeParams { + name: string; + topic: string; +} + +/** 'RegisterEventType' return type */ +export type IRegisterEventTypeResult = void; + +/** 'RegisterEventType' query type */ +export interface IRegisterEventTypeQuery { + params: IRegisterEventTypeParams; + result: IRegisterEventTypeResult; +} + +const registerEventTypeIR: any = {"usedParamSet":{"name":true,"topic":true},"params":[{"name":"name","required":true,"transform":{"type":"scalar"},"locs":[{"a":60,"b":65}]},{"name":"topic","required":true,"transform":{"type":"scalar"},"locs":[{"a":70,"b":76}]}],"statement":"INSERT INTO registered_event (\n name,\n topic\n) VALUES (\n :name!,\n :topic!\n)"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO registered_event ( + * name, + * topic + * ) VALUES ( + * :name!, + * :topic! + * ) + * ``` + */ +export const registerEventType = new PreparedQuery(registerEventTypeIR); + + +/** 'GetTopicsForEvent' parameters type */ +export interface IGetTopicsForEventParams { + name: string; +} + +/** 'GetTopicsForEvent' return type */ +export interface IGetTopicsForEventResult { + topic: string; +} + +/** 'GetTopicsForEvent' query type */ +export interface IGetTopicsForEventQuery { + params: IGetTopicsForEventParams; + result: IGetTopicsForEventResult; +} + +const getTopicsForEventIR: any = {"usedParamSet":{"name":true},"params":[{"name":"name","required":true,"transform":{"type":"scalar"},"locs":[{"a":48,"b":53}]}],"statement":"SELECT topic FROM registered_event WHERE name = :name!"}; + +/** + * Query generated from SQL: + * ``` + * SELECT topic FROM registered_event WHERE name = :name! + * ``` + */ +export const getTopicsForEvent = new PreparedQuery(getTopicsForEventIR); + + +/** 'GetTopics' parameters type */ +export type IGetTopicsParams = void; + +/** 'GetTopics' return type */ +export interface IGetTopicsResult { + name: string; + topic: string; +} + +/** 'GetTopics' query type */ +export interface IGetTopicsQuery { + params: IGetTopicsParams; + result: IGetTopicsResult; +} + +const getTopicsIR: any = {"usedParamSet":{},"params":[],"statement":"SELECT name, topic FROM registered_event"}; + +/** + * Query generated from SQL: + * ``` + * SELECT name, topic FROM registered_event + * ``` + */ +export const getTopics = new PreparedQuery(getTopicsIR); + + +/** 'GetEventByTopic' parameters type */ +export interface IGetEventByTopicParams { + topic: string; +} + +/** 'GetEventByTopic' return type */ +export interface IGetEventByTopicResult { + name: string; +} + +/** 'GetEventByTopic' query type */ +export interface IGetEventByTopicQuery { + params: IGetEventByTopicParams; + result: IGetEventByTopicResult; +} + +const getEventByTopicIR: any = {"usedParamSet":{"topic":true},"params":[{"name":"topic","required":true,"transform":{"type":"scalar"},"locs":[{"a":48,"b":54}]}],"statement":"SELECT name FROM registered_event WHERE topic = :topic!"}; + +/** + * Query generated from SQL: + * ``` + * SELECT name FROM registered_event WHERE topic = :topic! + * ``` + */ +export const getEventByTopic = new PreparedQuery(getEventByTopicIR); + + diff --git a/packages/node-sdk/paima-db/src/sql/events.sql b/packages/node-sdk/paima-db/src/sql/events.sql new file mode 100644 index 00000000..d4c20d11 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/events.sql @@ -0,0 +1,41 @@ +/* @name getEvents */ +SELECT * FROM event WHERE + COALESCE(block_height >= :from, 1=1) AND + COALESCE(block_height <= :to, 1=1) AND + COALESCE(address = :address, 1=1) AND + topic = :topic!; + +/* @name insertEvent */ +INSERT INTO event ( + topic, + address, + data, + block_height, + tx, + log_index +) VALUES ( + :topic!, + :address!, + :data!, + :block_height!, + :tx!, + :log_index! +); + +/* @name registerEventType */ +INSERT INTO registered_event ( + name, + topic +) VALUES ( + :name!, + :topic! +); + +/* @name getTopicsForEvent */ +SELECT topic FROM registered_event WHERE name = :name!; + +/* @name getTopics */ +SELECT name, topic FROM registered_event; + +/* @name getEventByTopic */ +SELECT name FROM registered_event WHERE topic = :topic!; \ No newline at end of file diff --git a/packages/paima-sdk/paima-events/package.json b/packages/paima-sdk/paima-events/package.json index c88b7161..05d1d4ad 100644 --- a/packages/paima-sdk/paima-events/package.json +++ b/packages/paima-sdk/paima-events/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "mqtt": "^5.7.3", - "mqtt-pattern": "^2.1.0" + "mqtt-pattern": "^2.1.0", + "js-sha3": "^0.9.3" } } diff --git a/packages/paima-sdk/paima-events/src/app-events.ts b/packages/paima-sdk/paima-events/src/app-events.ts new file mode 100644 index 00000000..f74455d7 --- /dev/null +++ b/packages/paima-sdk/paima-events/src/app-events.ts @@ -0,0 +1,87 @@ +import type { LogEvent, LogEventFields } from './types.js'; +import type { genEvent } from './types.js'; +import { toPath, TopicPrefix } from './types.js'; +import type { TSchema, Static } from '@sinclair/typebox'; +import { keccak_256 } from 'js-sha3'; + +type Data[]>> = { + name: T['name']; + fields: KeypairToObj; + topic: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AllEventsUnion> = { + [K in keyof T]: Data; +}[number]; + +export type EventQueue[]>>> = { + address: `0x${string}`; + data: AllEventsUnion; +}[]; + +export const toSignature = []>>(event: T): string => { + return event.name + '(' + event.fields.map(f => f.type.type).join(',') + ')'; +}; + +export const toSignatureHash = []>>( + event: T +): string => { + return keccak_256(toSignature(event)); +}; + +export type AppEvents = ReturnType; + +// this generates the type for the events import. +export const generateAppEvents = []>>>( + entries: T +): { + [K in T[number] as K['name']]: (ReturnType> & { + definition: T[0]; + topicHash: string; + })[]; +} => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: Record = {}; // we can't know the type here + for (const event of entries) { + if (!result[event.name]) { + result[event.name] = []; + } + const topicHash = toSignatureHash(event); + + result[event.name].push({ + ...toPath(TopicPrefix.App, event, topicHash), + // keep the original definition around since it's nicer to work with, it + // also has the advantage that it allows recovering the initial order in + // case the signature/topicHash needs to be computed again, which can't be + // done from the path (since you don't know which non indexed fields go in + // between each indexed field). + definition: event, + // we add this to avoid having to re-compute it all the time. + topicHash, + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return result as any; +}; + +// Create payload for the stf from an object. Using this allows statically +// checking `data` with the type from `T`. +export const encodeEventForStf = >(args: { + from: `0x${string}`; + topic: T; + data: KeypairToObj; +}): { + address: `0x${string}`; + data: { name: T['name']; fields: KeypairToObj; topic: string }; +} => { + return { + address: args.from, + data: { name: args.topic.name, fields: args.data, topic: toSignatureHash(args.topic) }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KeypairToObj = { + [K in T[number] as K['name']]: Static; +}; diff --git a/packages/paima-sdk/paima-events/src/builtin-events.ts b/packages/paima-sdk/paima-events/src/builtin-events.ts index 8637e4c8..c7a49ed2 100644 --- a/packages/paima-sdk/paima-events/src/builtin-events.ts +++ b/packages/paima-sdk/paima-events/src/builtin-events.ts @@ -108,10 +108,7 @@ type HostInfo = { backendUri: string; batcherUri?: string; }; -export function toAsyncApi( - info: HostInfo, - events: Record -): AsyncAPI300Schema { +export function toAsyncApi(info: HostInfo, events: [string, EventPathAndDef][]): AsyncAPI300Schema { const parsedUrl = new URL(info.backendUri); const servers: NonNullable = { [PaimaEventBrokerNames.PaimaEngine]: { @@ -132,7 +129,7 @@ export function toAsyncApi( } const channels: Channels = {}; - for (const [k, v] of Object.entries(events)) { + for (const [k, v] of events) { if (v.broker === PaimaEventBrokerNames.Batcher && info.batcherUri == null) { continue; } @@ -169,7 +166,7 @@ export function toAsyncApi( } const operations: Operations = {}; - for (const [k, v] of Object.entries(events)) { + for (const [k, v] of events) { if (v.broker === PaimaEventBrokerNames.Batcher && info.batcherUri == null) { continue; } diff --git a/packages/paima-sdk/paima-events/src/index.ts b/packages/paima-sdk/paima-events/src/index.ts index 4606674a..752f2d1d 100644 --- a/packages/paima-sdk/paima-events/src/index.ts +++ b/packages/paima-sdk/paima-events/src/index.ts @@ -1,3 +1,4 @@ +export * from './app-events.js'; export * from './event-manager.js'; export * from './builtin-events.js'; export * from './builtin-event-utils.js'; diff --git a/packages/paima-sdk/paima-events/src/types.ts b/packages/paima-sdk/paima-events/src/types.ts index 43849814..28c99835 100644 --- a/packages/paima-sdk/paima-events/src/types.ts +++ b/packages/paima-sdk/paima-events/src/types.ts @@ -1,7 +1,6 @@ import { Type } from '@sinclair/typebox'; import type { TString, - TNumber, TInteger, TExtends, Kind, @@ -236,7 +235,8 @@ type IndexedFields[]> = ExcludeFromTuple< export function toPath[]>, Prefix extends TopicPrefix>( prefix: Prefix, - event: T + event: T, + signatureHash?: string ): { path: AddStringPath>; broker: BrokerName; @@ -249,6 +249,7 @@ export function toPath[]>, Prefix ext return { path: [ prefix, + ...[signatureHash].filter(x => x), ...event.fields .filter(input => input.indexed) .flatMap(input => [ diff --git a/packages/paima-sdk/paima-mw-core/src/helpers/paima-node-rest-schema.d.ts b/packages/paima-sdk/paima-mw-core/src/helpers/paima-node-rest-schema.d.ts index 7f58371e..9ddf08cb 100644 --- a/packages/paima-sdk/paima-mw-core/src/helpers/paima-node-rest-schema.d.ts +++ b/packages/paima-sdk/paima-mw-core/src/helpers/paima-node-rest-schema.d.ts @@ -148,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/get_logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["GetLogsPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/achievements/public/list": { parameters: { query?: never; @@ -281,6 +297,36 @@ export interface components { result: components["schemas"]["TransactionContentResponse"]; }; Result_TransactionContentResponse_: components["schemas"]["SuccessfulResult_TransactionContentResponse_"] | components["schemas"]["FailedResult"]; + "SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": { + /** @enum {boolean} */ + success: true; + result: { + /** Format: double */ + logIndex: number; + /** Format: double */ + tx: number; + data: { + [key: string]: unknown; + }; + /** Format: double */ + blockNumber: number; + address: string; + topic: string; + }[]; + }; + "Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_": components["schemas"]["SuccessfulResult__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_"] | components["schemas"]["FailedResult"]; + GetLogsResponse: components["schemas"]["Result__topic-string--address-string--blockNumber-number--data_58___91_fieldName-string_93__58_any_--tx-number--logIndex-number_-Array_"]; + GetLogsParams: { + topic: string; + filters?: { + [key: string]: string; + }; + address: string; + /** Format: double */ + toBlock?: number; + /** Format: double */ + fromBlock: number; + }; Achievement: { /** @description Unique Achievement String */ name: string; @@ -769,6 +815,54 @@ export interface operations { }; }; }; + GetLogsPost: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetLogsParams"]; + }; + }; + responses: { + /** @description Ok */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetLogsResponse"]; + }; + }; + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FailedResult"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FailedResult"]; + }; + }; + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalServerErrorResult"]; + }; + }; + }; + }; AchievementsPublic_list: { parameters: { query?: { diff --git a/packages/paima-sdk/paima-utils/src/config.ts b/packages/paima-sdk/paima-utils/src/config.ts index 1b88cbcb..af37e589 100644 --- a/packages/paima-sdk/paima-utils/src/config.ts +++ b/packages/paima-sdk/paima-utils/src/config.ts @@ -157,6 +157,10 @@ export class ENV { return process.env.MQTT_BATCHER_BROKER_URL || 'ws://127.0.0.1:' + ENV.MQTT_BATCHER_BROKER_PORT; } + static get GET_LOGS_MAX_BLOCK_RANGE(): number { + return parseFloat(process.env.GET_LOGS_MAX_BLOCK_RANGE || '5000'); + } + // Utils private static isTrue(value: string | undefined, defaultValue = false): boolean { if (value == null || value === '') return defaultValue;