@effectstream/sync 0.100.12 → 0.100.13

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.13",
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.13",
17
+ "@effectstream/db": "0.100.13",
18
+ "@effectstream/log": "0.100.13",
19
+ "@effectstream/utils": "0.100.13",
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",
@@ -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) {
@@ -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,11 +82,16 @@ 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(
@@ -146,6 +151,8 @@ export class MidnightFetcher extends BaseDataFetcher<
146
151
  for (const primitiveEntry of primitiveEntries) {
147
152
  if (primitiveEntry.primitive.type === "Midnight:Nullifier") {
148
153
  syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
154
+ } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
155
+ syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
149
156
  } else {
150
157
  asyncOps.push(
151
158
  this.fetchContractState(height, client, primitiveEntry, block),
@@ -191,6 +198,38 @@ export class MidnightFetcher extends BaseDataFetcher<
191
198
  return results;
192
199
  }
193
200
 
201
+ @bound
202
+ fetchUnshieldedSpends(
203
+ height: number,
204
+ primitiveEntry: PrimitiveEntryType,
205
+ block: MidnightGqlBlockState,
206
+ ): PrimitiveType[] {
207
+ const results: PrimitiveType[] = [];
208
+ for (const tx of block.block.transactions) {
209
+ for (const spend of tx.unshieldedSpentOutputs ?? []) {
210
+ results.push({
211
+ syncProtocol: {
212
+ name: primitiveEntry.syncProtocol,
213
+ blockNumber: height,
214
+ transactionHash: tx.hash,
215
+ contractAddress: "",
216
+ },
217
+ primitive: primitiveEntry.primitive.name,
218
+ output: {
219
+ payloadType: "midnight-unshielded-spend",
220
+ payload: {
221
+ owner: spend.owner,
222
+ intentHash: spend.intentHash,
223
+ outputIndex: spend.outputIndex,
224
+ txHash: tx.hash,
225
+ },
226
+ },
227
+ });
228
+ }
229
+ }
230
+ return results;
231
+ }
232
+
194
233
  @bound
195
234
  *fetchContractState(
196
235
  height: number,
@@ -0,0 +1,286 @@
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
+ const intersect = point ? [point] : [];
80
+ const promises: Promise<void>[] = [];
81
+ for (const [key, watcher] of this.watchers) {
82
+ const watchClient = new CardanoWatchClient(this.clientOptions);
83
+ promises.push(this.runWatcher(key, watcher, watchClient, intersect));
84
+ }
85
+
86
+ console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
87
+ await Promise.all(promises);
88
+ }
89
+
90
+ private async runWatcher(
91
+ key: string,
92
+ watcher: WatcherState,
93
+ watchClient: CardanoWatchClient,
94
+ intersect: ChainPoint[],
95
+ ): Promise<void> {
96
+ const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
97
+ for await (const event of stream) {
98
+ this.handleEvent(key, watcher, event);
99
+ }
100
+ console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
101
+ }
102
+
103
+ private handleEvent(
104
+ key: string,
105
+ watcher: WatcherState,
106
+ event: CardanoTxEvent,
107
+ ): void {
108
+ if (event.action === "apply") {
109
+ const height = Number(event.Block.header!.height);
110
+ let pending = watcher.pendingBlocks.get(height);
111
+ if (!pending) {
112
+ pending = { txs: [], block: undefined, idle: false, idleRef: undefined };
113
+ watcher.pendingBlocks.set(height, pending);
114
+ }
115
+ pending.txs.push(event.Tx);
116
+ pending.block = event.Block;
117
+ pending.idle = false;
118
+ if (this.slotOriginTimestamp === undefined) {
119
+ const blk = event.Block;
120
+ this.slotOriginTimestamp = blk.timestamp - blk.header!.slot;
121
+ }
122
+ if (height > watcher.highestSeenHeight) {
123
+ watcher.highestSeenHeight = height;
124
+ }
125
+ this.tryCompleteBlocks();
126
+ } else if (event.action === "idle") {
127
+ const height = Number(event.BlockRef.height);
128
+ let pending = watcher.pendingBlocks.get(height);
129
+ if (!pending) {
130
+ pending = { txs: [], block: undefined, idle: true, idleRef: event.BlockRef };
131
+ watcher.pendingBlocks.set(height, pending);
132
+ } else {
133
+ pending.idle = true;
134
+ pending.idleRef = event.BlockRef;
135
+ }
136
+ if (height > watcher.highestSeenHeight) {
137
+ watcher.highestSeenHeight = height;
138
+ }
139
+ this.tryCompleteBlocks();
140
+ } else if (event.action === "undo") {
141
+ const height = Number(event.Block.header!.height);
142
+ this.handleUndo(height);
143
+ }
144
+ }
145
+
146
+ private handleUndo(fromHeight: number): void {
147
+ // Pop all completed blocks at or above the undo height
148
+ while (this.completedBuffer.length > 0) {
149
+ const last = this.completedBuffer.peekBack()!;
150
+ if (last.output.height < fromHeight) break;
151
+ this.completedBuffer.pop();
152
+ }
153
+
154
+ // Clear pending blocks at or above the undo height for all watchers
155
+ for (const [, watcher] of this.watchers) {
156
+ for (const [h] of watcher.pendingBlocks) {
157
+ if (h >= fromHeight) {
158
+ watcher.pendingBlocks.delete(h);
159
+ }
160
+ }
161
+ if (watcher.highestSeenHeight >= fromHeight) {
162
+ watcher.highestSeenHeight = fromHeight - 1;
163
+ }
164
+ }
165
+
166
+ // Reset confirmed height
167
+ if (this.highestConfirmedHeight != null && this.highestConfirmedHeight >= fromHeight) {
168
+ this.highestConfirmedHeight = fromHeight - 1;
169
+ }
170
+ if (this.lowestIncompleteHeight >= fromHeight) {
171
+ this.lowestIncompleteHeight = fromHeight;
172
+ }
173
+ }
174
+
175
+ private tryCompleteBlocks(): void {
176
+ const minHighest = Math.min(...[...this.watchers.values()].map(w => w.highestSeenHeight));
177
+ if (minHighest <= 0) return;
178
+
179
+ let height = this.lowestIncompleteHeight;
180
+ if (height === 0) {
181
+ let lowest = Infinity;
182
+ for (const [, watcher] of this.watchers) {
183
+ for (const [h] of watcher.pendingBlocks) {
184
+ if (h < lowest) lowest = h;
185
+ }
186
+ }
187
+ if (lowest === Infinity) return;
188
+ height = lowest;
189
+ }
190
+
191
+ // A block at `height` is only safe to process once every watcher has
192
+ // advanced past it (received an event for height+1 or later). This
193
+ // guarantees all Apply events for that height have been delivered —
194
+ // the Watch protocol can send multiple Apply events per block.
195
+ while (height < minHighest) {
196
+ const completed = this.buildCompletedBlock(height);
197
+ if (completed) {
198
+ const finalityThreshold = minHighest - this.finality;
199
+ if (height <= finalityThreshold) {
200
+ this.completedBuffer.push({
201
+ output: completed,
202
+ cleanup: () => this.completedBuffer.shift(),
203
+ });
204
+ this.highestConfirmedHeight = height;
205
+ this.newDataCondVar.wake();
206
+ } else {
207
+ break;
208
+ }
209
+ }
210
+
211
+ for (const [, watcher] of this.watchers) {
212
+ watcher.pendingBlocks.delete(height);
213
+ }
214
+
215
+ height++;
216
+ }
217
+ this.lowestIncompleteHeight = height;
218
+ }
219
+
220
+ private buildCompletedBlock(height: number): CompletedBlock | null {
221
+ let block: cardano.Block | undefined;
222
+ let slot = 0;
223
+ let hash = "";
224
+ const matchedTxsByPredicate = new Map<string, cardano.Tx[]>();
225
+
226
+ for (const [key, watcher] of this.watchers) {
227
+ const pending = watcher.pendingBlocks.get(height);
228
+ if (!pending) return null;
229
+
230
+ if (pending.block) {
231
+ block = pending.block;
232
+ slot = Number(pending.block.header!.slot);
233
+ hash = Buffer.from(pending.block.header!.hash).toString("hex");
234
+ } else if (pending.idleRef && !block) {
235
+ slot = Number(pending.idleRef.slot);
236
+ hash = Buffer.from(pending.idleRef.hash).toString("hex");
237
+ }
238
+
239
+ if (pending.txs.length > 0) {
240
+ matchedTxsByPredicate.set(key, pending.txs);
241
+ }
242
+ }
243
+
244
+ if (!block) {
245
+ const idleSlot = BigInt(slot);
246
+ const idleHash = hexStringToUint8Array(hash);
247
+ const estimatedTimestamp = this.slotOriginTimestamp != null
248
+ ? this.slotOriginTimestamp + idleSlot
249
+ : idleSlot;
250
+ block = new cardano.Block({
251
+ header: new cardano.BlockHeader({
252
+ slot: idleSlot,
253
+ hash: idleHash,
254
+ height: BigInt(height),
255
+ }),
256
+ body: new cardano.BlockBody({ tx: [] }),
257
+ timestamp: estimatedTimestamp,
258
+ });
259
+ }
260
+
261
+ return { height, slot, hash, block, matchedTxsByPredicate };
262
+ }
263
+
264
+ public fetchBlocks(
265
+ from: BlockNumber,
266
+ to: BlockNumber,
267
+ ): OutputAndCleanup<CompletedBlock>[] {
268
+ const blocks: OutputAndCleanup<CompletedBlock>[] = [];
269
+ for (let i = 0; i < this.completedBuffer.length; i++) {
270
+ const entry = this.completedBuffer.peekAt(i)!;
271
+ if (entry.output.height >= from) {
272
+ blocks.push(entry);
273
+ }
274
+ if (entry.output.height >= to) {
275
+ break;
276
+ }
277
+ }
278
+ return blocks;
279
+ }
280
+
281
+ public lastHeight(): bigint | undefined {
282
+ if (this.highestConfirmedHeight == null) return undefined;
283
+ return BigInt(this.highestConfirmedHeight);
284
+ }
285
+
286
+ }
@@ -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,16 +39,16 @@ 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) {
42
+ const blocks = this.multiplexer.fetchBlocks(data.from, data.to);
43
+ for (const entry of blocks) {
44
+ const completed = entry.output;
45
45
  outputs.push({
46
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),
47
+ blockHashes: [completed.hash],
48
+ raw: completed.block,
49
+ primitives: this.findPrimitivesFromWatchData(completed),
51
50
  },
52
- cleanup: block.cleanup,
51
+ cleanup: entry.cleanup,
53
52
  });
54
53
  }
