@dcasegr/skein 0.1.1 → 0.2.8

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.
@@ -0,0 +1,132 @@
1
+ import type { Libp2p } from "@libp2p/interface";
2
+ import { type P2PHost } from "./p2p.ts";
3
+ /** The network's bootstrap peers (go-teranode-p2p-client config.go); regtest has none. */
4
+ export declare const TERANODE_BOOTSTRAP: Record<string, string[]>;
5
+ /** The default data hub per network (first sync; the gorillanode Teranode asset API); regtest has none. */
6
+ export declare const DEFAULT_HUBS: Record<string, string>;
7
+ /** The block topic of a network (SKEIN_NETWORK's names, or the topic's own). */
8
+ export declare function blockTopic(network: string): string;
9
+ /** A header's hash, display hex. */
10
+ export declare const hashOf: (raw: Uint8Array) => string;
11
+ /** A header's parent's hash, display hex. */
12
+ export declare const prevOf: (raw: Uint8Array) => string;
13
+ /** One block announcement, as the tracker reads it. */
14
+ export interface Announcement {
15
+ hash: string;
16
+ height: number;
17
+ header: Uint8Array;
18
+ hub?: string;
19
+ peer?: string;
20
+ }
21
+ /**
22
+ * A block announcement out of a message on the block topic: the bus's envelope or a bare
23
+ * BlockMessage. Undefined (dropped) when it is neither, its header is not 80 bytes of hex, or the
24
+ * header's hash is not the `Hash` it names.
25
+ */
26
+ export declare function parseAnnouncement(data: Uint8Array): Announcement | undefined;
27
+ export interface HubOptions {
28
+ fetch?: typeof fetch;
29
+ timeoutMs?: number;
30
+ }
31
+ /** The hub's best header (80 bytes) and, when it says, its height (`bestblockheader/json`). */
32
+ export declare function hubBest(hub: string, o?: HubOptions): Promise<{
33
+ header: Uint8Array;
34
+ hash: string;
35
+ height?: number;
36
+ }>;
37
+ /**
38
+ * Up to `n` headers from `hash` back (child to parent, `hash` first): each one's proof of work and
39
+ * every link checked; a page that does not start at `hash` or does not link is refused.
40
+ */
41
+ export declare function hubBack(hub: string, hash: string, n: number, o?: HubOptions): Promise<Uint8Array[]>;
42
+ /** What the tracker reads and writes: the host's image chain and the intake behind it (Router). */
43
+ export interface TrackerChain {
44
+ /** The tip held (undefined: nothing held). */
45
+ tip(): {
46
+ height: number;
47
+ hash: string;
48
+ } | undefined;
49
+ /** The height of a header held near the tip, by hash (display hex). */
50
+ find(hash: string): number | undefined;
51
+ /** A run of headers, parents first, at `height` (its first one's): into the image chain and on to the host chain and the instances. */
52
+ take(height: number, raws: Uint8Array[]): Promise<void>;
53
+ }
54
+ export interface TrackerOptions {
55
+ network: string;
56
+ /** Bootstrap multiaddrs (dnsaddr resolved by libp2p): the network's and SKEIN_TERANODE_BOOTSTRAP's; dialled while the host node has few peers. */
57
+ bootstrap: string[];
58
+ /** The host's libp2p (#156): the tracker rides its host node. None: no mesh (a hub-only first sync). */
59
+ p2p?: P2PHost;
60
+ /** The hub for the first sync (DEFAULT_HUBS, SKEIN_HEADERS_HUB); none: the first announcement's. */
61
+ hub?: string;
62
+ /**
63
+ * Where the peers met are kept between runs (go-p2p-message-bus's peer cache): dialled at start
64
+ * beside the bootstrap peers, so a restart does not hang on the bootstrap; none: not kept.
65
+ */
66
+ peersFile?: string;
67
+ chain: TrackerChain;
68
+ /** Headers per hub request: a gap's crawl (default 2 000) and the first sync's (default 10 000; a hub may answer fewer). */
69
+ page?: number;
70
+ bulkPage?: number;
71
+ /** The most headers a gap's crawl walks back before it gives up (default 20 160). */
72
+ maxCrawl?: number;
73
+ /** Peer upkeep: bootstrap redialled while too few are on the block topic (ms, default 30 000). */
74
+ discoveryMs?: number;
75
+ hub_?: HubOptions;
76
+ log?(line: string): void;
77
+ }
78
+ export declare class TeranodeTracker {
79
+ readonly o: TrackerOptions;
80
+ readonly topic: string;
81
+ node?: Libp2p;
82
+ /** Settles when the first sync is done (or there was nothing to do); rejects never. */
83
+ ready: Promise<void>;
84
+ private queue;
85
+ private stopped;
86
+ private timer?;
87
+ private syncing;
88
+ /** The newest announcement seen while the first sync ran (caught up from after it). */
89
+ private pending?;
90
+ private peers;
91
+ private lastBootErr;
92
+ /** Counts for the logs and tests. */
93
+ readonly stats: {
94
+ announcements: number;
95
+ dropped: number;
96
+ taken: number;
97
+ crawled: number;
98
+ known: number;
99
+ };
100
+ constructor(o: TrackerOptions);
101
+ private say;
102
+ /** Join the mesh (on the host node) and start the first sync when the image chain is empty. */
103
+ start(): Promise<void>;
104
+ stop(): Promise<void>;
105
+ /** The block topic on the host node: each message judged here (an announcement Accept and followed, anything else Reject); upkeep every round. */
106
+ private join;
107
+ /** The bootstrap peers dialled while too few are on the block topic. */
108
+ private upkeep;
109
+ /** The host node's DHT routing table's size, the skein DHT's (undefined: the DHT off). */
110
+ tables(): {
111
+ skein?: number;
112
+ };
113
+ /** The peers on the block topic the host node knows (GossipSub's view). */
114
+ onTopic(): number;
115
+ /** The peers kept from earlier runs (their dialable addresses). */
116
+ private cached;
117
+ /** The peers connected now — the block topic's first — kept for the next run (at most 32). */
118
+ private keep;
119
+ /** An announcement: in turn with the others (one change to the chain at a time). */
120
+ announce(a: Announcement): void;
121
+ /** go-chaintracks processBlockMessage: known, a child of a header near the tip, or a crawl back from its hub. */
122
+ private follow;
123
+ /**
124
+ * go-chaintracks SyncFromRemoteTip: back from `hash` through the hub until a header held (its
125
+ * height `base`), or the genesis header (base -1: the run starts at 0); the run parents first.
126
+ */
127
+ private crawl;
128
+ /** The first sync: an empty chain filled from a hub (the default, else the first announcement's). */
129
+ private initial;
130
+ /** The whole chain, from `best` back to genesis through `hub`, written in height order. */
131
+ private bulk;
132
+ }
@@ -0,0 +1,406 @@
1
+ // The host's headers tracker over the Teranode P2P network (shruggr/skein#156):
2
+ // a packaged host learns block headers itself, as any ChainTracks instance
3
+ // does (bsv-blockchain/go-chaintracks chainmanager p2p.go + sync.go), with no
4
+ // headers service configured.
5
+ //
6
+ // The Teranode network is libp2p (go-p2p-message-bus): TCP, noise, yamux, a
7
+ // kad-DHT (which skein does not join: see below), GossipSub with StrictSign. Bootstrap peers are the network's
8
+ // `/dnsaddr/<net>.bootstrap.teranode.bsvb.tech` (go-teranode-p2p-client
9
+ // config.go); the block topic is `teranode/bitcoin/1.0.0/<network>-block`
10
+ // (topics.go: main → mainnet, test → testnet, regtest → regtest). A message on
11
+ // it is the bus's envelope `{"name", "data": <base64>}` (client.go Publish)
12
+ // whose data is teranode's BlockMessage as Go marshals it (no JSON tags:
13
+ // services/p2p/message_types.go): `{PeerID, ClientName, DataHubURL, Hash,
14
+ // Height, Header (80 bytes, hex), Coinbase}`. A bare BlockMessage (no
15
+ // envelope) is taken too. Anything else on the topic is dropped.
16
+ //
17
+ // What it does with an announcement (TrackerChain: the host's image chain,
18
+ // image-chain.ts, and the router's intake behind it):
19
+ //
20
+ // - a header the host holds near its tip: nothing;
21
+ // - one whose parent it holds near its tip: taken at the parent's height + 1
22
+ // (the intake replaces from there: a reorg one deep);
23
+ // - otherwise: crawl back from the announcer's data hub (`GET
24
+ // <hub>/headers/<hash>?n=<count>`: up to `count` 80-byte headers, child to
25
+ // parent, the asked one first) until a header the host holds, then the run,
26
+ // parents first, to the intake. Capped (`maxCrawl` headers), every page's
27
+ // links checked and each header's proof of work, a timeout per request, no
28
+ // redirect followed.
29
+ //
30
+ // First boot (`initial`): with an empty image chain, the whole chain from the
31
+ // network's default hub (gorillanode, or SKEIN_HEADERS_HUB) — `bestblockheader`
32
+ // then `headers/<hash>?n=` back to genesis — written in height order; one log
33
+ // line per page ("headers: initial sync <have>/<tip height>"). No default hub
34
+ // (regtest) and none named: it waits for the first announcement and syncs from
35
+ // ITS hub. Same crawl both ways.
36
+ //
37
+ // The node is the HOST node (p2p.ts P2PHost.hostNode, #156 one node): the
38
+ // node of the host key, which on a one-key host carries the instances' overlay
39
+ // too. The block topic is a host topic on it (judged here: Accept when the
40
+ // message reads as an announcement, else Reject; never the front door). Its
41
+ // block-topic peers come from the bootstrap list (SKEIN_TERANODE_BOOTSTRAP, the
42
+ // network's defaults unioned), redialled while fewer than 4 are on the topic,
43
+ // and from GossipSub; there is no Teranode DHT (2026-11-02): it is the public
44
+ // IPFS DHT, and even a kad client crawls it and fills a js-libp2p node's
45
+ // connection cap, which then refuses every newcomer. The peers met are kept in
46
+ // teranode-peers.json between runs.
47
+ import { createHash } from "node:crypto";
48
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
49
+ import { multiaddr } from "@multiformats/multiaddr";
50
+ import { headerProblem } from "./image-chain.js";
51
+ import { NETWORK_NAMES, tableSize } from "./p2p.js";
52
+ /** The network's bootstrap peers (go-teranode-p2p-client config.go); regtest has none. */
53
+ export const TERANODE_BOOTSTRAP = {
54
+ main: ["/dnsaddr/mainnet.bootstrap.teranode.bsvb.tech"],
55
+ test: ["/dnsaddr/testnet.bootstrap.teranode.bsvb.tech"],
56
+ };
57
+ /** The default data hub per network (first sync; the gorillanode Teranode asset API); regtest has none. */
58
+ export const DEFAULT_HUBS = {
59
+ main: "https://mainnet.gorillanode.io/api/v1",
60
+ test: "https://testnet.gorillanode.io/api/v1",
61
+ };
62
+ /** The block topic of a network (SKEIN_NETWORK's names, or the topic's own). */
63
+ export function blockTopic(network) {
64
+ return `teranode/bitcoin/1.0.0/${NETWORK_NAMES[network] ?? network}-block`;
65
+ }
66
+ const SIZE = 80;
67
+ const sha256d = (b) => createHash("sha256").update(createHash("sha256").update(b).digest()).digest();
68
+ const display = (h) => Buffer.from(h).reverse().toString("hex");
69
+ /** A header's hash, display hex. */
70
+ export const hashOf = (raw) => display(sha256d(raw));
71
+ /** A header's parent's hash, display hex. */
72
+ export const prevOf = (raw) => display(raw.subarray(4, 36));
73
+ const ZERO = "0".repeat(64);
74
+ /**
75
+ * A block announcement out of a message on the block topic: the bus's envelope or a bare
76
+ * BlockMessage. Undefined (dropped) when it is neither, its header is not 80 bytes of hex, or the
77
+ * header's hash is not the `Hash` it names.
78
+ */
79
+ export function parseAnnouncement(data) {
80
+ let v;
81
+ try {
82
+ v = JSON.parse(Buffer.from(data).toString("utf8"));
83
+ }
84
+ catch {
85
+ return undefined;
86
+ } // not JSON: not a block message
87
+ let o = v;
88
+ if (!o || typeof o !== "object")
89
+ return undefined;
90
+ if (typeof o.data === "string" && o.Header === undefined) {
91
+ try {
92
+ o = JSON.parse(Buffer.from(o.data, "base64").toString("utf8"));
93
+ }
94
+ catch {
95
+ return undefined;
96
+ } // an envelope whose data is not a BlockMessage
97
+ if (!o || typeof o !== "object")
98
+ return undefined;
99
+ }
100
+ if (typeof o.Header !== "string" || !/^[0-9a-fA-F]{160}$/.test(o.Header))
101
+ return undefined;
102
+ const header = Uint8Array.from(Buffer.from(o.Header, "hex"));
103
+ const hash = hashOf(header);
104
+ if (typeof o.Hash !== "string" || o.Hash.toLowerCase() !== hash)
105
+ return undefined;
106
+ const height = typeof o.Height === "number" && Number.isInteger(o.Height) && o.Height >= 0 ? o.Height : -1;
107
+ if (height < 0)
108
+ return undefined;
109
+ const hub = typeof o.DataHubURL === "string" && /^https?:\/\//.test(o.DataHubURL) ? o.DataHubURL.replace(/\/+$/, "") : undefined;
110
+ return { hash, height, header, ...(hub ? { hub } : {}), ...(typeof o.PeerID === "string" ? { peer: o.PeerID } : {}) };
111
+ }
112
+ async function hubGet(url, o, max) {
113
+ const res = await (o.fetch ?? fetch)(url, { redirect: "error", signal: AbortSignal.timeout(o.timeoutMs ?? 30_000) });
114
+ if (!res.ok)
115
+ throw new Error(`${url}: HTTP ${res.status}`);
116
+ const b = Buffer.from(await res.arrayBuffer());
117
+ if (b.length > max)
118
+ throw new Error(`${url}: ${b.length} bytes, more than ${max}`);
119
+ return b;
120
+ }
121
+ /** The hub's best header (80 bytes) and, when it says, its height (`bestblockheader/json`). */
122
+ export async function hubBest(hub, o = {}) {
123
+ const b = await hubGet(`${hub}/bestblockheader`, o, SIZE);
124
+ if (b.length !== SIZE)
125
+ throw new Error(`${hub}/bestblockheader: ${b.length} bytes, not a header`);
126
+ const why = headerProblem(b);
127
+ if (why)
128
+ throw new Error(`${hub}/bestblockheader: ${why}`);
129
+ let height;
130
+ try {
131
+ const j = JSON.parse((await hubGet(`${hub}/bestblockheader/json`, o, 64 * 1024)).toString("utf8"));
132
+ if (j.hash === hashOf(b) && typeof j.height === "number")
133
+ height = j.height;
134
+ }
135
+ catch { /* the height is only for the progress line */ }
136
+ return { header: Uint8Array.from(b), hash: hashOf(b), ...(height !== undefined ? { height } : {}) };
137
+ }
138
+ /**
139
+ * Up to `n` headers from `hash` back (child to parent, `hash` first): each one's proof of work and
140
+ * every link checked; a page that does not start at `hash` or does not link is refused.
141
+ */
142
+ export async function hubBack(hub, hash, n, o = {}) {
143
+ const url = `${hub}/headers/${hash}?n=${n}`;
144
+ const b = await hubGet(url, o, n * SIZE);
145
+ if (b.length === 0 || b.length % SIZE)
146
+ throw new Error(`${url}: ${b.length} bytes, not whole headers`);
147
+ const out = [];
148
+ for (let i = 0; i < b.length; i += SIZE)
149
+ out.push(Uint8Array.from(b.subarray(i, i + SIZE)));
150
+ if (hashOf(out[0]) !== hash)
151
+ throw new Error(`${url}: its first header is not ${hash}`);
152
+ for (const [i, raw] of out.entries()) {
153
+ const why = headerProblem(raw);
154
+ if (why)
155
+ throw new Error(`${url}: header ${i}: ${why}`);
156
+ if (i > 0 && prevOf(out[i - 1]) !== hashOf(raw))
157
+ throw new Error(`${url}: header ${i} is not header ${i - 1}'s parent`);
158
+ }
159
+ return out;
160
+ }
161
+ export class TeranodeTracker {
162
+ o;
163
+ topic;
164
+ node;
165
+ /** Settles when the first sync is done (or there was nothing to do); rejects never. */
166
+ ready = Promise.resolve();
167
+ queue = Promise.resolve();
168
+ stopped = false;
169
+ timer;
170
+ syncing = false;
171
+ /** The newest announcement seen while the first sync ran (caught up from after it). */
172
+ pending;
173
+ peers = "";
174
+ lastBootErr = "";
175
+ /** Counts for the logs and tests. */
176
+ stats = { announcements: 0, dropped: 0, taken: 0, crawled: 0, known: 0 };
177
+ constructor(o) {
178
+ this.o = o;
179
+ this.topic = blockTopic(o.network);
180
+ }
181
+ say(line) { this.o.log?.(line); }
182
+ /** Join the mesh (on the host node) and start the first sync when the image chain is empty. */
183
+ async start() {
184
+ if (this.o.p2p)
185
+ await this.join(this.o.p2p);
186
+ // In turn with the announcements (one change to the chain at a time).
187
+ const p = this.queue.then(() => this.initial()).catch((e) => this.say(`headers: initial sync failed: ${e.message}; the next announcement syncs from its hub`));
188
+ this.queue = p;
189
+ this.ready = p;
190
+ }
191
+ async stop() {
192
+ this.stopped = true;
193
+ if (this.timer)
194
+ clearInterval(this.timer);
195
+ // The node is the host's: it stops with the host's libp2p (P2PHost.stop), the block topic left first.
196
+ await this.o.p2p?.hostTopic(this.topic, undefined).catch(() => { });
197
+ }
198
+ /** The block topic on the host node: each message judged here (an announcement Accept and followed, anything else Reject); upkeep every round. */
199
+ async join(p2p) {
200
+ const t0 = Date.now();
201
+ const node = await p2p.hostNode();
202
+ this.node = node;
203
+ if (this.stopped)
204
+ return;
205
+ await p2p.hostTopic(this.topic, (data) => {
206
+ const a = parseAnnouncement(data);
207
+ if (!a) {
208
+ this.stats.dropped++;
209
+ return "reject";
210
+ }
211
+ this.announce(a);
212
+ return "accept";
213
+ });
214
+ this.say(`headers: Teranode ${this.o.network} mesh on the host node ${node.peerId}, ${this.topic}; bootstrap ${this.o.bootstrap.join(" ") || "none"}`);
215
+ const upkeep = () => void this.upkeep(t0).catch((e) => this.say(`headers: Teranode peers: ${e.message}`));
216
+ upkeep();
217
+ this.timer = setInterval(upkeep, this.o.discoveryMs ?? 30_000);
218
+ this.timer.unref?.();
219
+ }
220
+ /** The bootstrap peers dialled while too few are on the block topic. */
221
+ async upkeep(t0) {
222
+ const node = this.node;
223
+ if (!node || this.stopped)
224
+ return;
225
+ // One node carries the overlay too (#156): its Teranode side counts the peers on the block topic.
226
+ if (this.o.p2p.hostSubscribers(this.topic).length < 4) {
227
+ const errs = [];
228
+ await Promise.all([...this.o.bootstrap, ...this.cached()].map(async (a, i) => {
229
+ try {
230
+ await node.dial(multiaddr(a), { signal: AbortSignal.timeout(20_000) });
231
+ }
232
+ catch (e) {
233
+ if (i < this.o.bootstrap.length)
234
+ errs.push(`${a}: ${e.message}`);
235
+ }
236
+ }));
237
+ // One line when the bootstrap's answer changes (it is retried every round).
238
+ const err = errs.join("; ");
239
+ if (err !== this.lastBootErr) {
240
+ this.lastBootErr = err;
241
+ this.say(err ? `headers: Teranode bootstrap ${err}` : "headers: Teranode bootstrap reached");
242
+ }
243
+ }
244
+ const p2p = this.o.p2p;
245
+ const subs = p2p.hostSubscribers(this.topic);
246
+ const n = node.getConnections().length, onTopic = subs.length;
247
+ this.keep(node, subs);
248
+ const t = this.tables();
249
+ // 2026-11-01: the skein DHT's mode (auto flips to server on a confirmed public address) and those addresses.
250
+ const m = p2p.hostDhtMode(), pub = p2p.hostPublicAddrs();
251
+ const now = `${n} ${onTopic} ${t.skein} ${m} ${pub.join(" ")}`;
252
+ if (now !== this.peers) {
253
+ this.peers = now;
254
+ this.say(`headers: Teranode peers ${n} (${onTopic} on the block topic; skein DHT table ${t.skein ?? "off"}, mode ${m ?? "off"}; public ${pub.join(" ") || "none confirmed"}) at ${((Date.now() - t0) / 1000).toFixed(1)} s`);
255
+ }
256
+ }
257
+ /** The host node's DHT routing table's size, the skein DHT's (undefined: the DHT off). */
258
+ tables() {
259
+ return { skein: tableSize(this.o.p2p?.hostDht()) };
260
+ }
261
+ /** The peers on the block topic the host node knows (GossipSub's view). */
262
+ onTopic() { return this.o.p2p?.hostSubscribers(this.topic).length ?? 0; }
263
+ /** The peers kept from earlier runs (their dialable addresses). */
264
+ cached() {
265
+ const f = this.o.peersFile;
266
+ if (!f || !existsSync(f))
267
+ return [];
268
+ try {
269
+ const v = JSON.parse(readFileSync(f, "utf8"));
270
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string").slice(0, 32) : [];
271
+ }
272
+ catch {
273
+ return [];
274
+ } // an unreadable cache: none
275
+ }
276
+ /** The peers connected now — the block topic's first — kept for the next run (at most 32). */
277
+ keep(node, onTopic) {
278
+ const f = this.o.peersFile;
279
+ if (!f)
280
+ return;
281
+ const conns = node.getConnections().filter((c) => c.direction === "outbound" && !c.remoteAddr.toString().includes("/p2p-circuit"));
282
+ conns.sort((a, b) => Number(onTopic.includes(b.remotePeer.toString())) - Number(onTopic.includes(a.remotePeer.toString())));
283
+ const addrs = [...new Set(conns.map((c) => `${c.remoteAddr.toString().replace(/\/p2p\/[^/]+$/, "")}/p2p/${c.remotePeer}`))].slice(0, 32);
284
+ if (!addrs.length)
285
+ return;
286
+ try {
287
+ writeFileSync(f, `${JSON.stringify(addrs)}\n`);
288
+ }
289
+ catch (e) {
290
+ this.say(`headers: the Teranode peers not kept: ${e.message}`);
291
+ }
292
+ }
293
+ /** An announcement: in turn with the others (one change to the chain at a time). */
294
+ announce(a) {
295
+ this.stats.announcements++;
296
+ if (this.syncing) {
297
+ this.pending = a;
298
+ return;
299
+ }
300
+ this.queue = this.queue.then(() => this.follow(a)).catch((e) => this.say(`headers: block ${a.height} ${a.hash}: ${e.message}`));
301
+ }
302
+ /** go-chaintracks processBlockMessage: known, a child of a header near the tip, or a crawl back from its hub. */
303
+ async follow(a) {
304
+ const c = this.o.chain;
305
+ if (!c.tip()) {
306
+ // The first sync failed or had no hub: this announcement's hub, from it.
307
+ if (!a.hub)
308
+ throw new Error("nothing held yet, and the announcement names no data hub: dropped");
309
+ await this.bulk(a.hub, { hash: a.hash, height: a.height });
310
+ return;
311
+ }
312
+ if (c.find(a.hash) !== undefined) {
313
+ this.stats.known++;
314
+ return;
315
+ }
316
+ const parent = c.find(prevOf(a.header));
317
+ if (parent !== undefined) {
318
+ await c.take(parent + 1, [a.header]);
319
+ this.stats.taken++;
320
+ this.say(`headers: block ${parent + 1} ${a.hash}${a.peer ? ` (announced by ${a.peer})` : ""}`);
321
+ return;
322
+ }
323
+ if (!a.hub)
324
+ throw new Error("links to no header near the tip, and names no data hub: dropped");
325
+ const { base, run } = await this.crawl(a.hub, a.hash, this.o.page ?? 2000, this.o.maxCrawl ?? 20_160);
326
+ await c.take(base + 1, run);
327
+ this.stats.crawled += run.length;
328
+ this.say(`headers: ${run.length} header${run.length === 1 ? "" : "s"} from ${a.hub} to ${a.height} (from ${base + 1})`);
329
+ }
330
+ /**
331
+ * go-chaintracks SyncFromRemoteTip: back from `hash` through the hub until a header held (its
332
+ * height `base`), or the genesis header (base -1: the run starts at 0); the run parents first.
333
+ */
334
+ async crawl(hub, hash, page, max, progress) {
335
+ const c = this.o.chain;
336
+ const pages = [];
337
+ let have = 0;
338
+ let at = hash;
339
+ for (;;) {
340
+ if (this.stopped)
341
+ throw new Error("stopped");
342
+ const got = await hubBack(hub, at, page, this.o.hub_);
343
+ if (pages.length && prevOf(pages.at(-1).at(-1)) !== hashOf(got[0]))
344
+ throw new Error(`${hub}: a page that does not continue the last`);
345
+ for (const [i, raw] of got.entries()) {
346
+ const h = c.find(hashOf(raw));
347
+ if (h !== undefined) {
348
+ pages.push(got.slice(0, i));
349
+ return { base: h, run: pages.flat().reverse() };
350
+ }
351
+ }
352
+ pages.push(got);
353
+ have += got.length;
354
+ progress?.(have);
355
+ const last = got.at(-1);
356
+ if (prevOf(last) === ZERO)
357
+ return { base: -1, run: pages.flat().reverse() }; // the genesis header: the whole chain
358
+ if (have >= max)
359
+ throw new Error(`${hub}: no header held within ${have} back from ${hash}`);
360
+ at = prevOf(last);
361
+ }
362
+ }
363
+ /** The first sync: an empty chain filled from a hub (the default, else the first announcement's). */
364
+ async initial() {
365
+ const hub = this.o.hub;
366
+ if (hub) {
367
+ // Every start: to the hub's best (go-chaintracks's bootstrap sync) — the whole chain when none is held,
368
+ // else what was missed while the host was down (the mesh may take a while to find).
369
+ const best = await hubBest(hub, this.o.hub_);
370
+ if (this.o.chain.find(best.hash) !== undefined)
371
+ return;
372
+ if (!this.o.chain.tip())
373
+ return await this.bulk(hub, best);
374
+ const { base, run } = await this.crawl(hub, best.hash, this.o.bulkPage ?? 10_000, Number.POSITIVE_INFINITY);
375
+ await this.o.chain.take(base + 1, run);
376
+ this.say(`headers: caught up from ${hub}: ${run.length} header${run.length === 1 ? "" : "s"} to ${this.o.chain.tip()?.height}`);
377
+ return;
378
+ }
379
+ if (this.o.chain.tip())
380
+ return;
381
+ this.say(`headers: no data hub for ${this.o.network}: waiting for the first block announcement to sync from its hub`);
382
+ // follow() runs the sync for the first announcement (bulk from its hub).
383
+ }
384
+ /** The whole chain, from `best` back to genesis through `hub`, written in height order. */
385
+ async bulk(hub, best) {
386
+ const c = this.o.chain;
387
+ this.syncing = true;
388
+ try {
389
+ const t0 = Date.now();
390
+ const of = best.height !== undefined ? String(best.height + 1) : "?";
391
+ this.say(`headers: initial sync from ${hub}: ${of} headers to ${best.hash}`);
392
+ const { base, run } = await this.crawl(hub, best.hash, this.o.bulkPage ?? 10_000, Number.POSITIVE_INFINITY, (have) => this.say(`headers: initial sync ${have}/${of}`));
393
+ const fetched = Date.now() - t0;
394
+ await c.take(base + 1, run);
395
+ this.say(`headers: initial sync done: ${run.length} headers to ${c.tip()?.height} in ${((Date.now() - t0) / 1000).toFixed(1)} s (fetch ${(fetched / 1000).toFixed(1)} s)`);
396
+ }
397
+ finally {
398
+ this.syncing = false;
399
+ }
400
+ // What was announced meanwhile: followed now.
401
+ const p = this.pending;
402
+ this.pending = undefined;
403
+ if (p)
404
+ this.announce(p);
405
+ }
406
+ }
@@ -25,10 +25,15 @@ export declare function testHost(t: {
25
25
  arc?: Router["o"]["arc"];
26
26
  arcRetry?: Router["o"]["arcRetry"];
27
27
  headersFeed?: string;
28
+ teranode?: Router["o"]["teranode"];
28
29
  billing?: Router["o"]["billing"];
29
30
  lmdb?: boolean;
30
31
  rocks?: boolean;
31
32
  sqlite?: boolean;
33
+ reuse?: {
34
+ home: string;
35
+ keyOf(handle: string): PrivateKey;
36
+ };
32
37
  }): Promise<{
33
38
  home: string;
34
39
  db: HostDb;
@@ -74,16 +74,19 @@ export async function messagesIn(store) {
74
74
  }
75
75
  /** A host with instances (mailbox instances among them), each instance's key its own (the signer's stand-in). */
76
76
  export async function testHost(t, o = {}) {
77
- const home = await fs.mkdtemp(join(tmpdir(), "skein-router-"));
77
+ // #156: `reuse` — a second host over the first's home and keys (a restart: the first's router stopped first).
78
+ const home = o.reuse?.home ?? await fs.mkdtemp(join(tmpdir(), "skein-router-"));
78
79
  // #96: o.lmdb — every instance a set of one LMDB environment, <home>/store (host.db `lmdb:<dir>`), else a file each.
79
80
  // #158: o.rocks — every instance a set of one RocksDB database, <home>/store-rocksdb, run by one `skein-kernel host` process.
80
81
  // The settled split: o.sqlite (or SKEIN_TEST_STORE=sqlite) — every instance a set of one SQLite database, <home>/store.sqlite, on the wasm kernel.
81
82
  const sqlite = o.sqlite ?? process.env.SKEIN_TEST_STORE === "sqlite";
82
83
  const storeOf = (handle) => (sqlite ? `sqlite:${join(home, "store.sqlite")}` : o.rocks ? `rocksdb:${join(home, "store-rocksdb")}` : o.lmdb ? `lmdb:${join(home, "store")}` : join(home, "instances", handle, "runtime.db"));
83
- t.after(() => fs.rm(home, { recursive: true, force: true }));
84
+ if (!o.reuse)
85
+ t.after(() => fs.rm(home, { recursive: true, force: true }));
84
86
  const db = new HostDb(join(home, "host.db"));
85
87
  const keys = new Map();
86
- const keyOf = (h) => { let k = keys.get(h); if (!k) {
88
+ const keyOf = (h) => { if (o.reuse)
89
+ return o.reuse.keyOf(h); let k = keys.get(h); if (!k) {
87
90
  k = PrivateKey.fromRandom();
88
91
  keys.set(h, k);
89
92
  } return k; };
@@ -98,6 +101,8 @@ export async function testHost(t, o = {}) {
98
101
  ...(o.arc ? { arc: o.arc } : {}), ...(o.arcRetry ? { arcRetry: o.arcRetry } : {}),
99
102
  // The host's headers feed (#102).
100
103
  ...(o.headersFeed ? { headersFeed: o.headersFeed } : {}),
104
+ // #156: the Teranode headers tracker (a fake mesh and hub in the tests).
105
+ ...(o.teranode ? { teranode: o.teranode } : {}),
101
106
  // #130: what the host bills (billing.ts); none: it bills no one.
102
107
  ...(o.billing ? { billing: o.billing } : {}),
103
108
  ...(o.lmdb ? { lmdbStore: join(home, "store") } : {}),
@@ -1,6 +1,8 @@
1
1
  import { Worker } from "node:worker_threads";
2
2
  import type { CID } from "multiformats/cid";
3
3
  import { KernelSurface, type HostChainTip, type KernelOptions } from "./kernel.ts";
4
+ /** The set an image's chain part is put in for a host chain's import (#156), emptied after it. */
5
+ export declare const IMAGE_SET = "host/image";
4
6
  /** The wasm kernel: $SKEIN_KERNEL_WASM, else kernel-zig's `zig build web` output (the package ships it there). */
5
7
  export declare const KERNEL_WASM: string;
6
8
  /** The pinned modules (the boundary programs): $SKEIN_WASM_DIR, else the repo's (the package's) wasm/. */
@@ -58,6 +60,13 @@ export declare class WasmHost {
58
60
  chainHeaders(raws: Uint8Array[]): Promise<HostChainTip>;
59
61
  /** #156: an image's chain/headers (the tree `root`, whose blocks the set `set` holds) into the empty host chain. */
60
62
  chainImport(set: string, root: CID): Promise<HostChainTip>;
63
+ /**
64
+ * #156: an image's chain part (the `chain` tree and every object under it) into the EMPTY host
65
+ * chain, as an instance born with it would bring it (`chain-import`): its objects put in a set of
66
+ * their own (IMAGE_SET), a tree naming them `chain` as the root, the import, then that set's
67
+ * members and the blocks no other set holds removed.
68
+ */
69
+ importChain(chain: CID, objects: Array<[CID, Uint8Array]>): Promise<HostChainTip>;
61
70
  /** #156: the host chain as it stands, or undefined (empty). */
62
71
  chainTip(): Promise<HostChainTip | undefined>;
63
72
  /** One holder less; the last stops every machine. */
@@ -24,7 +24,11 @@ import { WalletWireProcessor } from "@bsv/sdk";
24
24
  import { decode, encode } from "../runtime/cid.js";
25
25
  import { CHAIN_SET, parseSqliteSpec } from "../runtime/index-store.js";
26
26
  import { Rejected } from "../runtime/store.js";
27
+ import { hashTree } from "../runtime/tree.js";
27
28
  import { KernelSurface } from "./kernel.js";
29
+ import { openHostDb, SqliteSetStore } from "./store-sqlite.js";
30
+ /** The set an image's chain part is put in for a host chain's import (#156), emptied after it. */
31
+ export const IMAGE_SET = "host/image";
28
32
  const HERE = dirname(fileURLToPath(import.meta.url));
29
33
  const ROOT = resolvePath(HERE, "../..");
30
34
  /** The wasm kernel: $SKEIN_KERNEL_WASM, else kernel-zig's `zig build web` output (the package ships it there). */
@@ -204,6 +208,48 @@ export class WasmHost {
204
208
  }
205
209
  /** #156: an image's chain/headers (the tree `root`, whose blocks the set `set` holds) into the empty host chain. */
206
210
  async chainImport(set, root) { return tipOf(await (await this.holder()).call("chain-import", { set, root })); }
211
+ /**
212
+ * #156: an image's chain part (the `chain` tree and every object under it) into the EMPTY host
213
+ * chain, as an instance born with it would bring it (`chain-import`): its objects put in a set of
214
+ * their own (IMAGE_SET), a tree naming them `chain` as the root, the import, then that set's
215
+ * members and the blocks no other set holds removed.
216
+ */
217
+ async importChain(chain, objects) {
218
+ const db = openHostDb(this.db);
219
+ try {
220
+ const s = new SqliteSetStore(db, IMAGE_SET);
221
+ const root = hashTree([{ mode: "40000", name: "chain", cid: chain }]);
222
+ s.begin();
223
+ for (const [cid, bytes] of objects)
224
+ s.putBlock(cid.bytes, bytes);
225
+ s.putBlock(root.cid.bytes, root.object);
226
+ s.commit();
227
+ try {
228
+ return await this.chainImport(IMAGE_SET, root.cid);
229
+ }
230
+ finally {
231
+ db.exec("BEGIN IMMEDIATE");
232
+ try {
233
+ // members has no index by cid: each object looked up in every other set by its key.
234
+ const sets = db.prepare(`SELECT DISTINCT "set" FROM members`).all().map((r) => r.set).filter((x) => x !== IMAGE_SET);
235
+ const held = db.prepare(`SELECT 1 FROM members WHERE "set" = ? AND cid = ?`);
236
+ const drop = db.prepare("DELETE FROM blocks WHERE cid = ?");
237
+ for (const cid of [...objects.map(([c]) => c), root.cid])
238
+ if (!sets.some((x) => held.get(x, cid.bytes)))
239
+ drop.run(cid.bytes);
240
+ db.prepare(`DELETE FROM members WHERE "set" = ?`).run(IMAGE_SET);
241
+ db.exec("COMMIT");
242
+ }
243
+ catch (e) {
244
+ db.exec("ROLLBACK");
245
+ throw e;
246
+ }
247
+ }
248
+ }
249
+ finally {
250
+ db.close();
251
+ }
252
+ }
207
253
  /** #156: the host chain as it stands, or undefined (empty). */
208
254
  async chainTip() { const v = await (await this.holder()).call("chain"); return v == null ? undefined : tipOf(v); }
209
255
  /** One holder less; the last stops every machine. */