@effectstream/sm 0.102.0 → 0.103.1

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 CHANGED
@@ -77,7 +77,7 @@ inferred at call sites rather than imported directly.
77
77
 
78
78
  Subpath exports:
79
79
 
80
- - `@effectstream/sm/builtin`: `PrimitiveTypeERC20`, `PrimitiveTypeERC721`, `PrimitiveTypeERC1155`, `PrimitiveTypeCardanoTransfer`, `PrimitiveTypeMidnightGeneric`, and 20+ more chain-specific event tags.
80
+ - `@effectstream/sm/builtin`: `PrimitiveTypeEVMERC20`, `PrimitiveTypeEVMERC721`, `PrimitiveTypeEVMERC1155`, `PrimitiveTypeEVMEffectstreamL2`, `PrimitiveTypeCardanoTransfer`, `PrimitiveTypeMidnightGeneric`, `PrimitiveTypeUtxorpcGeneric`, and 20+ more chain-specific event tags.
81
81
  - `@effectstream/sm/grammar`: the underlying grammar/parsing utilities, also re-exported from `@effectstream/concise`.
82
82
 
83
83
  ## Examples
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sm",
3
- "version": "0.102.0",
3
+ "version": "0.103.1",
4
4
  "license": "(MIT OR Apache-2.0)",
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.102.0",
21
- "@effectstream/config": "0.102.0",
22
- "@effectstream/coroutine": "0.102.0",
23
- "@effectstream/crypto": "0.102.0",
24
- "@effectstream/db": "0.102.0",
25
- "@effectstream/event-client": "0.102.0",
26
- "@effectstream/log": "0.102.0",
27
- "@effectstream/utils": "0.102.0",
20
+ "@effectstream/concise": "0.103.1",
21
+ "@effectstream/config": "0.103.1",
22
+ "@effectstream/coroutine": "0.103.1",
23
+ "@effectstream/crypto": "0.103.1",
24
+ "@effectstream/db": "0.103.1",
25
+ "@effectstream/event-client": "0.103.1",
26
+ "@effectstream/log": "0.103.1",
27
+ "@effectstream/utils": "0.103.1",
28
28
  "pg": "8.21.0",
29
29
  "pg-tx": "^1.0.1",
30
30
  "@sinclair/typebox": "0.34.41",
@@ -1,7 +1,13 @@
1
1
  // list of built-in primitives
2
2
  // this list is exposed to the effectstream-sdk modules via the @effectstream/sm/builtin module
3
+ import {
4
+ SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
5
+ SOLANA_PRIMITIVE_PROGRAM_LOG,
6
+ SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
7
+ } from "@effectstream/config";
8
+
3
9
  export const PrimitiveTypeMidnightGeneric = "Midnight:Generic" as const;
4
- export const PrimitiveTypeMidnightNullifier = "Midnight:Nullifier" as const;
10
+ export const PrimitiveTypeMidnightNullifierAndCommitment = "Midnight:NullifierAndCommitment" as const;
5
11
  export const PrimitiveTypeMidnightUnshieldedSpend = "Midnight:UnshieldedSpend" as const;
6
12
  export const PrimitiveTypeMidnightUnshieldedCreate = "Midnight:UnshieldedCreate" as const;
7
13
  export const PrimitiveTypeMidnightZswapRoot = "Midnight:ZswapRoot" as const;
@@ -33,12 +39,21 @@ export const PrimitiveTypeNEARIntent = "NEAR:Intent" as const;
33
39
  export const PrimitiveTypeNEARGeneric = "NEAR:Generic" as const;
34
40
  export const PrimitiveTypeNEARAccountWatch = "NEAR:AccountWatch" as const;
35
41
 
36
- export const PrimitiveTypeSolanaProgramLog = "SOLANA:ProgramLog" as const;
37
- export const PrimitiveTypeSolanaAccountBalance = "SOLANA:AccountBalance" as const;
42
+ // Solana's discriminators are defined in @effectstream/config, beside the
43
+ // `SolanaPrimitive` type they discriminate, because the sync fetcher dispatches on
44
+ // them and @effectstream/sync does not depend on @effectstream/sm. Re-exported here
45
+ // under the PrimitiveType* names so consumers keep importing them from
46
+ // @effectstream/sm/builtin like every other primitive.
47
+ // Aliased through local consts rather than `export { X as Y } from`, because the
48
+ // `BuiltInPrimitives` union below needs `typeof PrimitiveTypeSolana*` in scope and a
49
+ // bare re-export does not bind the name locally.
50
+ export const PrimitiveTypeSolanaProgramLog = SOLANA_PRIMITIVE_PROGRAM_LOG;
51
+ export const PrimitiveTypeSolanaAccountBalance = SOLANA_PRIMITIVE_ACCOUNT_BALANCE;
52
+ export const PrimitiveTypeSolanaTokenAccount = SOLANA_PRIMITIVE_TOKEN_ACCOUNT;
38
53
 
