@effectstream/sync 0.100.12 → 0.100.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.12",
3
+ "version": "0.100.14",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,10 +13,10 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.100.12",
17
- "@effectstream/db": "0.100.12",
18
- "@effectstream/log": "0.100.12",
19
- "@effectstream/utils": "0.100.12",
16
+ "@effectstream/config": "0.100.14",
17
+ "@effectstream/db": "0.100.14",
18
+ "@effectstream/log": "0.100.14",
19
+ "@effectstream/utils": "0.100.14",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
@@ -48,11 +48,16 @@ export class AvailFetcher extends BaseDataFetcher<
48
48
  lastPage: LastPage<Page, RootPage> | undefined,
49
49
  ): Operation<DataFetched<Output, Page, RootPage>> {
50
50
  const outputs: OutputAndCleanup<Output>[] = [];
51
+ const fetchAllBlocks = this.config.primitives.some(
52
+ (p) => p.primitive.getAllBlockHeaders,
53
+ );
51
54
  console.log(
52
55
  `[Avail] Fetching blocks from ${data.from} to ${data.to}. ${
53
56
  data.isPresync ? "[presync]" : ""
54
57
  }`,
55
58
  );
59
+
60
+ let lastHeader: Output["raw"] | undefined;
56
61
  for (let height = data.from; height <= data.to; height++) {
57
62
  let header;
58
63
  try {
@@ -60,26 +65,28 @@ export class AvailFetcher extends BaseDataFetcher<
60
65
  this.client.getBlockHeaderFromHeight(height)
61
66
  );
62
67
  } catch (_e) {
63
- // Likely reached the tip; stop here
64
68
  break;
65
69
  }
66
70
 
71
+ lastHeader = header;
67
72
  const primitives = yield* this.readPrimitives(
68
73
  height,
69
74
  header,
70
75
  this.config.primitives,
71
76
  );
72
77
 
73
- outputs.push({
74
- output: {
75
- raw: header,
76
- primitives,
77
- },
78
- cleanup: () => {},
79
- });
78
+ if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
79
+ outputs.push({
80
+ output: {
81
+ raw: header,
82
+ primitives,
83
+ },
84
+ cleanup: () => {},
85
+ });
86
+ }
80
87
  }
81
88
 
