@mcsherrylabs/pi-meerkat 0.6.0 → 0.8.0

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.
Files changed (67) hide show
  1. package/README.md +234 -54
  2. package/dist/installer/committee-init.sh +117 -0
  3. package/dist/installer/committee.conf +102 -0
  4. package/dist/installer/node-join.sh +228 -0
  5. package/dist/installer/node.conf +235 -0
  6. package/dist/installer/runtime.json +1 -0
  7. package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
  8. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
  9. package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
  10. package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
  11. package/dist/installer-runtime/dist/src/announcements.js +448 -0
  12. package/dist/installer-runtime/dist/src/app.js +539 -0
  13. package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
  14. package/dist/installer-runtime/dist/src/env.js +425 -0
  15. package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
  16. package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
  17. package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
  18. package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
  19. package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
  20. package/dist/installer-runtime/dist/src/registry/http.js +194 -0
  21. package/dist/installer-runtime/dist/src/registry/index.js +52 -0
  22. package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
  23. package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
  24. package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
  25. package/dist/installer-runtime/dist/src/registry/store.js +242 -0
  26. package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
  27. package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
  28. package/dist/installer-runtime/dist/src/runtime.js +164 -0
  29. package/dist/installer-runtime/dist/src/store.js +138 -0
  30. package/dist/installer-runtime/package.json +1 -0
  31. package/dist/installer.d.ts +224 -0
  32. package/dist/installer.d.ts.map +1 -0
  33. package/dist/installer.js +336 -0
  34. package/dist/installer.js.map +1 -0
  35. package/dist/mcp-announce.d.ts +42 -0
  36. package/dist/mcp-announce.d.ts.map +1 -0
  37. package/dist/mcp-announce.js +231 -0
  38. package/dist/mcp-announce.js.map +1 -0
  39. package/dist/mcp-cli.d.ts +3 -0
  40. package/dist/mcp-cli.d.ts.map +1 -0
  41. package/dist/mcp-cli.js +137 -0
  42. package/dist/mcp-cli.js.map +1 -0
  43. package/dist/mcp-config.d.ts +28 -0
  44. package/dist/mcp-config.d.ts.map +1 -0
  45. package/dist/mcp-config.js +104 -0
  46. package/dist/mcp-config.js.map +1 -0
  47. package/dist/mcp-facade.d.ts +20 -0
  48. package/dist/mcp-facade.d.ts.map +1 -0
  49. package/dist/mcp-facade.js +200 -0
  50. package/dist/mcp-facade.js.map +1 -0
  51. package/dist/mcp-upstream.d.ts +42 -0
  52. package/dist/mcp-upstream.d.ts.map +1 -0
  53. package/dist/mcp-upstream.js +151 -0
  54. package/dist/mcp-upstream.js.map +1 -0
  55. package/dist/registry.d.ts.map +1 -1
  56. package/dist/registry.js +11 -5
  57. package/dist/registry.js.map +1 -1
  58. package/dist/tools.d.ts +6 -2
  59. package/dist/tools.d.ts.map +1 -1
  60. package/dist/tools.js +123 -119
  61. package/dist/tools.js.map +1 -1
  62. package/installer/Committee.Dockerfile +6 -0
  63. package/installer/Node.Dockerfile +6 -0
  64. package/installer/Registry.Dockerfile +13 -0
  65. package/installer/runtime-control.mjs +172 -0
  66. package/installer/session-files.mjs +16 -0
  67. package/package.json +14 -5
