@mcsherrylabs/pi-meerkat 0.7.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.
- package/README.md +85 -33
- package/dist/installer/committee-init.sh +117 -0
- package/dist/installer/committee.conf +102 -0
- package/dist/installer/node-join.sh +228 -0
- package/dist/installer/node.conf +235 -0
- package/dist/installer/runtime.json +1 -0
- package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
- package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
- package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
- package/dist/installer-runtime/dist/src/announcements.js +448 -0
- package/dist/installer-runtime/dist/src/app.js +539 -0
- package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
- package/dist/installer-runtime/dist/src/env.js +425 -0
- package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
- package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
- package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
- package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
- package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
- package/dist/installer-runtime/dist/src/registry/http.js +194 -0
- package/dist/installer-runtime/dist/src/registry/index.js +52 -0
- package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
- package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
- package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
- package/dist/installer-runtime/dist/src/registry/store.js +242 -0
- package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
- package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
- package/dist/installer-runtime/dist/src/runtime.js +164 -0
- package/dist/installer-runtime/dist/src/store.js +138 -0
- package/dist/installer-runtime/package.json +1 -0
- package/dist/installer.d.ts +224 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +336 -0
- package/dist/installer.js.map +1 -0
- package/dist/mcp-cli.d.ts.map +1 -1
- package/dist/mcp-cli.js +31 -5
- package/dist/mcp-cli.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +8 -4
- package/dist/registry.js.map +1 -1
- package/installer/Committee.Dockerfile +6 -0
- package/installer/Node.Dockerfile +6 -0
- package/installer/Registry.Dockerfile +13 -0
- package/installer/runtime-control.mjs +172 -0
- package/installer/session-files.mjs +16 -0
- package/package.json +5 -4
|
@@ -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
|
+
}
|