82
- if (outputs.length === 0) {
89
+ if (!lastHeader) {
83
90
  if (!lastPage) {
84
91
  throw new Error(
85
92
  `Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
@@ -91,16 +98,18 @@ export class AvailFetcher extends BaseDataFetcher<
91
98
  };
92
99
  }
93
100
 
94
- const lastOutput = outputs[outputs.length - 1].output;
95
101
  return {
96
102
  output: outputs,
97
103
  lastPage: {
98
- ownBlockNumber: lastOutput.raw.number,
104
+ ownBlockNumber: lastHeader.number,
99
105
  own: {
100
- height: lastOutput.raw.number,
101
- hash: lastOutput.raw.hash,
106
+ height: lastHeader.number,
107
+ hash: lastHeader.hash,
102
108
  },
103
- root: rootConversion.toRootPage(lastOutput),
109
+ root: rootConversion.toRootPage({
110
+ raw: lastHeader,
111
+ primitives: [],
112
+ }),
104
113
  },
105
114
  };
106
115
  }
@@ -111,24 +111,31 @@ export class BitcoinFetcher
111
111
  );
112
112
  const groupedByPage = this.groupByPage(primitives);
113
113
 
114
+ const fetchAllBlocks = this.config.primitives.some(
115
+ (p) => p.primitive.getAllBlockHeaders,
116
+ );
117
+
114
118
  const outputs: OutputAndCleanup<Output>[] = [];
119
+ let lastBlock: BitcoinBlock | undefined;
115
120
  for (let height = Number(data.from); height <= Number(data.to); height++) {
116
121
  const blockHeight = height as Page;
117
122
  const block = yield* call(() => pageFetcher(blockHeight));
118
- outputs.push({
119
- output: {
120
- raw: block,
121
- primitives: groupedByPage[String(height)] ?? [],
122
- blockHashes: [block.hash],
123
- },
124
- cleanup: () => {},
125
- });
123
+ lastBlock = block;
124
+ const blockPrimitives = groupedByPage[String(height)] ?? [];
125
+ if (fetchAllBlocks || blockPrimitives.length > 0 || height === Number(data.to)) {
126
+ outputs.push({
127
+ output: {
128
+ raw: block,
129
+ primitives: blockPrimitives,
130
+ blockHashes: [block.hash],
131
+ },
132
+ cleanup: () => {},
133
+ });
134
+ }
126
135
  }
127
- const lastOutput = outputs[outputs.length - 1];
128
- if (!lastOutput) {
136
+ if (!lastBlock) {
129
137
  throw new Error("BitcoinFetcher: no block data fetched for interval");
130
138
  }
131
- const lastBlock = lastOutput.output.raw;
132
139
  const lastPageRef = this.asPage(Number(data.to));
133
140
  return {
134
141
  output: outputs,
@@ -53,6 +53,7 @@ const CELESTIA_NODE_URL = ENV.getString(
53
53
  "CELESTIA_NODE_URL",
54
54
  "http://localhost:26658",
55
55
  );
56
+ const CELESTIA_AUTH_TOKEN = ENV.getString("CELESTIA_AUTH_TOKEN", "");
56
57
 
57
58
  /**
58
59
  * Converts a hex-encoded Celestia namespace to a base64-encoded 29-byte array.
@@ -81,9 +82,11 @@ export class CelestiaClient {
81
82
  private async rpc<T>(method: string, params: unknown[]): Promise<T | null> {
82
83
  let res: Response;
83
84
  try {
85
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
86
+ if (CELESTIA_AUTH_TOKEN) headers["Authorization"] = `Bearer ${CELESTIA_AUTH_TOKEN}`;
84
87
  res = await fetch(this.rpcUrl, {
85
88
  method: "POST",
86
- headers: { "Content-Type": "application/json" },
89
+ headers,
87
90
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
88
91
  });
89
92
  } catch (err: unknown) {
@@ -47,6 +47,9 @@ export class CelestiaFetcher extends BaseDataFetcher<
47
47
  lastPage: LastPage<Page, RootPage> | undefined,
48
48
  ): Operation<DataFetched<Output, Page, RootPage>> {
49
49
  const outputs: OutputAndCleanup<Output>[] = [];
50
+ const fetchAllBlocks = this.config.primitives.some(
51
+ (p) => p.primitive.getAllBlockHeaders,
52
+ );
50
53
 
51
54
  console.log(
52
55
  `[Celestia] Fetching blocks from ${data.from} to ${data.to}.${
@@ -54,6 +57,7 @@ export class CelestiaFetcher extends BaseDataFetcher<
54
57
  }`,
55
58
  );
56
59
 
60
+ let lastFetchedOutput: Output | undefined;
57
61
  for (let height = data.from; height <= data.to; height++) {
58
62
  let extHeader;
59
63
  try {
@@ -61,7 +65,6 @@ export class CelestiaFetcher extends BaseDataFetcher<
61
65
  this.client.getHeaderAtHeight(height)
62
66
  );
63
67
  } catch (_e) {
64
- // Likely reached the chain tip; stop here
65
68
  break;
66
69
  }
67
70
 
@@ -73,18 +76,23 @@ export class CelestiaFetcher extends BaseDataFetcher<
73
76
  this.config.primitives,
74
77
  );
75
78
 
76
- outputs.push({
77
- output: {
78
- timestamp: extHeader.header.time,
79
- height,
80
- hash: blockHash,
81
- primitives,
82
- },
83
- cleanup: () => {},
84
- });
79
+ const output: Output = {
80
+ timestamp: extHeader.header.time,
81
+ height,
82
+ hash: blockHash,
83
+ primitives,
84
+ };
85
+ lastFetchedOutput = output;
86
+
87
+ if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
88
+ outputs.push({
89
+ output,
90
+ cleanup: () => {},
91
+ });
92
+ }
85
93
  }
86
94
 
87
- if (outputs.length === 0) {
95
+ if (!lastFetchedOutput) {
88
96
  if (!lastPage) {
89
97
  throw new Error(
90
98
  `[Celestia] Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
@@ -96,16 +104,15 @@ export class CelestiaFetcher extends BaseDataFetcher<
96
104
  };
97
105
  }
98
106
 
99
- const lastOutput = outputs[outputs.length - 1].output;
100
107
  return {
101
108
  output: outputs,
102
109
  lastPage: {
103
- ownBlockNumber: lastOutput.height,
110
+ ownBlockNumber: lastFetchedOutput.height,
104
111
  own: {
105
- height: lastOutput.height,
106
- hash: lastOutput.hash,
112
+ height: lastFetchedOutput.height,
113
+ hash: lastFetchedOutput.hash,
107
114
  },
108
- root: rootConversion.toRootPage(lastOutput),
115
+ root: rootConversion.toRootPage(lastFetchedOutput),
109
116
  },
110
117
  };
111
118
  }
@@ -25,7 +25,7 @@ import type {
25
25
  import { PageSchema } from "./types.ts";
26
26
  import {
27
27
  fetchNewestPage,
28
- genImmediatePageRequests,
28
+ genOnDemandPageRequests,
29
29
  type PageRange,
30
30
  type PageRequest,
31
31
  } from "../base/page.ts";
@@ -62,15 +62,17 @@ export class EvmFetcher
62
62
  data: Input,
63
63
  rootConversion: RootConversion<Output, RootOutput, RootPage>,
64
64
  ): Operation<DataFetched<Output, Page, RootPage>> {
65
- const allPages: Page[] = [];
66
- for (let p = data.from; p <= data.to; p++) allPages.push(p);
67
- const pageFetcher = genImmediatePageRequests(
68
- allPages,
65
+ const fetchAllBlocks = this.config.primitives.some(
66
+ (p) => p.primitive.getAllBlockHeaders,
67
+ );
68
+
69
+ const pageFetcher = genOnDemandPageRequests(
70
+ data.from,
71
+ data.to,
69
72
  (page) => this.client.getBlock({ blockNumber: BigInt(page) }),
73
+ blockNumberRelation,
70
74
  );
71
75
 
72
- // TODO: if we expect multiple primitives per block
73
- // it can, depending on the chain, be faster to just download the full block and parse it locally
74
76
  const primitives = yield* this.readPrimitives(
75
77
  data,
76
78
  pageFetcher,
@@ -92,40 +94,43 @@ export class EvmFetcher
92
94
  };
93
95
  }),
94
96
  ),
95
- // we always need to get the last page in the parallel case
96
- // so we can use it for pagination
97
+ // we always need the last page for pagination
97
98
  call(() => pageFetcher(Number(data.to))),
98
99
  ],
99
100
  );
100
101
 
101
- // Build an output with all page info, as we need all the hashes to build the effectstream-block-hash
102
- // All blocks are already pre-fetched in parallel by genImmediatePageRequests above
103
102
  const allOutputs: Output[] = [];
104
103
  for (let page = data.from; page <= data.to; page++) {
105
104
  const _output = output.find((o) => o.raw.number === BigInt(page));
106
105
  if (_output) {
107
106
  allOutputs.push(_output);
108
- } else {
107
+ } else if (fetchAllBlocks) {
109
108
  const raw = yield* call(() => pageFetcher(page));
110
109
  allOutputs.push({
111
110
  raw,
112
111
  primitives: [],
113
112
  blockHashes: [raw.hash] as EvmBlockHash[],
114
113
  });
114
+ } else if (page === data.to) {
115
+ allOutputs.push({
116
+ raw: lastPage,
117
+ primitives: [],
118
+ blockHashes: [lastPage.hash] as EvmBlockHash[],
119
+ });
115
120
  }
116
121
  }
117
122
 
