@effectstream/sync 0.100.20 → 0.100.22

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/CLAUDE.md CHANGED
@@ -35,7 +35,7 @@ config (syncProtocols.main + syncProtocols.parallel)
35
35
  │ PER-CHAIN FETCH LOOPS one spawn per protocol │
36
36
  │ startSync(state): stateToInput → readData → updateState │
37
37
  │ → push Output to bufferedData (Deque) + wake newData/newPageCondVar│
38
- │ → lastPage kept IN-MEMORY only (no DB write; runtime owns resume) │
38
+ │ → upsertPage(page_number = chunk_end) into sync_protocol_pagination │
39
39
  │ → producerChannel.send(...) ← NOTE: nothing consumes this today │
40
40
  └────────────────────────────────────────┬─────────────────────────────┘
41
41
  ▼
@@ -47,9 +47,8 @@ config (syncProtocols.main + syncProtocols.parallel)
47
47
  └────────────────────────────────────────┬─────────────────────────────┘
48
48
  ▼
49
49
  runtime/src/main.ts: for (block of each(finalizedBlockStream))
50
- acquire DB mutex → processFinalizedBlock (STF)
51
- → upsertPage(block.resumePages) + removePages(< committed) [in txn] → COMMIT
52
- → fire-and-forget MQTT events → each.next()
50
+ acquire DB mutex → processFinalizedBlock (STF) → COMMIT
51
+ → removePages(< merged block) → fire-and-forget MQTT events → each.next()
53
52
  ```
54
53
 
55
54
  ## The three per-chain abstractions (`src/sync-protocols/<chain>/`)
@@ -113,34 +112,12 @@ iteration it walks protocols in order:
113
112
  a parallel chain's timestamp so older blocks merge into more-recent root slots.
114
113
  4. **Mutate-after-commit.** `OutputAndCleanup` keeps data in the Deque until the block is
115
114
  safely produced; the consumer commits to Postgres *before* emitting events.
116
- 5. **Durable, block-accurate resume queue.** *(Single source of truth for how restart/resume
117
- works — code comments point here; see also Finding #2 for the bug this fixes.)*
118
-
119
- `effectstream.sync_protocol_pagination` is keyed `(protocol_name, page_number)` and the
120
- **runtime is the sole writer** of the resume position (Fix D). End to end:
121
- - **Fetch loop** (`base/state.ts:updateState`) keeps `lastPage` in memory only — it does
122
- **not** persist (it runs ahead of commits, so its chunk-end page is not a safe resume
123
- point).
124
- - **Merge** (`orchestration/merge.ts`) tags every `ChainBlock` with `resumePages`: one
125
- marker per protocol = `outputToLastPage(lastConsumed)`, the `LastPage`
126
- (`{ own, ownBlockNumber, root }`) of the **highest datum merged into the block**. Each
127
- protocol implements `outputToLastPage` (`base/state.ts` + per-chain `state.ts`) because
128
- `own` is a structured page for object-paged chains and can't be rebuilt from the block
129
- number. Pending data above the committed timestamp is **not** marked, so it is always
130
- re-fetched, never skipped.
131
- - **Commit** (`runtime/process-blocks.ts`, STEP 7) writes each marker **inside the block's
132
- transaction**: `upsertPage(page_number = ownBlockNumber)` then
133
- `removePages(page_number < ownBlockNumber)` — leaving exactly one row per protocol = the
134
- committed watermark, atomic with the block.
135
- - **Boot** (`restoreState`) reads `getPage` = `ORDER BY page_number ASC LIMIT 1` and
136
- resumes from `nextInterval(own).from` = the next uncommitted block.
137
-
138
- **Re-scan on restart is bounded by `stepSize`, not a chain's whole quiet tail.** Every
139
- fetched chunk's boundary page (`data.to`) is buffered and consumed even when empty, so the
140
- marker advances to within one `stepSize` of the committed frontier — and reaches the
141
- fetched tip exactly at full catch-up. So a long-quiet chain re-fetches at most ~`stepSize`
142
- blocks after a restart, regardless of how long ago its last data was. Trade-off:
143
- `getSyncAndLastPage` now reports `synced_page == fetched_page`.
115
+ 5. **Durable, resumable page queue.** `effectstream.sync_protocol_pagination` is keyed
116
+ `(protocol_name, page_number)`. Fetch appends a row per chunk (`upsertPage`,
117
+ `page_number = chunk_end`); on commit the consumer deletes consumed chunks
118
+ (`removePages(page_number < merged_block)`, `runtime/process-blocks.ts:328`); on boot
119
+ `restoreState` resumes from `getPage` = `ORDER BY page_number ASC LIMIT 1` (oldest
120
+ remaining chunk).
144
121
 
145
122
  ---
146
123
 
@@ -169,20 +146,24 @@ Consequences during catch-up:
169
146
 
170
147
  → Fix: **(C) backpressure** — cap `bufferedData` and pause the fetch loop until the merge drains.
171
148
 
172
- ### 2. Silent data gap on restart (correctness) — ✅ FIXED (D)
149
+ ### 2. Silent data gap on restart (correctness)
173
150
 
174
- **The bug (historical).** The page queue was **chunk-granular** but commits are
175
- **single-block-granular**: the fetch loop persisted `upsertPage(page_number = chunk_end)`,
176
- commit deleted `removePages(page_number < merged_block_N)`, and restart resumed from
177
- `getPage` = oldest remaining chunk at `nextInterval(own).from = chunk_end + 1`. During
178
- catch-up chunks are full `stepSize`, so the committed watermark `N` sat **inside** the
179
- oldest retained chunk `[start, end]` (`end ≥ N ⇒ not deleted`); resume jumped to `end + 1`,
180
- **silently skipping `(N, end]`** → missing primitives → divergent app state, with no error.
151
+ The page queue is **chunk-granular** but commits are **single-block-granular**:
152
+ - fetch persists `upsertPage(page_number = chunk_end)` (`base/state.ts:132`; `own = ownBlockNumber = Number(data.to)`),
153
+ - commit deletes `removePages(page_number < merged_block_N)` (`process-blocks.ts:328`),
154
+ - restart resumes from `getPage` = oldest remaining chunk, at `nextInterval(own).from = chunk_end + 1`.
181
155
 
182
- **The fix (D) — block-accurate resume, runtime is the sole writer.** The runtime persists a
183
- block-accurate marker per protocol on commit instead of the fetch loop persisting chunk-end
184
- pages. Full mechanism in **design idea #5** above. Regression test: the
185
- `consistency.test.ts` "mid-chunk restart" case (Postgres-only).
156
+ During catch-up chunks are full `stepSize`, so at restart the committed watermark `N` sits
157
+ **inside** the oldest retained chunk `[start, end]` (`start ≤ N < end ⇒ end ≥ N ⇒ not deleted`).
158
+ Resume jumps to `end + 1`, **silently skipping source blocks `(N, end]`** (up to
159
+ `stepSize - 1`). The in-memory Deque held them; they're gone on restart; the effectstream
160
+ height continues seamlessly with **missing primitives → divergent app state**. There is
161
+ **no error** and **no restart/resume test** (the only tests in `test/examples.test.ts` are
162
+ export smoke-checks). The existing `getSyncAndLastPage` `synced_page` (`MIN(page_number)`)
163
+ is itself chunk-granular and does not reflect the true committed block.
164
+
165
+ → Fix: **(D) block-accurate resume** — persist the committed per-protocol watermark and
166
+ resume from `watermark + 1`, reconciling stale fetched-ahead page rows.
186
167
 
187
168
  ### 3. Serial replay is the catch-up bottleneck
188
169
 
@@ -201,6 +182,6 @@ block-hash chain.
201
182
  > survives a restart. See `docs/ADDING-A-SYNC-PROTOCOL.md` for how to add a chain
202
183
  > (with `test` as the worked example).
203
184
  >
204
- > Deterministic reproductions of #1/#2/#3 and their fixes land alongside each fix:
205
- > **#2 → block-accurate resume is DONE** (`consistency.test.ts`). Still in follow-up
206
- > PRs: #1 → fetch backpressure; #3 → opt-in empty-block commit batching during catch-up.
185
+ > Deterministic reproductions of #1/#2/#3 and their fixes land in **follow-up
186
+ > PRs**, each test alongside its fix: #1 → fetch backpressure; #2 →
187
+ > block-accurate resume; #3 → opt-in empty-block commit batching during catch-up.
package/README.md CHANGED
@@ -46,55 +46,6 @@ const protocols = await genSyncProtocols(config);
46
46
  // protocols.parallelEvmRPC_fast.runOne() // poll one block
47
47
  ```
