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);