118
123
  return {
119
124
  output: allOutputs.map((o) => ({
120
125
  output: o,
121
- cleanup: () => {}, // no cleanup required
126
+ cleanup: () => {},
122
127
  })),
123
128
  lastPage: {
124
129
  own: Number(data.to),
125
130
  ownBlockNumber: Number(data.to),
126
131
  root: rootConversion.toRootPage({
127
132
  blockHashes: [lastPage.hash] as EvmBlockHash[],
128
- primitives: [], // unused in toRootPage
133
+ primitives: [],
129
134
  raw: lastPage,
130
135
  }),
131
136
  },
@@ -27,6 +27,11 @@ export interface MidnightGqlBlockState {
27
27
  raw: string;
28
28
  maxId: number;
29
29
  }[];
30
+ unshieldedSpentOutputs?: {
31
+ intentHash: string;
32
+ outputIndex: number;
33
+ owner: string;
34
+ }[];
30
35
  }[];
31
36
  };
32
37
  };
@@ -36,6 +41,8 @@ export interface BlockFetchOptions {
36
41
  contractActions?: boolean;
37
42
  /** Include zswapLedgerEvents in the transaction fields (needed for MidnightNullifierPrimitive). Default: true */
38
43
  zswapLedgerEvents?: boolean;
44
+ /** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
45
+ unshieldedSpentOutputs?: boolean;
39
46
  }
40
47
 
41
48
  type PublicDataProvider = ReturnType<typeof indexerPublicDataProvider>;
@@ -157,13 +164,20 @@ export class MidnightClient {
157
164
  options: BlockFetchOptions = {},
158
165
  signal?: AbortSignal,
159
166
  ): Promise<MidnightGqlBlockState> {
160
- const { contractActions = true, zswapLedgerEvents = true } = options;
167
+ const {
168
+ contractActions = true,
169
+ zswapLedgerEvents = true,
170
+ unshieldedSpentOutputs = false,
171
+ } = options;
161
172
  const contractActionsField = contractActions
162
173
  ? `contractActions { address state }`
163
174
  : "";
164
175
  const zswapField = zswapLedgerEvents
165
176
  ? `zswapLedgerEvents { id raw maxId }`
166
177
  : "";
178
+ const unshieldedSpentField = unshieldedSpentOutputs
179
+ ? `unshieldedSpentOutputs { intentHash outputIndex owner }`
180
+ : "";
167
181
  const query = `query {
168
182
  block(offset: { height: ${blockHeight} }) {
169
183
  hash
@@ -177,6 +191,7 @@ export class MidnightClient {
177
191
  hash
178
192
  ${contractActionsField}
179
193
  ${zswapField}
194
+ ${unshieldedSpentField}
180
195
  }
181
196
  }
182
197
  }`;
@@ -82,22 +82,27 @@ export class MidnightFetcher extends BaseDataFetcher<
82
82
  );
83
83
  const blockFetchOptions: BlockFetchOptions = {
84
84
  contractActions: this.config.primitives.some(
85
- (p) => p.primitive.type !== "Midnight:Nullifier",
85
+ (p) =>
86
+ p.primitive.type !== "Midnight:Nullifier" &&
87
+ p.primitive.type !== "Midnight:UnshieldedSpend",
86
88
  ),
87
89
  zswapLedgerEvents: this.config.primitives.some(
88
90
  (p) => p.primitive.type === "Midnight:Nullifier",
89
91
  ),
92
+ unshieldedSpentOutputs: this.config.primitives.some(
93
+ (p) => p.primitive.type === "Midnight:UnshieldedSpend",
94
+ ),
90
95
  };
91
96
  const self = this;
92
97
  const heights = Array.from(
93
98
  { length: data.to - data.from + 1 },
94
99
  (_, i) => i + data.from,
95
100
  );
101
+ const fetchAllBlocks = this.config.primitives.some(
102
+ (p) => p.primitive.getAllBlockHeaders,
103
+ );
104
+
96
105
  const fetched = yield* all(
97
- // The endpoint does not support range requests,
98
- // so we fetch each block individually in parallel.
99
- // useAbortSignal() ties each fetch's lifetime to its Effection scope,
100
- // so cancellation by all() cleanly aborts the underlying HTTP request.
101
106
  heights.map(function* (height) {
102
107
  const signal = yield* useAbortSignal();
103
108
  const result: MidnightGqlBlockState = yield* call(() =>
@@ -117,9 +122,22 @@ export class MidnightFetcher extends BaseDataFetcher<
117
122
  };
118
123
  }),
119
124
  );
120
- outputs.push(...fetched);
121
125
 
122
- const lastOutput = outputs[outputs.length - 1].output;
126
+ const lastFetched = fetched[fetched.length - 1];
127
+ if (fetchAllBlocks) {
128
+ outputs.push(...fetched);
129
+ } else {
130
+ for (const item of fetched) {
131
+ if (item.output.primitives.length > 0) {
132
+ outputs.push(item);
133
+ }
134
+ }
135
+ if (outputs.length === 0 || outputs[outputs.length - 1] !== lastFetched) {
136
+ outputs.push(lastFetched);
137
+ }
138
+ }
139
+
140
+ const lastOutput = lastFetched.output;
123
141
  return {
124
142
  output: outputs,
125
143
  lastPage: {
@@ -146,6 +164,8 @@ export class MidnightFetcher extends BaseDataFetcher<
146
164
  for (const primitiveEntry of primitiveEntries) {
147
165
  if (primitiveEntry.primitive.type === "Midnight:Nullifier") {
148
166
  syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
167
+ } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
168
+ syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
149
169
  } else {
150
170
  asyncOps.push(
151
171
  this.fetchContractState(height, client, primitiveEntry, block),
@@ -191,6 +211,38 @@ export class MidnightFetcher extends BaseDataFetcher<
191
211
  return results;
192
212
  }
193
213
 
214
+ @bound
215
+ fetchUnshieldedSpends(
216
+ height: number,
217
+ primitiveEntry: PrimitiveEntryType,
218
+ block: MidnightGqlBlockState,
219
+ ): PrimitiveType[] {
220
+ const results: PrimitiveType[] = [];
221
+ for (const tx of block.block.transactions) {
222
+ for (const spend of tx.unshieldedSpentOutputs ?? []) {
223
+ results.push({
224
+ syncProtocol: {
225
+ name: primitiveEntry.syncProtocol,
226
+ blockNumber: height,
227
+ transactionHash: tx.hash,
228
+ contractAddress: "",
229
+ },
230
+ primitive: primitiveEntry.primitive.name,
231
+ output: {
232
+ payloadType: "midnight-unshielded-spend",
233
+ payload: {
234
+ owner: spend.owner,
235
+ intentHash: spend.intentHash,
236
+ outputIndex: spend.outputIndex,
237
+ txHash: tx.hash,
238
+ },
239
+ },
240
+ });
241
+ }
242
+ }
243
+ return results;
244
+ }
245
+
194
246
  @bound
195
247
  *fetchContractState(
196
248
  height: number,
@@ -0,0 +1,300 @@
1
+ import { type CardanoSyncClient, CardanoWatchClient, type CardanoTxEvent, type ClientBuilderOptions } from "@utxorpc/sdk";
2
+ import { cardano } from "@utxorpc/spec";
3
+ import type { watch } from "@utxorpc/spec";
4
+ import type { OutputAndCleanup } from "../base/state.ts";
5
+ import Deque from "denque";
6
+ import type { BlockNumber } from "@effectstream/utils";
7
+ import { conditionVariable, hexStringToUint8Array } from "@effectstream/utils";
8
+ import type { ChainPoint, CompletedBlock, PrimitiveEntryType } from "./types.ts";
9
+ import type { UtxorpcTxPredicate } from "@effectstream/config";
10
+ import { configPredicateToSdkPredicate, isEffectiveServerPredicate, predicateKey } from "./utils.ts";
11
+ import { Buffer } from "node:buffer";
12
+
13
+ type WatcherPendingBlock = {
14
+ txs: cardano.Tx[];
15
+ block: cardano.Block | undefined;
16
+ idle: boolean;
17
+ idleRef: watch.BlockRef | undefined;
18
+ };
19
+
20
+ type WatcherState = {
21
+ predicateKey: string;
22
+ sdkPredicate: ReturnType<typeof configPredicateToSdkPredicate>;
23
+ pendingBlocks: Map<number, WatcherPendingBlock>;
24
+ highestSeenHeight: number;
25
+ };
26
+
27
+ export class WatchMultiplexer {
28
+ private readonly watchers: Map<string, WatcherState> = new Map();
29
+ private readonly completedBuffer: Deque<OutputAndCleanup<CompletedBlock>> = new Deque();
30
+ private readonly newDataCondVar = conditionVariable<void>();
31
+ private highestConfirmedHeight: number | undefined = undefined;
32
+ private lowestIncompleteHeight: number = 0;
33
+ private slotOriginTimestamp: bigint | undefined = undefined;
34
+
35
+ constructor(
36
+ private readonly clientOptions: ClientBuilderOptions,
37
+ primitivesByPredicate: Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }>,
38
+ private readonly finality: BlockNumber,
39
+ private readonly syncClient: CardanoSyncClient,
40
+ ) {
41
+ for (const [key, { predicate }] of primitivesByPredicate) {
42
+ const effective = isEffectiveServerPredicate(predicate);
43
+ // TODO(dolos): when Dolos implements has_certificate, remove this fallback
44
+ const sdkPredicate = effective
45
+ ? configPredicateToSdkPredicate(predicate)
46
+ : {};
47
+
48
+ if (!effective) {
49
+ console.warn(
50
+ `[WatchMultiplexer] Predicate for key "${key}" cannot be server-side filtered ` +
51
+ `(Dolos doesn't support has_certificate). Falling back to match-all with client-side filtering.`
52
+ );
53
+ }
54
+
55
+ this.watchers.set(key, {
56
+ predicateKey: key,
57
+ sdkPredicate,
58
+ pendingBlocks: new Map(),
59
+ highestSeenHeight: 0,
60
+ });
61
+ }
62
+ }
63
+
64
+ public async resolveOrigin(): Promise<ChainPoint> {
65
+ const blocks = await this.syncClient.fetchHistory(undefined, 1);
66
+ if (!blocks.length) throw new Error("No blocks found on chain");
67
+ const header = blocks[0].parsedBlock.header!;
68
+ return {
69
+ slot: Number(header.slot),
70
+ hash: Buffer.from(header.hash).toString("hex"),
71
+ };
72
+ }
73
+
74
+ public async start(point?: ChainPoint): Promise<void> {
75
+ if (this.watchers.size === 0) {
76
+ throw new Error("No watchers configured");
77
+ }
78
+
79
+ if (this.slotOriginTimestamp === undefined) {
80
+ try {
81
+ const blocks = await this.syncClient.fetchHistory(undefined, 1);
82
+ if (blocks.length > 0) {
83
+ const blk = blocks[0].parsedBlock;
84
+ if (blk.timestamp && blk.header?.slot != null) {
85
+ this.slotOriginTimestamp = blk.timestamp - blk.header.slot;
86
+ }
87
+ }
88
+ } catch {
89
+ // Chain may not be ready yet — slotOriginTimestamp will be set on the first Apply event
90
+ }
91
+ }
92
+
93
+ const intersect = point ? [point] : [];
94
+ const promises: Promise<void>[] = [];
95
+ for (const [key, watcher] of this.watchers) {
96
+ const watchClient = new CardanoWatchClient(this.clientOptions);
97
+ promises.push(this.runWatcher(key, watcher, watchClient, intersect));
98
+ }
99
+
100
+ console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
101
+ await Promise.all(promises);
102
+ }
103
+
104
+ private async runWatcher(
105
+ key: string,
106
+ watcher: WatcherState,
107
+ watchClient: CardanoWatchClient,
108
+ intersect: ChainPoint[],
109
+ ): Promise<void> {
110
+ const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
111
+ for await (const event of stream) {
112
+ this.handleEvent(key, watcher, event);
113
+ }
114
+ console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
115
+ }
116
+
117
+ private handleEvent(
118
+ key: string,
119
+ watcher: WatcherState,
120
+ event: CardanoTxEvent,
121
+ ): void {
122
+ if (event.action === "apply") {
123
+ const height = Number(event.Block.header!.height);
124
+ let pending = watcher.pendingBlocks.get(height);
125
+ if (!pending) {
126
+ pending = { txs: [], block: undefined, idle: false, idleRef: undefined };
127
+ watcher.pendingBlocks.set(height, pending);
128
+ }
129
+ pending.txs.push(event.Tx);
130
+ pending.block = event.Block;
131
+ pending.idle = false;
132
+ if (this.slotOriginTimestamp === undefined) {
133
+ const blk = event.Block;
134
+ this.slotOriginTimestamp = blk.timestamp - blk.header!.slot;
135
+ }
136
+ if (height > watcher.highestSeenHeight) {
137
+ watcher.highestSeenHeight = height;
138
+ }
139
+ this.tryCompleteBlocks();
140
+ } else if (event.action === "idle") {
141
+ const height = Number(event.BlockRef.height);
142
+ let pending = watcher.pendingBlocks.get(height);
143
+ if (!pending) {
144
+ pending = { txs: [], block: undefined, idle: true, idleRef: event.BlockRef };
145
+ watcher.pendingBlocks.set(height, pending);
146
+ } else {
147
+ pending.idle = true;
148
+ pending.idleRef = event.BlockRef;
149
+ }
150
+ if (height > watcher.highestSeenHeight) {
151
+ watcher.highestSeenHeight = height;
152
+ }
153
+ this.tryCompleteBlocks();
154
+ } else if (event.action === "undo") {
155
+ const height = Number(event.Block.header!.height);
156
+ this.handleUndo(height);
157
+ }
158
+ }
159
+
160
+ private handleUndo(fromHeight: number): void {
161
+ // Pop all completed blocks at or above the undo height
162
+ while (this.completedBuffer.length > 0) {
163
+ const last = this.completedBuffer.peekBack()!;
164
+ if (last.output.height < fromHeight) break;
165
+ this.completedBuffer.pop();
166
+ }
167
+
168
+ // Clear pending blocks at or above the undo height for all watchers
169
+ for (const [, watcher] of this.watchers) {
170
+ for (const [h] of watcher.pendingBlocks) {
171
+ if (h >= fromHeight) {
172
+ watcher.pendingBlocks.delete(h);
173
+ }
174
+ }
175
+ if (watcher.highestSeenHeight >= fromHeight) {
176
+ watcher.highestSeenHeight = fromHeight - 1;
177
+ }
178
+ }
179
+
180
+ // Reset confirmed height
181
+ if (this.highestConfirmedHeight != null && this.highestConfirmedHeight >= fromHeight) {
182
+ this.highestConfirmedHeight = fromHeight - 1;
183
+ }
184
+ if (this.lowestIncompleteHeight >= fromHeight) {
185
+ this.lowestIncompleteHeight = fromHeight;
186
+ }
187
+ }
188
+
189
+ private tryCompleteBlocks(): void {
190
+ const minHighest = Math.min(...[...this.watchers.values()].map(w => w.highestSeenHeight));
191
+ if (minHighest <= 0) return;
192
+
193
+ let height = this.lowestIncompleteHeight;
194
+ if (height === 0) {
195
+ let lowest = Infinity;
196
+ for (const [, watcher] of this.watchers) {
197
+ for (const [h] of watcher.pendingBlocks) {
198
+ if (h < lowest) lowest = h;
199
+ }
200
+ }
201
+ if (lowest === Infinity) return;
202
+ height = lowest;
203
+ }
204
+
205
+ // A block at `height` is only safe to process once every watcher has
206
+ // advanced past it (received an event for height+1 or later). This
207
+ // guarantees all Apply events for that height have been delivered —
208
+ // the Watch protocol can send multiple Apply events per block.
209
+ while (height < minHighest) {
210
+ const completed = this.buildCompletedBlock(height);
211
+ if (completed) {
212
+ const finalityThreshold = minHighest - this.finality;
213
+ if (height <= finalityThreshold) {
214
+ this.completedBuffer.push({
215
+ output: completed,
216
+ cleanup: () => this.completedBuffer.shift(),
217
+ });
218
+ this.highestConfirmedHeight = height;
219
+ this.newDataCondVar.wake();
220
+ } else {
221
+ break;
222
+ }
223
+ }
224
+
225
+ for (const [, watcher] of this.watchers) {
226
+ watcher.pendingBlocks.delete(height);
227
+ }
228
+
229
+ height++;
230
+ }
231
+ this.lowestIncompleteHeight = height;
232
+ }
233
+
234
+ private buildCompletedBlock(height: number): CompletedBlock | null {
235
+ let block: cardano.Block | undefined;
236
+ let slot = 0;
237
+ let hash = "";
238
+ const matchedTxsByPredicate = new Map<string, cardano.Tx[]>();
239
+
240
+ for (const [key, watcher] of this.watchers) {
241
+ const pending = watcher.pendingBlocks.get(height);
242
+ if (!pending) return null;
243
+
244
+ if (pending.block) {
245
+ block = pending.block;
246
+ slot = Number(pending.block.header!.slot);
247
+ hash = Buffer.from(pending.block.header!.hash).toString("hex");
248
+ } else if (pending.idleRef && !block) {
249
+ slot = Number(pending.idleRef.slot);
250
+ hash = Buffer.from(pending.idleRef.hash).toString("hex");
251
+ }
252
+
253
+ if (pending.txs.length > 0) {
254
+ matchedTxsByPredicate.set(key, pending.txs);
255
+ }
256
+ }
257
+
258
+ if (!block) {
259
+ const idleSlot = BigInt(slot);
260
+ const idleHash = hexStringToUint8Array(hash);
261
+ const estimatedTimestamp = this.slotOriginTimestamp != null
262
+ ? this.slotOriginTimestamp + idleSlot
263
+ : idleSlot;
264
+ block = new cardano.Block({
265
+ header: new cardano.BlockHeader({
266
+ slot: idleSlot,
267
+ hash: idleHash,
268
+ height: BigInt(height),
269
+ }),
270
+ body: new cardano.BlockBody({ tx: [] }),
271
+ timestamp: estimatedTimestamp,
272
+ });
273
+ }
274
+
275
+ return { height, slot, hash, block, matchedTxsByPredicate };
276
+ }
277
+
278
+ public fetchBlocks(
279
+ from: BlockNumber,
280
+ to: BlockNumber,
281
+ ): OutputAndCleanup<CompletedBlock>[] {
282
+ const blocks: OutputAndCleanup<CompletedBlock>[] = [];
283
+ for (let i = 0; i < this.completedBuffer.length; i++) {
284
+ const entry = this.completedBuffer.peekAt(i)!;
285
+ if (entry.output.height >= from) {
286
+ blocks.push(entry);
287
+ }
288
+ if (entry.output.height >= to) {
289
+ break;
290
+ }
291
+ }
292
+ return blocks;
293
+ }
294
+
295
+ public lastHeight(): bigint | undefined {
296
+ if (this.highestConfirmedHeight == null) return undefined;
297
+ return BigInt(this.highestConfirmedHeight);
298
+ }
299
+
300
+ }
@@ -2,31 +2,30 @@ import { BaseDataFetcher, type DataFetched } from "../base/fetcher.ts";
2
2
  import type { RootOutput, RootPage } from "../types.ts";
3
3
  import type { Operation } from "effection";
4
4
  import { bound, uint8ArrayToHexString } from "@effectstream/utils";
5
- import type { ChainPoint, ConfigType, Input, Output, Page, PrimitiveType } from "./types.ts";
5
+ import type { ChainPoint, CompletedBlock, ConfigType, Input, Output, Page, PrimitiveType } from "./types.ts";
6
6
  import type { OutputAndCleanup, RootConversion } from "../base/state.ts";
7
- import type { BufferedRpc } from "./BufferedRpc.ts";
8
- import { Buffer } from "node:buffer";
7
+ import type { WatchMultiplexer } from "./WatchMultiplexer.ts";
9
8
  import type { cardano } from "@utxorpc/spec";
10
- import { hashEqual, matchesPredicate } from "./utils.ts";
9
+ import { matchesPredicate, predicateKey } from "./utils.ts";
11
10
 
12
11
  export class UtxoRpcFetcher
13
- extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage> // PrimitiveFetcher<Input, Page, cardano.Block, PrimitiveType>,
12
+ extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>
14
13
  {
15
14
  constructor(
16
15
  readonly config: ConfigType,
17
- readonly client: BufferedRpc,
16
+ readonly multiplexer: WatchMultiplexer,
18
17
  ) {
19
18
  super(config.syncProtocol.name);
20
19
  }
21
20
 
22
21
  @bound
23
22
  async startAsync(point?: ChainPoint) {
24
- await this.client.start(point);
23
+ await this.multiplexer.start(point);
25
24
  }
26
25
 
27
26
  @bound
28
27
  async resolveOrigin(): Promise<ChainPoint> {
29
- return this.client.resolveOrigin();
28
+ return this.multiplexer.resolveOrigin();
30
29
  }
31
30
 
32
31
  @bound
@@ -40,63 +39,79 @@ export class UtxoRpcFetcher
40
39
  }`,
41
40
  );
42
41
  const outputs: OutputAndCleanup<Output>[] = [];
43
- const blocks = this.client.fetchBlocks(data.from, data.to);
44
- for (const block of blocks) {
45
- outputs.push({
46
- output: {
47
- // TODO: What is the correct block hash?
48
- blockHashes: [String(block.output.header?.hash)],
49
- raw: block.output,
50
- primitives: this.findPrimitives(block.output),
51
- },
52
- cleanup: block.cleanup,
53
- });
42
+ const blocks = this.multiplexer.fetchBlocks(data.from, data.to);
43
+ let lastEntry: typeof blocks[number] | undefined;
44
+ for (const entry of blocks) {
45
+ lastEntry = entry;
46
+ const completed = entry.output;
47
+ const primitives = this.findPrimitivesFromWatchData(completed);
48
+ if (primitives.length > 0) {
49
+ outputs.push({
50
+ output: {
51
+ blockHashes: [completed.hash],
52
+ raw: completed.block,
53
+ primitives,
54
+ },
55
+ cleanup: entry.cleanup,
56
+ });
57
+ }
54
58
  }
59
+ const lastCompleted = lastEntry!.output;
55
60
  return {
56
61
  output: outputs,
57
62
  lastPage: {
58
63
  ownBlockNumber: Number(data.to),
59
64
  own: {
60
- slot: Number(
61
- outputs[outputs.length - 1].output.raw.header!.slot,
62
- ),
63
- height: Number(
64
- outputs[outputs.length - 1].output.raw.header!.height,
65
- ),
66
- hash: Buffer.from(
67
- outputs[outputs.length - 1].output.raw.header!.hash,
68
- )
69
- .toString("hex"),
65
+ slot: Number(lastCompleted.block.header!.slot),
66
+ height: Number(lastCompleted.block.header!.height),
67
+ hash: lastCompleted.hash,
70
68
  },
71
69
  root: rootConversion.toRootPage({
72
70
  blockHashes: [],
73
- primitives: [], // TODO: I think this can be left empty here
74
- raw: outputs[outputs.length - 1].output.raw,
71
+ primitives: [],
72
+ raw: lastCompleted.block,
75
73
  }),
76
74
  },
77
75
  };
78
76
  }
79
77
 
80
78
  @bound
81
- findPrimitives(block: cardano.Block): PrimitiveType[] {
79
+ findPrimitivesFromWatchData(completed: CompletedBlock): PrimitiveType[] {
82
80
  const primitiveResponses: PrimitiveType[] = [];
83
- for (const tx of block.body?.tx ?? []) {
81
+ const block = completed.block;
82
+ const txIndexCache = new Map<string, number>();
83
+
84
+ for (const primitiveEntry of this.config.primitives) {
85
+ const pKey = predicateKey(primitiveEntry.primitive.predicate);
86
+ const matchedTxs = completed.matchedTxsByPredicate.get(pKey)
87
+ ?? this.findFallbackTxs(completed, primitiveEntry.primitive.predicate);
88
+
84
89
  let logIndex = 0;
85
- for (const primitveEntry of this.config.primitives) {
86
- if (!matchesPredicate(tx, primitveEntry.primitive.predicate)) {
90
+ for (const tx of matchedTxs) {
91
+ if (!matchesPredicate(tx, primitiveEntry.primitive.predicate)) {
87
92
  continue;
88
93
  }
89
- const transactionIndex = block.body?.tx.findIndex(t => hashEqual(t.hash, tx.hash));
94
+
95
+ const txHashHex = uint8ArrayToHexString(tx.hash);
96
+ let transactionIndex = txIndexCache.get(txHashHex);
97
+ if (transactionIndex === undefined) {
98
+ transactionIndex = block.body?.tx.findIndex(t =>
99
+ t.hash.length === tx.hash.length && t.hash.every((b, i) => b === tx.hash[i])
100
+ ) ?? -1;
101
+ txIndexCache.set(txHashHex, transactionIndex);
102
+ }
103
+
90
104
  primitiveResponses.push({
91
105
  syncProtocol: {
92
- name: primitveEntry.syncProtocol,
106
+ name: primitiveEntry.syncProtocol,
93
107
  blockNumber: Number(block.header!.height),
94
- transactionHash: uint8ArrayToHexString(tx.hash),
108
+ transactionHash: txHashHex,
95
109
  transactionIndex,
96
110
  contractAddress: '',
97
111
  logIndex,
112
+ absoluteSlot: Number(block.header!.slot),
98
113
  },
99
- primitive: primitveEntry.primitive.name,
114
+ primitive: primitiveEntry.primitive.name,
100
115
  output: {
101
116
  payloadType: 'utxorpc-response',
102
117
  payload: { tx }
@@ -108,9 +123,30 @@ export class UtxoRpcFetcher
108
123
  return primitiveResponses;
109
124
  }
110
125
 
126
+ /**
127
+ * When a primitive's predicate key doesn't exactly match any watcher key
128
+ * (e.g., has_certificate primitives merged into match-all watcher),
129
+ * fall back to scanning all matched txs across all watchers.
130
+ */
131
+ private findFallbackTxs(
132
+ completed: CompletedBlock,
133
+ _predicate: unknown,
134
+ ): cardano.Tx[] {
135
+ const seen = new Set<string>();
136
+ const txs: cardano.Tx[] = [];
137
+ for (const [, watcherTxs] of completed.matchedTxsByPredicate) {
138
+ for (const tx of watcherTxs) {
139
+ const hash = uint8ArrayToHexString(tx.hash);
140
+ if (!seen.has(hash)) {
141
+ seen.add(hash);
142
+ txs.push(tx);
143
+ }
144
+ }
145
+ }
146
+ return txs;
147
+ }
148
+
111
149
  lastHeight(): bigint | undefined {
112
- // Calling this.client.syncClient.getTip();
113
- // returns the hash and slot, but not the block height
114
- return this.client.lastHeight();
150
+ return this.multiplexer.lastHeight();
115
151
  }
116
152
  }
