@effectstream/sync 0.100.17 → 0.100.19
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 +206 -0
- package/README.md +49 -0
- package/docs/ADDING-A-SYNC-PROTOCOL.md +164 -0
- package/package.json +5 -5
- package/src/sync-protocols/avail/state.ts +11 -0
- package/src/sync-protocols/base/state.ts +37 -9
- package/src/sync-protocols/bitcoin/state.ts +13 -1
- package/src/sync-protocols/celestia/state.ts +11 -0
- package/src/sync-protocols/common/page-helpers.ts +37 -0
- package/src/sync-protocols/common/root.ts +9 -0
- package/src/sync-protocols/evm/state.ts +12 -1
- package/src/sync-protocols/midnight/state.ts +11 -0
- package/src/sync-protocols/mod.ts +4 -0
- package/src/sync-protocols/near/state.ts +11 -0
- package/src/sync-protocols/ntp/state.ts +13 -1
- package/src/sync-protocols/orchestration/merge.ts +16 -0
- package/src/sync-protocols/test/control.ts +27 -0
- package/src/sync-protocols/test/fetcher.ts +175 -0
- package/src/sync-protocols/test/state.ts +154 -0
- package/src/sync-protocols/test/types.ts +36 -0
- package/src/sync-protocols/types.ts +3 -1
- package/src/sync-protocols/utxorpc/state.ts +18 -0
- package/src/syncProtocolFactory.ts +13 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# `@effectstream/sync` — how the sync service works
|
|
2
|
+
|
|
3
|
+
This package fetches data from every configured chain, interleaves it into a single
|
|
4
|
+
deterministic stream of **effectstream blocks** ordered by time, and hands that stream
|
|
5
|
+
to the runtime for state-machine execution.
|
|
6
|
+
|
|
7
|
+
> **Naming gotcha:** despite the project name and an `effect` (Effect-TS) entry in the
|
|
8
|
+
> root `package.json`, the sync service uses **[`effection`](https://frontside.com/effection)**,
|
|
9
|
+
> a generator-based structured-concurrency library. Effect-TS is **not imported anywhere**
|
|
10
|
+
> in source. Everywhere below, an "effect" is an `effection` `Operation<T>` — a lazy,
|
|
11
|
+
> cancellable computation written as a `function*` and composed with `yield*`.
|
|
12
|
+
|
|
13
|
+
## effection in 30 seconds
|
|
14
|
+
|
|
15
|
+
| effection | rough analogy |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `Operation<T>` | a lazy, cancellable `async` function |
|
|
18
|
+
| `function*` / `yield*` | `async` / `await` |
|
|
19
|
+
| `spawn(op)` | fork a child task (dies with its parent scope) |
|
|
20
|
+
| `call(() => promise)` / `until(promise)` | `await` a Promise inside an Operation |
|
|
21
|
+
| `all([...ops])` | `Promise.all` |
|
|
22
|
+
| `createChannel()` + `each(ch)` | an async stream / `for await` |
|
|
23
|
+
| `conditionVariable()` (`@effectstream/utils`, `concurrency/condVar.ts`) | a monitor wait/wake |
|
|
24
|
+
|
|
25
|
+
Because every loop is `spawn`ed under the runtime's `start()` scope, cancelling the parent
|
|
26
|
+
tears down every fetcher, the merge, the heartbeat, and the HTTP server cleanly.
|
|
27
|
+
|
|
28
|
+
## The pipeline at a glance
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
config (syncProtocols.main + syncProtocols.parallel)
|
|
32
|
+
│ genSyncProtocols() → one SyncState per chain, main at index 0
|
|
33
|
+
▼
|
|
34
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
35
|
+
│ PER-CHAIN FETCH LOOPS one spawn per protocol │
|
|
36
|
+
│ startSync(state): stateToInput → readData → updateState │
|
|
37
|
+
│ → push Output to bufferedData (Deque) + wake newData/newPageCondVar│
|
|
38
|
+
│ → lastPage kept IN-MEMORY only (no DB write; runtime owns resume) │
|
|
39
|
+
│ → producerChannel.send(...) ← NOTE: nothing consumes this today │
|
|
40
|
+
└────────────────────────────────────────┬─────────────────────────────┘
|
|
41
|
+
▼
|
|
42
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
43
|
+
│ MERGE LOOP startMerge() one spawn, produces ONE ChainBlock/iter │
|
|
44
|
+
│ i=0 main: toRootOutput(front datum) → new root block @ timestamp T│
|
|
45
|
+
│ i>0 parallel: wait page>T, drain buffered data ≤T via mergeDatum() │
|
|
46
|
+
│ → finalizedBlockStream.send(block); then run cleanups (pop Deques) │
|
|
47
|
+
└────────────────────────────────────────┬─────────────────────────────┘
|
|
48
|
+
▼
|
|
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()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## The three per-chain abstractions (`src/sync-protocols/<chain>/`)
|
|
56
|
+
|
|
57
|
+
1. **Fetcher** — `BaseDataFetcher` subclass (`base/fetcher.ts`). `readData(input)` fetches a
|
|
58
|
+
page-range of raw chain data → `DataFetched { output[], lastPage }`. Implements
|
|
59
|
+
`PaginatedFetcher` (`getLatestPage`, `nextInterval`, `previousInterval`,
|
|
60
|
+
`intervalFromStart`) for chunked pagination by `stepSize`, and optionally
|
|
61
|
+
`PrimitiveFetcher` (`base/primitive.ts`: `readPrimitives` / `groupByPage`) to extract
|
|
62
|
+
app-relevant primitives from raw blocks.
|
|
63
|
+
|
|
64
|
+
2. **State** — `SyncState` subclass (`base/state.ts`). Holds `bufferedData` (a `denque`
|
|
65
|
+
Deque of fetched-not-yet-merged data, `state.ts:50`), `lastPage` (high-water mark,
|
|
66
|
+
`state.ts:70`), two CondVars (`newDataCondVar` / `newPageCondVar`, `state.ts:57-63`),
|
|
67
|
+
and error/health counters. Defines the chain↔root contract:
|
|
68
|
+
- `stateToInput()` — compute the next page-range to fetch (via `common/page-helpers.ts:genInputRange`)
|
|
69
|
+
- `toRootPage()` — map a block → millisecond timestamp (the **merge key**)
|
|
70
|
+
- `toRootOutput()` — **main only** — produce a root `ChainBlock`
|
|
71
|
+
- `mergeDatum()` — **parallel only** — fold this chain's data into the root
|
|
72
|
+
- `updateState()` (`state.ts:109`) — push outputs to the Deque, wake CondVars, and
|
|
73
|
+
persist the page (`upsertPage`, `state.ts:132`).
|
|
74
|
+
|
|
75
|
+
3. **types.ts** — `Input` / `Output` / `Page` aliases (+ a `Client.ts` per chain where an
|
|
76
|
+
RPC client is needed).
|
|
77
|
+
|
|
78
|
+
**main vs parallel** is structural in config — `toSyncProtocolWithNetwork`
|
|
79
|
+
(`@effectstream/config`, `config/utils.ts`) builds the array **main-first**, then parallels —
|
|
80
|
+
but enforced at runtime only by thrown errors: e.g. `evm/state.ts` `toRootOutput` throws
|
|
81
|
+
"Only main chains create root outputs"; `ntp/state.ts` `mergeDatum` throws "Only parallel
|
|
82
|
+
chains merge into root". **The main chain must be at index 0.**
|
|
83
|
+
|
|
84
|
+
## The two driving loops
|
|
85
|
+
|
|
86
|
+
**`startSync(state)`** (`src/sync-protocols/orchestration/sync.ts`) spawns:
|
|
87
|
+
- `startAsync()` — optional background producer (websocket subs for utxorpc/midnight;
|
|
88
|
+
no-op for NTP/EVM).
|
|
89
|
+
- the **polling loop**: `stateToInput()` → `readData()` → `updateState()` →
|
|
90
|
+
`producerChannel.send()` (`sync.ts:70`). Errors are swallowed via `tryYield`, bump
|
|
91
|
+
`consecutiveErrors`, log, sleep `pollingInterval`, continue. The loop only sleeps when
|
|
92
|
+
`stateToInput` returns `undefined` (caught up); while behind it fetches chunks
|
|
93
|
+
back-to-back.
|
|
94
|
+
|
|
95
|
+
**`startMerge(syncProtocols, finalizedBlockStream)`** (`orchestration/merge.ts`) loops; per
|
|
96
|
+
iteration it walks protocols in order:
|
|
97
|
+
- **main (i=0, root not yet set):** wait for first page, peek the front buffered datum,
|
|
98
|
+
`toRootOutput` → a fresh `ChainBlock` whose `timestamp` becomes the slot boundary `T`.
|
|
99
|
+
- **parallel (i>0):** block on `newPageCondVar` until *that chain's page has advanced past
|
|
100
|
+
`T`* (proving it scanned all data up to `T`), then drain every buffered datum with
|
|
101
|
+
timestamp ≤ `T` into the root via `mergeDatum`.
|
|
102
|
+
- send the block downstream, **then** run cleanups that pop consumed data from each Deque.
|
|
103
|
+
|
|
104
|
+
## Key design ideas
|
|
105
|
+
|
|
106
|
+
1. **Time is the universal merge key.** Every chain maps blocks → ms timestamp. The main
|
|
107
|
+
chain (usually NTP, a wall-clock heartbeat at `blockTimeMS`) emits empty root blocks at a
|
|
108
|
+
fixed cadence; parallel chains' data is bucketed into those slots.
|
|
109
|
+
2. **Page ≠ Data.** `lastPage` advances even on an empty query, so a parallel chain can
|
|
110
|
+
signal "scanned up to `T`, found nothing" and let the merge proceed. `newPageCondVar`
|
|
111
|
+
= progress; `newDataCondVar` = content.
|
|
112
|
+
3. **Confirmation depth as a time shift.** `common/utils.ts:applyDelay` *adds* `delayMs` to
|
|
113
|
+
a parallel chain's timestamp so older blocks merge into more-recent root slots.
|
|
114
|
+
4. **Mutate-after-commit.** `OutputAndCleanup` keeps data in the Deque until the block is
|
|
115
|
+
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`.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Findings (open issues, as of this investigation)
|
|
148
|
+
|
|
149
|
+
Both bite hardest during **deep catch-up** — e.g. DB synced 3 days ago with a 0.25s chain
|
|
150
|
+
(~1.04M blocks behind) and a 6s chain (~43k behind), NTP main at `blockTimeMS: 1000`.
|
|
151
|
+
|
|
152
|
+
### 1. Unbounded buffering + head-of-line blocking (performance / OOM)
|
|
153
|
+
|
|
154
|
+
The fetch loop has **no backpressure** and **no clamp** relative to the merge or other
|
|
155
|
+
chains: `genInputRange` (`common/page-helpers.ts`) clamps each fetch only to the chain's own
|
|
156
|
+
tip and `nextPage + stepSize`. A `grep` confirms the only reads of `bufferedData.size()` are
|
|
157
|
+
the merge's first-datum wait (`merge.ts:110`) and the heartbeat log (`runtime/main.ts:106`) —
|
|
158
|
+
nothing pauses fetching when the Deque grows.
|
|
159
|
+
|
|
160
|
+
Consequences during catch-up:
|
|
161
|
+
- Each chain races to its own tip, filling its in-memory Deque toward the **entire backlog**
|
|
162
|
+
(~1.04M block objects for the 0.25s chain; NTP fills ~259k near-instantly since it's pure
|
|
163
|
+
arithmetic). Drain happens only at serial-replay speed (one DB txn + STF per block in
|
|
164
|
+
`processFinalizedBlock`), which is far slower than fetch → peak memory ≈ whole backlog →
|
|
165
|
+
**OOM risk**. No cap exists.
|
|
166
|
+
- The merge is gated per timestamp `T` on the slowest chain's *page* to advance
|
|
167
|
+
(`mergeIntoRoot`, `merge.ts`), so a slow/stalled chain stalls all block production while
|
|
168
|
+
the others keep ballooning (head-of-line blocking).
|
|
169
|
+
|
|
170
|
+
→ Fix: **(C) backpressure** — cap `bufferedData` and pause the fetch loop until the merge drains.
|
|
171
|
+
|
|
172
|
+
### 2. Silent data gap on restart (correctness) — ✅ FIXED (D)
|
|
173
|
+
|
|
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.
|
|
181
|
+
|
|
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).
|
|
186
|
+
|
|
187
|
+
### 3. Serial replay is the catch-up bottleneck
|
|
188
|
+
|
|
189
|
+
Block cadence is the main chain's granularity (1/s for NTP@1000), so 3 days ≈ 259,200
|
|
190
|
+
effectstream blocks, each a separate DB transaction + STF run. Most are empty during
|
|
191
|
+
catch-up.
|
|
192
|
+
|
|
193
|
+
→ Fix: **(E) faster replay** (conservative, flag-gated) — coalesce consecutive empty
|
|
194
|
+
catch-up blocks into fewer transactions while preserving steady-state semantics and the
|
|
195
|
+
block-hash chain.
|
|
196
|
+
|
|
197
|
+
> This PR adds the synthetic `test` chain (`src/sync-protocols/test/`) and an
|
|
198
|
+
> in-process harness (`packages/node-sdk/runtime/test/reproduction/`, in the
|
|
199
|
+
> runtime package because it boots the full `start()`, which sync must not depend
|
|
200
|
+
> on). `sanity.test.ts` proves the chain syncs, processes a configured event, and
|
|
201
|
+
> survives a restart. See `docs/ADDING-A-SYNC-PROTOCOL.md` for how to add a chain
|
|
202
|
+
> (with `test` as the worked example).
|
|
203
|
+
>
|
|
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.
|
package/README.md
CHANGED
|
@@ -46,6 +46,55 @@ 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
|
+
|
|
49
98
|
## Key exports
|
|
50
99
|
|
|
51
100
|
- `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.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Adding a new chain / sync protocol
|
|
2
|
+
|
|
3
|
+
This is the end-to-end checklist for registering a new chain, distilled from
|
|
4
|
+
adding the synthetic **`test`** chain (its files are the worked example — search
|
|
5
|
+
for `TEST_MAIN` / `TEST_PARALLEL` / `ConfigNetworkType.TEST`). Read
|
|
6
|
+
`packages/node-sdk/sync/CLAUDE.md` first for how the sync service works.
|
|
7
|
+
|
|
8
|
+
A chain needs a **network type** (`ConfigNetworkType`) and one or more
|
|
9
|
+
**sync-protocol types** (`ConfigSyncProtocolType`). A network may have several
|
|
10
|
+
sync protocols (e.g. Cardano has CARP + UTXORPC; the `test` chain has a `main`
|
|
11
|
+
clock variant and a `parallel` variant). Each sync protocol is either **main**
|
|
12
|
+
(produces root blocks via `toRootOutput`) or **parallel** (folds data into the
|
|
13
|
+
root via `mergeDatum`).
|
|
14
|
+
|
|
15
|
+
> TypeScript is your friend here: several registries are
|
|
16
|
+
> `satisfies Record<ConfigSyncProtocolType, …>`, so the compiler forces you to
|
|
17
|
+
> add entries. But note this repo does **not** typecheck cleanly under
|
|
18
|
+
> `bunx tsc` (it runs via bun's loaders); validate by running
|
|
19
|
+
> `bun test ./packages` and a config that uses the new chain.
|
|
20
|
+
|
|
21
|
+
## Pick an archetype to copy
|
|
22
|
+
|
|
23
|
+
Start from the closest existing chain rather than from scratch:
|
|
24
|
+
|
|
25
|
+
| Your chain | Copy | Notes |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Synthetic / arithmetic clock (no RPC) | `ntp/`, `test/` | blocks computed from `startTime`+`blockTimeMS`; main role |
|
|
28
|
+
| RPC, polled, emits primitives (parallel) | `evm/` | `getLatestPage` from RPC; `readPrimitives` filters logs; needs a viem client |
|
|
29
|
+
| Bitcoin-style RPC | `bitcoin/` | custom `Client.ts` (`BitcoinRpcClient`), small `stepSize` (25) |
|
|
30
|
+
| Streaming / subscription (push) | `utxorpc/`, `midnight/` | override **`startAsync`**; a `Client.ts` + a multiplexer/buffer feed the channel |
|
|
31
|
+
| Substrate-based | `avail/`, `midnight/` | substrate network schema + genesis hash |
|
|
32
|
+
| NEAR-style | `near/` | `Client.ts`, event-standard filters |
|
|
33
|
+
|
|
34
|
+
`stepSize` (chunk size) defaults vary by chain: 1000 (EVM/NTP/Avail), 25
|
|
35
|
+
(Bitcoin), 10 (Midnight/Celestia/NEAR) — pick what the RPC can serve per call.
|
|
36
|
+
|
|
37
|
+
## 1. Config (`@effectstream/config`, `src/schema/`)
|
|
38
|
+
|
|
39
|
+
- **`network/types.ts`** — add to the `ConfigNetworkType` enum.
|
|
40
|
+
- **`network/<chain>.ts`** (new) — a `new ConfigSchema({ required, optional })`
|
|
41
|
+
describing the network (mirror `network/ntp.ts`). Optional fields **must** have
|
|
42
|
+
a `default`.
|
|
43
|
+
- **`network/all.ts`** — import the schema and add it to `networkTypes`. (This is
|
|
44
|
+
all that's needed for the network to appear in `NetworkConfig` — it's derived
|
|
45
|
+
from `networkTypes`.)
|
|
46
|
+
- **`network/utils.ts`** — add a `case` to `caip2PrefixFor` (the trailing
|
|
47
|
+
`throw` means an un-handled type fails at runtime).
|
|
48
|
+
- **`sync-protocols/types.ts`** —
|
|
49
|
+
- add to the `ConfigSyncProtocolType` enum;
|
|
50
|
+
- add to `SyncProtocolToNetwork` (**exhaustive** `satisfies Record`);
|
|
51
|
+
- add a `…Primitive` type and an entry in `ProtocolPrimitiveMap`.
|
|
52
|
+
- **`sync-protocols/<chain>/rpc.ts`** (new) — `ConfigSchema`s for each variant +
|
|
53
|
+
a `CommonResponse…` per variant (mirror `ntp/rpc.ts` for main,
|
|
54
|
+
`evm/rpc.ts` for parallel). Shared fields come from `NameField`,
|
|
55
|
+
`PollingSyncProtocol`, `StartStopBlockheight` via `.cloneMerge(...)`.
|
|
56
|
+
- **`sync-protocols/all.ts`** — register each variant in `mainSyncProtocolTypes`
|
|
57
|
+
/ `parallelSyncProtocolTypes`, and its `CommonResponse` in
|
|
58
|
+
`syncProtocolCommonResponse` (**exhaustive** `satisfies Record`).
|
|
59
|
+
- **`primitive/types.ts`** — add a payload type + entry in `ProtocolPayloadMap`
|
|
60
|
+
(used by `FlattenSyncProtocolIOFor`).
|
|
61
|
+
|
|
62
|
+
`mod.ts` re-exports are optional for the rpc files (NTP/NEAR/test rpc are *not*
|
|
63
|
+
re-exported; `all.ts` imports them directly). `CommonResponse` has **no
|
|
64
|
+
node-sdk runtime consumer** — it's config-side type derivation — so you have
|
|
65
|
+
latitude on its exact shape.
|
|
66
|
+
|
|
67
|
+
## 2. Sync (`@effectstream/sync`, `src/`)
|
|
68
|
+
|
|
69
|
+
- **`sync-protocols/<chain>/types.ts`** — `Page`, `Input` (`PageSyncRange<Page>`),
|
|
70
|
+
`Output` (must include `primitives: PrimitiveType[]` if the chain emits any),
|
|
71
|
+
`PrimitiveType = FlattenSyncProtocolIOFor<…>`, `toMsTimestamp`.
|
|
72
|
+
- **`sync-protocols/<chain>/fetcher.ts`** — extend `BaseDataFetcher`; implement
|
|
73
|
+
`readData` (+ `PaginatedFetcher`: `getLatestPage`/`nextInterval`/
|
|
74
|
+
`previousInterval`/`intervalFromStart`, and `PrimitiveFetcher` if it emits
|
|
75
|
+
primitives). `lastPage.own`/`ownBlockNumber` = the **chunk end** (`data.to`).
|
|
76
|
+
Page-request helpers live in `base/page.ts`
|
|
77
|
+
(`genImmediatePageRequests`/`genOnDemandPageRequests`).
|
|
78
|
+
- **`sync-protocols/<chain>/Client.ts`** (new, only if it talks to a node) — the
|
|
79
|
+
RPC/stream client (e.g. `bitcoin/fetcher.ts:BitcoinRpcClient`,
|
|
80
|
+
`near/NearClient.ts`). EVM-family chains instead use a viem client built with
|
|
81
|
+
`createViemPublicClient` + `getViemNetwork` (declare the network via the
|
|
82
|
+
builder's `addViemNetwork`).
|
|
83
|
+
- **`sync-protocols/<chain>/state.ts`** — extend `SyncState`; implement
|
|
84
|
+
`stateToInput` (via `common/page-helpers.ts:genInputRange`), `toRootPage`
|
|
85
|
+
(→ ms timestamp; use `applyDelay` for confirmation depth), `toRootOutput`
|
|
86
|
+
(main), `mergeDatum` (parallel), `getNamespace`, and a static `restoreState`
|
|
87
|
+
that reads `getPage`. For **streaming** chains, override **`startAsync()`** to
|
|
88
|
+
run the subscription/producer in the background (it feeds `bufferedData`);
|
|
89
|
+
polled chains leave it as the no-op default. (A `maxBufferedData` backpressure
|
|
90
|
+
cap on the constructor is a planned follow-up — see CLAUDE.md finding #1.)
|
|
91
|
+
- **`syncProtocolFactory.ts`** — add an `else if (entry.networkType === …)`
|
|
92
|
+
branch constructing the client + fetcher + `restoreState`.
|
|
93
|
+
- **`sync-protocols/types.ts`** — add the state class to the `AllSyncProtocols`
|
|
94
|
+
union.
|
|
95
|
+
- **`sync-protocols/mod.ts`** — export the new fetcher/state.
|
|
96
|
+
|
|
97
|
+
## 3. Runtime (`@effectstream/runtime`, `src/`)
|
|
98
|
+
|
|
99
|
+
- **`api/http-server.ts`** — add a `case` to the `switch (networkType)` in the
|
|
100
|
+
blocks endpoint (import the fetcher).
|
|
101
|
+
- **`config-snapshot.ts`** — `extractImmutableConfig` **throws** for un-handled
|
|
102
|
+
protocol types. Add the chain to the right branch (NTP/TEST share
|
|
103
|
+
`startTime`+`blockTimeMS`; most parallel chains use `startBlockHeight`).
|
|
104
|
+
- **`main.ts` clock detection** (only if your chain can be the **main** clock) —
|
|
105
|
+
the lag threshold is derived from the main clock's `blockTimeMS` via a
|
|
106
|
+
`syncInfo.find(...)` that today keys on `NTP_MAIN`. If your chain is a new main
|
|
107
|
+
clock and you want lag logging (and the planned catch-up batching) to engage,
|
|
108
|
+
add its `*_MAIN` type to that predicate.
|
|
109
|
+
|
|
110
|
+
## 4. Primitives (only if the chain emits data)
|
|
111
|
+
|
|
112
|
+
A fetched primitive's `primitive` field must equal a **registered primitive's
|
|
113
|
+
instanceName**, which is the config primitive's `id` (= its `name`). The fetcher
|
|
114
|
+
typically tags emitted primitives with `this.config.primitives[i].id`. The
|
|
115
|
+
primitive class (built-in in `@effectstream/sm` or supplied via
|
|
116
|
+
`StartConfig.userDefinedPrimitives`) turns raw data into `primitive_accounting`
|
|
117
|
+
rows and optional scheduled STF inputs.
|
|
118
|
+
|
|
119
|
+
## Gotchas / learnings
|
|
120
|
+
|
|
121
|
+
- **One network → many sync protocols** is allowed (Cardano, `test`). `FlipObject`
|
|
122
|
+
unions them, so `addMain`/`addParallel` narrow correctly.
|
|
123
|
+
- **Resume is currently chunk-granular** (a known issue — CLAUDE.md finding #2):
|
|
124
|
+
on restart a chain resumes from the *end* of its oldest retained page chunk,
|
|
125
|
+
which can silently skip an un-committed tail. A block-accurate-resume fix is
|
|
126
|
+
planned; until then, avoid relying on exact restart positions mid-chunk.
|
|
127
|
+
- **Merge boundary gate**: a parallel chain only merges into a root block at
|
|
128
|
+
timestamp `T` once its own page is *strictly* past `T`. If the main clock runs
|
|
129
|
+
to the same height as a parallel chain's fetched tip, the merge stalls at that
|
|
130
|
+
boundary — keep the main clock at/behind the parallel tips.
|
|
131
|
+
- **`finalizedBlockStream` is unbuffered**: a message sent with no active
|
|
132
|
+
subscriber is dropped. The merge is spawned before the consumer loop
|
|
133
|
+
subscribes, so a very fast producer could in principle drop early blocks
|
|
134
|
+
(RPC-paced chains don't hit this); a subscribe-before-spawn hardening is a
|
|
135
|
+
planned follow-up.
|
|
136
|
+
- **`isPresync`** is currently ~always false (`genInputRange` TODO) — the
|
|
137
|
+
historical-presync path may not trigger as intended.
|
|
138
|
+
|
|
139
|
+
## Verify
|
|
140
|
+
|
|
141
|
+
Build a `ConfigBuilder` config that declares the chain (see
|
|
142
|
+
`packages/node-sdk/runtime/test/reproduction/sanity.test.ts` for the `test`-chain
|
|
143
|
+
config), then `toSyncProtocolWithNetwork(config)` + `start({...})`, or run
|
|
144
|
+
`bun test packages/node-sdk/runtime/test/reproduction/`.
|
|
145
|
+
|
|
146
|
+
## Testing a new chain (in-process harness)
|
|
147
|
+
|
|
148
|
+
`packages/node-sdk/runtime/test/reproduction/harness.ts` is the reusable
|
|
149
|
+
template — it boots the real `start()` in-process against an in-process PGLite
|
|
150
|
+
server and the synthetic `test` chain. Reuse its patterns for any chain:
|
|
151
|
+
|
|
152
|
+
- **Env is lazy** (`ENV.*` reads `process.env` at access), so set
|
|
153
|
+
`PGLITE`/`DB_*`/`EFFECTSTREAM_API_PORT` per run before `start()`. With multiple
|
|
154
|
+
harnesses in one file, re-assert DB env in `runNode` (shared `process.env`).
|
|
155
|
+
- **Determinism**: drive the synthetic chain's tip via the in-memory
|
|
156
|
+
`TestChainControl` registry (no real clock); poll with
|
|
157
|
+
`waitForHeight`/`waitUntilStable` (fixed tips → a well-defined stable state).
|
|
158
|
+
- **Observe live state** via the test-only `StartConfig.dev.onStarted` hook
|
|
159
|
+
(exposes the `AllSyncProtocols[]`, e.g. for `bufferedData.size()` assertions)
|
|
160
|
+
and assert effects against DB tables (`primitive_accounting`,
|
|
161
|
+
`rollup_input_result`, `effectstream_blocks`, `sync_protocol_pagination`).
|
|
162
|
+
- **Simulate a restart** by halting the node task while keeping PGLite alive
|
|
163
|
+
(state persists), then booting again.
|
|
164
|
+
- Set `MQTT_BROKER=false` (it binds fixed ports that aren't freed on halt).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sync",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.19",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/config": "0.100.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
16
|
+
"@effectstream/config": "0.100.19",
|
|
17
|
+
"@effectstream/db": "0.100.19",
|
|
18
|
+
"@effectstream/log": "0.100.19",
|
|
19
|
+
"@effectstream/utils": "0.100.19",
|
|
20
20
|
"@midnight-ntwrk/ledger-v8": "8.0.3",
|
|
21
21
|
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
|
|
22
22
|
"@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
|
|
@@ -10,6 +10,7 @@ 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";
|
|
13
14
|
|
|
14
15
|
export class AvailSyncState extends SyncState<
|
|
15
16
|
Input,
|
|
@@ -63,6 +64,7 @@ export class AvailSyncState extends SyncState<
|
|
|
63
64
|
|
|
64
65
|
@bound
|
|
65
66
|
override *stateToInput(): Operation<Input | undefined> {
|
|
67
|
+
if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
|
|
66
68
|
const latestHeight = yield* call(() => this.client.getLatestBlockHeight());
|
|
67
69
|
const startHeight = this.lastPage?.own.height ??
|
|
68
70
|
this.config.syncProtocol.startBlockHeight - 1;
|
|
@@ -99,6 +101,15 @@ export class AvailSyncState extends SyncState<
|
|
|
99
101
|
rootOutput.primitives.push(...primitives);
|
|
100
102
|
}
|
|
101
103
|
|
|
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
|
+
|
|
102
113
|
@bound
|
|
103
114
|
override getNamespace(): string[] {
|
|
104
115
|
return [this.config.network.name, this.config.syncProtocol.name];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Operation
|
|
1
|
+
import { type Operation } from "effection";
|
|
2
2
|
import Deque from "denque";
|
|
3
3
|
import {
|
|
4
4
|
type BlockNumber,
|
|
@@ -11,7 +11,6 @@ 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";
|
|
15
14
|
|
|
16
15
|
export type LastPage<Page, RootPage> = {
|
|
17
16
|
own: Page;
|
|
@@ -75,6 +74,20 @@ export abstract class SyncState<
|
|
|
75
74
|
/** Timestamp of the most recent error, or 0 if no error has occurred. */
|
|
76
75
|
public lastErrorTimestamp: number = 0;
|
|
77
76
|
|
|
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
|
+
|
|
78
91
|
constructor(
|
|
79
92
|
public readonly name: string,
|
|
80
93
|
lastPage: undefined | LastPage<Page, RootPage>,
|
|
@@ -106,6 +119,24 @@ export abstract class SyncState<
|
|
|
106
119
|
*/
|
|
107
120
|
abstract mergeDatum(ourOutput: Output, rootOutput: RootOutput): void;
|
|
108
121
|
|
|
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
|
+
|
|
109
140
|
@bound
|
|
110
141
|
*updateState(
|
|
111
142
|
_input: Input,
|
|
@@ -115,6 +146,8 @@ export abstract class SyncState<
|
|
|
115
146
|
for (const datum of data.output) {
|
|
116
147
|
this.bufferedData.push(datum);
|
|
117
148
|
}
|
|
149
|
+
// Peak right after a push — the local max before the merge drains it.
|
|
150
|
+
this.bufferHighWater = Math.max(this.bufferHighWater, this.bufferedData.size());
|
|
118
151
|
this.newDataCondVar.wake();
|
|
119
152
|
}
|
|
120
153
|
|
|
@@ -129,13 +162,8 @@ export abstract class SyncState<
|
|
|
129
162
|
),
|
|
130
163
|
);
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
|
|
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}`);
|
|
165
|
+
// lastPage is in-memory only; the runtime is the sole writer of the
|
|
166
|
+
// persisted resume marker (see CLAUDE.md design idea #5).
|
|
139
167
|
this.lastPage = data.lastPage;
|
|
140
168
|
this.newPageCondVar.wake();
|
|
141
169
|
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type Output,
|
|
17
17
|
type Page,
|
|
18
18
|
} from "./types.ts";
|
|
19
|
-
import { genInputRange } from "../common/page-helpers.ts";
|
|
19
|
+
import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
|
|
20
20
|
import type {
|
|
21
21
|
ConfigNetworkType,
|
|
22
22
|
SyncProtocolWithNetwork,
|
|
@@ -58,6 +58,7 @@ export class BitcoinSyncState extends SyncState<
|
|
|
58
58
|
block_number: Number(data.raw.height),
|
|
59
59
|
blockHash: hash,
|
|
60
60
|
})),
|
|
61
|
+
resumePages: [],
|
|
61
62
|
primitives: data.primitives.map((primitive) => ({
|
|
62
63
|
...primitive,
|
|
63
64
|
source: this.config.syncProtocol.name,
|
|
@@ -75,6 +76,7 @@ export class BitcoinSyncState extends SyncState<
|
|
|
75
76
|
|
|
76
77
|
@bound
|
|
77
78
|
override *stateToInput(): Operation<Input | undefined> {
|
|
79
|
+
if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
|
|
78
80
|
return yield* genInputRange(
|
|
79
81
|
this as BitcoinSyncState,
|
|
80
82
|
this.config.syncProtocol.startBlockHeight as Page,
|
|
@@ -112,6 +114,16 @@ export class BitcoinSyncState extends SyncState<
|
|
|
112
114
|
rootOutput.blockInfo.push(...blockInfo);
|
|
113
115
|
}
|
|
114
116
|
|
|
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
|
+
|
|
115
127
|
@bound
|
|
116
128
|
override getNamespace(): string[] {
|
|
117
129
|
return [this.config.network.name, this.config.syncProtocol.name];
|
|
@@ -10,6 +10,7 @@ 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";
|
|
13
14
|
|
|
14
15
|
export class CelestiaSyncState extends SyncState<
|
|
15
16
|
Input,
|
|
@@ -61,6 +62,7 @@ export class CelestiaSyncState extends SyncState<
|
|
|
61
62
|
|
|
62
63
|
@bound
|
|
63
64
|
override *stateToInput(): Operation<Input | undefined> {
|
|
65
|
+
if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
|
|
64
66
|
const latestHeight = yield* call(() =>
|
|
65
67
|
this.client.getLatestBlockHeight()
|
|
66
68
|
);
|
|
@@ -99,6 +101,15 @@ export class CelestiaSyncState extends SyncState<
|
|
|
99
101
|
rootOutput.primitives.push(...primitives);
|
|
100
102
|
}
|
|
101
103
|
|
|
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
|
+
|
|
102
113
|
@bound
|
|
103
114
|
override getNamespace(): string[] {
|
|
104
115
|
return [this.config.network.name, this.config.syncProtocol.name];
|