@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 +28 -47
- package/README.md +0 -49
- package/package.json +6 -6
- package/src/sync-protocols/avail/state.ts +0 -11
- package/src/sync-protocols/base/state.ts +9 -37
- package/src/sync-protocols/bitcoin/state.ts +1 -13
- package/src/sync-protocols/celestia/state.ts +0 -11
- package/src/sync-protocols/common/page-helpers.ts +0 -37
- package/src/sync-protocols/common/root.ts +0 -9
- package/src/sync-protocols/evm/state.ts +1 -12
- package/src/sync-protocols/midnight/MidnightClient.ts +24 -0
- package/src/sync-protocols/midnight/fetcher.ts +118 -18
- package/src/sync-protocols/midnight/state.ts +1 -17
- package/src/sync-protocols/near/state.ts +0 -11
- package/src/sync-protocols/ntp/state.ts +1 -13
- package/src/sync-protocols/orchestration/merge.ts +0 -16
- package/src/sync-protocols/test/state.ts +1 -13
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +1 -3
- package/src/sync-protocols/utxorpc/state.ts +0 -18
- package/src/sync-protocols/utxorpc/utils.ts +19 -12
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
|
-
│ →
|
|
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
|
-
→
|
|
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,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
`
|
|
120
|
-
|
|
121
|
-
|
|
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)
|
|
149
|
+
### 2. Silent data gap on restart (correctness)
|
|
173
150
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
commit
|
|
177
|
-
`getPage` = oldest remaining chunk at `nextInterval(own).from = chunk_end + 1`.
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`
|
|
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
|
|
205
|
-
>
|
|
206
|
-
>
|
|
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.
|
|
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.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
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.
|
|
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
|
-
|
|
166
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
self.
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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}"
|
|
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
|
|
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
|
}
|