48
48
 
49
- ## Backpressure (`maxBufferedPages`)
50
-
51
- During deep catch-up a chain's fetch loop races to its tip far faster than the
52
- merge can drain (the merge applies one block per DB transaction). Without a bound
53
- the in-memory buffer (`SyncState.bufferedData`) grows toward the **entire backlog**
54
- — hundreds of thousands of block objects — which is an OOM risk.
55
-
56
- **The cap.** Every chain's `stateToInput` calls `bufferAtCap(state, syncProtocol)`
57
- first (`sync-protocols/common/page-helpers.ts`): when
58
- `bufferedData.size() >= maxBufferedPages` it returns `undefined`, so the chain stops
59
- fetching — exactly as if it had caught up to the tip — and the polling loop sleeps
60
- `pollingInterval` and retries. The merge keeps draining the buffer; once it drops
61
- below the cap the next poll resumes. Peak in-memory buffering is therefore bounded
62
- to **≈ `maxBufferedPages + stepSize`** per chain (one in-flight chunk can overshoot
63
- the cap), instead of the whole backlog.
64
-
65
- **Config.** `maxBufferedPages` is an optional field on every sync-protocol config
66
- (declared once on the shared `PollingSyncProtocol` schema). When unset it defaults to
67
- **`4 × stepSize`** (the `MAX_BUFFER_MULTIPLE` constant in `page-helpers.ts`), always
68
- clamped to **`≥ stepSize + 1`** so a chain can always fetch at least one chunk to feed
69
- the merge. ~4 chunks of look-ahead never starves the merge while keeping memory
70
- bounded; raise it to trade memory for more fetch look-ahead. The cap only bites during
71
- catch-up — in steady state the buffer sits near zero and the cap is never reached.
72
-
73
- **Deadlock-safety.** Pausing a full chain cannot deadlock: a chain's page only
74
- advances via a fetch (`updateState`), and the merge drains a chain's buffered data
75
- *before* it waits on that chain's page — so the paused (full) chain is never the one
76
- the merge is blocked on without a path to resume.
77
-
78
- **Scope.** The guard runs in **every** chain's `stateToInput`, so all sync chains are
79
- covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Midnight, Cardano (UTXO-RPC), and
80
- the synthetic `test` chain. Two notes:
81
- - **Cardano (UTXO-RPC)** has no `stepSize` (it streams one block per pass), so the cap
82
- falls back to a default chunk size of 1000 (⇒ default cap 4000); set `maxBufferedPages`
83
- explicitly to tune it.
84
- - The cap bounds `SyncState.bufferedData` (the merge-facing Deque the backpressure
85
- feature protects). The UTXO-RPC fetcher additionally keeps its own internal FollowTip stream
86
- buffer; pausing `stateToInput` stops draining it into `bufferedData`, but bounding
87
- that lower-level stream is a separate, fetcher-specific concern.
88
-
89
- **Observability.** Each `SyncState` tracks, and the runtime's `/debug/metrics`
90
- endpoint reports per protocol: `cap` (resolved `maxBufferedPages`), `buf` (current
91
- size), `bufHighWater` (peak since boot — catches spikes between samples), `pausedNow`,
92
- `pauses` (rising-edge count — **the "backpressure engaged" signal**), and `pausedMs`
93
- (total time paused). `pauses > 0` means the cap actively bounded memory; `0` means it
94
- was never needed in that run. Steady-state these sit at `0`; during a real deep
95
- catch-up (or under the perf harness's `PERF_APPLY_DELAY_MS` drain throttle) they climb
96
- as `buf` pins to `cap`.
97
-
98
49
  ## Key exports
99
50
 
100
51
  - `genSyncProtocols(dbConn, syncInfo)` - Effection generator that instantiates a runtime fetcher + state pair for every protocol in `syncInfo` (from `config.syncProtocols`). Called from the runtime's process-blocks loop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.20",
3
+ "version": "0.100.22",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,12 +13,12 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.100.20",
17
- "@effectstream/db": "0.100.20",
18
- "@effectstream/log": "0.100.20",
19
- "@effectstream/utils": "0.100.20",
16
+ "@effectstream/config": "0.100.22",
17
+ "@effectstream/db": "0.100.22",
18
+ "@effectstream/log": "0.100.22",
19
+ "@effectstream/utils": "0.100.22",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
- "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
21
+ "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
23
23
  "effection": "3.5.0",
24
24
  "graphql-ws": "^6.0.6",
@@ -10,7 +10,6 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { AvailClient } from "./AvailClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
- import { bufferAtCap } from "../common/page-helpers.ts";
14
13
 
15
14
  export class AvailSyncState extends SyncState<
16
15
  Input,
@@ -64,7 +63,6 @@ export class AvailSyncState extends SyncState<
64
63
 
65
64
  @bound
66
65
  override *stateToInput(): Operation<Input | undefined> {
67
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
68
66
  const latestHeight = yield* call(() => this.client.getLatestBlockHeight());
69
67
  const startHeight = this.lastPage?.own.height ??
70
68
  this.config.syncProtocol.startBlockHeight - 1;
@@ -101,15 +99,6 @@ export class AvailSyncState extends SyncState<
101
99
  rootOutput.primitives.push(...primitives);
102
100
  }
103
101
 
104
- @bound
105
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
106
- return {
107
- own: { height: data.raw.number, hash: data.raw.hash },
108
- ownBlockNumber: data.raw.number,
109
- root: this.toRootPage(data),
110
- };
111
- }
112
-
113
102
  @bound