39
54
  type BuiltInPrimitives =
40
55
  typeof PrimitiveTypeMidnightGeneric |
41
- typeof PrimitiveTypeMidnightNullifier |
56
+ typeof PrimitiveTypeMidnightNullifierAndCommitment |
42
57
  typeof PrimitiveTypeMidnightUnshieldedSpend |
43
58
  typeof PrimitiveTypeMidnightUnshieldedCreate |
44
59
  typeof PrimitiveTypeMidnightZswapRoot |
@@ -63,7 +78,8 @@ type BuiltInPrimitives =
63
78
  typeof PrimitiveTypeNEARGeneric |
64
79
  typeof PrimitiveTypeNEARAccountWatch |
65
80
  typeof PrimitiveTypeSolanaProgramLog |
66
- typeof PrimitiveTypeSolanaAccountBalance // |
81
+ typeof PrimitiveTypeSolanaAccountBalance |
82
+ typeof PrimitiveTypeSolanaTokenAccount // |
67
83
  // typeof PrimitiveTypeEVMGeneric
68
84
  ;
69
85
 
@@ -17,6 +17,7 @@ import { nearGenericGrammar } from "./near-generic/near-generic-grammar.ts";
17
17
  import { nearAccountWatchGrammar } from "./near-account-watch/near-account-watch-grammar.ts";
18
18
  import { solanaProgramLogGrammar } from "./solana-program-log/solana-program-log-grammar.ts";
19
19
  import { solanaAccountBalanceGrammar } from "./solana-account-balance/solana-account-balance-grammar.ts";
20
+ import { solanaTokenAccountGrammar } from "./solana-token-account/solana-token-account-grammar.ts";
20
21
  import { mintBurnGrammar } from "./cardano-mint-burn/mint-burn-grammar.ts";
21
22
  import { transferGrammar } from "./cardano-transfer/transfer-grammar.ts";
22
23
  import { poolDelegationGrammar } from "./cardano-pool-delegation/pool-delegation-grammar.ts";
