@dcasegr/skein 0.0.0-stage → 0.1.2
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 +525 -2
- package/bin/skein-host.js +22 -0
- package/bin/skein.js +8 -0
- package/dist/client/admin-cli.d.ts +10 -0
- package/dist/client/admin-cli.js +370 -0
- package/dist/client/admin.d.ts +139 -0
- package/dist/client/admin.js +256 -0
- package/dist/client/bundle.d.ts +9 -0
- package/dist/client/bundle.js +34 -0
- package/dist/client/cli.d.ts +44 -0
- package/dist/client/cli.js +306 -0
- package/dist/client/client.d.ts +161 -0
- package/dist/client/client.js +237 -0
- package/dist/client/config.d.ts +18 -0
- package/dist/client/config.js +31 -0
- package/dist/client/conversation.d.ts +62 -0
- package/dist/client/conversation.js +113 -0
- package/dist/client/raw.d.ts +94 -0
- package/dist/client/raw.js +154 -0
- package/dist/client/target.d.ts +34 -0
- package/dist/client/target.js +158 -0
- package/dist/dev/cli.d.ts +8 -0
- package/dist/dev/cli.js +248 -0
- package/dist/dev/scan.d.ts +15 -0
- package/dist/dev/scan.js +173 -0
- package/dist/envelope-cbor.d.ts +110 -0
- package/dist/envelope-cbor.js +160 -0
- package/dist/envelope.d.ts +38 -0
- package/dist/envelope.js +101 -0
- package/dist/host/arc.d.ts +131 -0
- package/dist/host/arc.js +425 -0
- package/dist/host/billing.d.ts +102 -0
- package/dist/host/billing.js +167 -0
- package/dist/host/boot.d.ts +246 -0
- package/dist/host/boot.js +643 -0
- package/dist/host/cli.d.ts +57 -0
- package/dist/host/cli.js +1253 -0
- package/dist/host/clock.d.ts +3 -0
- package/dist/host/clock.js +8 -0
- package/dist/host/control.d.ts +45 -0
- package/dist/host/control.js +170 -0
- package/dist/host/cron.d.ts +99 -0
- package/dist/host/cron.js +196 -0
- package/dist/host/deliver.d.ts +53 -0
- package/dist/host/deliver.js +251 -0
- package/dist/host/deploy.d.ts +29 -0
- package/dist/host/deploy.js +55 -0
- package/dist/host/door-answer.d.ts +24 -0
- package/dist/host/door-answer.js +21 -0
- package/dist/host/events.d.ts +89 -0
- package/dist/host/events.js +222 -0
- package/dist/host/fake-arcade.d.ts +40 -0
- package/dist/host/fake-arcade.js +149 -0
- package/dist/host/fake-discovery.d.ts +4 -0
- package/dist/host/fake-discovery.js +58 -0
- package/dist/host/feeds.d.ts +127 -0
- package/dist/host/feeds.js +422 -0
- package/dist/host/frontdoor.d.ts +92 -0
- package/dist/host/frontdoor.js +172 -0
- package/dist/host/genesis.d.ts +259 -0
- package/dist/host/genesis.js +353 -0
- package/dist/host/handles.d.ts +40 -0
- package/dist/host/handles.js +88 -0
- package/dist/host/hostenv.d.ts +30 -0
- package/dist/host/hostenv.js +83 -0
- package/dist/host/image-chain.d.ts +127 -0
- package/dist/host/image-chain.js +440 -0
- package/dist/host/install.d.ts +22 -0
- package/dist/host/install.js +139 -0
- package/dist/host/instances.d.ts +227 -0
- package/dist/host/instances.js +430 -0
- package/dist/host/kernel.d.ts +246 -0
- package/dist/host/kernel.js +603 -0
- package/dist/host/liveness.d.ts +65 -0
- package/dist/host/liveness.js +155 -0
- package/dist/host/manifest.d.ts +160 -0
- package/dist/host/manifest.js +597 -0
- package/dist/host/p2p.d.ts +337 -0
- package/dist/host/p2p.js +1002 -0
- package/dist/host/packet.d.ts +121 -0
- package/dist/host/packet.js +562 -0
- package/dist/host/plan.d.ts +154 -0
- package/dist/host/plan.js +418 -0
- package/dist/host/providers.d.ts +238 -0
- package/dist/host/providers.js +565 -0
- package/dist/host/roster.d.ts +72 -0
- package/dist/host/roster.js +172 -0
- package/dist/host/router.d.ts +782 -0
- package/dist/host/router.js +2411 -0
- package/dist/host/signer.d.ts +49 -0
- package/dist/host/signer.js +167 -0
- package/dist/host/store-sqlite.d.ts +39 -0
- package/dist/host/store-sqlite.js +170 -0
- package/dist/host/stores.d.ts +43 -0
- package/dist/host/stores.js +342 -0
- package/dist/host/sync.d.ts +121 -0
- package/dist/host/sync.js +467 -0
- package/dist/host/testbeef.d.ts +21 -0
- package/dist/host/testbeef.js +37 -0
- package/dist/host/testhost.d.ts +149 -0
- package/dist/host/testhost.js +211 -0
- package/dist/host/vcdiff.d.ts +9 -0
- package/dist/host/vcdiff.js +210 -0
- package/dist/host/wasm-kernel.d.ts +87 -0
- package/dist/host/wasm-kernel.js +296 -0
- package/dist/host/wasm-worker.d.ts +1 -0
- package/dist/host/wasm-worker.js +186 -0
- package/dist/peers/cron.d.ts +33 -0
- package/dist/peers/cron.js +75 -0
- package/dist/peers/infer-main.d.ts +2 -0
- package/dist/peers/infer-main.js +74 -0
- package/dist/peers/infer.d.ts +163 -0
- package/dist/peers/infer.js +367 -0
- package/dist/runtime/beef.d.ts +28 -0
- package/dist/runtime/beef.js +107 -0
- package/dist/runtime/bitcoin.d.ts +36 -0
- package/dist/runtime/bitcoin.js +141 -0
- package/dist/runtime/cid.d.ts +14 -0
- package/dist/runtime/cid.js +51 -0
- package/dist/runtime/dispatch.d.ts +92 -0
- package/dist/runtime/dispatch.js +142 -0
- package/dist/runtime/envelope.d.ts +60 -0
- package/dist/runtime/envelope.js +101 -0
- package/dist/runtime/heads.d.ts +25 -0
- package/dist/runtime/heads.js +37 -0
- package/dist/runtime/identity.d.ts +28 -0
- package/dist/runtime/identity.js +67 -0
- package/dist/runtime/index-store.d.ts +152 -0
- package/dist/runtime/index-store.js +715 -0
- package/dist/runtime/log.d.ts +62 -0
- package/dist/runtime/log.js +104 -0
- package/dist/runtime/programs.d.ts +30 -0
- package/dist/runtime/programs.js +61 -0
- package/dist/runtime/records.d.ts +135 -0
- package/dist/runtime/records.js +149 -0
- package/dist/runtime/sqlite.d.ts +33 -0
- package/dist/runtime/sqlite.js +604 -0
- package/dist/runtime/store.d.ts +141 -0
- package/dist/runtime/store.js +24 -0
- package/dist/runtime/syscalls.d.ts +9 -0
- package/dist/runtime/syscalls.js +41 -0
- package/dist/runtime/tree.d.ts +60 -0
- package/dist/runtime/tree.js +196 -0
- package/dist/runtime/types.d.ts +100 -0
- package/dist/runtime/types.js +7 -0
- package/dist/testapps.d.ts +101 -0
- package/dist/testapps.js +275 -0
- package/dist/testkit.d.ts +25 -0
- package/dist/testkit.js +48 -0
- package/dist/wallet.d.ts +35 -0
- package/dist/wallet.js +87 -0
- package/images/README.md +68 -0
- package/images/default/README.md +38 -0
- package/images/default/bin/frontdoor.cid +1 -0
- package/images/default/bin/frontdoor.json +5 -0
- package/images/default/bin/messagebox.cid +1 -0
- package/images/default/bin/messagebox.json +8 -0
- package/images/default/bin/wallet.cid +1 -0
- package/images/default/bin/wallet.json +14 -0
- package/images/default/etc/config.json +1 -0
- package/images/default/etc/dispatch.json +82 -0
- package/images/host/etc/apps.json +8 -0
- package/kernel-zig/README.md +1247 -0
- package/kernel-zig/web/client.js +65 -0
- package/kernel-zig/web/kernel.d.ts +59 -0
- package/kernel-zig/web/kernel.js +329 -0
- package/kernel-zig/web/store.js +67 -0
- package/kernel-zig/web/worker.js +79 -0
- package/kernel-zig/zig-out/web/skein-kernel.wasm +0 -0
- package/package.json +77 -4
- package/wasm/README.md +35 -0
- package/wasm/frontdoor.wasm +0 -0
- package/wasm/messagebox.wasm +0 -0
- package/wasm/resolve.wasm +0 -0
- package/wasm/wallet.wasm +0 -0
- package/wasm/wire-probe.wasm +0 -0
package/dist/host/arc.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// The host's broadcaster (#58, #65): one Arcade (bsv-blockchain/arcade) per
|
|
2
|
+
// host, the wiring that carries the instances' broadcast events out and
|
|
3
|
+
// their transactions' proofs and statuses in. The instance never sees a URL,
|
|
4
|
+
// a 503 or a retry: a step emits a broadcast event (kernel `emit`, {event:
|
|
5
|
+
// "broadcast", tx, beef?}) and ends waiting on the transaction; the kernel
|
|
6
|
+
// hands the event here once the step is committed (providers.ts, transport
|
|
7
|
+
// `event`).
|
|
8
|
+
//
|
|
9
|
+
// Out — the durable queue (#58's): each transaction is queued in host.db
|
|
10
|
+
// (`broadcast_queue`, by txid: the bytes for Arcade, who broadcast it) and
|
|
11
|
+
// posted to Arcade's POST /tx in Extended Format (the Atomic BEEF's subject
|
|
12
|
+
// with its inputs' sources; the raw transaction when the event carried no
|
|
13
|
+
// BEEF) under the host's one callback token (X-CallbackToken,
|
|
14
|
+
// X-FullStatusUpdates: true, X-CallbackUrl when the host has a public
|
|
15
|
+
// webhook URL). Arcade's answer settles the row: a 2xx (RECEIVED, or a
|
|
16
|
+
// duplicate's current status) or a 4xx (a rejection) is taken as a status
|
|
17
|
+
// (below) and the row goes; a 5xx or no answer (backpressure, Arcade down)
|
|
18
|
+
// keeps it, tried again with backoff (`retry`: 1 s doubling to 5 min) until
|
|
19
|
+
// `giveUpMs` (a day) after it was queued. A restart picks the queue up where
|
|
20
|
+
// it was; a transaction queued again while queued is one row (its
|
|
21
|
+
// broadcasters added).
|
|
22
|
+
//
|
|
23
|
+
// In — one SSE session per host: Arcade's `/events?callbackToken=<the host
|
|
24
|
+
// token>` carries every transaction submitted under the token, resumed with
|
|
25
|
+
// `Last-Event-ID` (the last event id taken, host.db `stream_cursor`, written
|
|
26
|
+
// once the event is routed), reconnecting with backoff (feeds.ts SseStream);
|
|
27
|
+
// Arcade's webhooks, when it has a callback URL, take the same path (POST
|
|
28
|
+
// /arc/callback, the host token as bearer). Each status goes to **every
|
|
29
|
+
// instance whose state holds the transaction** (#58), two ways (#65):
|
|
30
|
+
//
|
|
31
|
+
// a merkle path (MINED, IMMUTABLE with its BUMP) a **proof event** in box `chain`, unsigned:
|
|
32
|
+
// {kind: "proof", subject: <tx CID>, txid, path: bytes (BRC-74), blockHash?, blockHeight?}
|
|
33
|
+
// — self-validating, recorded by the VM only when the path's root is its header's at that
|
|
34
|
+
// height; specific wiring (this session), never an open box
|
|
35
|
+
// anything else (RECEIVED, SEEN_ON_NETWORK, REJECTED, DOUBLE_SPEND_ATTEMPTED, …)
|
|
36
|
+
// a **status message** from the host's status provider (providers.ts `status`): a signed
|
|
37
|
+
// message, box `chain/status` (the chain app's, #128), `subject` the transaction's CID, body
|
|
38
|
+
// {kind: "status", txid, txStatus, blockHash?, blockHeight?, extraInfo?} — admitted by an
|
|
39
|
+
// instance only if it has a row from that provider ({sender: <its key>, box: "chain/status"}). Optional: an instance
|
|
40
|
+
// without the subscription learns acceptance from the proof, rejection from a competing
|
|
41
|
+
// proof or abandonment (docs/WALLET.md).
|
|
42
|
+
//
|
|
43
|
+
// A status already routed (txid + txStatus + blockHash, host.db
|
|
44
|
+
// `status_seen`) is not routed again — but Arcade's answer to a post always
|
|
45
|
+
// reaches the instances that broadcast it (a later broadcaster's news). Who holds a transaction is read, never
|
|
46
|
+
// written: the kernel's `has` of the transaction's CID (bitcoin-tx: its
|
|
47
|
+
// txid), cached per txid — filled by the broadcasts (who sent it) and by a
|
|
48
|
+
// sweep of every enabled instance (hydrated to ask) at a txid's first status
|
|
49
|
+
// since the router started; a later status asks again only the running
|
|
50
|
+
// instances not yet known.
|
|
51
|
+
//
|
|
52
|
+
// Arcade itself may later sign its statuses (a signed-message callback beside
|
|
53
|
+
// webhook and SSE): it would then be the status provider an instance
|
|
54
|
+
// subscribes to, and nothing on the instance's side changes.
|
|
55
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
56
|
+
import { join } from "node:path";
|
|
57
|
+
import { Transaction } from "@bsv/sdk";
|
|
58
|
+
import { DEFAULT_BOX, SseStream, statusOf, txCid } from "./feeds.js";
|
|
59
|
+
/** The webhook's path on the router. */
|
|
60
|
+
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 = {};
|
|
64
|
+
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");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const get = (k) => vars[k] || file[k] || undefined;
|
|
73
|
+
const url = get("SKEIN_ARC_URL");
|
|
74
|
+
if (!url)
|
|
75
|
+
return undefined;
|
|
76
|
+
const token = get("SKEIN_ARC_TOKEN");
|
|
77
|
+
if (!token)
|
|
78
|
+
throw new Error("SKEIN_ARC_URL is set but SKEIN_ARC_TOKEN is not: the host's callback token scopes its status stream");
|
|
79
|
+
const events = get("SKEIN_ARC_EVENTS_URL"), callbackUrl = get("SKEIN_ARC_CALLBACK_URL");
|
|
80
|
+
return { url: url.replace(/\/+$/, ""), token, ...(events ? { events } : {}), ...(callbackUrl ? { callbackUrl } : {}) };
|
|
81
|
+
}
|
|
82
|
+
const enc = (v) => new TextEncoder().encode(JSON.stringify(v));
|
|
83
|
+
const json = (status, v, headers = {}) => ({ status, headers: { "content-type": "application/json", ...headers }, body: enc(v) });
|
|
84
|
+
/** The transaction a broadcast body carries (Atomic BEEF, BEEF, raw), and the bytes to post to Arcade: Extended Format when its inputs' sources are there. */
|
|
85
|
+
export function arcadeBody(body) {
|
|
86
|
+
const bytes = [...body];
|
|
87
|
+
let tx;
|
|
88
|
+
for (const parse of [() => Transaction.fromAtomicBEEF(bytes), () => Transaction.fromBEEF(bytes)]) {
|
|
89
|
+
try {
|
|
90
|
+
tx = parse();
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
catch { /* not this form: the next, then a raw transaction below */ }
|
|
94
|
+
}
|
|
95
|
+
if (!tx) {
|
|
96
|
+
try {
|
|
97
|
+
return { txid: Transaction.fromBinary(bytes).id("hex"), bytes: body };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { bytes: body };
|
|
101
|
+
} // no txid: enqueue says so and posts the raw transaction
|
|
102
|
+
}
|
|
103
|
+
// toEF throws when an input's source transaction is not in the BEEF: then the raw transaction, which Arcade takes too.
|
|
104
|
+
try {
|
|
105
|
+
return { txid: tx.id("hex"), bytes: Uint8Array.from(tx.toEF()) };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { txid: tx.id("hex"), bytes: Uint8Array.from(tx.toBinary()) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A status with a merkle path as the proof event an instance records (#65), or undefined. */
|
|
112
|
+
export function proofOf(ev) {
|
|
113
|
+
if (!(ev.merklePath instanceof Uint8Array))
|
|
114
|
+
return undefined;
|
|
115
|
+
return {
|
|
116
|
+
kind: "proof", subject: ev.subject, txid: ev.txid, path: ev.merklePath,
|
|
117
|
+
...(typeof ev.blockHash === "string" ? { blockHash: ev.blockHash } : {}), ...(typeof ev.blockHeight === "number" ? { blockHeight: ev.blockHeight } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** A status without a path as the status provider's message body (#65). */
|
|
121
|
+
export function statusBodyOf(ev) {
|
|
122
|
+
return {
|
|
123
|
+
kind: "status", txid: ev.txid, txStatus: ev.txStatus,
|
|
124
|
+
...(typeof ev.blockHash === "string" ? { blockHash: ev.blockHash } : {}), ...(typeof ev.blockHeight === "number" ? { blockHeight: ev.blockHeight } : {}),
|
|
125
|
+
...(typeof ev.extraInfo === "string" && ev.extraInfo ? { extraInfo: ev.extraInfo } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export class Broadcaster {
|
|
129
|
+
o;
|
|
130
|
+
/** txid → the instances known to hold it, and whether every enabled instance was asked. */
|
|
131
|
+
known = new Map();
|
|
132
|
+
/** Statuses being routed now (txid + key): a webhook and the stream delivering the same one route it once. */
|
|
133
|
+
inflight = new Map();
|
|
134
|
+
stream;
|
|
135
|
+
timer;
|
|
136
|
+
pumping;
|
|
137
|
+
again = false;
|
|
138
|
+
stopped = false;
|
|
139
|
+
/** Counts (tests, the log): statuses routed, redeliveries dropped, admissions (proofs and status messages), posts. */
|
|
140
|
+
stats = { routed: 0, duplicates: 0, admitted: 0, proofs: 0, statuses: 0, posted: 0 };
|
|
141
|
+
constructor(o) { this.o = o; }
|
|
142
|
+
say(source, line) { this.o.log?.(source, line); }
|
|
143
|
+
now() { return (this.o.now ?? Date.now)(); }
|
|
144
|
+
/** Arcade's SSE service for the host's token. */
|
|
145
|
+
eventsUrl() { return this.o.arc.events ?? `${this.o.arc.url}/events`; }
|
|
146
|
+
/** Open the subscription (resuming after the last event id taken), and take up the queue where it was. */
|
|
147
|
+
start() {
|
|
148
|
+
this.stopped = false;
|
|
149
|
+
this.kick();
|
|
150
|
+
if (this.stream)
|
|
151
|
+
return;
|
|
152
|
+
const key = this.eventsUrl();
|
|
153
|
+
const url = new URL(key);
|
|
154
|
+
url.searchParams.set("callbackToken", this.o.arc.token);
|
|
155
|
+
this.stream = new SseStream(url.toString(), async (ev) => {
|
|
156
|
+
if (ev.event === undefined || ev.event === "status" || ev.event === "message") {
|
|
157
|
+
let v;
|
|
158
|
+
try {
|
|
159
|
+
v = JSON.parse(ev.data);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
this.say("router", `arcade: an event that is not JSON: ignored: ${ev.data.slice(0, 200)}`);
|
|
163
|
+
v = undefined;
|
|
164
|
+
}
|
|
165
|
+
if (v !== undefined)
|
|
166
|
+
await this.deliver(v, "stream");
|
|
167
|
+
}
|
|
168
|
+
if (ev.id !== undefined)
|
|
169
|
+
this.o.db.setCursor(key, ev.id);
|
|
170
|
+
}, { lastId: this.o.db.cursor(key), backoff: this.o.backoff, fetch: this.o.fetch, log: (l) => this.say("router", `arcade events ${key}: ${l}`) });
|
|
171
|
+
this.stream.start();
|
|
172
|
+
}
|
|
173
|
+
async stop() {
|
|
174
|
+
this.stopped = true;
|
|
175
|
+
clearTimeout(this.timer);
|
|
176
|
+
this.timer = undefined;
|
|
177
|
+
await this.pumping;
|
|
178
|
+
await this.stream?.stop();
|
|
179
|
+
this.stream = undefined;
|
|
180
|
+
}
|
|
181
|
+
/** `handle` holds `txid` (it broadcast it). */
|
|
182
|
+
note(txid, handle) {
|
|
183
|
+
const t = txid.toLowerCase();
|
|
184
|
+
let k = this.known.get(t);
|
|
185
|
+
if (!k) {
|
|
186
|
+
this.known.set(t, (k = { handles: new Set(), swept: false }));
|
|
187
|
+
const max = this.o.maxKnown ?? 100_000;
|
|
188
|
+
if (this.known.size > max)
|
|
189
|
+
this.known.delete(this.known.keys().next().value);
|
|
190
|
+
}
|
|
191
|
+
k.handles.add(handle);
|
|
192
|
+
}
|
|
193
|
+
/** The instances holding `txid`: the cached ones, and those a read finds (every enabled one the first time, then the running ones not known yet). */
|
|
194
|
+
async holders(txid) {
|
|
195
|
+
const k = this.known.get(txid) ?? { handles: new Set(), swept: false };
|
|
196
|
+
this.known.set(txid, k);
|
|
197
|
+
const { all, running } = this.o.instances();
|
|
198
|
+
const ask = (k.swept ? running : all).filter((h) => !k.handles.has(h));
|
|
199
|
+
for (const h of ask) {
|
|
200
|
+
try {
|
|
201
|
+
if (await this.o.holds(h, txid))
|
|
202
|
+
k.handles.add(h);
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
this.say(h, `arcade: could not read whether it holds ${txid.slice(0, 8)}: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
k.swept = true;
|
|
209
|
+
return [...k.handles].filter((h) => all.includes(h));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* One status from Arcade (the stream's, a webhook's, or its answer to a
|
|
213
|
+
* post): routed to every instance holding the transaction, once — a proof
|
|
214
|
+
* as an event, anything else as a status message. `askers` (a post's
|
|
215
|
+
* broadcasters) get it even when it was routed before: Arcade's answer to
|
|
216
|
+
* a duplicate is the news for an instance that broadcast it later. The
|
|
217
|
+
* number of instances it reached.
|
|
218
|
+
*/
|
|
219
|
+
async deliver(v, via, askers = []) {
|
|
220
|
+
const ev = statusOf(v);
|
|
221
|
+
if (typeof ev === "string") {
|
|
222
|
+
this.say("router", `arcade ${via}: not a status (${ev}): ignored`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
const txid = ev.txid;
|
|
226
|
+
const key = `${String(ev.txStatus)} ${String(ev.blockHash ?? "")}`;
|
|
227
|
+
const proof = proofOf(ev);
|
|
228
|
+
const send = async (hs) => {
|
|
229
|
+
let n = 0;
|
|
230
|
+
for (const h of hs) {
|
|
231
|
+
try {
|
|
232
|
+
if (proof)
|
|
233
|
+
await this.o.admit(h, DEFAULT_BOX, proof);
|
|
234
|
+
else
|
|
235
|
+
await this.o.status(h, statusBodyOf(ev), txCid(txid));
|
|
236
|
+
n++;
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.say(h, `arcade: ${proof ? "proof" : "status"} ${txid.slice(0, 8)} ${String(ev.txStatus)} not delivered: ${e.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return n;
|
|
243
|
+
};
|
|
244
|
+
const busy = this.inflight.get(`${txid} ${key}`);
|
|
245
|
+
if (busy)
|
|
246
|
+
await busy;
|
|
247
|
+
if (busy || this.o.db.hasStatus(txid, key)) {
|
|
248
|
+
this.stats.duplicates++;
|
|
249
|
+
if (!askers.length)
|
|
250
|
+
return 0;
|
|
251
|
+
const n = await send(askers);
|
|
252
|
+
this.say("router", `arcade ${via}: ${txid.slice(0, 8)} ${String(ev.txStatus)} (routed before) → ${askers.join(", ")}, who broadcast it`);
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
const p = (async () => {
|
|
256
|
+
const hs = [...new Set([...(await this.holders(txid)), ...askers])];
|
|
257
|
+
const n = await send(hs);
|
|
258
|
+
this.o.db.markStatus(txid, key);
|
|
259
|
+
this.stats.routed++;
|
|
260
|
+
this.stats.admitted += n;
|
|
261
|
+
if (proof)
|
|
262
|
+
this.stats.proofs += n;
|
|
263
|
+
else
|
|
264
|
+
this.stats.statuses += n;
|
|
265
|
+
this.say("router", `arcade ${via}: ${txid.slice(0, 8)} ${String(ev.txStatus)}${proof ? " (a proof event)" : " (a status message)"} → ${hs.length ? hs.join(", ") : "no instance holds it"}`);
|
|
266
|
+
return n;
|
|
267
|
+
})();
|
|
268
|
+
this.inflight.set(`${txid} ${key}`, p);
|
|
269
|
+
try {
|
|
270
|
+
return await p;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
this.inflight.delete(`${txid} ${key}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ---------------------------------------------------------------- out: the queue
|
|
277
|
+
/**
|
|
278
|
+
* A broadcast event from `handle` (#65): the transaction's bytes and, if
|
|
279
|
+
* the event carried it, its Atomic BEEF. Queued durably and posted; the
|
|
280
|
+
* txid, or undefined when the bytes are not a transaction.
|
|
281
|
+
*/
|
|
282
|
+
enqueue(handle, tx, beef) {
|
|
283
|
+
let raw;
|
|
284
|
+
try {
|
|
285
|
+
raw = Transaction.fromBinary([...tx]).id("hex");
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
this.say(handle, "broadcast: the event's transaction does not parse: dropped");
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
// The BEEF's subject must be the transaction: else the raw transaction goes (Arcade then needs its parents).
|
|
292
|
+
const fromBeef = beef ? arcadeBody(beef) : undefined;
|
|
293
|
+
const bytes = fromBeef?.txid === raw ? fromBeef.bytes : tx;
|
|
294
|
+
if (beef && fromBeef?.txid !== raw)
|
|
295
|
+
this.say(handle, `broadcast ${raw.slice(0, 8)}: the event's BEEF is not of its transaction: the raw transaction goes`);
|
|
296
|
+
this.note(raw, handle);
|
|
297
|
+
this.o.db.queueBroadcast(raw, bytes, handle, this.now());
|
|
298
|
+
this.kick();
|
|
299
|
+
return raw;
|
|
300
|
+
}
|
|
301
|
+
/** Post what is due now, then wait for the next. */
|
|
302
|
+
kick() {
|
|
303
|
+
if (this.stopped)
|
|
304
|
+
return;
|
|
305
|
+
if (this.pumping) {
|
|
306
|
+
this.again = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.pumping = this.pump().catch((e) => this.say("router", `broadcast queue: ${e.message}`)).finally(() => {
|
|
310
|
+
this.pumping = undefined;
|
|
311
|
+
if (this.again) {
|
|
312
|
+
this.again = false;
|
|
313
|
+
this.kick();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async pump() {
|
|
318
|
+
clearTimeout(this.timer);
|
|
319
|
+
this.timer = undefined;
|
|
320
|
+
for (const row of this.o.db.dueBroadcasts(this.now())) {
|
|
321
|
+
if (this.stopped)
|
|
322
|
+
return;
|
|
323
|
+
await this.post(row);
|
|
324
|
+
}
|
|
325
|
+
const next = this.o.db.nextBroadcastAt();
|
|
326
|
+
if (next !== undefined && !this.stopped)
|
|
327
|
+
this.timer = setTimeout(() => this.kick(), Math.min(2 ** 31 - 1, Math.max(0, next - this.now()) + 1));
|
|
328
|
+
}
|
|
329
|
+
async post(row) {
|
|
330
|
+
for (const h of row.instances)
|
|
331
|
+
this.note(row.txid, h);
|
|
332
|
+
const from = row.instances[0] ?? "router";
|
|
333
|
+
const a = this.o.arc;
|
|
334
|
+
const r = await this.arcade("POST", "/tx", {
|
|
335
|
+
headers: {
|
|
336
|
+
"content-type": "application/octet-stream", "x-callbacktoken": a.token, "x-fullstatusupdates": "true",
|
|
337
|
+
...(a.callbackUrl ? { "x-callbackurl": a.callbackUrl } : {}),
|
|
338
|
+
},
|
|
339
|
+
body: row.body,
|
|
340
|
+
});
|
|
341
|
+
this.stats.posted++;
|
|
342
|
+
const what = summary(r.body);
|
|
343
|
+
this.say(from, `broadcast ${row.txid.slice(0, 8)} → Arcade: ${r.status ? `HTTP ${r.status}` : "no answer"}${what ? ` ${what}` : ""}`);
|
|
344
|
+
if (r.status >= 200 && r.status < 500 && r.status !== 408 && r.status !== 425 && r.status !== 429) {
|
|
345
|
+
// Taken (or a duplicate's current status) or refused: the row goes, and the answer is a status.
|
|
346
|
+
this.o.db.unqueueBroadcast(row.txid);
|
|
347
|
+
let v = {};
|
|
348
|
+
try {
|
|
349
|
+
v = JSON.parse(new TextDecoder().decode(r.body));
|
|
350
|
+
}
|
|
351
|
+
catch { /* no JSON: the status is the HTTP code's (below), and the line above logged the answer */ }
|
|
352
|
+
const reason = [v.reason, v.extraInfo, v.detail, v.title].find((x) => typeof x === "string" && x);
|
|
353
|
+
const status = r.status < 300
|
|
354
|
+
? { ...v, txid: row.txid, txStatus: typeof v.txStatus === "string" && v.txStatus ? v.txStatus : "RECEIVED" }
|
|
355
|
+
: { txid: row.txid, txStatus: typeof v.txStatus === "string" && v.txStatus ? v.txStatus : "REJECTED", ...(reason ? { extraInfo: reason } : {}) };
|
|
356
|
+
await this.deliver(status, "post", row.instances);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Not taken (backpressure, unreachable): tried again later, until given up.
|
|
360
|
+
const at = this.now();
|
|
361
|
+
if (at - row.since >= (this.o.giveUpMs ?? 86_400_000)) {
|
|
362
|
+
this.o.db.unqueueBroadcast(row.txid);
|
|
363
|
+
this.say(from, `broadcast ${row.txid.slice(0, 8)}: Arcade did not take it in ${Math.round((at - row.since) / 1000)} s: dropped (the instance abandons it)`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const { min, max } = this.o.retry ?? { min: 1000, max: 300_000 };
|
|
367
|
+
const wait = Math.min(max, min * 2 ** Math.min(row.attempts, 30));
|
|
368
|
+
const retryAfter = Number(r.headers["retry-after"]);
|
|
369
|
+
this.o.db.retryBroadcast(row.txid, at + Math.max(wait, Number.isFinite(retryAfter) ? retryAfter * 1000 : 0), `${r.status || "no answer"} ${what}`.trim());
|
|
370
|
+
}
|
|
371
|
+
/** Whether a post (and the routing of its answer) is under way. */
|
|
372
|
+
busy() { return this.pumping !== undefined; }
|
|
373
|
+
/** Until the posts under way, and the routing of their answers, are done (tests, the corpus: Router.settled). */
|
|
374
|
+
async idle() {
|
|
375
|
+
while (this.pumping)
|
|
376
|
+
await this.pumping;
|
|
377
|
+
}
|
|
378
|
+
/** The queue as it stands (tests, the log). */
|
|
379
|
+
queued() { return this.o.db.broadcasts().map(({ txid, attempts, next }) => ({ txid, attempts, next })); }
|
|
380
|
+
async arcade(method, path, init = {}) {
|
|
381
|
+
try {
|
|
382
|
+
const r = await (this.o.fetch ?? fetch)(`${this.o.arc.url}${path}`, {
|
|
383
|
+
method, headers: { accept: "application/json", ...init.headers }, body: init.body,
|
|
384
|
+
signal: AbortSignal.timeout(this.o.timeoutMs ?? 30_000),
|
|
385
|
+
});
|
|
386
|
+
const headers = { "content-type": r.headers.get("content-type") ?? "application/json" };
|
|
387
|
+
const retry = r.headers.get("retry-after");
|
|
388
|
+
if (retry)
|
|
389
|
+
headers["retry-after"] = retry;
|
|
390
|
+
return { status: r.status, headers, body: new Uint8Array(await r.arrayBuffer()) };
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
// No answer: not taken, tried again.
|
|
394
|
+
return json(0, { title: "Arcade unreachable", extraInfo: e.message });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// ---------------------------------------------------------------- the webhook
|
|
398
|
+
/** POST /arc/callback: Arcade's webhook, with the host token as bearer; answered once the status is routed (Arcade retries otherwise). */
|
|
399
|
+
async callback(headers, raw) {
|
|
400
|
+
if (headers.authorization !== `Bearer ${this.o.arc.token}`)
|
|
401
|
+
return json(401, { status: "error", code: "ERR_UNAUTHORIZED" });
|
|
402
|
+
let v;
|
|
403
|
+
try {
|
|
404
|
+
v = JSON.parse(new TextDecoder().decode(raw));
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return json(400, { status: "error", code: "ERR_BAD_BODY", description: "not JSON" });
|
|
408
|
+
}
|
|
409
|
+
const ev = statusOf(v);
|
|
410
|
+
if (typeof ev === "string")
|
|
411
|
+
return json(400, { status: "error", code: "ERR_BAD_CALLBACK", description: ev });
|
|
412
|
+
await this.deliver(v, "webhook");
|
|
413
|
+
return json(200, { status: "success" });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** An Arcade answer in a few words for the log: its txStatus, else its reason / title. */
|
|
417
|
+
function summary(body) {
|
|
418
|
+
try {
|
|
419
|
+
const j = JSON.parse(new TextDecoder().decode(body.subarray(0, 4000)));
|
|
420
|
+
return String(j.txStatus ?? j.reason ?? j.title ?? j.extraInfo ?? "");
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return "";
|
|
424
|
+
} // not JSON: the log line keeps the HTTP status alone
|
|
425
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { DispatchRow } from "../runtime/dispatch.ts";
|
|
3
|
+
/** The kernel's head (billing.zig HEAD). */
|
|
4
|
+
export declare const BILLING_HEAD = "billing";
|
|
5
|
+
/** The host row's kernel operation. */
|
|
6
|
+
export declare const TICK_OP = "tick";
|
|
7
|
+
/** The host row's box by convention (the row's address is what a tick goes to). */
|
|
8
|
+
export declare const BILLING_BOX = "billing";
|
|
9
|
+
export declare const NSAT = 1000000000n;
|
|
10
|
+
export declare const RATE_NAMES: readonly ["fuel", "storage", "served", "fetch", "authfetch", "publish"];
|
|
11
|
+
export type RateName = typeof RATE_NAMES[number];
|
|
12
|
+
/**
|
|
13
|
+
* The rates, whole sats: fuel per 10^9 fuel; storage per 10^6 bytes held a day; served per 10^6
|
|
14
|
+
* bytes served; fetch, authfetch and publish per event (billing.zig).
|
|
15
|
+
*/
|
|
16
|
+
export type Rates = Record<RateName, number>;
|
|
17
|
+
/** What this host supports. */
|
|
18
|
+
export interface BillingConfig {
|
|
19
|
+
/** The block a skein prepays at a time (sats). */
|
|
20
|
+
x: number;
|
|
21
|
+
rates: Rates;
|
|
22
|
+
/** The free allowance per skein (sats): its allocation before any payment. */
|
|
23
|
+
allowance: number;
|
|
24
|
+
/** How often it ticks a billed skein (ms). */
|
|
25
|
+
tickMs: number;
|
|
26
|
+
/** How long after a skein went asleep it may be reclaimed (ms). */
|
|
27
|
+
graceMs: number;
|
|
28
|
+
}
|
|
29
|
+
/** Small dev defaults (#130 decided, "Numbers": configuration; real numbers later). */
|
|
30
|
+
export declare const DEV_BILLING: BillingConfig;
|
|
31
|
+
/**
|
|
32
|
+
* The host's billing from its environment: SKEIN_BILLING=off for none; else DEV_BILLING with
|
|
33
|
+
* SKEIN_BILLING_X, SKEIN_BILLING_RATES (JSON {fuel, storage, served, fetch, authfetch, publish}),
|
|
34
|
+
* SKEIN_BILLING_ALLOWANCE, SKEIN_BILLING_TICK_MS, SKEIN_BILLING_GRACE_MS over it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function billingConfig(v: Record<string, string | undefined>): BillingConfig | undefined;
|
|
37
|
+
/** The host row's terms: the host's key (hex), X, the rates, the box ticks go to. */
|
|
38
|
+
export interface Terms {
|
|
39
|
+
host: string;
|
|
40
|
+
x: number;
|
|
41
|
+
rates: Rates;
|
|
42
|
+
address: string;
|
|
43
|
+
}
|
|
44
|
+
/** The host row (billing.zig termsOf): the first kernel route whose fn is `tick`, in table order; #143: the host's key its `host` setting. */
|
|
45
|
+
export declare function termsOf(rows: DispatchRow[]): Terms | undefined;
|
|
46
|
+
/** Why the terms are not what this host (key `host`) supports (undefined: they are): another key, X or a rate below its own. */
|
|
47
|
+
export declare function mismatch(cfg: BillingConfig, host: string, t: Terms): string | undefined;
|
|
48
|
+
/** The kernel's billing state (the head `billing`'s record), as the host reads it. */
|
|
49
|
+
export interface BillingState {
|
|
50
|
+
host: string;
|
|
51
|
+
allowance: bigint;
|
|
52
|
+
paid: bigint;
|
|
53
|
+
tally: bigint;
|
|
54
|
+
lastTick: number;
|
|
55
|
+
ticks: number;
|
|
56
|
+
bytes: number;
|
|
57
|
+
payments: number;
|
|
58
|
+
asleep: boolean;
|
|
59
|
+
}
|
|
60
|
+
export declare function stateOf(rec: unknown): BillingState | undefined;
|
|
61
|
+
/** What the instance may consume (nanosats). */
|
|
62
|
+
export declare const allocation: (s: BillingState) => bigint;
|
|
63
|
+
/** The host's amounts at the rates (nanosats): billing.zig priceHost. */
|
|
64
|
+
export declare const priceHost: (r: Rates, fuel: number, served: number) => bigint;
|
|
65
|
+
/** One line of the host's log for a period: a read call, or a response served. */
|
|
66
|
+
export interface LogLine {
|
|
67
|
+
at: number;
|
|
68
|
+
op: string;
|
|
69
|
+
caller?: string;
|
|
70
|
+
fuel: number;
|
|
71
|
+
bytes: number;
|
|
72
|
+
}
|
|
73
|
+
/** What the host has metered for an instance since its last tick. */
|
|
74
|
+
export declare class Period {
|
|
75
|
+
since: number;
|
|
76
|
+
fuel: number;
|
|
77
|
+
served: number;
|
|
78
|
+
lines: LogLine[];
|
|
79
|
+
constructor(since: number);
|
|
80
|
+
add(l: LogLine): void;
|
|
81
|
+
get empty(): boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The period's record — {kind: "host-log", instance: <its identity>, from, to, lines: [{at, op,
|
|
85
|
+
* caller?, fuel, bytes}]} — and its CID, which the tick commits to (`log`): the host keeps the
|
|
86
|
+
* record (host.db billing_ticks), so it can show what it charged.
|
|
87
|
+
*/
|
|
88
|
+
export declare function periodRecord(identity: string, p: Period, to: number): {
|
|
89
|
+
record: Record<string, unknown>;
|
|
90
|
+
bytes: Uint8Array;
|
|
91
|
+
cid: CID;
|
|
92
|
+
};
|
|
93
|
+
/** A tick's body (billing.zig tickOf). */
|
|
94
|
+
export declare function tickBody(at: number, cfg: BillingConfig, p: Period, log: CID): Record<string, unknown>;
|
|
95
|
+
/** What the host knows of a billed instance after it last read it. */
|
|
96
|
+
export interface BillingView {
|
|
97
|
+
terms?: Terms;
|
|
98
|
+
state?: BillingState;
|
|
99
|
+
mismatch?: string;
|
|
100
|
+
}
|
|
101
|
+
/** Why the host forwards nothing to the instance (undefined: it forwards as usual). */
|
|
102
|
+
export declare function closedBy(v: BillingView | undefined): string | undefined;
|