114
103
  override getNamespace(): string[] {
115
104
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -1,4 +1,4 @@
1
- import { type Operation } from "effection";
1
+ import { type Operation, until } from "effection";
2
2
  import Deque from "denque";
3
3
  import {
4
4
  type BlockNumber,
@@ -11,6 +11,7 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
11
11
  import type { BaseDataFetcher, DataFetched } from "./fetcher.ts";
12
12
  import type { PageRelation } from "./page.ts";
13
13
  import type { PoolClient } from "pg";
14
+ import { acquireDBMutex, releaseDBMutex, upsertPage } from "@effectstream/db";
14
15
 
15
16
  export type LastPage<Page, RootPage> = {
16
17
  own: Page;
@@ -74,20 +75,6 @@ export abstract class SyncState<
74
75
  /** Timestamp of the most recent error, or 0 if no error has occurred. */
75
76
  public lastErrorTimestamp: number = 0;
76
77
 
77
- // ── Backpressure observability (see common/page-helpers.ts + README) ──
78
- /** Resolved fetch cap (`maxBufferedPages`); set on each backpressure check, 0 until the first. */
79
- public bufferCap: number = 0;
80
- /** Peak `bufferedData.size()` seen since boot — catches spikes the periodic sampler misses. */
81
- public bufferHighWater: number = 0;
82
- /** Whether fetching is currently paused because the buffer is at/over the cap. */
83
- public pausedNow: boolean = false;
84
- /** How many times the cap engaged (rising edges) — the "backpressure fired" counter. */
85
- public backpressurePauses: number = 0;
86
- /** Total time fetching has been paused by the cap, in ms. */
87
- public backpressurePausedMs: number = 0;
88
- /** Start of the current pause, for accumulating `backpressurePausedMs`. */
89
- private pauseStartMs: number = 0;
90
-
91
78
  constructor(
92
79
  public readonly name: string,
93
80
  lastPage: undefined | LastPage<Page, RootPage>,
@@ -119,24 +106,6 @@ export abstract class SyncState<
119
106
  */
120
107
  abstract mergeDatum(ourOutput: Output, rootOutput: RootOutput): void;
121
108
 
122
- recordBackpressure(atCap: boolean, cap: number): void {
123
- this.bufferCap = cap;
124
- const now = Date.now();
125
- if (atCap && !this.pausedNow) {
126
- this.pausedNow = true;
127
- this.pauseStartMs = now;
128
- this.backpressurePauses += 1;
129
- } else if (!atCap && this.pausedNow) {
130
- this.pausedNow = false;
131
- this.backpressurePausedMs += now - this.pauseStartMs;
132
- }
133
- }
134
- /**
135
- * Build the resume marker for a single output: the {@link LastPage} to resume
136
- * fetching *after* it. See CLAUDE.md design idea #5 (restart/resume).
137
- */
138
- abstract outputToLastPage(data: Output): LastPage<Page, RootPage>;
139
-
140
109
  @bound
141
110
  *updateState(
142
111
  _input: Input,
@@ -146,8 +115,6 @@ export abstract class SyncState<
146
115
  for (const datum of data.output) {
147
116
  this.bufferedData.push(datum);
148
117
  }
149
- // Peak right after a push — the local max before the merge drains it.
150
- this.bufferHighWater = Math.max(this.bufferHighWater, this.bufferedData.size());
151
118
  this.newDataCondVar.wake();
152
119
  }
153
120
 
@@ -162,8 +129,13 @@ export abstract class SyncState<
162
129
  ),
163
130
  );
164
131
 
165
- // lastPage is in-memory only; the runtime is the sole writer of the
166
- // persisted resume marker (see CLAUDE.md design idea #5).
132
+ yield* acquireDBMutex(`update-state-${this.name}`);
133
+ yield* until(upsertPage.run({
134
+ protocol_name: this.name,
135
+ page_number: data.lastPage.ownBlockNumber,
136
+ page: JSON.stringify(data.lastPage),
137
+ }, this.dbConn as any)); // Client,
138
+ releaseDBMutex(`update-state-${this.name}`);
167
139
  this.lastPage = data.lastPage;
168
140
  this.newPageCondVar.wake();
169
141
  }
@@ -16,7 +16,7 @@ import {
16
16
  type Output,
17
17
  type Page,
18
18
  } from "./types.ts";
19
- import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
19
+ import { genInputRange } from "../common/page-helpers.ts";
20
20
  import type {
21
21
  ConfigNetworkType,
22
22
  SyncProtocolWithNetwork,
@@ -58,7 +58,6 @@ export class BitcoinSyncState extends SyncState<
58
58
  block_number: Number(data.raw.height),
59
59
  blockHash: hash,
60
60
  })),
