@dcasegr/skein 0.1.2 → 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.
- package/README.md +37 -0
- package/dist/host/arc.d.ts +12 -2
- package/dist/host/arc.js +41 -13
- package/dist/host/cli.d.ts +5 -0
- package/dist/host/cli.js +37 -4
- package/dist/host/fake-teranode.d.ts +29 -0
- package/dist/host/fake-teranode.js +100 -0
- package/dist/host/feeds.d.ts +9 -1
- package/dist/host/feeds.js +30 -5
- package/dist/host/image-chain.d.ts +30 -0
- package/dist/host/image-chain.js +84 -2
- package/dist/host/instances.d.ts +19 -0
- package/dist/host/instances.js +38 -1
- package/dist/host/liveness.d.ts +22 -2
- package/dist/host/liveness.js +42 -5
- package/dist/host/p2p.d.ts +107 -8
- package/dist/host/p2p.js +298 -53
- package/dist/host/router.d.ts +74 -1
- package/dist/host/router.js +213 -20
- package/dist/host/signer.d.ts +6 -0
- package/dist/host/signer.js +8 -0
- package/dist/host/teranode.d.ts +132 -0
- package/dist/host/teranode.js +406 -0
- package/dist/host/testhost.d.ts +5 -0
- package/dist/host/testhost.js +8 -3
- package/dist/host/wasm-kernel.d.ts +9 -0
- package/dist/host/wasm-kernel.js +46 -0
- package/package.json +4 -1
|
@@ -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
|
+
}
|
package/dist/host/testhost.d.ts
CHANGED
|
@@ -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;
|
package/dist/host/testhost.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) => {
|
|
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. */
|
package/dist/host/wasm-kernel.js
CHANGED
|
@@ -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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcasegr/skein",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "A deterministic WASI machine over a content-addressed graph: the node host (it loads the wasm kernel, kernel-zig built for the web target), the client, its peers, and dev tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,15 +34,18 @@
|
|
|
34
34
|
"@chainsafe/libp2p-yamux": "8.0.1",
|
|
35
35
|
"@ipld/dag-cbor": "^10.0.2",
|
|
36
36
|
"@ipld/dag-json": "^11.0.1",
|
|
37
|
+
"@libp2p/autonat": "3.0.32",
|
|
37
38
|
"@libp2p/circuit-relay-v2": "4.2.13",
|
|
38
39
|
"@libp2p/crypto": "5.1.23",
|
|
39
40
|
"@libp2p/gossipsub": "17.1.2",
|
|
40
41
|
"@libp2p/identify": "4.1.14",
|
|
41
42
|
"@libp2p/kad-dht": "16.4.5",
|
|
43
|
+
"@libp2p/logger": "6.2.16",
|
|
42
44
|
"@libp2p/mdns": "12.0.32",
|
|
43
45
|
"@libp2p/peer-id": "6.0.15",
|
|
44
46
|
"@libp2p/ping": "3.1.13",
|
|
45
47
|
"@libp2p/tcp": "11.0.28",
|
|
48
|
+
"@libp2p/upnp-nat": "4.0.32",
|
|
46
49
|
"@libp2p/utils": "7.4.1",
|
|
47
50
|
"@libp2p/websockets": "10.1.21",
|
|
48
51
|
"@multiformats/multiaddr": "13.0.3",
|