From 5a1e76f4e42a55788d6ca3e724e30ac15c000680 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini <48031343+ecioppettini@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:18:48 -0300 Subject: [PATCH 1/3] add documentation for app defined events --- .../100-define-machine/10-read-data.md | 4 +- .../100-events/100-general-interface.md | 192 +++++++++++++++++- .../100-events/200-low-level-api.md | 2 +- .../325-creating-events/50-timers-ticks.md | 4 +- .../100-delegate-wallet/200-integrate.mdx | 2 +- 5 files changed, 195 insertions(+), 9 deletions(-) diff --git a/docs/home/100-state-machine/100-define-machine/10-read-data.md b/docs/home/100-state-machine/100-define-machine/10-read-data.md index 9e61a57a..762a19bb 100644 --- a/docs/home/100-state-machine/100-define-machine/10-read-data.md +++ b/docs/home/100-state-machine/100-define-machine/10-read-data.md @@ -37,7 +37,7 @@ export default async function ( blockHeader: BlockHeader, randomnessGenerator: Prando, dbConn: Pool -): Promise { +): Promise<{ stateTransitions: SQLUpdate[], events: [] }> { console.log(inputData, 'parsing input data'); const user = inputData.userAddress.toLowerCase(); @@ -53,7 +53,7 @@ export default async function ( case 'createLobby': // handle this input however you need (but needs to be deterministic) default: - return []; + return { stateTransitions: [], events: [] }; } } ``` diff --git a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md index f3a956c3..3fc55ce6 100644 --- a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md +++ b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md @@ -5,7 +5,7 @@ Events are defined by two components: 1. A set of `fields` which defines the event content. Fields are made up of 1. A `name` for a *positional argument* 2. A `type` defined with [typebox](https://github.com/sinclairzx81/typebox) to ensure JSON compatibility - 3. A `indexed` boolean for whether or not events will be [indexable](https://en.wikipedia.org/wiki/Database_index) on this field + 3. A `indexed` boolean for whether or not events will be [indexable](https://en.wikipedia.org/wiki/Database_index) on this field. One index per field will be created on the underlying table. Here is an example of an event that tracks completing quests in a game. In this example, we create an `index` on the `playerId` so a client could get realtime updates whenever a user has completing a quest. @@ -29,7 +29,20 @@ const QuestCompletionEvent = genEvent({ } as const); ``` -*TODO*: the API to register these events with Paima Engine itself is still under development +# Register app events + +In order for Paima Engine to be able to use the events, these need to be +exported through a module in the packaged files. This can be generated by having +an `events` package in the game's directory, where the result of +`generateAppEvents` is exported: + +```ts +const eventDefinitions = [ + QuestCompletionEvent, +] as const; + +export const events = generateAppEvents(eventDefinitions); +``` # Listening to events @@ -59,10 +72,183 @@ await unsubscribe(); # Posting new events -You can publish messages from your game's state machine at any time. You need to provide *all* fields (both those indexed and those that aren't). Paima Engine, under the hood, will take care of only sending these events to the clients that need them. +You can publish messages from your game's state machine at any time. You need to +provide *all* fields (both those indexed and those that aren't). This is done by +returning the new events as a part of the state transition function's result, +alongside the SQL queries that change the state. If all the changes to the +database state are applied correctly for a particular input, then Paima Engine +will take care of only sending these events to the clients that +need them. ```ts import { PaimaEventManager } from '@paima/sdk/events'; await PaimaEventListener.Instance.sendMessage(QuestCompletionEvent, { questId: 5, playerId: 10 }); ``` + +## Typed helpers + +From Paima Engine's point of view, the type of the `stateTransitionFunction` +looks something like this. + +```ts +async function stateTransitionFunction ( + inputData: SubmittedChainData, + header: { blockHeight: number; timestamp: number }, + randomnessGenerator: Prando, + dbConn: Pool +): Promise<{ + stateTransitions: SQLUpdate[]; + events: { + address: `0x${string}`; + data: { + name: string; + fields: { [fieldName:string]: any }; + topic: string; + }; + }[]; +}>; +``` + +Since the event definitions are loaded at runtime, there is no way for it +to narrow the type of the events. + +Then the most straightforward way of emitting events from the stf would be this: + +```ts +return { + stateTransitions: [], + events: [ + { + address: precompiles.foo, + data: { + name: QuestCompletion.name, + fields: { + questId: 5, + playerId: 10, + }, + topic: toSignatureHash(QuestCompletion), + }, + }, + ], +}; +``` + +However, this doesn't leverage the typescript's type system at all, which makes +it error prone. Instead, the recommended approach is to use the typed helpers +provided in the SDK. + +The main one is the `EventQueue` type, which can be used to statically guarantee +that the emitted events are part of the exported events. For example: + +```ts +type Events = EventQueue; + +async function stateTransitionFunction ( + inputData: SubmittedChainData, + header: { blockHeight: number; timestamp: number }, + randomnessGenerator: Prando, + dbConn: Pool +): Promise<{ + stateTransitions: SQLUpdate[]; + events: Events; +}>; +``` + +This prevents you from emitting events that are not part of the +`eventDefinitions` array. + +The second helper is the `encodeEventForStf` function, which can be used to +rewrite the previous code like this: + +```ts +return { + stateTransitions: [], + events: [ + encodeEventForStf({ + from: precompiles.foo, + topic: QuestCompletion, + data: { + questId: 5, + playerId: 10, + }, + }), + ], +}; +``` + +The main reason to use this function is to ensure that the signature hash +matches the event type through encapsulation. The easiest way to make this +mistake would be when there are overloaded events. + +For example, if there was another registered event with this definition: + +```ts +const QuestCompletionEvent_v2 = genEvent({ + name: 'QuestCompletion', + fields: [ + { + name: 'playerId', + type: Type.Integer(), + indexed: true, + }, + { + name: 'questId', + type: Type.Integer(), + }, + { + name: 'points', + type: Type.Integer(), + }, + ], +} as const); +``` + +Then the following event will typecheck, but the topic will be incorrect, since +it has a different signature. + +```ts +return { + stateTransitions: [], + events: [ + { + address: precompiles.foo, + data: { + name: QuestCompletion.name, + fields: { + questId: 5, + playerId: 10, + points: 20 + }, + topic: toSignatureHash(QuestCompletion), + }, + }, + ], +}; +``` + +Using `encodeEventForStf` also has the secondary advantage of providing slightly +better error messages and editor support in the case of overloads, since once +the topic argument is fixed, the type of the data can be fully computed instead +of having to compare to the full union of possible values. + +# Signature hash + +A unique identifier is computed for each app defined event. This can be computed +with the `toSignatureHash` function. + +```ts +const questCompletion = toSignatureHash(QuestCompletionEvent); +``` + +The way this works is that the signature is first encoded as text: + +`QuestCompletion(integer,integer)` + +And then hashed with keccak_256 to get the identifier: + +`3e3198e308aafca217c68bc72b3adcc82aa03160ef5e9e7b97e1d4afa8f792d5` + +This gets stored in the database on startup, and it's used to check that no +events are removed (or modified). Note that this doesn't take into account +whether the fields are indexed or not. \ No newline at end of file diff --git a/docs/home/100-state-machine/325-creating-events/100-events/200-low-level-api.md b/docs/home/100-state-machine/325-creating-events/100-events/200-low-level-api.md index b9162a8f..2ebd148e 100644 --- a/docs/home/100-state-machine/325-creating-events/100-events/200-low-level-api.md +++ b/docs/home/100-state-machine/325-creating-events/100-events/200-low-level-api.md @@ -33,7 +33,7 @@ const QuestCompletionEvent = genEvent({ - The content of the MQTT messages is `{ questId: number }` Note that all events starts with a prefix depending on its origin (`TopicPrefix`): -- `app` for events defined by the user +- `app/{signatureHash}` for events defined by the user. The `signatureHash` is explained [here](./100-general-interface.md#signature-hash). - `batcher` for events coming from the [batcher](../../200-direct-write/400-batched-mode.md) - `node` for events that come from the Paima Engine node diff --git a/docs/home/100-state-machine/325-creating-events/50-timers-ticks.md b/docs/home/100-state-machine/325-creating-events/50-timers-ticks.md index 9d3ee5c2..110b3730 100644 --- a/docs/home/100-state-machine/325-creating-events/50-timers-ticks.md +++ b/docs/home/100-state-machine/325-creating-events/50-timers-ticks.md @@ -122,7 +122,7 @@ export default async function ( blockHeader: BlockHeader, randomnessGenerator: Prando, dbConn: Pool -): Promise { +): Promise<{ stateTransitions: SQLUpdate[], events: [] }> { const input = parse(inputData.inputData); @@ -147,7 +147,7 @@ export default async function ( )); // highlight-end - return commands; + return { stateTransitions: commands, events: [] }; } } ... diff --git a/docs/home/700-multichain-support/2-wallet-layer/100-delegate-wallet/200-integrate.mdx b/docs/home/700-multichain-support/2-wallet-layer/100-delegate-wallet/200-integrate.mdx index 8ae3e1e0..5b895074 100644 --- a/docs/home/700-multichain-support/2-wallet-layer/100-delegate-wallet/200-integrate.mdx +++ b/docs/home/700-multichain-support/2-wallet-layer/100-delegate-wallet/200-integrate.mdx @@ -102,7 +102,7 @@ export default async function ( blockHeight: number, randomnessGenerator: Prando, dbConn: Pool -): Promise { +): Promise<{ stateTransitions: SQLUpdate[], events: [] }> { // highlight-start /* use this user to identify the player instead of userAddress or realAddress */ const user = String(inputData.userId); From 50b9158b9328ef611fdf95e5b31460d5e7e3e5e3 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini <48031343+ecioppettini@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:26:53 -0300 Subject: [PATCH 2/3] update to the labeled events api changes --- .../100-events/100-general-interface.md | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md index 3fc55ce6..596a5efe 100644 --- a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md +++ b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md @@ -34,14 +34,14 @@ const QuestCompletionEvent = genEvent({ In order for Paima Engine to be able to use the events, these need to be exported through a module in the packaged files. This can be generated by having an `events` package in the game's directory, where the result of -`generateAppEvents` is exported: +`registerEvents` is exported: ```ts -const eventDefinitions = [ +const eventDefinitions = { QuestCompletionEvent, -] as const; +} as const; -export const events = generateAppEvents(eventDefinitions); +export const events = registerEvents(eventDefinitions); ``` # Listening to events @@ -55,10 +55,11 @@ A few things to note: ```ts import { PaimaEventManager } from '@paima/sdk/events'; +import { events } from '@game/events'; const unsubscribe = await PaimaEventManager.Instance.subscribe( { - topic: QuestCompletionEvent, + topic: events.QuestCompletionEvent, filter: { playerId: undefined }, // all players }, event => { @@ -76,9 +77,9 @@ You can publish messages from your game's state machine at any time. You need to provide *all* fields (both those indexed and those that aren't). This is done by returning the new events as a part of the state transition function's result, alongside the SQL queries that change the state. If all the changes to the -database state are applied correctly for a particular input, then Paima Engine -will take care of only sending these events to the clients that -need them. +database state are applied correctly for that transaction, then Paima Engine +will take care of sending these events *only* to the clients that need them. +Under the hood, the engine uses the following API for this. ```ts import { PaimaEventManager } from '@paima/sdk/events'; @@ -86,7 +87,7 @@ import { PaimaEventManager } from '@paima/sdk/events'; await PaimaEventListener.Instance.sendMessage(QuestCompletionEvent, { questId: 5, playerId: 10 }); ``` -## Typed helpers +## State transition function From Paima Engine's point of view, the type of the `stateTransitionFunction` looks something like this. @@ -135,11 +136,15 @@ return { ``` However, this doesn't leverage the typescript's type system at all, which makes -it error prone. Instead, the recommended approach is to use the typed helpers -provided in the SDK. +it error prone. + +## Typed helpers + +Instead, the recommended approach is to use the typed helpers provided in the +SDK. -The main one is the `EventQueue` type, which can be used to statically guarantee -that the emitted events are part of the exported events. For example: +The first one is the `EventQueue` type, which can be used to statically +guarantee that the emitted events are part of the exported events. For example: ```ts type Events = EventQueue; @@ -156,18 +161,19 @@ async function stateTransitionFunction ( ``` This prevents you from emitting events that are not part of the -`eventDefinitions` array. +`eventDefinitions` object. The second helper is the `encodeEventForStf` function, which can be used to rewrite the previous code like this: ```ts +import { events } from '@game/events'; return { stateTransitions: [], events: [ encodeEventForStf({ from: precompiles.foo, - topic: QuestCompletion, + topic: events.QuestCompletion, data: { questId: 5, playerId: 10, @@ -227,10 +233,10 @@ return { }; ``` -Using `encodeEventForStf` also has the secondary advantage of providing slightly -better error messages and editor support in the case of overloads, since once -the topic argument is fixed, the type of the data can be fully computed instead -of having to compare to the full union of possible values. +Using `encodeEventForStf` also has the secondary advantage of providing better +error messages and editor support, particularly with overloaded events, since +once the topic argument is fixed, the type of the data can be fully computed +instead of having to compare to the full union of possible values. # Signature hash From 6e9467fc3afd5dc07f0a531322d3d9cc9a61a3b5 Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini <48031343+ecioppettini@users.noreply.github.com> Date: Sat, 14 Sep 2024 00:33:30 -0300 Subject: [PATCH 3/3] fix event unsuscribe example to match the current api --- .../325-creating-events/100-events/100-general-interface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md index 596a5efe..f5101fdb 100644 --- a/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md +++ b/docs/home/100-state-machine/325-creating-events/100-events/100-general-interface.md @@ -57,7 +57,7 @@ A few things to note: import { PaimaEventManager } from '@paima/sdk/events'; import { events } from '@game/events'; -const unsubscribe = await PaimaEventManager.Instance.subscribe( +const suscriptionHandle = await PaimaEventManager.Instance.subscribe( { topic: events.QuestCompletionEvent, filter: { playerId: undefined }, // all players @@ -68,7 +68,7 @@ const unsubscribe = await PaimaEventManager.Instance.subscribe( ); // later -await unsubscribe(); +await PaimaEventManager.Instance.unsubscribe(suscriptionHandle); ``` # Posting new events