55
54
  return {
@@ -63,14 +62,11 @@ export class UtxoRpcFetcher
63
62
  height: Number(
64
63
  outputs[outputs.length - 1].output.raw.header!.height,
65
64
  ),
66
- hash: Buffer.from(
67
- outputs[outputs.length - 1].output.raw.header!.hash,
68
- )
69
- .toString("hex"),
65
+ hash: outputs[outputs.length - 1].output.blockHashes[0],
70
66
  },
71
67
  root: rootConversion.toRootPage({
72
68
  blockHashes: [],
73
- primitives: [], // TODO: I think this can be left empty here
69
+ primitives: [],
74
70
  raw: outputs[outputs.length - 1].output.raw,
75
71
  }),
76
72
  },
@@ -78,25 +74,42 @@ export class UtxoRpcFetcher
78
74
  }
79
75
 
80
76
  @bound
81
- findPrimitives(block: cardano.Block): PrimitiveType[] {
77
+ findPrimitivesFromWatchData(completed: CompletedBlock): PrimitiveType[] {
82
78
  const primitiveResponses: PrimitiveType[] = [];
83
- for (const tx of block.body?.tx ?? []) {
79
+ const block = completed.block;
80
+ const txIndexCache = new Map<string, number>();
81
+
82
+ for (const primitiveEntry of this.config.primitives) {
83
+ const pKey = predicateKey(primitiveEntry.primitive.predicate);
84
+ const matchedTxs = completed.matchedTxsByPredicate.get(pKey)
85
+ ?? this.findFallbackTxs(completed, primitiveEntry.primitive.predicate);
86
+
84
87
  let logIndex = 0;
85
- for (const primitveEntry of this.config.primitives) {
86
- if (!matchesPredicate(tx, primitveEntry.primitive.predicate)) {
88
+ for (const tx of matchedTxs) {
89
+ if (!matchesPredicate(tx, primitiveEntry.primitive.predicate)) {
87
90
  continue;
88
91
  }
89
- const transactionIndex = block.body?.tx.findIndex(t => hashEqual(t.hash, tx.hash));
92
+
93
+ const txHashHex = uint8ArrayToHexString(tx.hash);
94
+ let transactionIndex = txIndexCache.get(txHashHex);
95
+ if (transactionIndex === undefined) {
96
+ transactionIndex = block.body?.tx.findIndex(t =>
97
+ t.hash.length === tx.hash.length && t.hash.every((b, i) => b === tx.hash[i])
98
+ ) ?? -1;
99
+ txIndexCache.set(txHashHex, transactionIndex);
100
+ }
101
+
90
102
  primitiveResponses.push({
91
103
  syncProtocol: {
92
- name: primitveEntry.syncProtocol,
104
+ name: primitiveEntry.syncProtocol,
93
105
  blockNumber: Number(block.header!.height),
94
- transactionHash: uint8ArrayToHexString(tx.hash),
106
+ transactionHash: txHashHex,
95
107
  transactionIndex,
96
108
  contractAddress: '',
97
109
  logIndex,
110
+ absoluteSlot: Number(block.header!.slot),
98
111
  },
99
- primitive: primitveEntry.primitive.name,
112
+ primitive: primitiveEntry.primitive.name,
100
113
  output: {
101
114
  payloadType: 'utxorpc-response',
102
115
  payload: { tx }
@@ -108,9 +121,30 @@ export class UtxoRpcFetcher
108
121
  return primitiveResponses;
109
122
  }
110
123
 
124
+ /**
125
+ * When a primitive's predicate key doesn't exactly match any watcher key
126
+ * (e.g., has_certificate primitives merged into match-all watcher),
127
+ * fall back to scanning all matched txs across all watchers.
128
+ */
129
+ private findFallbackTxs(
130
+ completed: CompletedBlock,
131
+ _predicate: unknown,
132
+ ): cardano.Tx[] {
133
+ const seen = new Set<string>();
134
+ const txs: cardano.Tx[] = [];
135
+ for (const [, watcherTxs] of completed.matchedTxsByPredicate) {
136
+ for (const tx of watcherTxs) {
137
+ const hash = uint8ArrayToHexString(tx.hash);
138
+ if (!seen.has(hash)) {
139
+ seen.add(hash);
140
+ txs.push(tx);
141
+ }
142
+ }
143
+ }
144
+ return txs;
145
+ }
146
+
111
147
  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();
148
+ return this.multiplexer.lastHeight();
115
149
  }
116
150
  }
@@ -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
+ }