@@ -42,6 +43,7 @@ export const builtinGrammars = {
42
43
  nearAccountWatch: nearAccountWatchGrammar,
43
44
  solanaProgramLog: solanaProgramLogGrammar,
44
45
  solanaAccountBalance: solanaAccountBalanceGrammar,
46
+ solanaTokenAccount: solanaTokenAccountGrammar,
45
47
  cardanoMintBurn: mintBurnGrammar,
46
48
  cardanoTransfer: transferGrammar,
47
49
  cardanoPoolDelegation: poolDelegationGrammar,
@@ -0,0 +1,37 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const midnightNullifierAndCommitmentGrammar = [
4
+ [
5
+ "payload",
6
+ Type.Union([
7
+ Type.Object({
8
+ kind: Type.Literal("nullifier"),
9
+ /** 32-byte nullifier, hex (no 0x prefix) */
10
+ nullifier: Type.String(),
11
+ /** 32-byte transaction hash, hex (no 0x prefix) */
12
+ txHash: Type.String(),
13
+ /** Indexer-assigned zswap ledger event id */
14
+ eventId: Type.Number(),
15
+ /** Logical segment the event was emitted in */
16
+ logicalSegment: Type.Number(),
17
+ /** Contract address that spent the coin, hex — absent for user coins */
18
+ contract: Type.Optional(Type.String()),
19
+ }),
20
+ Type.Object({
21
+ kind: Type.Literal("commitment"),
22
+ /** 32-byte coin commitment, hex (no 0x prefix) */
23
+ commitment: Type.String(),
24
+ /** Index of the commitment in the zswap Merkle tree (u64 as decimal string) */
25
+ mtIndex: Type.String(),
26
+ /** 32-byte transaction hash, hex (no 0x prefix) */
27
+ txHash: Type.String(),
28
+ /** Indexer-assigned zswap ledger event id */
29
+ eventId: Type.Number(),
30
+ /** Logical segment the event was emitted in */
31
+ logicalSegment: Type.Number(),
32
+ /** Contract address that received the coin, hex — absent for user coins */
33
+ contract: Type.Optional(Type.String()),
34
+ }),
35
+ ]),
36
+ ],
37
+ ] as const;
@@ -16,28 +16,40 @@ import type {
16
16
  ProtocolPrimitiveMap,
17
17
  } from "@effectstream/config";
18
18
  import type { SyncStateUpdateStream } from "@effectstream/coroutine";
19
- import { PrimitiveTypeMidnightNullifier } from "../builtin.ts";
20
- import { midnightNullifierGrammar } from "./midnight-nullifier-grammar.ts";
19
+ import { PrimitiveTypeMidnightNullifierAndCommitment } from "../builtin.ts";
20
+ import { midnightNullifierAndCommitmentGrammar } from "./midnight-nullifier-and-commitment-grammar.ts";
21
+
22
+ /** Which zswap ledger event kinds the primitive emits. */
23
+ export type MidnightZswapCapture = "nullifiers" | "commitments" | "both";
21
24
 
22
25
  /**
23
- * MidnightNullifierPrimitive
26
+ * MidnightNullifierAndCommitmentPrimitive
24
27
  *
25
- * Watches all ZswapInput (nullifier-spend) events on the Midnight shielded
26
- * ledger. One state-machine input is emitted per nullifier event observed in
27
- * a block, regardless of which contract produced it.
28
+ * Watches ZswapInput (nullifier-spend) and ZswapOutput (commitment-create)
29
+ * events on the Midnight shielded ledger. Both event kinds arrive in the
30
+ * same indexer field (`zswapLedgerEvents`), so tracking both costs no extra
31
+ * indexer queries. One state-machine input is emitted per event observed in
32
+ * a block, regardless of which contract produced it. The `capture` option
33
+ * selects which kinds are emitted (default: both).
28
34
  *
29
35
  * Usage:
30
36
  * stm.addStateTransition("myPrefix", function* (data) {
31
37
  * const { payload } = data.parsedInput;
32
- * // payload = { nullifier, txHash, eventId, logicalSegment }
38
+ * if (payload.kind === "nullifier") {
39
+ * // { kind, nullifier, txHash, eventId, logicalSegment, contract? }
40
+ * } else {
41
+ * // { kind, commitment, mtIndex, txHash, eventId, logicalSegment, contract? }
42
+ * }
33
43
  * });
34
44
  */
35
- export class MidnightNullifierPrimitive extends Primitive<
45
+ export class MidnightNullifierAndCommitmentPrimitive extends Primitive<
36
46
  ConfigSyncProtocolType.MIDNIGHT_PARALLEL,
37
- typeof midnightNullifierGrammar
47
+ typeof midnightNullifierAndCommitmentGrammar
38
48
  > {
39
- readonly internalTypeName = PrimitiveTypeMidnightNullifier;
40
- override readonly grammar = midnightNullifierGrammar;
49
+ readonly internalTypeName = PrimitiveTypeMidnightNullifierAndCommitment;
50
+ override readonly grammar = midnightNullifierAndCommitmentGrammar;
51
+
52
+ readonly capture: MidnightZswapCapture;
41
53
 
42
54
  override dynamicTables = undefined;
43
55
  override getIntermediatePrefix(): string[] {
@@ -51,8 +63,11 @@ export class MidnightNullifierPrimitive extends Primitive<
51
63
  instanceName: string;
52
64
  startBlockHeight: number;
53
65
  stateMachinePrefix: string;
66
+ /** Which zswap event kinds to emit. Default: "both". */
67
+ capture?: MidnightZswapCapture;
54
68
  }) {
55
69
  super(config);
70
+ this.capture = config.capture ?? "both";
56
71
  }
57
72
 
58
73
  override *getPayload(
@@ -65,12 +80,19 @@ export class MidnightNullifierPrimitive extends Primitive<
65
80
  data: {
66
81
  fromAddressAndType: AddressAndType;
67
82
  stateMachinePayload:
68
- | StaticDecode<CommandTuple<string, typeof midnightNullifierGrammar>>
83
+ | StaticDecode<
84
+ CommandTuple<string, typeof midnightNullifierAndCommitmentGrammar>
85
+ >
69
86
  | null;
70
- accountingPayload: ParamToData<typeof midnightNullifierGrammar>;
87
+ accountingPayload: ParamToData<
88
+ typeof midnightNullifierAndCommitmentGrammar
89
+ >;
71
90
  }[];
72
91
  }> {
73
- const payload = primitiveTransactionData.output.payload;
92
+ // The sync-protocol payload map types Midnight payloads as Record<string, any>;
93
+ // the fetcher's fetchZswapEvents builds exactly the grammar's payload shape.
94
+ const payload = primitiveTransactionData.output
95
+ .payload as ParamToData<typeof this.grammar>["payload"];
74
96
 
75
97
  const accountingPayload: ParamToData<typeof this.grammar> = {
76
98
  payload,
@@ -109,6 +131,7 @@ export class MidnightNullifierPrimitive extends Primitive<
109
131
  type: this.internalTypeName,
110
132
  startBlockHeight: this.startBlockHeight,
111
133
  scheduledPrefix: this.stateMachinePrefix ?? "",
134
+ capture: this.capture,
112
135
  } as const;
113
136
  }
114
137
  }