@@ -51,3 +51,11 @@ export type ConfigType = Extract<
51
51
  SyncProtocolWithNetwork,
52
52
  { syncProtocolType: ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL }
53
53
  >;
54
+
55
+ export type CompletedBlock = {
56
+ height: number;
57
+ slot: number;
58
+ hash: string;
59
+ block: cardano.Block;
60
+ matchedTxsByPredicate: Map<string, cardano.Tx[]>;
61
+ };
@@ -3,6 +3,8 @@ import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPatte
3
3
  import { hexStringToUint8Array } from "@effectstream/utils";
4
4
  import { Cardano } from '@cardano-sdk/core';
5
5
  import { HexBlob } from '@cardano-sdk/util';
6
+ import type { CardanoTxPredicate as TxPredicate } from "@utxorpc/sdk";
7
+ import stableStringify from "json-stable-stringify";
6
8
 
7
9
  export function hashEqual(lhs: Uint8Array, rhs: Uint8Array): boolean {
8
10
  if (lhs.length !== rhs.length) {
@@ -38,9 +40,10 @@ function matchesPattern(tx: cardano.Tx, pattern: UtxorpcAnyChainTxPattern): bool
38
40
  const outputs = tx.outputs;
39
41
  const inputs = tx.inputs.map(input => input.asOutput!).filter(x => x);
40
42
  const matchHasAddress = !cardanoPattern.has_address || matchesAddress(outputs, cardanoPattern.has_address) || matchesAddress(inputs, cardanoPattern.has_address);
41
- const matchMovesAsset = !cardanoPattern.moves_asset || matchesAsset(inputs, cardanoPattern.moves_asset);
43
+ const matchMovesAsset = !cardanoPattern.moves_asset || matchesAsset(outputs, cardanoPattern.moves_asset) || matchesAsset(inputs, cardanoPattern.moves_asset);
42
44
  const matchMintsAsset = !cardanoPattern.mints_asset || matchesAsset(outputs, cardanoPattern.mints_asset);
43
- return matchHasAddress && matchMovesAsset && matchMintsAsset;
45
+ const matchHasCertificate = !cardanoPattern.has_certificate || tx.certificates.length > 0;
46
+ return matchHasAddress && matchMovesAsset && matchMintsAsset && matchHasCertificate;
44
47
  }
45
48
 
46
49
  function matchesOutputPattern(outputs: cardano.TxOutput[], pattern: UtxorpcTxOutputPattern): boolean {
@@ -77,4 +80,81 @@ function matchesAsset(outputs: cardano.TxOutput[], pattern: UtxorpcAssetPattern)
77
80
  }
78
81
  return ma.assets.some(a => !assetName || hashEqual(a.name, assetName));
79
82
  }));