@@ -0,0 +1,539 @@
1
+ // ── template app: the smallest thing that is really an evolver app ──────────
2
+ //
3
+ // Four steps, in the order you will need them in your own app:
4
+ //
5
+ // 1. PREFLIGHT is a node there, and is its chain advancing?
6
+ // 2. IDENTITIES load them from application state, or claim and persist them
7
+ // 3. WRITE build → sign → submit a transaction, and wait for it to commit
8
+ // 4. VERIFY read the result back off the chain, never trust the submit
9
+ //
10
+ // AND THEN THREE MORE, which are this app's own and the reason it runs a node of its own:
11
+ //
12
+ // 5. CHANNEL claim `announcements` — an owned ledger id the quorum orders opaquely
13
+ // 6. ANNOUNCE write a message onto it
14
+ // 7. STREAM read that message back off the ORDERED STREAM, through this node
15
+ //
16
+ // Steps 5-7 are SKIPPED, loudly, when the node cannot serve a stream: `/api/channel/next`
17
+ // exists only on a processing node, so a public committee node has none to give. See
18
+ // src/announcements.ts for the three facts about that cursor that shape all of it.
19
+ //
20
+ // RUN IT TWICE. The second run does not re-claim: step 2 finds the identities in this
21
+ // target's application state and reuses them. That is what makes the flow repeatable
22
+ // rather than merely re-runnable — and it is why `state:reset` exists as a separate,
23
+ // deliberate act. See src/env.ts for the four states and how they relate.
24
+ //
25
+ // WHY A TRANSFER IS THE WRITE. Step 3 needs a transaction any identity may send with zero
26
+ // setup. A channel message is the obvious choice and the wrong one here: a channel needs an
27
+ // owner, a write-authorised group, and a DA group to hold the body — and a public devnet
28
+ // retains no channel bodies at all unless it lists that channel in
29
+ // `ce.evolver.subscribed-channels`. A credit transfer is permissionless: a freshly claimed
30
+ // identity is faucet-funded, so it can pay another immediately. Swap this step for whatever
31
+ // your app's real transaction is.
32
+ import { isValidNodeId, NodeClient, transferBody, buildSignedTx, LEDGER, FAUCET_SPONSORED_CREDIT, } from "@mcsherrylabs/evolver-client";
33
+ import { readFileSync } from "node:fs";
34
+ import { currentTarget, stateExists, TargetError, ROOT } from "./env.js";
35
+ // ONE implementation of "what may an identity be called", shared with scripts/rename.mjs.
36
+ import { identityPrefix } from "../scripts/versions.mjs";
37
+ import * as store from "./store.js";
38
+ // The channel this app owns. Everything about the ordered stream — and why it needs a node of
39
+ // its own — lives in that module.
40
+ import { CHANNEL, DA_THRESHOLD_M, ensureChannelWithDa, ensureDaGroup, announce, readBack, retainedMessages, paceApply, paceQuietly, nodeSelfId } from "./announcements.js";
41
+ import { processorHttpPort, registryChannel } from "./env.js";
42
+ import { join } from "node:path";
43
+ import { registryModule, registryReadRoutes } from "./registry/index.js";
44
+ import { meerkatShutdown, startMeerkatProcessor, startMeerkatService } from "./runtime.js";
45
+ import { deadlineFetch, CHAIN_TIMEOUT_MS, validateDaConfig } from "./chainfetch.js";
46
+ const AMOUNT = 10n;
47
+ /** This app's own name, from package.json — never a literal, so a rename cannot leave a
48
+ * stale one behind in the output. */
49
+ const APP_NAME = (() => {
50
+ try {
51
+ return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name ?? "app";
52
+ }
53
+ catch {
54
+ return "app";
55
+ }
56
+ })();
57
+ // Resolving the chain is the first thing a deployment can get wrong, and a TargetError says
58
+ // exactly what is missing — so it is reported as the refusal it is, not as a stack trace out
59
+ // of env.ts. (`die` is defined below; this cannot use it, hence the explicit exit.)
60
+ const target = (() => {
61
+ try {
62
+ return currentTarget();
63
+ }
64
+ catch (e) {
65
+ if (e instanceof TargetError) {
66
+ console.error(`\n✗ ${e.message}\n`);
67
+ process.exit(1);
68
+ }
69
+ throw e;
70
+ }
71
+ })();
72
+ // TLS verification is left ON. The local chain serves plain HTTP (see docker-compose.yml for
73
+ // why), and the devnet has a real certificate, so nothing needs bypassing — which matters
74
+ // because `NODE_TLS_REJECT_UNAUTHORIZED=0` disables verification PROCESS-WIDE, for every
75
+ // host, not just the node. Set EVOLVER_INSECURE_TLS=1 only if you point a target at a chain
76
+ // serving the node's own short-lived identity certificate.
77
+ if (process.env.EVOLVER_INSECURE_TLS === "1")
78
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
79
+ // BOUNDED, not bare: a `fetch` with no deadline never returns if the peer never answers, and
80
+ // this app's confirm loops have budgets that a hang makes meaningless. Ported from the
81
+ // scheduler, which had it first — see src/chainfetch.ts.
82
+ const node = new NodeClient({ baseUrl: target.nodeUrl, fetch: deadlineFetch(CHAIN_TIMEOUT_MS) });
83
+ // A SECOND client for the BOOTSTRAP CLAIM ONLY, and only when the faucet is somewhere else.
84
+ // Everything else — submits, commit waits, reads — goes to `node`. The scope is narrow on
85
+ // purpose: see `faucetFor` in src/env.ts. A claim needs a SPONSOR with funds (structural, and
86
+ // a one-time bootstrap step); a submit needs a node that has caught up (transient, and the
87
+ // answer is to wait — `waitUntilNodeCanWrite` below — not to post somewhere else, which would
88
+ // trade one slow minute at boot for a dependency that never goes away).
89
+ const faucet = target.faucetUrl === target.nodeUrl
90
+ ? node
91
+ : new NodeClient({ baseUrl: target.faucetUrl, fetch: deadlineFetch(CHAIN_TIMEOUT_MS) });
92
+ const say = (s) => console.log(s);
93
+ // Throw, never exit past the owned worker/listener cleanup in finally.
94
+ const die = (s) => { throw new Error(s); };
95
+ // ── identity naming, and why it differs per target ─────────────────────────
96
+ //
97
+ // LOCAL: the chain is ephemeral, so fixed names are safe AND better — every run of a fresh
98
+ // chain produces the same identities, which is what "deterministic" should mean.
99
+ //
100
+ // DEVNET: the chain is shared and permanent. A fixed name would collide with a previous run
101
+ // (or another developer) and the claim would come back `exists` with keys we do not hold. So
102
+ // devnet names carry a suffix generated once and kept in this target's state, which means
103
+ // they are stable across runs and unique across resets.
104
+ //
105
+ // The prefix is DERIVED FROM THIS APP'S NAME, not a literal. It used to be `tmpl`, which
106
+ // survived `npm run rename` — so an app called anything at all still claimed identities
107
+ // called `tmplA`/`tmplB`, and on a shared chain like devnet that is exactly where the name
108
+ // is the only provenance anyone has.
109
+ const PREFIX = identityPrefix(APP_NAME);
110
+ function nameFor(role) {
111
+ const letter = role === "sender" ? "A" : "B";
112
+ if (target.chainIsEphemeral)
113
+ return `${PREFIX}${letter}`;
114
+ const suffix = devnetSuffix();
115
+ return `${PREFIX}${letter}${suffix}`;
116
+ }
117
+ let cachedSuffix = null;
118
+ function devnetSuffix() {
119
+ if (cachedSuffix)
120
+ return cachedSuffix;
121
+ // Derived from an identity already in state if there is one, so a partially completed run
122
+ // resumes with the SAME names rather than claiming a second pair.
123
+ for (const { nodeId } of store.summary(target)) {
124
+ const m = new RegExp(`^${PREFIX}[AB](\\d+)$`).exec(nodeId);
125
+ if (m)
126
+ return (cachedSuffix = m[1]);
127
+ }
128
+ return (cachedSuffix = String(Date.now()).slice(-6));
129
+ }
130
+ // ── 1. preflight ───────────────────────────────────────────────────────────
131
+ async function preflight() {
132
+ say(`\n① preflight — chain '${target.name}' → ${target.nodeUrl} (from the ${target.source})`);
133
+ say(` ${target.describe}`);
134
+ // Never silent: if claims go somewhere other than the node we operate through, that is the
135
+ // first thing to know when one of them misbehaves.
136
+ if (target.faucetUrl !== target.nodeUrl)
137
+ say(` faucet for the first claim only: ${target.faucetUrl}`);
138
+ let res;
139
+ try {
140
+ res = await deadlineFetch(CHAIN_TIMEOUT_MS)(`${target.nodeUrl}/status`); // NOTE: /status, not /api/status (404)
141
+ }
142
+ catch (e) {
143
+ return die(`cannot reach ${target.nodeUrl}/status — ${e instanceof Error ? e.message : String(e)}\n` +
144
+ (target.chainIsEphemeral
145
+ ? ` the local chain is not running: npm run chain:up`
146
+ : ` is the devnet up, and is EVOLVER_DEVNET_URL right?`));
147
+ }
148
+ if (!res.ok)
149
+ return die(`${target.nodeUrl}/status → HTTP ${res.status} (expected 200)`);
150
+ say(` status ok`);
151
+ let round;
152
+ try {
153
+ round = await node.currentRound();
154
+ }
155
+ catch (e) {
156
+ return die(`chain tip unreadable — ${e instanceof Error ? e.message : String(e)}`);
157
+ }
158
+ if (round === 0)
159
+ say(` ⚠ tip is round 0 — a fresh chain that has not committed yet`);
160
+ else
161
+ say(` chain tip round ${round}`);
162
+ return round;
163
+ }
164
+ // ── 2. identities: load, or claim and persist ──────────────────────────────
165
+ //
166
+ // A faucet claim is node-SPONSORED: the node pays, so the client needs no funds and no
167
+ // permission. What the client must NOT do is sign whatever bytes the node hands back — so
168
+ // the SDK recomputes the proof-of-possession digest locally and refuses to sign if the
169
+ // server's differs. That makes this safe against a node you do not control, and it is the
170
+ // drift alarm: if the node's claim recipe changes you get `digest_mismatch` by name rather
171
+ // than a signature over unknown bytes.
172
+ async function identity(role) {
173
+ const existing = store.get(target, role);
174
+ if (existing) {
175
+ // RECONCILE against the chain rather than trusting state. Persisting before claiming
176
+ // (below) means state can hold keys for an identity whose claim never committed — a
177
+ // crash, a transient node error, or a name that was already taken. Without this check
178
+ // the app happily "reuses" those keys and fails later at the WRITE, reporting
179
+ // `unknown_key` under a hint about version mismatches: a wrong diagnosis for the one
180
+ // failure the reader has been trained to expect. Reconciling here is also what makes
181
+ // persist-before-claim pay off — a half-finished run RESUMES with the same keys.
182
+ // ⚠️ ASK THE CHAIN, NOT THIS NODE — and the difference is not pedantic, it is a DEADLOCK.
183
+ //
184
+ // "Is this name on chain?" is a HAS-THE-CHAIN-AGREED question. A processing node answers
185
+ // every committed-state read AS OF ITS CURSOR, and that cursor is advanced by whatever
186
+ // drains /api/channel/next. Historically startup waited here before starting that owner;
187
+ // services now bind recovery control first and reconcile identities in parent-only setup.
188
+ // On a node replaying a chain (a fresh volume, a resync, a first join), the old sequence
189
+ // is: apply walks forward, PAUSES at the first announcement it has no body for, and waits
190
+ // for a consumer; the consumer is us; we are blocked here asking about an identity that is
191
+ // applied LATER than that pause point. Neither side can move.
192
+ //
193
+ // Observed exactly that way: the node answered `not_found` for an identity devnet showed
194
+ // holding 5000, this app declared "state/devnet is stale" and exited, and the restart loop
195
+ // guaranteed the cursor was never drained — so the node could never reach the block that
196
+ // would have made the answer true.
197
+ //
198
+ // The faucet client is the chain-agreed view (it is a quorum node, fully applied, and it is
199
+ // already trusted for the CLAIM below, so this introduces no new trust). When faucet and
200
+ // node are the same url `faucet === node` and nothing changes for a single-node target.
201
+ //
202
+ // ⚠️ A negative from here is authoritative; a negative from the local node is merely
203
+ // "not yet". That asymmetry is the whole bug — Constitution III, an undetermined answer is
204
+ // not a "no".
205
+ const onChain = await faucet.getIdentity(existing.nodeId);
206
+ if (onChain !== null) {
207
+ say(` ${role}: reusing ${existing.nodeId} from state/${target.name}`);
208
+ return existing;
209
+ }
210
+ say(` ${role}: ${existing.nodeId} is in state but not on chain — claiming it now`);
211
+ const retry = await faucet.claimIdentity(existing);
212
+ if (retry.kind === "committed") {
213
+ say(` ${role}: claimed ${existing.nodeId} (block ${retry.blockHeight})`);
214
+ return existing;
215
+ }
216
+ if (retry.kind === "exists") {
217
+ // The name is taken by keys that are not ours, so this identity is unusable. Say
218
+ // which one, because the fix differs per target.
219
+ return die(`'${existing.nodeId}' is on chain with keys this app does not hold.\n` +
220
+ ` state/${target.name} is stale for '${role}'. npm run state:reset` +
221
+ (target.chainIsEphemeral ? ` (or npm run reset for chain + state)` : ``));
222
+ }
223
+ return die(`re-claim of '${existing.nodeId}' failed: ${retry.error}` +
224
+ `${retry.message ? ` — ${retry.message}` : ""}`);
225
+ }
226
+ const name = nameFor(role);
227
+ if (!isValidNodeId(name)) {
228
+ return die(`'${name}' is not a usable identity name — the node requires 1–50 ASCII alphanumeric characters ` +
229
+ `(letters and digits only: no hyphens, dots or underscores).`);
230
+ }
231
+ const id = store.create(target, role, name); // persisted BEFORE the claim, so a claim
232
+ const r = await faucet.claimIdentity(id); // that commits is never orphaned
233
+ switch (r.kind) {
234
+ case "committed":
235
+ say(` ${role}: claimed ${name} (block ${r.blockHeight}) → state/${target.name}`);
236
+ return id;
237
+ case "exists":
238
+ // The remedy differs by WHERE this runs. Inside the published image there is no npm
239
+ // project, so `npm run reset` would be a dead end — the equivalent is discarding both
240
+ // volumes, which is what `down -v` does.
241
+ return die(`'${name}' already exists on chain and these keys are not its keys.\n` +
242
+ (!target.chainIsEphemeral
243
+ ? ` this target's state was reset but the name was reused — npm run state:reset, then retry`
244
+ : process.env.EVOLVER_IN_CONTAINER === "1"
245
+ ? ` the chain outlived the app's state. Discard both together:\n` +
246
+ ` docker compose down -v (or: docker compose -f oci://<ref> down -v)`
247
+ : ` the local chain outlived its state: npm run reset (chain + state together)`));
248
+ case "failed":
249
+ if (r.error === "digest_mismatch") {
250
+ return die(`CLAIM RECIPE DRIFT — ${r.message}\n` +
251
+ ` Nothing was signed. The node and @mcsherrylabs/evolver-client disagree on the\n` +
252
+ ` proof-of-possession digest, which means their versions do not match.`);
253
+ }
254
+ return die(`claim of '${name}' failed: ${r.error}${r.message ? ` — ${r.message}` : ""}`);
255
+ }
256
+ }
257
+ // ── 3. write ───────────────────────────────────────────────────────────────
258
+ // `signerFor` moved to src/store.ts, beside the identities it signs for — three callers need
259
+ // it now (this app, the registry, the rig's announcer) and a private copy per caller is the
260
+ // duplication Constitution IX exists to prevent.
261
+ const signerFor = store.signerFor;
262
+ async function transfer(from, to, amount, expectAtLeast, pace) {
263
+ // THE ROUND IS READ HERE, not passed in from preflight.
264
+ //
265
+ // A TTL is `currentRound + 50`, so it is only meaningful relative to a CURRENT round — and
266
+ // anything between preflight and this line can take arbitrarily long: waiting for a joining
267
+ // node to stop syncing, or a faucet claim on a remote chain. Measured: preflight read round
268
+ // 850 while the node was still catching up, the claims took the chain to 4628, and the
269
+ // transfer was refused `ttl_expired` for a TTL of 900 — a transaction dead on arrival,
270
+ // signed against a round that had passed minutes earlier.
271
+ const tip = await node.currentRound();
272
+ const tx = buildSignedTx({
273
+ // Use the SDK's LEDGER constants, never a literal. A core ledger id that is even
274
+ // slightly wrong is not a 404: the node reads an unknown id as an APPLICATION channel
275
+ // and refuses with `malformed_signed_tx … is out-of-band`, which reads like a broken
276
+ // transaction rather than a mistyped address.
277
+ ledgerId: LEDGER.identity,
278
+ senderNodeId: from.nodeId,
279
+ body: transferBody([{ recipient: to, amount }]),
280
+ // A real round number, not a far-future constant: the node refuses a TTL more than
281
+ // `maxTtlOffset` rounds ahead (`TtlTooFar`), so it must come from the current tip.
282
+ ttlRound: BigInt(tip + 50),
283
+ signers: [signerFor(from)],
284
+ });
285
+ const r = await node.submitTx(tx);
286
+ if (r.kind === "committed") {
287
+ say(` transfer committed: ${from.nodeId} → ${to}, ${amount}`);
288
+ return;
289
+ }
290
+ // AN UNKNOWN OUTCOME IS SETTLED BY LOOKING THE TRANSACTION UP, NEVER BY RESENDING.
291
+ //
292
+ // `timeout` does not mean refused — it means the node accepted the transaction and no
293
+ // commit was observed in the wait window. On a node of your own that has just joined, the
294
+ // likeliest cause is that it is still draining its routing backlog (which is why
295
+ // `waitUntilNodeCanWrite` runs before this); on a quiescent chain it can simply be waiting
296
+ // for the next block. Either way the transaction may still commit, so re-sending is the one
297
+ // thing not to do: identical bytes are refused as a duplicate (the txId is deterministic and
298
+ // the node dedupes on it in four states), and a REBUILT transaction has a new TTL, so a new
299
+ // txId, so it is a SECOND payment.
300
+ if (r.kind === "timeout") {
301
+ say(` submit timed out — outcome UNKNOWN, nothing resent. Resolving by reading the chain.`);
302
+ // FIVE MINUTES, and the reason is a cold node rather than a slow chain. A freshly joined
303
+ // node forwards nothing until its ROUTER has drained the per-block events it accumulated
304
+ // while syncing — thousands of them — and no API reports when that is done (evolver#82).
305
+ // Measured both ways: on a node up ten minutes the transfer committed in seconds; on a
306
+ // node minutes old it had not been forwarded after 90 seconds. So the window is generous,
307
+ // and nothing is resent while it waits: the transaction is already queued and valid until
308
+ // its TTL.
309
+ const deadline = Date.now() + 300_000;
310
+ while (Date.now() < deadline) {
311
+ // PACING, not sleeping. On a processing node the commit this is waiting for becomes
312
+ // visible only once the node has APPLIED that block, and it applies only when polled —
313
+ // by this app. A loop that merely slept would wait out its deadline against a node that
314
+ // was never going to move, which is exactly what happened before this line existed.
315
+ if (pace)
316
+ await paceQuietly(pace);
317
+ else
318
+ await new Promise((x) => setTimeout(x, 1000));
319
+ const now = await node.getIdentity(to);
320
+ if (now !== null && BigInt(now.balance) >= expectAtLeast) {
321
+ say(` transfer committed: ${from.nodeId} → ${to}, ${amount} (confirmed by reading the chain, not by the submit)`);
322
+ return;
323
+ }
324
+ }
325
+ return die(`the transfer has not appeared within 90s and its outcome is still UNKNOWN.\n` +
326
+ ` txId ${r.txIdHex ?? "(unreported)"} — look it up; do NOT resend, that is either a\n` +
327
+ ` no-op or, once the TTL has passed and the transaction is rebuilt, a second payment.`);
328
+ }
329
+ return die(`transfer refused: ${JSON.stringify(r)}\n` +
330
+ ` invalid_signature here usually means the node and SDK versions differ.\n` +
331
+ ` others: insufficient_balance · ttl_expired · invalid_identity_body`);
332
+ }
333
+ // ── 4. verify ──────────────────────────────────────────────────────────────
334
+ // Read the chain; do not trust the write. A transfer the sender could no longer fund commits
335
+ // with its instructions ABANDONED, and the charge is chain-wide configuration no client can
336
+ // read — so the balances are the only ground truth for what moved.
337
+ /**
338
+ * WAIT UNTIL THIS NODE CAN ACTUALLY WRITE — i.e. until it has stopped bulk-syncing.
339
+ *
340
+ * Why an app that owns a node still has to wait, and why waiting is the right answer rather
341
+ * than posting somewhere else: a joining node catches up its CHAIN STORE and its ROUTER
342
+ * independently. Measured on a 102 follower joining devnet — with the store already at the
343
+ * tip, the routing loop was still replaying per-block events at round 3750, so a submit was
344
+ * ACCEPTED and never forwarded, and the only symptom was a commit-wait timeout. Routing the
345
+ * write to a remote committee node makes that go away by making the node pointless: the app
346
+ * would then depend on somebody else's endpoint forever, for every write, which is a strictly
347
+ * worse property than being slow for a minute at boot.
348
+ *
349
+ * HOW "still bulk-syncing" is detected WITHOUT asking anything remote: sync and normal
350
+ * operation move the tip at wildly different rates. Measured: sync persisted ~130 blocks/sec,
351
+ * while devnet's block cadence is 600ms (~1.7/sec) and its idle heartbeat is 5 minutes. So a
352
+ * tip advancing by more than a few rounds per half-second is a node still catching up, and one
353
+ * that is not is a node keeping pace with the chain. That is inference, and it is inference
354
+ * only because the node has no readiness surface to ask (evolver#82); when it grows one, this
355
+ * becomes a question instead of a measurement.
356
+ */
357
+ async function waitUntilNodeCanWrite() {
358
+ const deadline = Date.now() + 180_000;
359
+ let last = await node.currentRound();
360
+ let announced = false;
361
+ while (Date.now() < deadline) {
362
+ await new Promise((r) => setTimeout(r, 500));
363
+ const now = await node.currentRound();
364
+ const advanced = now - last;
365
+ last = now;
366
+ // Fewer than 5 rounds in half a second = chain cadence, not bulk sync.
367
+ if (advanced < 5)
368
+ return;
369
+ if (!announced) {
370
+ say(` this node is still catching up (round ${now}, +${advanced}/500ms) — waiting`);
371
+ announced = true;
372
+ }
373
+ }
374
+ die(`this node is still bulk-syncing after 3 minutes (round ${last}).\n` +
375
+ ` A submit would be accepted and then sit unrouted, so nothing is sent.`);
376
+ }
377
+ async function verify(sender, recipient, before, amount) {
378
+ const s = await node.getIdentity(sender);
379
+ const r = await node.getIdentity(recipient);
380
+ if (s === null)
381
+ return die(`sender '${sender}' is not on chain`);
382
+ if (r === null)
383
+ return die(`recipient '${recipient}' is not on chain`);
384
+ say(` ${sender} balance ${s.balance}`);
385
+ say(` ${recipient} balance ${r.balance}`);
386
+ const expected = before + amount;
387
+ if (BigInt(r.balance) !== expected) {
388
+ return die(`recipient balance ${r.balance}, expected ${expected} — the transfer did not move what was asked`);
389
+ }
390
+ say(` ✓ recipient received exactly ${amount}`);
391
+ }
392
+ // ── run ────────────────────────────────────────────────────────────────────
393
+ //
394
+ // Wrapped so that unusable application state is REFUSED with the one sentence that fixes it.
395
+ // It used to escape as an unhandled rejection — a stack trace naming a line of store.ts —
396
+ // for the most ordinary mistake there is: switching target with the state directories crossed.
397
+ const shutdown = meerkatShutdown();
398
+ try {
399
+ const port = processorHttpPort();
400
+ const service = port > 0;
401
+ if (service) {
402
+ // Recovery control is independent of /status, chain wait, identity reconciliation and
403
+ // provisioning. Name resolution is local only; keys are loaded/claimed lazily in setup.
404
+ const operator = store.get(target, "sender")?.nodeId ?? nameFor("sender");
405
+ const module = registryModule({
406
+ chain: target.name, node, chainUrl: target.nodeUrl, chainAgreedUrl: target.faucetUrl,
407
+ operator, nodeIdentity: process.env.APP_NODE_ID?.trim() ?? "", say,
408
+ beforeProvision: waitUntilNodeCanWrite,
409
+ identity: async () => {
410
+ const sender = await identity("sender");
411
+ return { sender, signer: signerFor(sender) };
412
+ },
413
+ });
414
+ const reg = await shutdown.own(() => startMeerkatService({
415
+ target, node, say, worker: module.worker, lifecycle: module.lifecycle, port,
416
+ routes: runtime => registryReadRoutes(runtime, async () => String(await node.currentRound())),
417
+ }), resource => resource.stop());
418
+ say(`registry service on :${reg.port} — private recovery control available independently of node readiness`);
419
+ say(`one cursor, one consumer; '${registryChannel()}' follows saved subscriptions (including []).`);
420
+ await new Promise(() => { });
421
+ }
422
+ // The preflight round is REPORTED, not reused: every transaction below reads a fresh one when
423
+ // it is built (see `transfer`), because a TTL is only meaningful against a current round and
424
+ // the steps in between can take minutes on a joining node.
425
+ await preflight();
426
+ const fresh = !stateExists(target);
427
+ // ASK FIRST, because it changes what every step below means. `/api/channel/next` exists only
428
+ // on a PROCESSING node, and on one of those apply is DEFERRED and paced by these polls — so
429
+ // this app is the only thing that can advance its node, and it must do so BEFORE it writes,
430
+ // not just before it reads: the node's own submit gate (`identityStore.exists(selfNodeId)`)
431
+ // reads applied state as well. Measured: with nothing pacing it, a node whose store was level
432
+ // with devnet answered `Unknown identity` about ITSELF indefinitely.
433
+ // Processing is an explicit deployment capability, not a destructive cursor probe. A
434
+ // public/non-processing demo has no fixed binding and never touches the channel route.
435
+ const processingConfigured = Boolean(process.env.EVOLVER_CHAIN_ID?.trim() || process.env.APP_NODE_ID?.trim());
436
+ const hasStream = service || processingConfigured;
437
+ // A demo uses the same shared collector with ZERO modules: original bytes for every channel
438
+ // stay in its inbox. Its read-back below projects that inbox, never an extra drain or handler.
439
+ const demo = hasStream && !service
440
+ ? await shutdown.own(() => startMeerkatProcessor({ target, node, say }), runtime => runtime.stop())
441
+ : null;
442
+ if (demo)
443
+ await paceApply(demo.pace, say);
444
+ const demoMessages = async () => {
445
+ if (!demo)
446
+ return [];
447
+ return retainedMessages(demo.store, CHANNEL);
448
+ };
449
+ say(`\n② identities — application state: state/${target.name}${fresh ? " (empty: will claim)" : " (present: will reuse)"}`);
450
+ const sender = await identity("sender");
451
+ const recipient = await identity("recipient");
452
+ // This is the one-shot demo only. Services branched before preflight or identities.
453
+ say(`\n③ write — ${sender.nodeId} pays ${recipient.nodeId} ${AMOUNT}`);
454
+ // Only meaningful when the node is OURS and may still be joining. A public endpoint on a
455
+ // running chain is caught up by definition, and this returns on its first poll.
456
+ await waitUntilNodeCanWrite();
457
+ // Then pace apply, because the node's SUBMIT gate reads applied state too — and read the
458
+ // recipient's balance AFTER both, so the "before" figure is not one from mid-sync.
459
+ if (demo)
460
+ await paceApply(demo.pace, say);
461
+ const before = BigInt((await node.getIdentity(recipient.nodeId))?.balance ?? FAUCET_SPONSORED_CREDIT);
462
+ await transfer(sender, recipient.nodeId, AMOUNT, before + AMOUNT, demo?.pace ?? null);
463
+ say(`\n④ verify — read the result back off the chain`);
464
+ // Again, and for a different reason: the transfer above is invisible to a read on this node
465
+ // until the cursor has passed its block.
466
+ if (demo)
467
+ await paceApply(demo.pace, say);
468
+ await verify(sender.nodeId, recipient.nodeId, before, AMOUNT);
469
+ // ── ⑤–⑦: the channel, when this node can serve one ─────────────────────────
470
+ if (!hasStream) {
471
+ say(`\n⑤ channel — SKIPPED: this node serves no ordered stream`);
472
+ say(` /api/channel/next exists only on a processing node (ce.evolver.execution.enabled).`);
473
+ say(` A public committee node cannot serve one, and the bundled single-node chain IS the`);
474
+ say(` committee, so it is not a processing node. Run docker-compose.devnet.yml — a node of`);
475
+ say(` this app's own, joined to devnet, with the channel declared — to exercise the rest.`);
476
+ }
477
+ else {
478
+ say(`\n⑤ channel — '${CHANNEL}', owned by this app, with a DA group whose member is this node`);
479
+ // The DA group's member is the NODE, because the node is what stores and attests a channel
480
+ // body. The app must therefore learn its node's on-chain name — read from /status, the only
481
+ // place a node states it (see nodeSelfId).
482
+ const selfNode = await nodeSelfId(target.nodeUrl);
483
+ if (!selfNode) {
484
+ die(`could not read this node's own identity from ${target.nodeUrl}/status — without it the\n` +
485
+ ` DA group has no member, and a channel with no DA group cannot take a body-form write.`);
486
+ }
487
+ // PER DEPLOYMENT, not per app. Derived from the app name alone it is the same string for
488
+ // every instance — and a group name is GLOBAL on a shared chain, so the second deployment
489
+ // finds a group owned by the first and can neither add its node nor use it. The sender's
490
+ // identity already carries the per-deployment suffix, so it is the right root.
491
+ const daGroup = `${sender.nodeId}da`;
492
+ // VALIDATED BEFORE ANYTHING IS CLAIMED, with every problem reported at once — the
493
+ // scheduler's shape, and its rule: at ONE DA node a threshold of 1 can never be satisfied
494
+ // (a node is never its own flood target and does not self-attest), so this app uses 0.
495
+ // Checking here rather than discovering it as `503 da_unavailable` on the first write.
496
+ validateDaConfig({ channel: CHANNEL, daGroup, daNodes: [selfNode], daThresholdM: DA_THRESHOLD_M });
497
+ const pace = () => paceQuietly(demo.pace);
498
+ await ensureDaGroup(node, target.nodeUrl, sender, signerFor(sender), daGroup, selfNode, say, pace);
499
+ const role = await ensureChannelWithDa(node, target.nodeUrl, sender, signerFor(sender), daGroup, say, pace);
500
+ if (role === "reader") {
501
+ // Not this app's channel to write to — so what it demonstrates is the half that any
502
+ // subscriber gets: it declares the channel, follows the ordered stream, and reports what
503
+ // is on it. Whoever owns the name can open it to other writers (156 write policy).
504
+ say(`\n⑥ stream — follow '${CHANNEL}' as a subscriber (this app is not its owner)`);
505
+ const { seen } = await readBack(demo.pace, "\u0000never-matches", say, { deadlineMs: 20_000, messages: demoMessages });
506
+ say(` ✓ subscribed and followed the stream (${seen.length} message(s) while watching)`);
507
+ }
508
+ else {
509
+ say(`\n⑥ announce — write a message onto '${CHANNEL}'`);
510
+ const text = `hello from ${APP_NAME} at round ${await node.currentRound()}`;
511
+ await announce(node, sender, signerFor(sender), text, say);
512
+ say(`\n⑦ stream — read it back off the ordered stream, through this node`);
513
+ // No stored cursor yet: this follows from wherever the node's cursor is, which is why the
514
+ // announcement above had to be written AFTER pacing. A real app persists its position —
515
+ // history cannot be replayed (see fact 1 in src/announcements.ts).
516
+ const { seen, found } = await readBack(demo.pace, text, say, { messages: demoMessages });
517
+ if (!found) {
518
+ die(`the announcement did not arrive on the stream.\n` +
519
+ ` If the write was accepted, the likeliest cause is that this node does not DECLARE\n` +
520
+ ` '${CHANNEL}' in ce.evolver.subscribed-channels: a processing node stores bodies only\n` +
521
+ ` for channels it lists, so the body never lands here and can never be delivered.\n` +
522
+ ` Seen instead: ${seen.length} message(s).`);
523
+ }
524
+ say(` ✓ the app read its own write back through consensus, from its own node`);
525
+ }
526
+ }
527
+ say(`\n✓ ${APP_NAME} OK — chain '${target.name}', ${target.nodeUrl}${hasStream ? `, channel '${CHANNEL}'` : ""}\n`);
528
+ }
529
+ catch (e) {
530
+ const message = e instanceof store.StateError
531
+ ? `${e.message}\n the application state for '${target.name}' cannot be used.\n` +
532
+ ` Preserve its identities/keys and repair it explicitly; nothing was reset.`
533
+ : e instanceof Error ? e.message : String(e);
534
+ console.error(message);
535
+ process.exitCode = 1;
536
+ }
537
+ finally {
538
+ await shutdown.close();
539
+ }
@@ -0,0 +1,87 @@
1
+ import { deadlineFetch } from "@mcsherrylabs/evolver-client/processor";
2
+ export { deadlineFetch } from "@mcsherrylabs/evolver-client/processor";
3
+ /**
4
+ * A `fetch` with a deadline, to be injected into `NodeClient` so EVERY call it makes is
5
+ * bounded — not only the ones this app writes by hand.
6
+ *
7
+ * The failure this closes: a bare `fetch` never returns if the peer never answers, so a
8
+ * confirm loop with a 300-second budget ran three times past it and reported nothing at all.
9
+ * A read with no deadline is not a read. Wrapping the client's fetch covers the SDK's own
10
+ * calls too, which is the difference between fixing two call sites and fixing all of them.
11
+ *
12
+ * `AbortSignal.any` rather than replacing the caller's signal: a caller that passes its own
13
+ * abort still gets it honoured, and gets this deadline as well.
14
+ */
15
+ // Implementation is shared with the collector; no application-local timeout variant.
16
+ /** How long any single chain call may take before it is abandoned. Generous, because a node
17
+ * under load is slow rather than broken; finite, because a node that has stopped answering
18
+ * must not stop this app forever. */
19
+ export const CHAIN_TIMEOUT_MS = Number(process.env.EVOLVER_NODE_TIMEOUT_MS?.trim() || 30_000);
20
+ /**
21
+ * EVERY problem with a configuration, not just the first one.
22
+ *
23
+ * The scheduler's shape, and the reason for it: an operator fixing one variable at a time,
24
+ * with a restart and a chain sync between attempts, pays minutes for each round trip. Say all
25
+ * of it at once.
26
+ */
27
+ export class ConfigError extends Error {
28
+ problems;
29
+ constructor(problems) {
30
+ super(`configuration is unusable:\n${problems.map((p) => ` · ${p}`).join("\n")}`);
31
+ this.problems = problems;
32
+ }
33
+ }
34
+ /**
35
+ * Validate the channel/DA configuration BEFORE anything is claimed or written.
36
+ *
37
+ * THE RULE THAT MATTERS, and it is the scheduler's, measured there rather than reasoned here:
38
+ * at ONE DA node, a threshold of 1 can never be satisfied — a node is never its own flood
39
+ * target and does not self-attest, so a submit that needs one attestation waits for an
40
+ * attestation that will not come and ends in `503 da_unavailable`. Use 0 (best-effort) with a
41
+ * single member.
42
+ *
43
+ * AND THE COST OF BEST-EFFORT, stated because it is invisible until it bites: at one member a
44
+ * body lives in ONE volume. Lose it and every message that member alone held is unrecoverable
45
+ * — and any future processing node that declares the channel WEDGES on the first such message,
46
+ * permanently, because apply is forward-only (evolver#83). Two members on separate volumes is
47
+ * the answer when the messages matter.
48
+ */
49
+ export function validateDaConfig(args) {
50
+ const problems = [];
51
+ const say = args.say ?? ((s) => console.log(s));
52
+ const { channel, daGroup, daNodes, daThresholdM } = args;
53
+ if (!channel)
54
+ problems.push("no channel id — there is nothing to claim or read");
55
+ if (!daGroup)
56
+ problems.push("no DA group — a channel without one refuses every body-form write");
57
+ if (daNodes.length === 0)
58
+ problems.push("the DA group has no members — a body needs a node to hold it, and only a node can attest one");
59
+ if (daThresholdM > daNodes.length)
60
+ problems.push(`threshold ${daThresholdM} exceeds the ${daNodes.length} DA node(s) — the chain refuses the claim`);
61
+ // ⚠️ THERE USED TO BE A RULE HERE REFUSING `1 node, m=1` AS "NEVER SATISFIABLE", AND IT WAS
62
+ // FALSE. What the gateway computes is
63
+ // required = if (daMembers.contains(sender)) m - 1 else m (NodeBootstrap:1736-1738)
64
+ // where `daMembers` is the group's members ∪ ITS OWNER (`GroupStore.membersAndOwnerOfTx`). So
65
+ // satisfiability depends on WHO IS WRITING, which this function cannot see:
66
+ //
67
+ // a THIRD PARTY writing to a one-node group at m=1 needs one attestation and the node is
68
+ // there to give it — that is the announcements registry, and it works;
69
+ //
70
+ // THIS APP writing to a group IT OWNS needs `m - 1`, so at m=1 it needs none, and the write
71
+ // succeeds while proving nothing about availability.
72
+ //
73
+ // The refusal was written after diagnosing a commit-wait timeout on a paced node as a DA
74
+ // failure, and it is the reason this template's demo channel defaults to m=0. Both m=0 and
75
+ // m=1 are legitimate here; what an operator needs told is what each one buys.
76
+ if (daNodes.length === 1 && daThresholdM === 1)
77
+ say(" note: threshold 1 with a single DA node means a STRANGER's write needs that node's " +
78
+ "attestation, while this app's OWN write needs none (it owns the group, so its " +
79
+ "requirement is m-1 = 0) — its bodies are available because they are stored locally at " +
80
+ "origination, not because anything attested them.");
81
+ if (problems.length > 0)
82
+ throw new ConfigError(problems);
83
+ }
84
+ /** A client whose every call is bounded. Use this instead of `new NodeClient({baseUrl})`. */
85
+ export function boundedClient(NodeClientCtor, baseUrl, timeoutMs = CHAIN_TIMEOUT_MS) {
86
+ return new NodeClientCtor({ baseUrl, fetch: deadlineFetch(timeoutMs) });
87
+ }