@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.
package/README.md CHANGED
@@ -82,7 +82,10 @@ every user skein: the front door, the messagebox, the wallet, and one row
82
82
  to the kernel, `claim`, from anyone: the first claim's sender owns it
83
83
  (#127), so claim it first thing. The host signs no user's claim; `skein
84
84
  claim --messagebox <url>` names where the instance reaches its owner. A
85
- second claim is refused. It has no apps (#152). After the claim, install
85
+ second claim is refused. `GET <its origin>/.well-known/skein` answers
86
+ `{handle, identity, origin, claimed}` (the host's read: whether anyone
87
+ holds root yet); `GET /skeins?key=<key>` lists the instances a key is root
88
+ on. It has no apps (#152). After the claim, install
86
89
  the chain, git and site apps by message, and root's route `/` to the site:
87
90
 
88
91
  ```
@@ -311,6 +314,43 @@ bin/skein-host run # the first run: operator.key, master.key, host.db and
311
314
  default image with the whole header chain from that chaintracks' history
312
315
  at start and appends every header the feed brings, so a new skein is born
313
316
  with the chain (#132, docs/BOOTSTRAP.md, "The image's chain part").
317
+ With no setting at all (#156, `src/host/teranode.ts`), `skein-host run`
318
+ follows the network's Teranode P2P mesh for block announcements (the
319
+ block topic `teranode/bitcoin/1.0.0/<network>-block`, bootstrap
320
+ `/dnsaddr/<network>.bootstrap.teranode.bsvb.tech`), crawls a gap back from
321
+ the announcer's data hub, and on an empty image chain syncs the whole
322
+ chain first from the network's default hub (gorillanode;
323
+ `SKEIN_HEADERS_HUB`); an empty host chain is then filled from the image
324
+ chain. Beside `SKEIN_HEADERS_URL` both feed the same intake;
325
+ `SKEIN_TERANODE=off` turns it off. The tracker rides the host's ONE
326
+ libp2p node (the host key's; a one-key host's instances are on it too),
327
+ which carries ONE Kademlia DHT, the skein DHT
328
+ (`/skein/kad/<network>/1.0.0`, the overlay's topics). It does not join
329
+ Teranode's DHT: that is the public IPFS DHT, and even a kad client crawls
330
+ it and fills a js-libp2p node's connection cap, which then refuses every
331
+ newcomer. Teranode peers come from the bootstrap list and GossipSub:
332
+ `SKEIN_TERANODE_BOOTSTRAP` adds peers beside the network's defaults
333
+ (gorillanode in DHT-server mode, the bsvb seeds, a hosted skein node are
334
+ all fine entries; any one that accepts is enough), redialled while fewer
335
+ than 4 are on the block topic.
336
+ - **Per-network defaults** (`SKEIN_NETWORK`, default `main`): a host needs
337
+ no setting to find the overlay or broadcast. Unset means the network's;
338
+ `off` turns the default off.
339
+
340
+ | setting | unset = the network's | |
341
+ |---|---|---|
342
+ | `SKEIN_LIBP2P_BOOTSTRAP` | test: `/dns4/p2p.testnet.openex.dev/tcp/7534/p2p/16Uiu2HAmVN7Cc7TrFGVrbdoV1mMGBF8LQBvPZ5trzgPArfagBRSB` (the hosted Open Exchange node); main, regtest: none yet | skein peers named here are dialled beside the network's (unioned); `off`: none at all. A node whose own peer id is a default skips it |
343
+ | `SKEIN_ARC_URL` | test: `https://testnet.arcade.1sat.app`; main: `https://arcade.1sat.app`; regtest: none | `off`: no Arcade (a broadcast event is dropped). With the network's Arcade and no `SKEIN_ARC_TOKEN`, the host makes its callback token once (32 random bytes, hex) and appends `SKEIN_ARC_TOKEN=…` to `$SKEIN_HOME/host.env`, read from there after, as the key file is. A URL named here needs its token named too |
344
+ - The defaults are tuned for discovery: the node listens on TCP port
345
+ **7534** on every interface (IPv4 and IPv6; another port if 7534 is
346
+ taken), asks the router to open it by UPnP/NAT-PMP where allowed
347
+ (`SKEIN_LIBP2P_UPNP=off` stops that), and runs the skein DHT in `auto`
348
+ mode — a client until AutoNAT confirms a public address, then a server
349
+ (`SKEIN_LIBP2P_DHT` sets its mode).
350
+ Nobody has to open a firewall; to be a bootstrap for others, open
351
+ TCP 7534 (or forward it on the router) and the node becomes a DHT
352
+ server by itself. A hosted node needs nothing set; `SKEIN_LIBP2P_DHT=server`
353
+ makes it a server from the start instead of waiting for AutoNAT.
314
354
 
315
355
  ## Talk to it
316
356
 
@@ -13,8 +13,18 @@ export interface ArcConfig {
13
13
  }
14
14
  /** The webhook's path on the router. */
15
15
  export declare const ARC_ROUTE = "/arc";
16
- /** The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the environment, else $SKEIN_HOME/host.env); none without a URL. */
17
- export declare function hostArcConfig(vars: Record<string, string | undefined>, home?: string): ArcConfig | undefined;
16
+ /** The network's Arcade (SKEIN_NETWORK's names) when SKEIN_ARC_URL is unset; regtest has none. */
17
+ export declare const DEFAULT_ARC: Record<string, string>;
18
+ /**
19
+ * The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the
20
+ * environment, else $SKEIN_HOME/host.env). SKEIN_ARC_URL unset: the network's (DEFAULT_ARC, by SKEIN_NETWORK,
21
+ * default main); `off`: none, as on regtest. With an Arcade and no SKEIN_ARC_TOKEN, the host's token is made
22
+ * (32 random bytes, hex) and appended to $SKEIN_HOME/host.env (the file made if absent), then read from it after —
23
+ * as the key file is; `made` reports it. Without a home there is nowhere to keep one: an error.
24
+ */
25
+ export declare function hostArcConfig(vars: Record<string, string | undefined>, home?: string, o?: {
26
+ made?: (file: string) => void;
27
+ }): ArcConfig | undefined;
18
28
  export interface ArcAnswer {
19
29
  status: number;
20
30
  headers: Record<string, string>;
package/dist/host/arc.js CHANGED
@@ -52,30 +52,58 @@
52
52
  // Arcade itself may later sign its statuses (a signed-message callback beside
53
53
  // webhook and SSE): it would then be the status provider an instance
54
54
  // subscribes to, and nothing on the instance's side changes.
55
- import { existsSync, readFileSync } from "node:fs";
55
+ import { randomBytes } from "node:crypto";
56
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
56
57
  import { join } from "node:path";
57
58
  import { Transaction } from "@bsv/sdk";
58
59
  import { DEFAULT_BOX, SseStream, statusOf, txCid } from "./feeds.js";
59
60
  /** The webhook's path on the router. */
60
61
  export const ARC_ROUTE = "/arc";
61
- /** The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the environment, else $SKEIN_HOME/host.env); none without a URL. */
62
- export function hostArcConfig(vars, home) {
63
- const file = {};
62
+ /** The network's Arcade (SKEIN_NETWORK's names) when SKEIN_ARC_URL is unset; regtest has none. */
63
+ export const DEFAULT_ARC = {
64
+ main: "https://arcade.1sat.app",
65
+ test: "https://testnet.arcade.1sat.app",
66
+ };
67
+ /**
68
+ * The host's Arcade from SKEIN_ARC_URL, SKEIN_ARC_TOKEN, SKEIN_ARC_EVENTS_URL, SKEIN_ARC_CALLBACK_URL (the
69
+ * environment, else $SKEIN_HOME/host.env). SKEIN_ARC_URL unset: the network's (DEFAULT_ARC, by SKEIN_NETWORK,
70
+ * default main); `off`: none, as on regtest. With an Arcade and no SKEIN_ARC_TOKEN, the host's token is made
71
+ * (32 random bytes, hex) and appended to $SKEIN_HOME/host.env (the file made if absent), then read from it after —
72
+ * as the key file is; `made` reports it. Without a home there is nowhere to keep one: an error.
73
+ */
74
+ export function hostArcConfig(vars, home, o = {}) {
64
75
  const envFile = home ? join(home, "host.env") : undefined;
65
- if (envFile && existsSync(envFile)) {
66
- for (const line of readFileSync(envFile, "utf8").split("\n")) {
67
- const m = /^\s*(?:export\s+)?(SKEIN_ARC_[A-Z_]+)=(.*)$/.exec(line);
68
- if (m)
69
- file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
76
+ const readFile = () => {
77
+ const file = {};
78
+ if (envFile && existsSync(envFile)) {
79
+ for (const line of readFileSync(envFile, "utf8").split("\n")) {
80
+ const m = /^\s*(?:export\s+)?(SKEIN_ARC_[A-Z_]+|SKEIN_NETWORK)=(.*)$/.exec(line);
81
+ if (m)
82
+ file[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
83
+ }
70
84
  }
71
- }
85
+ return file;
86
+ };
87
+ const file = readFile();
72
88
  const get = (k) => vars[k] || file[k] || undefined;
73
- const url = get("SKEIN_ARC_URL");
89
+ const set = get("SKEIN_ARC_URL");
90
+ if (set?.toLowerCase() === "off")
91
+ return undefined;
92
+ const url = set ?? DEFAULT_ARC[get("SKEIN_NETWORK") || "main"];
74
93
  if (!url)
75
94
  return undefined;
76
- const token = get("SKEIN_ARC_TOKEN");
77
- if (!token)
95
+ let token = get("SKEIN_ARC_TOKEN");
96
+ if (!token && set)
78
97
  throw new Error("SKEIN_ARC_URL is set but SKEIN_ARC_TOKEN is not: the host's callback token scopes its status stream");
98
+ if (!token) {
99
+ if (!envFile)
100
+ throw new Error(`the network's Arcade (${url}) needs the host's callback token (SKEIN_ARC_TOKEN), and there is no SKEIN_HOME to keep a new one in`);
101
+ token = randomBytes(32).toString("hex");
102
+ mkdirSync(home, { recursive: true });
103
+ const before = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
104
+ appendFileSync(envFile, `${before && !before.endsWith("\n") ? "\n" : ""}# The host's Arcade callback token (made on first run; it ties this host's transactions together at the Arcade).\nSKEIN_ARC_TOKEN=${token}\n`, { mode: 0o600 });
105
+ o.made?.(envFile);
106
+ }
79
107
  const events = get("SKEIN_ARC_EVENTS_URL"), callbackUrl = get("SKEIN_ARC_CALLBACK_URL");
80
108
  return { url: url.replace(/\/+$/, ""), token, ...(events ? { events } : {}), ...(callbackUrl ? { callbackUrl } : {}) };
81
109
  }
@@ -39,6 +39,11 @@ export interface Host {
39
39
  port?: number;
40
40
  stop(): Promise<void>;
41
41
  }
42
+ /**
43
+ * #156: the host's Teranode headers tracker (`skein-host run`): on unless SKEIN_TERANODE=off, where the
44
+ * network has bootstrap peers or SKEIN_TERANODE_BOOTSTRAP names some, or a hub is known for the first sync.
45
+ */
46
+ export declare function teranodeOptions(v: Record<string, string | undefined>, home: string): Pick<RouterOptions, "teranode">;
42
47
  /** The default image (#89): one genesis for everyone, no owner in it, a claim row (boot.ts). */
43
48
  export { DEFAULT_IMAGE };
44
49
  /**
package/dist/host/cli.js CHANGED
@@ -130,7 +130,10 @@
130
130
  // SKEIN_STORE_ROCKSDB and SKEIN_STORE. Unset, and no native kernel binary here (the npm package
131
131
  // ships none): $SKEIN_HOME/store.sqlite
132
132
  // SKEIN_ARC_URL the host's Arcade (#58, #65, arc.ts): where the instances' broadcast events go (a durable
133
- // queue), one status subscription, the instances' status provider; SKEIN_ARC_TOKEN its one callback token (required with it);
133
+ // queue), one status subscription, the instances' status provider. Unset: the network's (arc.ts DEFAULT_ARC:
134
+ // test https://testnet.arcade.1sat.app, main https://arcade.1sat.app, regtest none); `off`: none.
135
+ // SKEIN_ARC_TOKEN its one callback token (required with a named URL; with the network's and none set,
136
+ // made once and appended to $SKEIN_HOME/host.env, read from there after);
134
137
  // SKEIN_ARC_EVENTS_URL its SSE service (default <url>/events); SKEIN_ARC_CALLBACK_URL where Arcade
135
138
  // posts webhooks (this router's /arc/callback as Arcade reaches it; unset: SSE only)
136
139
  // SKEIN_BILLING `off`: this host bills no one (#130, billing.ts); else it bills a skein whose host row names its
@@ -144,6 +147,19 @@
144
147
  // and the default image's chain part (#132, image-chain.ts): every header it brings is
145
148
  // appended to the image, which a chaintracks URL's history (`…/tip/stream` → `…/headers`)
146
149
  // fills from genesis at start
150
+ // SKEIN_TERANODE #156: `off` turns the host's headers tracker over the Teranode P2P network off (teranode.ts;
151
+ // `skein-host run` only). On by default where the network has bootstrap peers (main, test):
152
+ // it joins the network's block topic (`teranode/bitcoin/1.0.0/<network>-block`), takes each
153
+ // announced header into the same intake as SKEIN_HEADERS_URL's, crawls a gap back from the
154
+ // announcer's data hub, and, with an empty image chain, syncs the whole chain first from the
155
+ // network's default hub (gorillanode). It rides the host node (the host key's, one per host:
156
+ // the instances' overlay on the same node), its block-topic peers from the
157
+ // bootstrap list and GossipSub (no Teranode DHT; the overlay's on its skein DHT, /skein/kad/<network>/1.0.0)
158
+ // SKEIN_TERANODE_BOOTSTRAP more Teranode bootstrap peers (multiaddrs, comma separated) beside the network's
159
+ // (/dnsaddr/<mainnet|testnet>.bootstrap.teranode.bsvb.tech); regtest has none: set it to run one
160
+ // SKEIN_HEADERS_HUB the data hub the tracker's first sync reads (a Teranode asset API: `bestblockheader`,
161
+ // `headers/<hash>?n=`) over the network's default (https://<mainnet|testnet>.gorillanode.io/api/v1);
162
+ // regtest has none: without one the first sync waits for the first announcement's hub
147
163
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
148
164
  import { tmpdir } from "node:os";
149
165
  import { CID as CIDClass, decode as decodeCbor, parse as parseCid } from "../runtime/cid.js";
@@ -162,6 +178,7 @@ import { CONTROL_SOCKET, controlRequest } from "./control.js";
162
178
  import { homeOf as homeOfVars, identityOf, operatorKey, withHostEnv } from "./hostenv.js";
163
179
  import { hostArcConfig } from "./arc.js";
164
180
  import { hostP2PConfig, peerIdOf } from "./p2p.js";
181
+ import { DEFAULT_HUBS, TERANODE_BOOTSTRAP } from "./teranode.js";
165
182
  import { addressBook } from "./deploy.js";
166
183
  import { fetchHttp, hostDomain, Router } from "./router.js";
167
184
  import { billingConfig, DEV_BILLING, NSAT } from "./billing.js";
@@ -822,6 +839,7 @@ function wallets(v, home) {
822
839
  const signer = signerOf(v, home); // #160: one-key mode (SKEIN_KEY_FILE / SKEIN_KEY), else the master key's
823
840
  return {
824
841
  walletFor: (row) => signer.wallet(row.handle), peerKeyFor: (handle) => signer.peerKey(handle), providerKeyFor: (name) => signer.providerKey(name),
842
+ hostPeerKey: signer.hostPeerKey(), // #156: the host node's (the tracker rides it; a one-key host's instances share it)
825
843
  // #162: a one-key host claims the instances it creates with its own key.
826
844
  ...(signer.shared ? { claimer: signer.wallet("") } : {}),
827
845
  };
@@ -850,11 +868,26 @@ function routerOptions(db, env) {
850
868
  ...(Number(v.SKEIN_ANSWER_WAIT_MS) > 0 ? { answerWaitMs: Number(v.SKEIN_ANSWER_WAIT_MS) } : {}),
851
869
  libp2p: hostP2PConfig(v, home),
852
870
  headersFeed: v.SKEIN_HEADERS_URL || undefined,
853
- arc: hostArcConfig(v, home),
871
+ arc: hostArcConfig(v, home, { made: (f) => env.out(`skein-host: the host's Arcade callback token made and kept in ${f} (SKEIN_ARC_TOKEN)`) }),
854
872
  billing: billingConfig(v),
855
873
  log: (source, line) => env.out(`[${source}] ${line}`),
856
874
  };
857
875
  }
876
+ /**
877
+ * #156: the host's Teranode headers tracker (`skein-host run`): on unless SKEIN_TERANODE=off, where the
878
+ * network has bootstrap peers or SKEIN_TERANODE_BOOTSTRAP names some, or a hub is known for the first sync.
879
+ */
880
+ export function teranodeOptions(v, home) {
881
+ if ((v.SKEIN_TERANODE ?? "").toLowerCase() === "off")
882
+ return {};
883
+ const net = hostNetwork(v);
884
+ const named = (v.SKEIN_TERANODE_BOOTSTRAP ?? "").split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
885
+ const bootstrap = [...new Set([...(TERANODE_BOOTSTRAP[net] ?? []), ...named])]; // #156: the network's and the named ones
886
+ const hub = (v.SKEIN_HEADERS_HUB || DEFAULT_HUBS[net])?.replace(/\/+$/, "");
887
+ if (!bootstrap.length && !hub)
888
+ return {};
889
+ return { teranode: { bootstrap, ...(hub ? { hub } : {}), peersFile: join(home, "teranode-peers.json") } };
890
+ }
858
891
  /** `skein-host billing [handle]` (#130): what host.db holds of billing — the instances, their ticks, their payments. */
859
892
  function billingCmd(db, rest, env) {
860
893
  const [handle] = rest;
@@ -1159,7 +1192,7 @@ async function migrateStoreCmd(db, rest, env) {
1159
1192
  export async function runHost(db, env, o = {}) {
1160
1193
  const v = env.vars;
1161
1194
  const home = homeOf(v);
1162
- const router = new Router({ ...routerOptions(db, env), ...o.router });
1195
+ const router = new Router({ ...routerOptions(db, env), ...teranodeOptions(v, home), ...o.router });
1163
1196
  const mport = Number(v.SKEIN_ROUTER_PORT ?? 8100);
1164
1197
  const binds = (v.SKEIN_ROUTER_BIND ?? "").split(",").map((x) => x.trim()).filter(Boolean);
1165
1198
  const mserver = await router.listen(mport, binds.length ? binds : undefined).then((s) => s, (e) => { env.err(`skein-host: router: ${e.message}`); return undefined; });
@@ -1178,7 +1211,7 @@ export async function runHost(db, env, o = {}) {
1178
1211
  if (router.arc)
1179
1212
  env.out(`skein-host: broadcaster: broadcast events → Arcade ${router.o.arc.url}; proofs and statuses (the status provider) from ${router.arc.eventsUrl()}${router.o.arc.callbackUrl ? ` and webhooks at /arc/callback (${router.o.arc.callbackUrl})` : ""}`);
1180
1213
  else
1181
- env.out("skein-host: no Arcade (SKEIN_ARC_URL): a broadcast event is dropped; a new genesis names no status provider");
1214
+ env.out(`skein-host: no Arcade (SKEIN_ARC_URL ${(v.SKEIN_ARC_URL ?? "").toLowerCase() === "off" ? "off" : `unset, and network ${hostNetwork(v)} has none`}): a broadcast event is dropped; a new genesis names no status provider`);
1182
1215
  await router.start();
1183
1216
  // #142: the first run makes the host skein (owned by the operator's key); later runs start what exists.
1184
1217
  if (!db.hostSkein() && o.firstRun !== false) {
@@ -0,0 +1,29 @@
1
+ import type { Libp2p } from "@libp2p/interface";
2
+ export declare const REGTEST_GENESIS: Buffer<ArrayBuffer>;
3
+ /** A regtest header on `prev` (its merkle root `root`, else one from `salt`). */
4
+ export declare function mineHeader(prev: Uint8Array, salt: number | string, root?: Uint8Array): Buffer;
5
+ export declare class FakeTeranode {
6
+ /** The chain the hub serves (height = index). */
7
+ chain: Buffer[];
8
+ node: Libp2p;
9
+ hub: string;
10
+ /** Every hub request's path and query, in order. */
11
+ readonly asked: string[];
12
+ readonly topic: string;
13
+ private server?;
14
+ constructor(network?: string);
15
+ static start(network?: string): Promise<FakeTeranode>;
16
+ private pubsub;
17
+ /** The node's address (the tracker's bootstrap). */
18
+ addr(): string;
19
+ /** Peers subscribed to the block topic. */
20
+ subscribers(): number;
21
+ /** Mine to `height` on the served chain. */
22
+ mineTo(height: number): void;
23
+ /** Announce the header at `height` as a Teranode node does (the bus envelope around a BlockMessage). */
24
+ announce(height: number, o?: {
25
+ hub?: string | null;
26
+ raw?: unknown;
27
+ }): Promise<void>;
28
+ stop(): Promise<void>;
29
+ }
@@ -0,0 +1,100 @@
1
+ // A fake Teranode network for the tests (#156; fake-arcade.ts is the pattern):
2
+ // one js-libp2p node on 127.0.0.1 (TCP, noise, yamux, GossipSub with
3
+ // StrictSign) that publishes block announcements on the block topic as the
4
+ // Teranode message bus does (`{"name", "data": <base64 BlockMessage JSON>}`),
5
+ // and a data hub (the Teranode asset API's two reads the tracker uses:
6
+ // `GET /bestblockheader` (+ `/json`) and `GET /headers/<hash>?n=` — child to
7
+ // parent, binary) serving a regtest chain the test mines. No DHT: skein nodes
8
+ // find Teranode peers by their bootstrap list and GossipSub alone (2026-11-02).
9
+ import { createHash } from "node:crypto";
10
+ import { createServer } from "node:http";
11
+ import { noise } from "@chainsafe/libp2p-noise";
12
+ import { yamux } from "@chainsafe/libp2p-yamux";
13
+ import { gossipsub } from "@libp2p/gossipsub";
14
+ import { identify } from "@libp2p/identify";
15
+ import { tcp } from "@libp2p/tcp";
16
+ import { createLibp2p } from "libp2p";
17
+ import { headerProblem } from "./image-chain.js";
18
+ import { blockTopic } from "./teranode.js";
19
+ const sha256d = (b) => createHash("sha256").update(createHash("sha256").update(b).digest()).digest();
20
+ const display = (h) => Buffer.from(h).reverse().toString("hex");
21
+ export const REGTEST_GENESIS = Buffer.from("0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff7f2002000000", "hex");
22
+ /** A regtest header on `prev` (its merkle root `root`, else one from `salt`). */
23
+ export function mineHeader(prev, salt, root) {
24
+ const h = Buffer.alloc(80);
25
+ h.writeInt32LE(1, 0);
26
+ Buffer.from(prev).copy(h, 4);
27
+ Buffer.from(root ?? sha256d(Buffer.from(`block ${salt}`))).copy(h, 36);
28
+ h.writeUInt32LE(1_790_300_000 + (typeof salt === "number" ? salt : 7) * 600, 68);
29
+ h.writeUInt32LE(0x207fffff, 72);
30
+ for (let nonce = 0;; nonce++) {
31
+ h.writeUInt32LE(nonce, 76);
32
+ if (!headerProblem(h))
33
+ return h;
34
+ }
35
+ }
36
+ export class FakeTeranode {
37
+ /** The chain the hub serves (height = index). */
38
+ chain = [REGTEST_GENESIS];
39
+ node;
40
+ hub = "";
41
+ /** Every hub request's path and query, in order. */
42
+ asked = [];
43
+ topic;
44
+ server;
45
+ constructor(network = "regtest") { this.topic = blockTopic(network); }
46
+ static async start(network = "regtest") {
47
+ const f = new FakeTeranode(network);
48
+ f.node = await createLibp2p({
49
+ addresses: { listen: ["/ip4/127.0.0.1/tcp/0"] },
50
+ transports: [tcp()], connectionEncrypters: [noise()], streamMuxers: [yamux()],
51
+ services: {
52
+ identify: identify(), pubsub: gossipsub({ globalSignaturePolicy: "StrictSign", allowPublishToZeroTopicPeers: true, emitSelf: false }),
53
+ },
54
+ });
55
+ f.pubsub().subscribe(f.topic);
56
+ f.server = createServer((req, res) => {
57
+ const u = new URL(req.url ?? "/", "http://x");
58
+ f.asked.push(`${u.pathname}${u.search}`);
59
+ const best = f.chain.at(-1);
60
+ if (u.pathname === "/bestblockheader")
61
+ return void res.writeHead(200, { "content-type": "application/octet-stream" }).end(best);
62
+ if (u.pathname === "/bestblockheader/json")
63
+ return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ hash: display(sha256d(best)), height: f.chain.length - 1 }));
64
+ const m = /^\/headers\/([0-9a-f]{64})$/.exec(u.pathname);
65
+ if (m) {
66
+ const at = f.chain.findIndex((h) => display(sha256d(h)) === m[1]);
67
+ if (at < 0)
68
+ return void res.writeHead(404).end();
69
+ const n = Math.min(Number(u.searchParams.get("n") ?? 1), 10_000);
70
+ const out = [];
71
+ for (let h = at; h >= 0 && out.length < n; h--)
72
+ out.push(f.chain[h]);
73
+ return void res.writeHead(200, { "content-type": "application/octet-stream" }).end(Buffer.concat(out));
74
+ }
75
+ res.writeHead(404).end();
76
+ });
77
+ await new Promise((r) => f.server.listen(0, "127.0.0.1", r));
78
+ f.hub = `http://127.0.0.1:${f.server.address().port}`;
79
+ return f;
80
+ }
81
+ pubsub() { return this.node.services.pubsub; }
82
+ /** The node's address (the tracker's bootstrap). */
83
+ addr() { return this.node.getMultiaddrs().find((a) => a.toString().includes("127.0.0.1")).toString(); }
84
+ /** Peers subscribed to the block topic. */
85
+ subscribers() { return this.pubsub().getSubscribers(this.topic).length; }
86
+ /** Mine to `height` on the served chain. */
87
+ mineTo(height) { while (this.chain.length <= height)
88
+ this.chain.push(mineHeader(sha256d(this.chain.at(-1)), this.chain.length)); }
89
+ /** Announce the header at `height` as a Teranode node does (the bus envelope around a BlockMessage). */
90
+ async announce(height, o = {}) {
91
+ const h = this.chain[height];
92
+ const msg = o.raw ?? { PeerID: this.node.peerId.toString(), ClientName: "fake", DataHubURL: o.hub === null ? "" : (o.hub ?? this.hub), Hash: display(sha256d(h)), Height: height, Header: h.toString("hex"), Coinbase: "" };
93
+ const data = Buffer.from(typeof msg === "string" ? msg : JSON.stringify({ name: "fake", data: Buffer.from(JSON.stringify(msg)).toString("base64") }));
94
+ await this.pubsub().publish(this.topic, data);
95
+ }
96
+ async stop() {
97
+ await this.node.stop();
98
+ await new Promise((r) => this.server?.close(r));
99
+ }
100
+ }
@@ -107,7 +107,7 @@ export declare class Feeds {
107
107
  * chain part grows by it): every header the stream brings, as well as the
108
108
  * subscribers'. The connection stays open while a listener is on it.
109
109
  */
110
- listen(url: string, fn: (raws: Uint8Array[]) => void): void;
110
+ listen(url: string, fn: (raws: Uint8Array[], at?: number) => Promise<void> | void): void;
111
111
  /** Whether `handle` is subscribed to the host's own feed. */
112
112
  hosts(handle: string): boolean;
113
113
  /** Subscribe `handle` to what it declared and the host's feed for it, and to nothing else. */
@@ -124,4 +124,12 @@ export declare class Feeds {
124
124
  pending(handle: string): number;
125
125
  private drain;
126
126
  private dispatch;
127
+ /**
128
+ * #156: headers the host brings itself (the Teranode tracker) as if `url`'s stream had: to its
129
+ * listener (the image chain, the host chain), then to its subscribers in runs of BACKFILL_RUN.
130
+ * `at`: the height of the first (a run that may replace from there).
131
+ */
132
+ inject(url: string, raws: Uint8Array[], at?: number): Promise<void>;
133
+ /** The listener first (awaited: the host chain holds the run before an instance is told), then every subscriber. */
134
+ private deliver;
127
135
  }
@@ -341,7 +341,7 @@ export class Feeds {
341
341
  if (s.subscribers.has(handle) && !specs.some((f) => f.kind === "headers" && f.url === url))
342
342
  s.subscribers.delete(handle);
343
343
  if (!s.subscribers.size && !this.listeners.has(url)) {
344
- void s.stream.stop();
344
+ void s.stream?.stop();
345
345
  this.sse.delete(url);
346
346
  }
347
347
  }
@@ -354,6 +354,11 @@ export class Feeds {
354
354
  /** The one connection to `url`, opened if it is not. */
355
355
  connect(url) {
356
356
  let s = this.sse.get(url);
357
+ if (!s && !/^https?:/i.test(url)) {
358
+ // #156: a source the host feeds itself (the Teranode tracker, `inject`): subscribers, no connection.
359
+ s = { subscribers: new Map() };
360
+ this.sse.set(url, s);
361
+ }
357
362
  if (!s) {
358
363
  const subscribers = new Map();
359
364
  const stream = new SseStream(url, (ev) => this.dispatch(url, subscribers, ev.data), {
@@ -370,7 +375,7 @@ export class Feeds {
370
375
  /** Close every connection (no reconnect); resolves once their loops have ended. */
371
376
  async stop() {
372
377
  this.stopped = true;
373
- const streams = [...this.sse.values()].map((s) => s.stream.stop());
378
+ const streams = [...this.sse.values()].map((s) => s.stream?.stop());
374
379
  this.sse.clear();
375
380
  await Promise.all(streams);
376
381
  }
@@ -407,7 +412,7 @@ export class Feeds {
407
412
  q.running = false;
408
413
  }
409
414
  }
410
- dispatch(url, subscribers, data) {
415
+ async dispatch(url, subscribers, data) {
411
416
  let dropped = false;
412
417
  const hs = headersOf(data, (why) => { dropped = true; this.say("router", `feed ${url}: ${why}`); });
413
418
  if (!hs.length) {
@@ -415,8 +420,28 @@ export class Feeds {
415
420
  this.say("router", `feed ${url}: an event with no header in it: ignored`);
416
421
  return;
417
422
  }
418
- this.listeners.get(url)?.(hs);
423
+ await this.deliver(url, subscribers, hs);
424
+ }
425
+ /**
426
+ * #156: headers the host brings itself (the Teranode tracker) as if `url`'s stream had: to its
427
+ * listener (the image chain, the host chain), then to its subscribers in runs of BACKFILL_RUN.
428
+ * `at`: the height of the first (a run that may replace from there).
429
+ */
430
+ async inject(url, raws, at) {
431
+ if (this.stopped || !raws.length)
432
+ return;
433
+ await this.deliver(url, this.connect(url).subscribers, raws, at);
434
+ }
435
+ /** The listener first (awaited: the host chain holds the run before an instance is told), then every subscriber. */
436
+ async deliver(url, subscribers, hs, at) {
437
+ try {
438
+ await this.listeners.get(url)?.(hs, at);
439
+ }
440
+ catch (e) {
441
+ this.say("router", `feed ${url}: ${hs.length} header${hs.length === 1 ? "" : "s"} not taken: ${e.message}`);
442
+ }
419
443
  for (const [handle, box] of subscribers)
420
- this.push(handle, box, hs);
444
+ for (let i = 0; i < hs.length; i += BACKFILL_RUN)
445
+ this.push(handle, box, hs.slice(i, i + BACKFILL_RUN));
421
446
  }
422
447
  }
@@ -1,3 +1,4 @@
1
+ import type { CID } from "multiformats/cid";
1
2
  import type { Store } from "../runtime/store.ts";
2
3
  import { type BootSource } from "./boot.ts";
3
4
  import type { HostDb } from "./instances.ts";
@@ -33,6 +34,10 @@ export declare class ImageChain {
33
34
  private stat?;
34
35
  /** #142, #152: the overlay images' static parts (images/host, a directory with no `bin/`), each merged over the default image once. */
35
36
  private merged;
37
+ /** Boots reading the image now (`hold`): while any, a write's drops wait in `pending`. */
38
+ private holds;
39
+ /** The objects writes replaced while a boot read the image: dropped once none does. */
40
+ private pending;
36
41
  /** Settles once the first sync (start) is done: what a creation waits for. */
37
42
  ready: Promise<void>;
38
43
  constructor(o: ImageChainOptions);
@@ -47,6 +52,24 @@ export declare class ImageChain {
47
52
  height: number;
48
53
  hash: string;
49
54
  } | undefined;
55
+ /**
56
+ * The height of a header held within `depth` of the tip (default two blocks' worth), by its hash
57
+ * (display hex); undefined when none there is it. An index over that window, rebuilt when the tip moves.
58
+ */
59
+ find(hash: string, depth?: number): number | undefined;
60
+ private window?;
61
+ /**
62
+ * The chain part as objects (#156: what an empty host chain imports): the `chain` tree's CID and
63
+ * every object under it (the tree, `headers` and its blocks, `tip`), as host.db holds them.
64
+ */
65
+ chainPart(): {
66
+ chain: CID;
67
+ objects: Array<[CID, Uint8Array]>;
68
+ } | undefined;
69
+ /** The header held at a height (raw, a copy), or undefined. */
70
+ headerAt(height: number): Uint8Array | undefined;
71
+ /** The headers held from `from` to the tip, `run` at a time (copies; the chain read as it stands at each run). */
72
+ runs(from: number, run: number): Generator<Uint8Array[]>;
50
73
  /** The header at a height (raw), from its block. */
51
74
  private at;
52
75
  /** One at a time: every change to the chain part goes through here. */
@@ -60,6 +83,13 @@ export declare class ImageChain {
60
83
  */
61
84
  put(height: number, raws: Uint8Array[]): Promise<boolean>;
62
85
  private write;
86
+ /**
87
+ * A boot reads the image (its root, then every object under it) from `source()` on: hold it from
88
+ * before `source()` until the walk is done. Until the last hold is released, a write keeps the
89
+ * objects it replaces (their deletes wait); the release returned (once) applies them when no
90
+ * other boot holds.
91
+ */
92
+ hold(): () => void;
63
93
  /** The static part: the image directory, scanned once (boot.ts imageDir: the apps it names beside it too). */
64
94
  private static;
65
95
  /**