83
+ }
84
+
85
+ // =============================================
86
+ // Config → SDK predicate conversion
87
+ // =============================================
88
+
89
+ function convertAddressPattern(pattern: UtxorpcAddressPattern): Record<string, Uint8Array> {
90
+ const result: Record<string, Uint8Array> = {};
91
+ if (pattern.exact_address) {
92
+ result.exactAddress = Uint8Array.from(atob(pattern.exact_address), c => c.charCodeAt(0));
93
+ }
94
+ if (pattern.payment_part) {
95
+ result.paymentPart = hexStringToUint8Array(pattern.payment_part);
96
+ }
97
+ if (pattern.delegation_part) {
98
+ result.delegationPart = hexStringToUint8Array(pattern.delegation_part);
99
+ }
100
+ return result;
101
+ }
102
+
103
+ function convertAssetPattern(pattern: UtxorpcAssetPattern): Record<string, Uint8Array> {
104
+ const result: Record<string, Uint8Array> = {};
105
+ if (pattern.policy_id) {
106
+ result.policyId = hexStringToUint8Array(pattern.policy_id);
107
+ }
108
+ if (pattern.asset_name) {
109
+ result.assetName = hexStringToUint8Array(pattern.asset_name);
110
+ }
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Convert a config-layer UtxorpcTxPredicate (snake_case) to the SDK's TxPredicate (camelCase).
116
+ *
117
+ * NOTE: `has_certificate` is in the UTxORPC spec but Dolos does NOT implement server-side
118
+ * filtering for it. Predicates using has_certificate will be sent to the server but silently
119
+ * ignored — client-side filtering in matchesPredicate() still catches them.
120
+ */
121
+ // TODO(dolos): when Dolos implements has_certificate filtering, this conversion will just work
122
+ export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPredicate {
123
+ const result: TxPredicate = {};
124
+
125
+ if (config.match?.cardano) {
126
+ const c = config.match.cardano;
127
+ const match: Record<string, unknown> = {};
128
+ if (c.has_address) match.hasAddress = convertAddressPattern(c.has_address);
129
+ if (c.moves_asset) match.movesAsset = convertAssetPattern(c.moves_asset);
130
+ if (c.mints_asset) match.mintsAsset = convertAssetPattern(c.mints_asset);
131
+ if (c.has_certificate) match.hasCertificate = {};
132
+ result.match = match as TxPredicate["match"];
133
+ }
134
+
135
+ if (config.not) result.not = config.not.map(configPredicateToSdkPredicate);
136
+ if (config.all_of) result.allOf = config.all_of.map(configPredicateToSdkPredicate);
137
+ if (config.any_of) result.anyOf = config.any_of.map(configPredicateToSdkPredicate);
138
+
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Deterministic string key for a predicate, used to deduplicate watchers.
144
+ * Predicates that produce the same key share a single WatchClient stream.
145
+ */
146
+ export function predicateKey(predicate: UtxorpcTxPredicate): string {
147
+ return stableStringify(predicate) ?? "{}";
148
+ }
149
+
150
+ /**
151
+ * Returns true if this predicate can be effectively filtered server-side by Dolos.
152
+ * Currently Dolos Watch ignores `has_certificate`, so predicates relying solely on it
153
+ * are not effective and should fall back to match-all.
154
+ */
155
+ // TODO(dolos): remove this when Dolos implements has_certificate filtering
156
+ export function isEffectiveServerPredicate(predicate: UtxorpcTxPredicate): boolean {
157
+ if (!predicate.match?.cardano) return false;
158
+ const c = predicate.match.cardano;
159
+ return !!(c.has_address || c.moves_asset || c.mints_asset);
80
160
  }
@@ -11,9 +11,12 @@ import {
11
11
  getViemNetwork,
12
12
  } from "@effectstream/config";
13
13
  import { CardanoSyncClient } from "@utxorpc/sdk";
14
- import { BufferedRpc } from "./sync-protocols/utxorpc/BufferedRpc.ts";
14
+ import { WatchMultiplexer } from "./sync-protocols/utxorpc/WatchMultiplexer.ts";
15
15
  import { UtxoRpcFetcher } from "./sync-protocols/utxorpc/fetcher.ts";
16
16
  import { UtxoRpcSyncState } from "./sync-protocols/utxorpc/state.ts";
17
+ import { isEffectiveServerPredicate, predicateKey } from "./sync-protocols/utxorpc/utils.ts";
18
+ import type { UtxorpcTxPredicate } from "@effectstream/config";
19
+ import type { PrimitiveEntryType } from "./sync-protocols/utxorpc/types.ts";
17
20
  import { MidnightFetcher, MidnightSyncState } from "@effectstream/sync";
18
21
  import { AvailFetcher } from "./sync-protocols/avail/fetcher.ts";
19
22
  import { AvailSyncState } from "./sync-protocols/avail/state.ts";
@@ -67,17 +70,23 @@ export function* genSyncProtocols(
67
70
  ) {
68
71
  throw new Error("CARP not supported yet");
69
72
  }
70
- const syncClient = new CardanoSyncClient({
73
+ const clientOptions = {
71
74
  uri: entry.syncProtocol.rpcUrl,
72
75
  headers: entry.syncProtocol.headers,
73
- });
74
- const bufferedRpc = new BufferedRpc(
75
- syncClient,
76
+ };
77
+ const syncClient = new CardanoSyncClient(clientOptions);
78
+ const primitivesByPredicate = groupPrimitivesByPredicate(
79
+ entry.primitives as PrimitiveEntryType[],
80
+ );
81
+ const multiplexer = new WatchMultiplexer(
82
+ clientOptions,
83
+ primitivesByPredicate,
76
84
  entry.syncProtocol.confirmationDepth,
85
+ syncClient,
77
86
  );
78
87
  const fetcher = new UtxoRpcFetcher(
79
88
  entry,
80
- bufferedRpc,
89
+ multiplexer,
81
90
  );
82
91
  const state = yield* UtxoRpcSyncState.restoreState(
83
92
  dbConn,
@@ -147,3 +156,27 @@ export function* genSyncProtocols(
147
156
 
148
157
  return result;
149
158
  }
159
+
160
+ function groupPrimitivesByPredicate(
161
+ primitives: PrimitiveEntryType[],
162
+ ): Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }> {
163
+ const groups = new Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }>();
164
+ const emptyKey = predicateKey({});
165
+
166
+ for (const entry of primitives) {
167
+ const predicate = entry.primitive.predicate;
168
+ // TODO(dolos): when Dolos implements has_certificate, remove this fallback to empty predicate
169
+ const effective = isEffectiveServerPredicate(predicate);
170
+ const key = effective ? predicateKey(predicate) : emptyKey;
171
+ const effectivePredicate = effective ? predicate : {};
172
+
173
+ let group = groups.get(key);
174
+ if (!group) {
175
+ group = { predicate: effectivePredicate, primitiveEntries: [] };
176
+ groups.set(key, group);
177
+ }
178
+ group.primitiveEntries.push(entry);
179
+ }
180
+
181
+ return groups;
182
+ }