@@ -3,6 +3,19 @@ import { Type } from "@sinclair/typebox";
3
3
  export const midnightUnshieldedCreateGrammar = [
4
4
  [
5
5
  "payload",
6
- Type.Any(),
6
+ Type.Object({
7
+ /** Bech32m owner address of the created UTXO */
8
+ owner: Type.String(),
9
+ /** Hex-encoded hash of the creating intent */
10
+ intentHash: Type.String(),
11
+ /** Output index within the creating intent's offer */
12
+ outputIndex: Type.Number(),
13
+ /** u128 value as a decimal string */
14
+ value: Type.String(),
15
+ /** Hex-encoded serialized token type */
16
+ tokenType: Type.String(),
17
+ /** Hash of the creating transaction */
18
+ txHash: Type.String(),
19
+ }),
7
20
  ],
8
21
  ] as const;
@@ -77,7 +77,10 @@ export class MidnightUnshieldedCreatePrimitive extends Primitive<
77
77
  accountingPayload: ParamToData<typeof midnightUnshieldedCreateGrammar>;
78
78
  }[];
79
79
  }> {
80
- const payload = primitiveTransactionData.output.payload;
80
+ // The sync-protocol payload map types Midnight payloads as Record<string, any>;
81
+ // the fetcher's fetchUnshieldedCreates builds exactly the grammar's payload shape.
82
+ const payload = primitiveTransactionData.output
83
+ .payload as ParamToData<typeof this.grammar>["payload"];
81
84
 
82
85
  const accountingPayload: ParamToData<typeof this.grammar> = {
83
86
  payload,
@@ -3,6 +3,19 @@ import { Type } from "@sinclair/typebox";
3
3
  export const midnightUnshieldedSpendGrammar = [
4
4
  [
5
5
  "payload",
6
- Type.Any(),
6
+ Type.Object({
7
+ /** Bech32m owner address of the spent UTXO */
8
+ owner: Type.String(),
9
+ /** Hex-encoded hash of the intent that CREATED the spent UTXO */
10
+ intentHash: Type.String(),
11
+ /** Output index within the creating intent's offer */
12
+ outputIndex: Type.Number(),
13
+ /** u128 value as a decimal string */
14
+ value: Type.String(),
15
+ /** Hex-encoded serialized token type */
16
+ tokenType: Type.String(),
17
+ /** Hash of the spending transaction */
18
+ txHash: Type.String(),
19
+ }),
7
20
  ],
8
21
  ] as const;
@@ -71,7 +71,10 @@ export class MidnightUnshieldedSpendPrimitive extends Primitive<
71
71
  accountingPayload: ParamToData<typeof midnightUnshieldedSpendGrammar>;
72
72
  }[];
73
73
  }> {
74
- const payload = primitiveTransactionData.output.payload;
74
+ // The sync-protocol payload map types Midnight payloads as Record<string, any>;
75
+ // the fetcher's fetchUnshieldedSpends builds exactly the grammar's payload shape.
76
+ const payload = primitiveTransactionData.output
77
+ .payload as ParamToData<typeof this.grammar>["payload"];
75
78
 
76
79
  const accountingPayload: ParamToData<typeof this.grammar> = {
77
80
  payload,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  PrimitiveTypeMidnightGeneric,
3
- PrimitiveTypeMidnightNullifier,
3
+ PrimitiveTypeMidnightNullifierAndCommitment,
4
4
  PrimitiveTypeMidnightUnshieldedSpend,
5
5
  PrimitiveTypeMidnightUnshieldedCreate,
6
6
  PrimitiveTypeMidnightZswapRoot,
@@ -21,6 +21,7 @@ import {
21
21
  PrimitiveTypeNEARAccountWatch,
22
22
  PrimitiveTypeSolanaProgramLog,
23
23
  PrimitiveTypeSolanaAccountBalance,
24
+ PrimitiveTypeSolanaTokenAccount,
24
25
  PrimitiveTypeCardanoMintBurn,
25
26
  PrimitiveTypeCardanoTransfer,
26
27
  PrimitiveTypeCardanoPoolDelegation,
@@ -30,7 +31,8 @@ import {
30
31
  } from "./builtin.ts";
31
32
 
32
33
  import { MidnightGenericPrimitive } from "./midnight-generic/midnight-genetic.ts";
33
- import { MidnightNullifierPrimitive } from "./midnight-nullifier/midnight-nullifier.ts";
34
+ import { MidnightNullifierAndCommitmentPrimitive } from "./midnight-nullifier-and-commitment/midnight-nullifier-and-commitment.ts";
35
+ export type { MidnightZswapCapture } from "./midnight-nullifier-and-commitment/midnight-nullifier-and-commitment.ts";
34
36
  import { MidnightUnshieldedSpendPrimitive } from "./midnight-unshielded-spend/midnight-unshielded-spend.ts";
35
37
  import { MidnightUnshieldedCreatePrimitive } from "./midnight-unshielded-create/midnight-unshielded-create.ts";
36
38
  import { MidnightZswapRootPrimitive } from "./midnight-zswap-root/midnight-zswap-root.ts";
@@ -51,6 +53,7 @@ import { NearGenericPrimitive } from "./near-generic/near-generic-primitive.ts";
51
53
  import { NearAccountWatchPrimitive } from "./near-account-watch/near-account-watch-primitive.ts";
52
54
  import { SolanaProgramLogPrimitive } from "./solana-program-log/solana-program-log-primitive.ts";
53
55
  import { SolanaAccountBalancePrimitive } from "./solana-account-balance/solana-account-balance-primitive.ts";
56
+ import { SolanaTokenAccountPrimitive } from "./solana-token-account/solana-token-account-primitive.ts";
54
57
  import { CardanoMintBurnPrimitive } from "./cardano-mint-burn/mint-burn-primitive.ts";
55
58
  import { CardanoTransferPrimitive } from "./cardano-transfer/transfer-primitive.ts";
56
59
  import { CardanoPoolDelegationPrimitive } from "./cardano-pool-delegation/pool-delegation-primitive.ts";
@@ -60,7 +63,7 @@ import { CardanoProjectedNftPrimitive } from "./cardano-projected-nft/projected-
60
63
 
61
64
  const builtInPrimitivesMap = {
62
65
  [PrimitiveTypeMidnightGeneric]: MidnightGenericPrimitive,
63
- [PrimitiveTypeMidnightNullifier]: MidnightNullifierPrimitive,
66
+ [PrimitiveTypeMidnightNullifierAndCommitment]: MidnightNullifierAndCommitmentPrimitive,
64
67
  [PrimitiveTypeMidnightUnshieldedSpend]: MidnightUnshieldedSpendPrimitive,
65
68
  [PrimitiveTypeMidnightUnshieldedCreate]: MidnightUnshieldedCreatePrimitive,
66
69
  [PrimitiveTypeMidnightZswapRoot]: MidnightZswapRootPrimitive,
@@ -81,6 +84,7 @@ const builtInPrimitivesMap = {
81
84
  [PrimitiveTypeNEARAccountWatch]: NearAccountWatchPrimitive,
82
85
  [PrimitiveTypeSolanaProgramLog]: SolanaProgramLogPrimitive,
83
86
  [PrimitiveTypeSolanaAccountBalance]: SolanaAccountBalancePrimitive,
87
+ [PrimitiveTypeSolanaTokenAccount]: SolanaTokenAccountPrimitive,
84
88
  [PrimitiveTypeCardanoMintBurn]: CardanoMintBurnPrimitive,
85
89
  [PrimitiveTypeCardanoTransfer]: CardanoTransferPrimitive,
86
90
  [PrimitiveTypeCardanoPoolDelegation]: CardanoPoolDelegationPrimitive,
@@ -95,7 +99,7 @@ export {
95
99
 
96
100
  // Built-in Primitives
97
101
  MidnightGenericPrimitive,
98
- MidnightNullifierPrimitive,
102
+ MidnightNullifierAndCommitmentPrimitive,
99
103
  MidnightUnshieldedSpendPrimitive,
100
104
  MidnightUnshieldedCreatePrimitive,
101
105
  MidnightZswapRootPrimitive,
@@ -116,6 +120,7 @@ export {
116
120
  NearAccountWatchPrimitive,
117
121
  SolanaProgramLogPrimitive,
118
122
  SolanaAccountBalancePrimitive,
123
+ SolanaTokenAccountPrimitive,
119
124
  CardanoMintBurnPrimitive,
120
125
  CardanoTransferPrimitive,
121
126
  CardanoPoolDelegationPrimitive,
@@ -0,0 +1,13 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const solanaTokenAccountGrammar = [
4
+ ["tokenAccount", Type.String()],
5
+ ["mint", Type.String()],
6
+ ["owner", Type.String()],
7
+ // SPL amounts are u64 and do not survive a JS number. Carried as the raw base
8
+ // unit string, matching nep141Grammar's `amount` and erc20Grammar's `value`;
9
+ // `decimals` is what turns it into a display value.
10
+ ["amount", Type.String()],
11
+ ["decimals", Type.Number()],
12
+ ["slot", Type.Number()],
13
+ ] as const;
@@ -0,0 +1,117 @@
1
+ import { expect, test } from "bun:test";
2
+ // Via the barrel, not the file directly: mod.ts registers every primitive class, so
3
+ // importing the file first hits a TDZ error on the map entry. Same as
4
+ // solana-program-log-primitive.test.ts.
5
+ import { SolanaTokenAccountPrimitive } from "./../mod.ts";
6
+ import { PrimitiveRegistry } from "../../PrimitiveRegistry.ts";
7
+
8
+ /**
9
+ * Unit coverage for the primitive half of SOLANA:TokenAccount — the config guard and
10
+ * the payload the state machine actually receives. The fetcher half (which balance
11
+ * records match, and lookup-table resolution) is covered in
12
+ * `sync/src/sync-protocols/solana/read-primitives.test.ts`.
13
+ *
14
+ * `getPayload` is a generator that never yields, so one `.next()` drives it to done.
15
+ */
16
+
17
+ const MINT = "Mint111111111111111111111111111111111111111";
18
+ const OWNER = "0wner11111111111111111111111111111111111111";
19
+ const TOKEN_ACCOUNT = "Tok111111111111111111111111111111111111111";
20
+
21
+ function makePrimitive(overrides: Record<string, unknown> = {}) {
22
+ // The Primitive constructor self-registers and PrimitiveRegistry.addPrimitive
23
+ // throws on a duplicate instanceName, so the singleton has to be cleared between
24
+ // constructions.
25
+ PrimitiveRegistry.primitives = {};
26
+ return new SolanaTokenAccountPrimitive({
27
+ instanceName: "WatchedToken",
28
+ startBlockHeight: 0,
29
+ mint: MINT,
30
+ stateMachinePrefix: "solana-token-account",
31
+ ...overrides,
32
+ } as any);
33
+ }
34
+
35
+ function payloadOf(primitive: SolanaTokenAccountPrimitive, payload: unknown) {
36
+ const it = primitive.getPayload(0 as any, { output: { payload } } as any) as any;
37
+ const step = it.next();
38
+ expect(step.done).toBe(true);
39
+ return step.value;
40
+ }
41
+
42
+ const balancePayload = {
43
+ tokenAccount: TOKEN_ACCOUNT,
44
+ mint: MINT,
45
+ owner: OWNER,
46
+ amount: "1500000000",
47
+ decimals: 9,
48
+ slot: 11,
49
+ };
50
+
51
+ test("rejects a config with no mint, owner or tokenAccount", () => {
52
+ // Without a filter it would match every token balance on chain, which is never
53
+ // what was meant — so this fails at construction rather than flooding the STM.
54
+ expect(() =>
55
+ makePrimitive({ mint: undefined })
56
+ ).toThrow(/needs at least one of/);
57
+ });
58
+
59
+ test("accepts a config filtered by owner alone", () => {
60
+ expect(() => makePrimitive({ mint: undefined, owner: OWNER })).not.toThrow();
61
+ });
62
+
63
+ test("accepts a config filtered by tokenAccount alone", () => {
64
+ expect(() =>
65
+ makePrimitive({ mint: undefined, tokenAccount: TOKEN_ACCOUNT })
66
+ ).not.toThrow();
67
+ });
68
+
69
+ test("carries the raw u64 amount through as a string", () => {
70
+ // 2^63 - 1: a value that silently loses precision as a JS number, which is why
71
+ // the grammar types `amount` as a string.
72
+ const huge = "9223372036854775807";
73
+ const out = payloadOf(makePrimitive(), { ...balancePayload, amount: huge });
74
+ expect(out.data[0].accountingPayload.amount).toBe(huge);
75
+ expect(out.data[0].accountingPayload.decimals).toBe(9);
76
+ });
77
+
78
+ test("attributes the event to the owner", () => {
79
+ const out = payloadOf(makePrimitive(), balancePayload);
80
+ expect(out.data[0].fromAddressAndType.address).toBe(OWNER);
81
+ });
82
+
83
+ test("falls back to the token account when the RPC omitted the owner", () => {
84
+ // `owner` is optional in the RPC's token balance record. The balance is still
85
+ // valid state, so it is reported against the token account rather than dropped.
86
+ const out = payloadOf(makePrimitive(), { ...balancePayload, owner: undefined });
87
+ expect(out.data[0].fromAddressAndType.address).toBe(TOKEN_ACCOUNT);
88
+ expect(out.data[0].accountingPayload.owner).toBe("");
89
+ });
90
+
91
+ test("builds a state machine payload prefixed by stateMachinePrefix", () => {
92
+ const out = payloadOf(makePrimitive(), balancePayload);
93
+ expect(out.data[0].stateMachinePayload).not.toBeNull();
94
+ expect(out.data[0].stateMachinePayload[0]).toBe("solana-token-account");
95
+ });
96
+
97
+ test("emits no state machine payload when no prefix is configured", () => {
98
+ // The accounting row is still written; only STM delivery is skipped. This is the
99
+ // documented meaning of omitting the prefix.
100
+ const out = payloadOf(
101
+ makePrimitive({ stateMachinePrefix: undefined }),
102
+ balancePayload,
103
+ );
104
+ expect(out.data[0].stateMachinePayload).toBeNull();
105
+ expect(out.data[0].accountingPayload.mint).toBe(MINT);
106
+ });
107
+
108
+ test("getConfig round-trips the state machine prefix under both names", () => {
109
+ // Every other builtin emits only `scheduledPrefix`, which the Primitive
110
+ // constructor does not read — so a config round-tripped through getConfig() loses
111
+ // its STM routing. Emitting both makes this primitive survive that trip.
112
+ const config = makePrimitive().getConfig() as Record<string, unknown>;
113
+ expect(config.stateMachinePrefix).toBe("solana-token-account");
114
+ expect(config.scheduledPrefix).toBe("solana-token-account");
115
+ expect(config.type).toBe("SOLANA:TokenAccount");
116
+ expect(config.mint).toBe(MINT);
117
+ });
@@ -0,0 +1,156 @@
1
+ import type { StaticDecode } from "@sinclair/typebox";
2
+ import type {
3
+ ConfigSyncProtocolType,
4
+ FlattenSyncProtocolIOFor,
5
+ ProtocolPrimitiveMap,
6
+ } from "@effectstream/config";
7
+ import {
8
+ type AddressAndType,
9
+ AddressType,
10
+ type EffectstreamBlockNumber,
11
+ } from "@effectstream/utils";
12
+ import { type JsonObject, Primitive } from "@effectstream/sm";
13
+ import {
14
+ type CommandTuple,
15
+ generateRawStmInput,
16
+ type ParamToData,
17
+ } from "@effectstream/concise";
18
+ import type { StateUpdateStream } from "@effectstream/coroutine";
19
+ import { solanaTokenAccountGrammar } from "./solana-token-account-grammar.ts";
20
+ import { PrimitiveTypeSolanaTokenAccount } from "../builtin.ts";
21
+
22
+ /**
23
+ * Built-in primitive: tracks the SPL token balance of a watched token account per
24
+ * slot, derived from a transaction's `meta.postTokenBalances` by
25
+ * `SolanaFetcher.readPrimitives`.
26
+ *
27
+ * Narrowed by any combination of `mint`, `owner` and `tokenAccount`; at least one
28
+ * must be set. `tokenProgramId` optionally pins it to classic SPL Token or to
29
+ * Token-2022 rather than accepting both.
30
+ *
31
+ * KNOWN LIMITATION — this reports post-state balances only, matching
32
+ * SOLANA:AccountBalance. A token account that is *closed* appears in
33
+ * `preTokenBalances` and is absent from `postTokenBalances`, so closure produces no
34
+ * event rather than a zero one. Emitting a pre/post delta would cover it and is a
35
+ * deliberate non-goal here; see the Solana chain docs.
36
+ */
37
+ export class SolanaTokenAccountPrimitive extends Primitive<
38
+ ConfigSyncProtocolType.SOLANA_RPC_PARALLEL,
39
+ typeof solanaTokenAccountGrammar
40
+ > {
41
+ readonly internalTypeName = PrimitiveTypeSolanaTokenAccount;
42
+ override grammar = solanaTokenAccountGrammar;
43
+ readonly mint: string | undefined;
44
+ readonly owner: string | undefined;
45
+ readonly tokenAccount: string | undefined;
46
+ readonly tokenProgramId: string | undefined;
47
+
48
+ constructor(config: {
49
+ instanceName: string;
50
+ startBlockHeight: number;
51
+ mint?: string;
52
+ owner?: string;
53
+ tokenAccount?: string;
54
+ tokenProgramId?: string;
55
+ stateMachinePrefix: string | undefined;
56
+ }) {
57
+ super(config);
58
+ // Rejected here rather than in the fetcher because this is the earliest point
59
+ // it can be caught: an entry with no filter at all would match every token
60
+ // balance in every transaction, which is never what was meant.
61
+ if (
62
+ config.mint == null && config.owner == null && config.tokenAccount == null
63
+ ) {
64
+ throw new Error(
65
+ `[${PrimitiveTypeSolanaTokenAccount}] "${config.instanceName}" needs at least one of ` +
66
+ `mint, owner or tokenAccount. Without a filter it would match every token balance on chain.`,
67
+ );
68
+ }
69
+ this.mint = config.mint;
70
+ this.owner = config.owner;
71
+ this.tokenAccount = config.tokenAccount;
72
+ this.tokenProgramId = config.tokenProgramId;
73
+ }
74
+
75
+ override *getPayload(
76
+ _: EffectstreamBlockNumber,
77
+ primitiveTransactionData: FlattenSyncProtocolIOFor<
78
+ ConfigSyncProtocolType.SOLANA_RPC_PARALLEL
79
+ >,
80
+ ): StateUpdateStream<{
81
+ isBatched: boolean;
82
+ data: {
83
+ fromAddressAndType: AddressAndType;
84
+ stateMachinePayload:
85
+ | StaticDecode<CommandTuple<string, typeof solanaTokenAccountGrammar>>
86
+ | null;
87
+ accountingPayload: JsonObject;
88
+ }[];
89
+ }> {
90
+ const payload = primitiveTransactionData.output.payload as {
91
+ tokenAccount?: string;
92
+ mint?: string;
93
+ owner?: string;
94
+ amount?: string;
95
+ decimals?: number;
96
+ slot?: number;
97
+ };
98
+
99
+ const accountingPayload: ParamToData<typeof solanaTokenAccountGrammar> = {
100
+ tokenAccount: String(payload.tokenAccount ?? ""),
101
+ mint: String(payload.mint ?? this.mint ?? ""),
102
+ // `owner` is optional in the RPC's token balance record, so it can genuinely
103
+ // be absent. Empty string rather than a thrown error: the balance itself is
104
+ // still valid state, and the grammar requires a string.
105
+ owner: String(payload.owner ?? this.owner ?? ""),
106
+ amount: String(payload.amount ?? "0"),
107
+ decimals: Number(payload.decimals ?? 0),
108
+ slot: Number(payload.slot ?? 0),
109
+ };
110
+
111
+ const stateMachinePayload = this.stateMachinePrefix
112
+ ? generateRawStmInput(
113
+ this.grammar,
114
+ this.stateMachinePrefix,
115
+ accountingPayload,
116
+ )
117
+ : null;
118
+
119
+ return {
120
+ isBatched: false,
121
+ data: [
122
+ {
123
+ // The owner is the party whose holdings changed, so it is the meaningful
124
+ // actor. Falls back to the token account when the RPC omitted the owner.
125
+ fromAddressAndType: {
126
+ type: AddressType.SOLANA,
127
+ address: accountingPayload.owner || accountingPayload.tokenAccount,
128
+ },
129
+ accountingPayload,
130
+ stateMachinePayload,
131
+ },
132
+ ],
133
+ };
134
+ }
135
+
136
+ override getConfig(): ProtocolPrimitiveMap[
137
+ ConfigSyncProtocolType.SOLANA_RPC_PARALLEL
138
+ ] {
139
+ return {
140
+ name: this.instanceName,
141
+ type: this.internalTypeName,
142
+ startBlockHeight: this.startBlockHeight,
143
+ mint: this.mint,
144
+ owner: this.owner,
145
+ tokenAccount: this.tokenAccount,
146
+ tokenProgramId: this.tokenProgramId,
147
+ // Both names on purpose. Every other builtin emits only `scheduledPrefix`,
148
+ // which the `Primitive` constructor does not read — so a config round-tripped
149
+ // through getConfig() silently loses its state-machine routing. Emitting
150
+ // `stateMachinePrefix` too makes this primitive survive that trip, and
151
+ // `scheduledPrefix` stays for anything still reading the deprecated name.
152
+ stateMachinePrefix: this.stateMachinePrefix,
153
+ scheduledPrefix: this.stateMachinePrefix,
154
+ } as ProtocolPrimitiveMap[ConfigSyncProtocolType.SOLANA_RPC_PARALLEL];
155
+ }
156
+ }
@@ -1,8 +0,0 @@
1
- import { Type } from "@sinclair/typebox";
2
-
3
- export const midnightNullifierGrammar = [
4
- [
5
- "payload",
6
- Type.Any(),
7
- ],
8
- ] as const;