61
- resumePages: [],
62
61
  primitives: data.primitives.map((primitive) => ({
63
62
  ...primitive,
64
63
  source: this.config.syncProtocol.name,
@@ -76,7 +75,6 @@ export class BitcoinSyncState extends SyncState<
76
75
 
77
76
  @bound
78
77
  override *stateToInput(): Operation<Input | undefined> {
79
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
80
78
  return yield* genInputRange(
81
79
  this as BitcoinSyncState,
82
80
  this.config.syncProtocol.startBlockHeight as Page,
@@ -114,16 +112,6 @@ export class BitcoinSyncState extends SyncState<
114
112
  rootOutput.blockInfo.push(...blockInfo);
115
113
  }
116
114
 
117
- @bound
118
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
119
- const block = Number(data.raw.height);
120
- return {
121
- own: block,
122
- ownBlockNumber: block,
123
- root: this.toRootPage(data),
124
- };
125
- }
126
-
127
115
  @bound
128
116
  override getNamespace(): string[] {
129
117
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -10,7 +10,6 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { CelestiaClient } from "./CelestiaClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
- import { bufferAtCap } from "../common/page-helpers.ts";
14
13
 
15
14
  export class CelestiaSyncState extends SyncState<
16
15
  Input,
@@ -62,7 +61,6 @@ export class CelestiaSyncState extends SyncState<
62
61
 
63
62
  @bound
64
63
  override *stateToInput(): Operation<Input | undefined> {
65
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
66
64
  const latestHeight = yield* call(() =>
67
65
  this.client.getLatestBlockHeight()
68
66
  );
@@ -101,15 +99,6 @@ export class CelestiaSyncState extends SyncState<
101
99
  rootOutput.primitives.push(...primitives);
102
100
  }
103
101
 
104
- @bound
105
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
106
- return {
107
- own: { height: data.height, hash: data.hash },
108
- ownBlockNumber: data.height,
109
- root: this.toRootPage(data),
110
- };
111
- }
112
-
113
102
  @bound
114
103
  override getNamespace(): string[] {
115
104
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -5,43 +5,6 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
5
5
  import type { PaginatedSyncProtocols } from "../types.ts";
6
6
  import type { PageTypeOf } from "../base/state.ts";
7
7
 
8
- // Fetch backpressure. See README "Backpressure (`maxBufferedPages`)".
9
- const MAX_BUFFER_MULTIPLE = 4;
10
- // Fallback chunk size for chains that fetch without an explicit stepSize
11
- // (e.g. utxorpc streams one block per pass).
12
- const DEFAULT_STEP_SIZE = 1000;
13
-
14
- /** Resolved fetch-backpressure cap for a chain. See README "Backpressure". */
15
- export function bufferCapFor(
16
- syncProtocol: { maxBufferedPages?: number; stepSize?: number },
17
- ): number {
18
- const step = syncProtocol.stepSize ?? DEFAULT_STEP_SIZE;
19
- return Math.max(
20
- syncProtocol.maxBufferedPages ?? MAX_BUFFER_MULTIPLE * step,
21
- step + 1,
22
- );
23
- }
24
-
25
- /**
26
- * Whether a chain should pause fetching because its in-memory buffer is at/over
27
- * the cap. Each chain's `stateToInput` calls this first and returns `undefined`
28
- * when true, so the fetch loop sleeps + retries while the merge drains. Also
29
- * records backpressure metrics on `state` (for `/debug/metrics`).
30
- * See README "Backpressure (`maxBufferedPages`)".
31
- */
32
- export function bufferAtCap(
33
- state: {
34
- bufferedData: { size(): number };
35
- recordBackpressure(atCap: boolean, cap: number): void;
36
- },
37
- syncProtocol: { maxBufferedPages?: number; stepSize?: number },
38
- ): boolean {
39
- const cap = bufferCapFor(syncProtocol);
40
- const atCap = state.bufferedData.size() >= cap;
41
- state.recordBackpressure(atCap, cap);
42
- return atCap;
43
- }
44
-
45
8
  type ConfigSubset<Page> = {
46
9
  name: string;
47
10
  /**
@@ -5,7 +5,6 @@ import type {
5
5
  TimestampMs,
6
6
  } from "@effectstream/utils";
7
7
  import type { PageRelation } from "../base/page.ts";
8
- import type { LastPage } from "../base/state.ts";
9
8
  import type {
10
9
  ConfigSyncProtocolType,
11
10
  FlattenSyncProtocolIOFor,
@@ -20,14 +19,6 @@ export type ChainBlock = {
20
19
  block_number: BlockNumber;
21
20
  blockHash: BlockHash;
22
21
  }[];
23
- /**
24
- * Per-protocol block-accurate resume markers the runtime persists on commit.
25
- * See `@effectstream/sync` CLAUDE.md, design idea #5 (restart/resume).
26
- */
27
- resumePages: {
28
- protocol_name: string;
29
- lastPage: LastPage<unknown, unknown>;
30
- }[];
31
22
  primitives: (
32
23
  & FlattenSyncProtocolIOFor<
33
24
  ConfigSyncProtocolType
@@ -7,7 +7,7 @@ import { bound, type EvmBlockNumber } from "@effectstream/utils";
7
7
  import { type LastPage, SyncState } from "../base/state.ts";
8
8
  import type { RootOutput, RootPage } from "../types.ts";
9
9
  import type { EvmFetcher } from "./fetcher.ts";
10
- import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
10
+ import { genInputRange } from "../common/page-helpers.ts";
11
11
  import type { Input, Output, Page } from "./types.ts";
12
12
  import { toMsTimestamp } from "./types.ts";
13
13
  import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/config";
@@ -53,7 +53,6 @@ export class EvmSyncState extends SyncState<
53
53
 
54
54
  @bound
55
55
  override *stateToInput(): Operation<Input | undefined> {
56
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
57
56
  return yield* genInputRange(
58
57
  this as EvmSyncState,
59
58
  this.config.syncProtocol.startBlockHeight as EvmBlockNumber,
@@ -91,16 +90,6 @@ export class EvmSyncState extends SyncState<
91
90
  rootOutput.blockInfo.push(...blockInfo);
92
91
  }
93
92
 
94
- @bound
95
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
96
- const block = Number(data.raw.number);
97
- return {
98
- own: block,
99
- ownBlockNumber: block,
100
- root: this.toRootPage(data),
101
- };
102
- }
103
-
104
93
  @bound
105
94
  override getNamespace(): string[] {
106
95
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -32,6 +32,14 @@ export interface MidnightGqlBlockState {
32
32
  outputIndex: number;
33
33
  owner: string;
34
34
  }[];
35
+ unshieldedCreatedOutputs?: {
36
+ intentHash: string;
37
+ outputIndex: number;
38
+ owner: string;
39
+ }[];
40
+ // Present only on RegularTransaction (selected via inline fragment): the
41
+ // coin-commitment Merkle tree root after this transaction.
42
+ zswapMerkleTreeRoot?: string;
35
43
  }[];
36
44
  };
37
45
  };
@@ -43,6 +51,10 @@ export interface BlockFetchOptions {
43
51
  zswapLedgerEvents?: boolean;
44
52
  /** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
45
53
  unshieldedSpentOutputs?: boolean;
54
+ /** Include unshieldedCreatedOutputs (needed for MidnightUnshieldedCreatePrimitive). Default: false */
55
+ unshieldedCreatedOutputs?: boolean;
56
+ /** Include `... on RegularTransaction { zswapMerkleTreeRoot }` (needed for MidnightZswapRootPrimitive). Default: false */
57
+ zswapRoots?: boolean;
46
58
  }
47
59
 
48
60
  type PublicDataProvider = ReturnType<typeof indexerPublicDataProvider>;
@@ -168,6 +180,8 @@ export class MidnightClient {
168
180
  contractActions = true,
169
181
  zswapLedgerEvents = true,
170
182
  unshieldedSpentOutputs = false,
183
+ unshieldedCreatedOutputs = false,
184
+ zswapRoots = false,
171
185
  } = options;
172
186
  const contractActionsField = contractActions
173
187
  ? `contractActions { address state }`
@@ -178,6 +192,14 @@ export class MidnightClient {
178
192
  const unshieldedSpentField = unshieldedSpentOutputs
179
193
  ? `unshieldedSpentOutputs { intentHash outputIndex owner }`
180
194
  : "";
195
+ const unshieldedCreatedField = unshieldedCreatedOutputs
196
+ ? `unshieldedCreatedOutputs { intentHash outputIndex owner }`
197
+ : "";
198
+ // zswapMerkleTreeRoot lives on RegularTransaction, not the Transaction
199
+ // interface, so it must be selected through an inline fragment.
200
+ const zswapRootFragment = zswapRoots
201
+ ? `... on RegularTransaction { zswapMerkleTreeRoot }`
202
+ : "";
181
203
  const query = `query {
182
204
  block(offset: { height: ${blockHeight} }) {
183
205
  hash
@@ -192,6 +214,8 @@ export class MidnightClient {
192
214
  ${contractActionsField}
193
215
  ${zswapField}
194
216
  ${unshieldedSpentField}
217
+ ${unshieldedCreatedField}
218
+ ${zswapRootFragment}
195
219
  }
196
220
  }
197
221
  }`;
@@ -84,7 +84,9 @@ export class MidnightFetcher extends BaseDataFetcher<
84
84
  contractActions: this.config.primitives.some(
85
85
  (p) =>
86
86
  p.primitive.type !== "Midnight:Nullifier" &&
87
- p.primitive.type !== "Midnight:UnshieldedSpend",
87
+ p.primitive.type !== "Midnight:UnshieldedSpend" &&
88
+ p.primitive.type !== "Midnight:UnshieldedCreate" &&
89
+ p.primitive.type !== "Midnight:ZswapRoot",
88
90
  ),
89
91
  zswapLedgerEvents: this.config.primitives.some(
90
92
  (p) => p.primitive.type === "Midnight:Nullifier",
@@ -92,6 +94,12 @@ export class MidnightFetcher extends BaseDataFetcher<
92
94
  unshieldedSpentOutputs: this.config.primitives.some(
93
95
  (p) => p.primitive.type === "Midnight:UnshieldedSpend",
94
96
  ),
97
+ unshieldedCreatedOutputs: this.config.primitives.some(
98
+ (p) => p.primitive.type === "Midnight:UnshieldedCreate",
99
+ ),
100
+ zswapRoots: this.config.primitives.some(
101
+ (p) => p.primitive.type === "Midnight:ZswapRoot",
102
+ ),
95
103
  };
96
104
  const self = this;
97
105
  const heights = Array.from(
@@ -105,14 +113,28 @@ export class MidnightFetcher extends BaseDataFetcher<
105
113
  const fetched = yield* all(
106
114
  heights.map(function* (height) {
107
115
  const signal = yield* useAbortSignal();
108
- const result: MidnightGqlBlockState = yield* call(() =>
109
- self.client.fetchBlock(height, blockFetchOptions, signal)
110
- );
111
- const primitives = yield* self.readPrimitives(
112
- height,
113
- result,
114
- self.config.primitives,
115
- );
116
+ let result: MidnightGqlBlockState;
117
+ let primitives;
118
+ try {
119
+ result = yield* call(() =>
120
+ self.client.fetchBlock(height, blockFetchOptions, signal)
121
+ );
122
+ primitives = yield* self.readPrimitives(
123
+ height,
124
+ result,
125
+ self.config.primitives,
126
+ );
127
+ } catch (e) {
128
+ // Surface per-height failures: the outer fetch loop swallows errors
129
+ // and retries silently, which previously hid a permanent failure
130
+ // (e.g. a wasm-instance mismatch in a contract ledger() call) as an
131
+ // ever-stuck page with no log output.
132
+ console.error(
133
+ `[Midnight] readData failed at height ${height}:`,
134
+ (e as Error)?.stack ?? e,
135
+ );
136
+ throw e;
137
+ }
116
138
  return {
117
139
  output: {
118
140
  raw: result.block as unknown as Block,
@@ -166,6 +188,10 @@ export class MidnightFetcher extends BaseDataFetcher<
166
188
  syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
167
189
  } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
168
190
  syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
191
+ } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedCreate") {
192
+ syncResults.push(...this.fetchUnshieldedCreates(height, primitiveEntry, block));
193
+ } else if (primitiveEntry.primitive.type === "Midnight:ZswapRoot") {
194
+ syncResults.push(...this.fetchZswapRoots(height, primitiveEntry, block));
169
195
  } else {
170
196
  asyncOps.push(
171
197
  this.fetchContractState(height, client, primitiveEntry, block),
@@ -243,6 +269,76 @@ export class MidnightFetcher extends BaseDataFetcher<
243
269
  return results;
244
270
  }
245
271
 
272
+ // Mirror of fetchUnshieldedSpends over `unshieldedCreatedOutputs` — every
273
+ // unshielded UTXO created in the block (regular AND system transactions).
274
+ // One state-machine input per created output.
275
+ @bound
276
+ fetchUnshieldedCreates(
277
+ height: number,
278
+ primitiveEntry: PrimitiveEntryType,
279
+ block: MidnightGqlBlockState,
280
+ ): PrimitiveType[] {
281
+ const results: PrimitiveType[] = [];
282
+ for (const tx of block.block.transactions) {
283
+ for (const created of tx.unshieldedCreatedOutputs ?? []) {
284
+ results.push({
285
+ syncProtocol: {
286
+ name: primitiveEntry.syncProtocol,
287
+ blockNumber: height,
288
+ transactionHash: tx.hash,
289
+ contractAddress: "",
290
+ },
291
+ primitive: primitiveEntry.primitive.name,
292
+ output: {
293
+ payloadType: "midnight-unshielded-create",
294
+ payload: {
295
+ owner: created.owner,
296
+ intentHash: created.intentHash,
297
+ outputIndex: created.outputIndex,
298
+ txHash: tx.hash,
299
+ },
300
+ },
301
+ });
302
+ }
303
+ }
304
+ return results;
305
+ }
306
+
307
+ // Emit the latest zswap coin-commitment Merkle tree root for the block: the
308
+ // `zswapMerkleTreeRoot` of the last RegularTransaction that carries one.
309
+ // Blocks with no regular transactions emit nothing (the root is unchanged).
310
+ @bound
311
+ fetchZswapRoots(
312
+ height: number,
313
+ primitiveEntry: PrimitiveEntryType,
314
+ block: MidnightGqlBlockState,
315
+ ): PrimitiveType[] {
316
+ let root: string | undefined;
317
+ let txHash = "";
318
+ for (const tx of block.block.transactions) {
319
+ if (tx.zswapMerkleTreeRoot) {
320
+ root = tx.zswapMerkleTreeRoot;
321
+ txHash = tx.hash;
322
+ }
323
+ }
324
+ if (!root) return [];
325
+ return [
326
+ {
327
+ syncProtocol: {
328
+ name: primitiveEntry.syncProtocol,
329
+ blockNumber: height,
330
+ transactionHash: txHash,
331
+ contractAddress: "",
332
+ },
333
+ primitive: primitiveEntry.primitive.name,
334
+ output: {
335
+ payloadType: "midnight-zswap-root",
336
+ payload: { root, txHash },
337
+ },
338
+ },
339
+ ];
340
+ }
341
+
246
342
  @bound
247
343
  *fetchContractState(
248
344
  height: number,
@@ -275,17 +371,21 @@ export class MidnightFetcher extends BaseDataFetcher<
275
371
  // Addresses length can be padded by 0's
276
372
  return c.address.padStart(longest, '0') === contractAddress.padStart(longest, '0');
277
373
  })!.state!;
278
- const byteState = new Uint8Array(rawState.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
279
374
  const primitive = primitiveEntry.primitive;
280
- const contractState = ContractState.deserialize(byteState);
281
- const stateValue = contractState.data.state;
282
- // Parse additional fields from the schema (defined per-contract on the primitive)
283
- const additionalFields = primitive.parseAdditionalLedgerFields?.(stateValue) ?? {};
284
- // Keep generated ledger fields, but let schema-parsed fields override.
285
- // This is important for map-like fields where contract.ledger() exposes
286
- // iterable wrappers that are not directly JSON-serializable.
287
375
  const f = primitive.contract ?? { ledger: (_: StateValue): Record<string, any> => ({}) };
288
- const state = { ...f.ledger(stateValue), ...additionalFields };
376
+ const ledgerFromTxStateHex = (f as {
377
+ ledgerFromTxStateHex?: (rawHexState: string) => Record<string, any>;
378
+ }).ledgerFromTxStateHex;
379
+ let state: Record<string, any>;
380
+ if (typeof ledgerFromTxStateHex === "function") {
381
+ state = ledgerFromTxStateHex(rawState);
382
+ } else {
383
+ const byteState = new Uint8Array(rawState.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
384
+ const contractState = ContractState.deserialize(byteState);
385
+ const stateValue = contractState.data.state;
386
+ const additionalFields = primitive.parseAdditionalLedgerFields?.(stateValue) ?? {};
387
+ state = { ...f.ledger(stateValue), ...additionalFields };
388
+ }
289
389
  return {
290
390
  syncProtocol: {
291
391
  name: primitiveEntry.syncProtocol,
@@ -10,7 +10,6 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { MidnightClient } from "./MidnightClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
- import { bufferAtCap } from "../common/page-helpers.ts";
14
13
 
15
14
  type LatestBlock = {
16
15
  block: {
@@ -73,7 +72,6 @@ export class MidnightSyncState extends SyncState<
73
72
 
74
73
  @bound
75
74
  override *stateToInput(): Operation<Input | undefined> {
76
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
77
75
  const latestBlockResult = yield* call(() => this.client.fetchLatestBlock());
78
76
  const latestHeight = latestBlockResult.block.height;
79
77
 
@@ -112,15 +110,6 @@ export class MidnightSyncState extends SyncState<
112
110
  rootOutput.primitives.push(...primitives);
113
111
  }
114
112
 
115
- @bound
116
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
117
- return {
118
- own: { height: data.raw.height, hash: data.raw.hash },
119
- ownBlockNumber: data.raw.height,
120
- root: this.toRootPage(data),
121
- };
122
- }
123
-
124
113
  @bound
125
114
  override getNamespace(): string[] {
126
115
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -146,12 +135,7 @@ export class MidnightSyncState extends SyncState<
146
135
  page,
147
136
  config,
148
137
  fetcher,
149
- // TODO: The urls should be part of the config.
150
- new MidnightClient(
151
- config.syncProtocol.indexer,
152
- config.syncProtocol.indexerWS ??
153
- "ws://127.0.0.1:8088/api/v3/graphql/ws",
154
- ),
138
+ fetcher.client,
155
139
  dbConn,
156
140
  );
157
141
  }
@@ -10,7 +10,6 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { NearClient } from "./NearClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
- import { bufferAtCap } from "../common/page-helpers.ts";
14
13
 
15
14
  export class NearSyncState extends SyncState<
16
15
  Input,
@@ -61,7 +60,6 @@ export class NearSyncState extends SyncState<
61
60
 
62
61
  @bound
63
62
  override *stateToInput(): Operation<Input | undefined> {
64
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
65
63
  const latestHeight = yield* call(() =>
66
64
  this.client.getLatestBlockHeight()
67
65
  );
@@ -100,15 +98,6 @@ export class NearSyncState extends SyncState<
100
98
  rootOutput.primitives.push(...primitives);
101
99
  }
102
100
 
103
- @bound
104
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
105
- return {
106
- own: { height: data.height, hash: data.hash },
107
- ownBlockNumber: data.height,
108
- root: this.toRootPage(data),
109
- };
110
- }
111
-
112
101
  @bound
113
102
  override getNamespace(): string[] {
114
103
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -7,7 +7,7 @@ import { bound, type NtpBlockNumber } from "@effectstream/utils";
7
7
  import { type LastPage, SyncState } from "../base/state.ts";
8
8
  import type { RootOutput, RootPage } from "../types.ts";
9
9
  import type { NtpFetcher } from "./fetcher.ts";
10
- import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
10
+ import { genInputRange } from "../common/page-helpers.ts";
11
11
  import type { Input, Output, Page } from "./types.ts";
12
12
  import { toMsTimestamp } from "./types.ts";
13
13
  import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/config";
@@ -48,7 +48,6 @@ export class NtpSyncState extends SyncState<
48
48
  })),
49
49
  blockNumber: Number(data.raw.blockNumber),
50
50
  timestamp: this.toRootPage(data),
51
- resumePages: [],
52
51
  primitives: [],
53
52
  };
54
53
  }
@@ -60,7 +59,6 @@ export class NtpSyncState extends SyncState<
60
59
 
61
60
  @bound
62
61
  override *stateToInput(): Operation<Input | undefined> {
63
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
64
62
  return yield* genInputRange(
65
63
  this as NtpSyncState,
66
64
  1, // NTP Pages start from 1, and the fetcher offsets using the start page (date)
@@ -88,16 +86,6 @@ export class NtpSyncState extends SyncState<
88
86
  throw new Error("Only parallel chains merge into root");
89
87
  }
90
88
 
91
- @bound
92
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
93
- const block = Number(data.raw.blockNumber);
94
- return {
95
- own: block,
96
- ownBlockNumber: block,
97
- root: this.toRootPage(data),
98
- };
99
- }
100
-
101
89
  @bound
102
90
  override getNamespace(): string[] {
103
91
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -124,11 +124,6 @@ export function* mergeIntoRoot<SyncProtocol extends AllSyncProtocols>(
124
124
  }
125
125
  const output = iState.bufferedData.peekAt(0)!;
126
126
  const newRoot = iState.toRootOutput(output.output);
127
- // Resume marker: the single datum this root block is built from (CLAUDE.md #5).
128
- newRoot.resumePages.push({
129
- protocol_name: state.name,
130
- lastPage: iState.outputToLastPage(output.output),
131
- });
132
127
  return {
133
128
  updateCache: () => {
134
129
  output.cleanup();
@@ -173,17 +168,6 @@ export function* mergeIntoRoot<SyncProtocol extends AllSyncProtocols>(
173
168
  iState.mergeDatum,
174
169
  );
175
170
 
176
- // Resume marker: the last (highest) datum merged this iteration, still in the
177
- // Deque at `cleanups.length - 1` (cleanup runs after send). Nothing consumed →
178
- // no marker. See CLAUDE.md #5.
179
- if (cleanups.length > 0) {
180
- const lastConsumed = iState.bufferedData.peekAt(cleanups.length - 1)!;
181
- rootInfo.value.resumePages.push({
182
- protocol_name: state.name,
183
- lastPage: iState.outputToLastPage(lastConsumed.output),
184
- });
185
- }
186
-
187
171
  return {
188
172
  updateCache: () => {
189
173
  state.bufferedData.remove(0, cleanups.length);
@@ -7,7 +7,7 @@ import { bound, type BlockNumber } from "@effectstream/utils";
7
7
  import { type LastPage, SyncState } from "../base/state.ts";
8
8
  import type { RootOutput, RootPage } from "../types.ts";
9
9
  import type { TestFetcher } from "./fetcher.ts";
10
- import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
10
+ import { genInputRange } from "../common/page-helpers.ts";
11
11
  import type { Input, Output, Page } from "./types.ts";
12
12
  import { toMsTimestamp } from "./types.ts";
13
13
  import type {
@@ -60,7 +60,6 @@ export class TestSyncState extends SyncState<
60
60
  })),
61
61
  blockNumber: Number(data.raw.blockNumber),
62
62
  timestamp: this.toRootPage(data),
63
- resumePages: [],
64
63
  primitives: data.primitives.map((p) => ({
65
64
  ...p,
66
65
  source: this.config.syncProtocol.name,
@@ -78,7 +77,6 @@ export class TestSyncState extends SyncState<
78
77
 
79
78
  @bound
80
79
  override *stateToInput(): Operation<Input | undefined> {
81
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
82
80
  return yield* genInputRange(
83
81
  this as TestSyncState,
84
82
  this.config.syncProtocol.startBlockHeight as BlockNumber,
@@ -116,16 +114,6 @@ export class TestSyncState extends SyncState<
116
114
  rootOutput.blockInfo.push(...blockInfo);
117
115
  }
118
116
 
119
- @bound
120
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
121
- const block = Number(data.raw.blockNumber);
122
- return {
123
- own: block,
124
- ownBlockNumber: block as BlockNumber,
125
- root: this.toRootPage(data),
126
- };
127
- }
128
-
129
117
  @bound
130
118
  override getNamespace(): string[] {
131
119
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -40,15 +40,13 @@ export class WatchMultiplexer {
40
40
  ) {
41
41
  for (const [key, { predicate }] of primitivesByPredicate) {
42
42
  const effective = isEffectiveServerPredicate(predicate);
43
- // TODO(dolos): when Dolos implements has_certificate, remove this fallback
44
43
  const sdkPredicate = effective
45
44
  ? configPredicateToSdkPredicate(predicate)
46
45
  : {};
47
46
 
48
47
  if (!effective) {
49
48
  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.`
49
+ `[WatchMultiplexer] Predicate for key "${key}" has no server-filterable fields. Falling back to match-all with client-side filtering.`
52
50
  );
53
51
  }
54
52
 
@@ -1,5 +1,4 @@
1
1
  import { applyDelay } from "../common/utils.ts";
2
- import { bufferAtCap } from "../common/page-helpers.ts";
3
2
  import type { Operation } from "effection";
4
3
  import { call } from "effection";
5
4
  import { getPage } from "@effectstream/db";
@@ -72,7 +71,6 @@ export class UtxoRpcSyncState extends SyncState<
72
71
 
73
72
  @bound
74
73
  override *stateToInput(): Operation<Input | undefined> {
75
- if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
76
74
  // Get the value of the latest block height.
77
75
  const tipHeight = yield* call(() => this.fetcher.lastHeight());
78
76
  if (!tipHeight) {
@@ -159,22 +157,6 @@ export class UtxoRpcSyncState extends SyncState<
159
157
  rootOutput.blockInfo.push(...blockInfo);
160
158
  }
161
159
 
162
- @bound
163
- override outputToLastPage(data: Output): LastPage<Page, RootPage> {
164
- // Mirror the fetcher's `own`: slot/height from the block header, and the
165
- // block hash from `blockHashes` (the fetcher builds it as `[completed.hash]`,
166
- // the same value it stored in `own.hash`).
167
- return {
168
- own: {
169
- slot: Number(data.raw.header!.slot),
170
- height: Number(data.raw.header!.height),
171
- hash: data.blockHashes[0],
172
- },
173
- ownBlockNumber: Number(data.raw.header!.height),
174
- root: this.toRootPage(data),
175
- };
176
- }
177
-
178
160
  @bound
179
161
  override getNamespace(): string[] {
180
162
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -1,5 +1,5 @@
1
1
  import type { cardano } from '@utxorpc/spec';
2
- import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPattern, UtxorpcTxOutputPattern, UtxorpcTxPredicate } from "@effectstream/config";
2
+ import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPattern, UtxorpcCertificatePattern, UtxorpcTxOutputPattern, UtxorpcTxPredicate } from "@effectstream/config";
3
3
  import { hexStringToUint8Array } from "@effectstream/utils";
4
4
  import { Cardano } from '@cardano-sdk/core';
5
5
  import { HexBlob } from '@cardano-sdk/util';
@@ -111,14 +111,24 @@ function convertAssetPattern(pattern: UtxorpcAssetPattern): Record<string, Uint8
111
111
  return result;
112
112
  }
113
113
 
114
+ function convertCertificatePattern(pattern: boolean | UtxorpcCertificatePattern): Record<string, unknown> {
115
+ if (typeof pattern === "boolean") return {};
116
+ if (pattern.stake_delegation) {
117
+ const value: Record<string, unknown> = {};
118
+ if (pattern.stake_delegation.pool_keyhash) {
119
+ value.poolKeyhash = hexStringToUint8Array(pattern.stake_delegation.pool_keyhash);
120
+ }
121
+ return { certificateType: { case: "stakeDelegation", value } };
122
+ }
123
+ if (pattern.any_pool_keyhash) {
124
+ return { certificateType: { case: "anyPoolKeyhash", value: hexStringToUint8Array(pattern.any_pool_keyhash) } };
125
+ }
126
+ return {};
127
+ }
128
+
114
129
  /**
115
130
  * 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
131
  */
121
- // TODO(dolos): when Dolos implements has_certificate filtering, this conversion will just work
122
132
  export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPredicate {
123
133
  const result: TxPredicate = {};
124
134
 
@@ -128,7 +138,7 @@ export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPre
128
138
  if (c.has_address) match.hasAddress = convertAddressPattern(c.has_address);
129
139
  if (c.moves_asset) match.movesAsset = convertAssetPattern(c.moves_asset);
130
140
  if (c.mints_asset) match.mintsAsset = convertAssetPattern(c.mints_asset);
131
- if (c.has_certificate) match.hasCertificate = {};
141
+ if (c.has_certificate) match.hasCertificate = convertCertificatePattern(c.has_certificate);
132
142
  result.match = match as TxPredicate["match"];
133
143
  }
134
144
 
@@ -148,13 +158,10 @@ export function predicateKey(predicate: UtxorpcTxPredicate): string {
148
158
  }
149
159
 
150
160
  /**
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.
161
+ * Returns true if this predicate can be effectively filtered server-side.
154
162
  */
155
- // TODO(dolos): remove this when Dolos implements has_certificate filtering
156
163
  export function isEffectiveServerPredicate(predicate: UtxorpcTxPredicate): boolean {
157
164
  if (!predicate.match?.cardano) return false;
158
165
  const c = predicate.match.cardano;
159
- return !!(c.has_address || c.moves_asset || c.mints_asset);
166
+ return !!(c.has_address || c.moves_asset || c.mints_asset || c.has_certificate);
160
167
  }