@effectstream/sm 0.100.14 → 0.100.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @effectstream/sm
|
|
2
|
+
|
|
3
|
+
The state-machine DSL inside an EffectStream node. Define a typed grammar
|
|
4
|
+
of commands, register one generator per command, and `Stm` parses each
|
|
5
|
+
incoming batcher input, dispatches it to the right handler, and yields
|
|
6
|
+
SQL updates through the runtime.
|
|
7
|
+
|
|
8
|
+
- State-machine DSL: define a typed grammar, register one generator per command.
|
|
9
|
+
- Parses each batcher input, dispatches to the handler, yields SQL through the runtime.
|
|
10
|
+
- `@effectstream/sm/builtin` ships common on-chain event primitives (ERC-20/721/1155, Cardano, Midnight, ...).
|
|
11
|
+
- DSL is directly testable in a pure-TS unit test, without a database.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @effectstream/sm
|
|
17
|
+
# or
|
|
18
|
+
npm install @effectstream/sm
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
This package pairs with [`@effectstream/runtime`](https://www.npmjs.com/package/@effectstream/runtime),
|
|
24
|
+
which drives a per-block loop that calls `stm.processInput(...)` for every
|
|
25
|
+
batcher subunit, collects the SQL yielded by your generators, and commits
|
|
26
|
+
it inside a per-block postgres transaction. You author the DSL here;
|
|
27
|
+
the runtime executes it.
|
|
28
|
+
|
|
29
|
+
The DSL is also directly testable in a pure-TS unit test (parse + dispatch
|
|
30
|
+
without a database) — see
|
|
31
|
+
[`primitives/src/evm-erc20/erc20-primitive.test.ts`](https://github.com/effectstream/effectstream/blob/main/packages/node-sdk/sm/primitives/src/evm-erc20/erc20-primitive.test.ts).
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { Stm } from "@effectstream/sm";
|
|
35
|
+
import { World } from "@effectstream/coroutine";
|
|
36
|
+
import { Type } from "@sinclair/typebox";
|
|
37
|
+
import { join, leave } from "./queries.ts"; // pgtyped queries
|
|
38
|
+
|
|
39
|
+
const grammar = {
|
|
40
|
+
join: [["user", Type.String()]] as const,
|
|
41
|
+
leave: [["user", Type.String()]] as const,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const stm = new Stm(grammar);
|
|
45
|
+
|
|
46
|
+
stm.addStateTransition("join", function* ({ parsedInput, msTimestamp }) {
|
|
47
|
+
yield* World.resolve(join, { user: parsedInput.user, ts: msTimestamp });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
stm.addStateTransition("leave", function* ({ parsedInput }) {
|
|
51
|
+
yield* World.resolve(leave, { user: parsedInput.user });
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The runtime calls `stm.processInput(input)` for every batcher subunit; the
|
|
56
|
+
DSL parses against `grammar`, finds the right handler, and the generator
|
|
57
|
+
yields `World.resolve(...)` so the runtime can execute the pgtyped queries.
|
|
58
|
+
|
|
59
|
+
## Inside EffectStream
|
|
60
|
+
|
|
61
|
+
`Stm` is the central piece a node author writes. The runtime's per-block
|
|
62
|
+
loop wires each user input through the corresponding `Stm` instance,
|
|
63
|
+
collects yielded SQL, and commits it inside the per-block transaction.
|
|
64
|
+
The built-in primitives package (`@effectstream/sm/builtin`) covers
|
|
65
|
+
common on-chain events — ERC-20/721/1155 transfers, Cardano transfers,
|
|
66
|
+
Midnight events, etc. — so you don't re-implement them.
|
|
67
|
+
|
|
68
|
+
## Key exports
|
|
69
|
+
|
|
70
|
+
- `Stm<Grammar, Events>`: the state machine. `.addStateTransition(prefix, handler)`, `.processInput(input)`, `.grammar`, `.fullJsonGrammar`, `.keyedJsonGrammar`.
|
|
71
|
+
- `ParamToData<Params>` derives the typed argument shape from a grammar entry.
|
|
72
|
+
- `BaseStfInput`: input shape passed to every handler. Includes `msTimestamp`, `blockHeight`, etc.
|
|
73
|
+
- `delegate-wallet` helpers — account delegation primitives reused by built-ins.
|
|
74
|
+
|
|
75
|
+
`MessageListener<Events, Params>` is exported as the handler type but is
|
|
76
|
+
inferred at call sites rather than imported directly.
|
|
77
|
+
|
|
78
|
+
Subpath exports:
|
|
79
|
+
|
|
80
|
+
- `@effectstream/sm/builtin`: `PrimitiveTypeERC20`, `PrimitiveTypeERC721`, `PrimitiveTypeERC1155`, `PrimitiveTypeCardanoTransfer`, `PrimitiveTypeMidnightGeneric`, and 20+ more chain-specific event tags.
|
|
81
|
+
- `@effectstream/sm/grammar`: the underlying grammar/parsing utilities, also re-exported from `@effectstream/concise`.
|
|
82
|
+
|
|
83
|
+
## Examples
|
|
84
|
+
|
|
85
|
+
- [`primitives/src/evm-erc20/erc20-primitive.test.ts`](https://github.com/effectstream/effectstream/blob/main/packages/node-sdk/sm/primitives/src/evm-erc20/erc20-primitive.test.ts) — a real primitive's behavior unit-tested.
|
|
86
|
+
- Game logic in
|
|
87
|
+
[`templates/dice/packages/node/`](https://github.com/effectstream/effectstream/tree/main/templates/dice/packages/node)
|
|
88
|
+
shows the full `new Stm(...).addStateTransition(...)` pattern.
|
|
89
|
+
|
|
90
|
+
Runnable: [`test/examples.test.ts`](./test/examples.test.ts).
|
|
91
|
+
|
|
92
|
+
## Links
|
|
93
|
+
|
|
94
|
+
- Docs: https://effectstream.github.io/docs/packages/node/sm
|
|
95
|
+
- Source: https://github.com/effectstream/effectstream/tree/main/packages/node-sdk/sm
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sm",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.16",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
"./grammar": "./primitives/src/grammar.ts"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@effectstream/concise": "0.100.
|
|
21
|
-
"@effectstream/config": "0.100.
|
|
22
|
-
"@effectstream/coroutine": "0.100.
|
|
23
|
-
"@effectstream/crypto": "0.100.
|
|
24
|
-
"@effectstream/db": "0.100.
|
|
25
|
-
"@effectstream/event-client": "0.100.
|
|
26
|
-
"@effectstream/log": "0.100.
|
|
27
|
-
"@effectstream/utils": "0.100.
|
|
20
|
+
"@effectstream/concise": "0.100.16",
|
|
21
|
+
"@effectstream/config": "0.100.16",
|
|
22
|
+
"@effectstream/coroutine": "0.100.16",
|
|
23
|
+
"@effectstream/crypto": "0.100.16",
|
|
24
|
+
"@effectstream/db": "0.100.16",
|
|
25
|
+
"@effectstream/event-client": "0.100.16",
|
|
26
|
+
"@effectstream/log": "0.100.16",
|
|
27
|
+
"@effectstream/utils": "0.100.16",
|
|
28
28
|
"pg": "8.17.2",
|
|
29
29
|
"pg-tx": "^1.0.1",
|
|
30
30
|
"@sinclair/typebox": "0.34.41",
|
|
@@ -63,14 +63,23 @@ export class CardanoMintBurnPrimitive extends Primitive<
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
override getConfig(): ProtocolPrimitiveMap[ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL] {
|
|
66
|
+
// UTxO-RPC's `mints_asset` predicate is interpreted by Dolos as
|
|
67
|
+
// "creates new supply" — it matches only positive-quantity mints and
|
|
68
|
+
// silently filters burns. To catch burns we also match `moves_asset`,
|
|
69
|
+
// which fires when the asset appears in any input/output of the tx
|
|
70
|
+
// (true for burns, since the asset is in the input UTxO being spent).
|
|
71
|
+
// Downstream consumers see both mint and burn payloads through the
|
|
72
|
+
// same primitive; sign-of-quantity discriminates.
|
|
73
|
+
const matchPolicy = (pid: string): UtxorpcTxPredicate => ({
|
|
74
|
+
any_of: [
|
|
75
|
+
{ match: { cardano: { mints_asset: { policy_id: pid } } } },
|
|
76
|
+
{ match: { cardano: { moves_asset: { policy_id: pid } } } },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
66
79
|
const predicate: UtxorpcTxPredicate =
|
|
67
80
|
this.policyIds.length === 1
|
|
68
|
-
?
|
|
69
|
-
: {
|
|
70
|
-
any_of: this.policyIds.map((pid) => ({
|
|
71
|
-
match: { cardano: { mints_asset: { policy_id: pid } } },
|
|
72
|
-
})),
|
|
73
|
-
};
|
|
81
|
+
? matchPolicy(this.policyIds[0])
|
|
82
|
+
: { any_of: this.policyIds.map(matchPolicy) };
|
|
74
83
|
|
|
75
84
|
return {
|
|
76
85
|
name: this.instanceName,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getEvmEvent } from "@effectstream/config";
|
|
1
|
+
import { getEvmEvent, getReadNamespaces } from "@effectstream/config";
|
|
2
|
+
import type { SecurityNamespace } from "@effectstream/config";
|
|
2
3
|
import {
|
|
3
4
|
type AddressAndType,
|
|
4
5
|
AddressType,
|
|
@@ -60,12 +61,14 @@ export class EffectstreamL2Primitive extends Primitive<
|
|
|
60
61
|
readonly contractAddress: EvmAddress;
|
|
61
62
|
override grammar = [];
|
|
62
63
|
readonly effectstreamL2Grammar: GrammarDefinition;
|
|
64
|
+
readonly securityNamespace: SecurityNamespace | undefined;
|
|
63
65
|
|
|
64
66
|
constructor(config: {
|
|
65
67
|
instanceName: string;
|
|
66
68
|
startBlockHeight: number;
|
|
67
69
|
contractAddress: EvmAddress;
|
|
68
70
|
effectstreamL2Grammar: GrammarDefinition;
|
|
71
|
+
securityNamespace: SecurityNamespace | undefined;
|
|
69
72
|
}) {
|
|
70
73
|
super({ ...config, stateMachinePrefix: undefined });
|
|
71
74
|
this.contractAddress = Value.Decode(
|
|
@@ -73,6 +76,7 @@ export class EffectstreamL2Primitive extends Primitive<
|
|
|
73
76
|
config.contractAddress,
|
|
74
77
|
);
|
|
75
78
|
this.effectstreamL2Grammar = config.effectstreamL2Grammar;
|
|
79
|
+
this.securityNamespace = config.securityNamespace;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
override *getPayload(
|
|
@@ -370,23 +374,34 @@ export class EffectstreamL2Primitive extends Primitive<
|
|
|
370
374
|
input: conciseInput,
|
|
371
375
|
target: batcherTarget,
|
|
372
376
|
} = parsed;
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// We yield the promise to the generator caller.
|
|
383
|
-
// Sync Generators cannot resolve promises.
|
|
384
|
-
const validSignature = yield* verifySignature(
|
|
385
|
-
addressType,
|
|
386
|
-
userAddress,
|
|
387
|
-
message,
|
|
388
|
-
userSignature,
|
|
377
|
+
// Re-verify the batched signature against each acceptable namespace
|
|
378
|
+
// (matches what the batcher checked when admitting the input). The
|
|
379
|
+
// frontend signs with the game's configured security namespace; if we
|
|
380
|
+
// don't try it here, every batched input is silently dropped.
|
|
381
|
+
// Namespace is injected at construction by the runtime — see
|
|
382
|
+
// processPrimitives in @effectstream/runtime.
|
|
383
|
+
const acceptableNamespaces = getReadNamespaces(
|
|
384
|
+
this.securityNamespace,
|
|
385
|
+
effectstream_block_height,
|
|
389
386
|
);
|
|
387
|
+
let validSignature = false;
|
|
388
|
+
for (const ns of acceptableNamespaces) {
|
|
389
|
+
const message = createMessageForBatcher(
|
|
390
|
+
ns,
|
|
391
|
+
millisecondTimestamp,
|
|
392
|
+
userAddress,
|
|
393
|
+
addressType,
|
|
394
|
+
conciseInput,
|
|
395
|
+
batcherTarget,
|
|
396
|
+
);
|
|
397
|
+
validSignature = yield* verifySignature(
|
|
398
|
+
addressType,
|
|
399
|
+
userAddress,
|
|
400
|
+
message,
|
|
401
|
+
userSignature,
|
|
402
|
+
);
|
|
403
|
+
if (validSignature) break;
|
|
404
|
+
}
|
|
390
405
|
|
|
391
406
|
const cryptoManager = CryptoManager.getCryptoManager(addressType);
|
|
392
407
|
const signerAddress = cryptoManager.decodeAddress(userAddress);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Examples for the README. We exercise Stm in isolation — define a
|
|
2
|
+
// grammar, register handlers, and check `.processInput` dispatches to
|
|
3
|
+
// the right one. The runtime side (executing yielded SQL) is the job of
|
|
4
|
+
// @effectstream/runtime, not this package.
|
|
5
|
+
|
|
6
|
+
import { test, expect } from "bun:test";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { Stm } from "../Stm.ts";
|
|
9
|
+
|
|
10
|
+
test("README: Stm dispatches inputs to the registered handler", () => {
|
|
11
|
+
const grammar = {
|
|
12
|
+
join: [["user", Type.String()]] as const,
|
|
13
|
+
leave: [["user", Type.String()]] as const,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
const stm = new Stm(grammar);
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
|
|
19
|
+
stm.addStateTransition("join", function* ({ parsedInput }) {
|
|
20
|
+
calls.push(`join:${parsedInput.user}`);
|
|
21
|
+
// Empty generator — no DB writes.
|
|
22
|
+
});
|
|
23
|
+
stm.addStateTransition("leave", function* ({ parsedInput }) {
|
|
24
|
+
calls.push(`leave:${parsedInput.user}`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Build the on-wire concise input.
|
|
28
|
+
const input = JSON.stringify(["join", "alice"]);
|
|
29
|
+
const gen = stm.processInput({
|
|
30
|
+
conciseInput: input,
|
|
31
|
+
blockHeight: 1,
|
|
32
|
+
msTimestamp: 0,
|
|
33
|
+
} as any);
|
|
34
|
+
|
|
35
|
+
// Drain the generator.
|
|
36
|
+
while (!gen.next().done) {}
|
|
37
|
+
|
|
38
|
+
expect(calls).toEqual(["join:alice"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("README: duplicate prefix registration throws", () => {
|
|
42
|
+
const grammar = { join: [["user", Type.String()]] as const } as const;
|
|
43
|
+
const stm = new Stm(grammar);
|
|
44
|
+
stm.addStateTransition("join", function* () {});
|
|
45
|
+
expect(() => stm.addStateTransition("join", function* () {})).toThrow();
|
|
46
|
+
});
|