@inversealtruism/cairn-sdk 0.4.0 → 0.4.1

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/dist/index.d.ts CHANGED
@@ -134,7 +134,9 @@ interface OfferFillCheck {
134
134
  * Pre-verify an offer on-chain before building a `fillOffer` payment. `light` is a PoW-verifying light client
135
135
  * (its `verifyTxInclusion` surfaces the merkle-proven tx + committed appPayloadHash); `client` reads source txs
136
136
  * for the prevout-owner author bind. Pass `servedOffer` (the resolver's offer object) to also bind its
137
- * payto/seller/terms to the proven values. See the module header for the trust posture.
137
+ * payto/seller/terms to the proven values. The FULL object is required: a served offer missing want.payto
138
+ * or seller is refused (P75-5). A token-priced offer's served want.ticker/want.amount are bound verbatim to
139
+ * the record. See the module header for the trust posture.
138
140
  */
139
141
  declare function preverifyOffer(opts: {
140
142
  light: {
@@ -158,7 +160,7 @@ declare function preverifyOffer(opts: {
158
160
 
159
161
  /** Stable machine-readable error codes (branch on these, not the message text). */
160
162
  type CairnErrorCode = "NOT_INSTALLED" | "USER_REJECTED" | "WALLET_LOCKED" | "UNSUPPORTED_METHOD" | "HTTP_ERROR" | "CONTENT_VERIFICATION" | "ACCOUNT_CHANGED" | "FIRST_PARTY_ONLY" | "RATE_LIMITED" | "APPROVAL_CLOSED" | "FORBIDDEN" | "INTERNAL" | "GHOST_INPUTS_SKIPPED" | "VERIFY_UNAVAILABLE" | "VERIFY_TAMPER" | "INSUFFICIENT" | "TOO_MANY_INPUTS" | "FEE_TOO_LOW" | "FEE_CAP" | "BAD_FEE" | "ZERO_ADDR_REFUSED" | "BAD_OUTPUTS" | "NO_OUTPUTS" | "BAD_REQUEST" | "OFFER_UNKNOWN" | "FILL_UNSAFE" | "FILL_WRONG_TARGET" | "SOURCE_DIVERGENCE" | "SUBMIT_REJECTED" | "SUBMIT_MAYBE_INFLIGHT" | "SUBMIT_DUPLICATE" | "NOTHING_TO_CONSOLIDATE" | "UNKNOWN";
161
- declare const SDK_VERSION = "0.4.0";
163
+ declare const SDK_VERSION = "0.4.1";
162
164
  interface CairnErrorOptions {
163
165
  code?: CairnErrorCode;
164
166
  docsPath?: string;
@@ -178,7 +180,7 @@ declare class CairnError extends Error {
178
180
  /** Relative docs path (under the SDK docs base), if any. */
179
181
  readonly docsPath?: string;
180
182
  /** The cairn-sdk version that produced this error. */
181
- readonly version = "0.4.0";
183
+ readonly version = "0.4.1";
182
184
  /**
183
185
  * Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
184
186
  * declines (the default); `true` only for provably transient infra faults where nothing was signed.
@@ -317,7 +319,7 @@ declare class Cairn {
317
319
  wallet?: WalletConnection;
318
320
  private readonly cairnHttp;
319
321
  private _spvLight;
320
- private _spvSeeded;
322
+ private _spvSeedInflight;
321
323
  private readonly _spvCp;
322
324
  private readonly _cairnBase;
323
325
  constructor(config?: CairnConfig);
@@ -328,18 +330,30 @@ declare class Cairn {
328
330
  * checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
329
331
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
330
332
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
333
+ * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
334
+ * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
335
+ * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
336
+ * read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
331
337
  */
332
338
  private verifiedHeaderMerkleAt;
333
- /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
334
- * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
339
+ private withSpvReseed;
340
+ private seedFreshSpvLight;
341
+ /** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
342
+ * (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
343
+ * the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
344
+ * the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
345
+ * fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
346
+ * structural (reorg) break is detected, see withSpvReseed. */
335
347
  private seededSpvLight;
336
348
  /**
337
349
  * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
338
350
  * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
339
351
  * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
340
352
  * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
341
- * server's 10s deadline) is a transient data-availability hiccup; back off (honouring Retry-After) a
342
- * bounded number of times, then THROW so `sync()` fails closed. A non-dense range is rejected.
353
+ * server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
354
+ * SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
355
+ * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
356
+ * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
343
357
  *
344
358
  * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
345
359
  * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
@@ -359,8 +373,15 @@ declare class Cairn {
359
373
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
360
374
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
361
375
  * before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
376
+ *
377
+ * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
378
+ * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
379
+ * EXACTLY the outputPlan, never resolver-served outputs.
362
380
  */
363
- verifyOfferForFill(offerId: string, servedOffer?: unknown): Promise<OfferFillCheck>;
381
+ verifyOfferForFill(offerId: string, servedOffer?: unknown, opts?: {
382
+ pay?: bigint | string | number;
383
+ plannedOutputs?: Record<string, bigint | string | number>;
384
+ }): Promise<OfferFillCheck>;
364
385
  /**
365
386
  * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
366
387
  * `cairn.board` for signed writes. Resolves to the connected address.
package/dist/index.js CHANGED
@@ -1,22 +1,24 @@
1
1
  import {
2
- RegistryClient
3
- } from "./chunk-BGJZF7S2.js";
2
+ Http,
3
+ RegistryClient,
4
+ readCapped
5
+ } from "./chunk-7UOTIAYK.js";
4
6
  import {
5
7
  BoardClient
6
- } from "./chunk-2ZML7IKV.js";
8
+ } from "./chunk-DUTVUIQF.js";
7
9
  import {
8
10
  Chain,
9
11
  LightClient
10
12
  } from "./chunk-C452UODV.js";
11
13
  import {
12
14
  ContentClient
13
- } from "./chunk-2L5QNDAE.js";
15
+ } from "./chunk-OCEHXK6A.js";
14
16
  import {
15
17
  IndexerClient
16
18
  } from "./chunk-J5J4JEJ6.js";
17
19
  import {
18
20
  CairnController
19
- } from "./chunk-GTTLMWGY.js";
21
+ } from "./chunk-QATE6AIP.js";
20
22
  import {
21
23
  WalletConnection,
22
24
  connect,
@@ -24,7 +26,7 @@ import {
24
26
  discoverProviders,
25
27
  getWallet,
26
28
  isInstalled
27
- } from "./chunk-RKR3JMIA.js";
29
+ } from "./chunk-PIPN6FSA.js";
28
30
  import {
29
31
  CairnError,
30
32
  ContentVerificationError,
@@ -38,147 +40,7 @@ import {
38
40
  errorCode,
39
41
  mapProviderError,
40
42
  mapSubmitResultError
41
- } from "./chunk-MREZG2HN.js";
42
-
43
- // src/http.ts
44
- var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
45
- var ERR_TEXT_CAP = 8 * 1024;
46
- async function readCapped(res, maxBytes) {
47
- const cl = Number(res.headers.get("content-length"));
48
- if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
49
- const body = res.body;
50
- if (!body || typeof body.getReader !== "function") {
51
- const buf = new Uint8Array(await res.arrayBuffer());
52
- if (buf.byteLength > maxBytes) throw new HttpError(res.status, res.url, `response too large (${buf.byteLength} > ${maxBytes})`);
53
- return buf;
54
- }
55
- const reader = body.getReader();
56
- const chunks = [];
57
- let total = 0;
58
- for (; ; ) {
59
- const { done, value } = await reader.read();
60
- if (done) break;
61
- if (value) {
62
- total += value.byteLength;
63
- if (total > maxBytes) {
64
- try {
65
- await reader.cancel();
66
- } catch {
67
- }
68
- throw new HttpError(res.status, res.url, `response exceeded ${maxBytes} bytes`);
69
- }
70
- chunks.push(value);
71
- }
72
- }
73
- const out = new Uint8Array(total);
74
- let off = 0;
75
- for (const c of chunks) {
76
- out.set(c, off);
77
- off += c.byteLength;
78
- }
79
- return out;
80
- }
81
- var Http = class {
82
- baseUrl;
83
- _fetch;
84
- timeoutMs;
85
- maxBytes;
86
- headers;
87
- retries;
88
- constructor(opts) {
89
- this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
90
- const f = opts.fetch ?? globalThis.fetch;
91
- if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
92
- this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
93
- this.timeoutMs = opts.timeoutMs ?? 15e3;
94
- this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
95
- this.headers = opts.headers ?? {};
96
- this.retries = Math.min(10, Math.max(0, Math.floor(opts.retries ?? 0)));
97
- }
98
- /** Build an absolute URL from a path + optional query params. */
99
- url(path, query) {
100
- const p = path.startsWith("/") ? path : `/${path}`;
101
- const u = new URL(this.baseUrl + p);
102
- const basePath = new URL(this.baseUrl + "/").pathname;
103
- if (basePath !== "/" && !(u.pathname + "/").startsWith(basePath)) {
104
- throw new Error(`illegal path '${path}' \u2014 escapes base ${basePath}`);
105
- }
106
- if (query) {
107
- for (const [k, v] of Object.entries(query)) {
108
- if (v !== void 0 && v !== null) u.searchParams.set(k, String(v));
109
- }
110
- }
111
- return u.toString();
112
- }
113
- async raw(method, path, opts) {
114
- const url = this.url(path, opts?.query);
115
- const ctrl = new AbortController();
116
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
117
- try {
118
- const res = await this._fetch(url, {
119
- method,
120
- signal: ctrl.signal,
121
- headers: {
122
- ...opts?.body !== void 0 ? { "content-type": "application/json" } : {},
123
- ...this.headers,
124
- ...opts?.headers ?? {}
125
- },
126
- body: opts?.body !== void 0 ? JSON.stringify(opts.body) : void 0
127
- });
128
- return res;
129
- } finally {
130
- clearTimeout(timer);
131
- }
132
- }
133
- // A malformed 2xx body must surface as a TYPED HttpError, not a leaked SyntaxError (B10: dApp
134
- // code branches on `instanceof CairnError`; a raw parse error escaped that contract).
135
- parseJson(res, text, url) {
136
- try {
137
- return JSON.parse(text);
138
- } catch (e) {
139
- throw new HttpError(res.status, url, `invalid JSON body: ${text.slice(0, 120)}`);
140
- }
141
- }
142
- async getJson(path, query) {
143
- const url = this.url(path, query);
144
- for (let attempt = 0; ; attempt++) {
145
- try {
146
- const res = await this.raw("GET", path, { query });
147
- if (!res.ok) {
148
- if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, await safeText(res));
149
- res.body?.cancel().catch(() => {
150
- });
151
- } else {
152
- return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
153
- }
154
- } catch (e) {
155
- if (e instanceof HttpError && e.status < 500) throw e;
156
- if (attempt >= this.retries) throw e;
157
- }
158
- await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt))));
159
- }
160
- }
161
- async postJson(path, body, headers) {
162
- const url = this.url(path);
163
- const res = await this.raw("POST", path, { body, headers });
164
- if (!res.ok) throw new HttpError(res.status, url, await safeText(res));
165
- return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
166
- }
167
- /** GET raw bytes (for content retrieval). Returns null on 404. */
168
- async getBytes(path) {
169
- const res = await this.raw("GET", path);
170
- if (res.status === 404) return null;
171
- if (!res.ok) throw new HttpError(res.status, this.url(path), await safeText(res));
172
- return { bytes: await readCapped(res, this.maxBytes), headers: res.headers };
173
- }
174
- };
175
- async function safeText(res) {
176
- try {
177
- return new TextDecoder().decode(await readCapped(res, ERR_TEXT_CAP));
178
- } catch {
179
- return "";
180
- }
181
- }
43
+ } from "./chunk-ERBT4DFD.js";
182
44
 
183
45
  // src/names.ts
184
46
  import { NAME_RE, RESERVED_NAMES } from "@inversealtruism/cairnx-core";
@@ -274,6 +136,7 @@ var NamesClient = class {
274
136
  import { rpcTxToTx } from "@inversealtruism/csd-client";
275
137
  import { txid, payloadHash } from "@inversealtruism/csd-codec";
276
138
  import {
139
+ DOMAIN,
277
140
  parseRecord,
278
141
  feeBpsAt,
279
142
  bindOfferTerms,
@@ -349,6 +212,8 @@ async function preverifyOffer(opts) {
349
212
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
350
213
  const app = incl.tx.app;
351
214
  if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
215
+ if (String(app.domain) !== DOMAIN)
216
+ return { ok: false, trust: "unverified", reason: `the offer tx is not a ${DOMAIN} Propose (wrong settlement domain)` };
352
217
  let rec;
353
218
  try {
354
219
  rec = parseRecord(app.uri, incl.appPayloadHash);
@@ -367,37 +232,47 @@ async function preverifyOffer(opts) {
367
232
  let outputPlan;
368
233
  let sumsMismatch;
369
234
  if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
235
+ const servedStatus = opts.servedOffer?.status;
370
236
  const provenState = {
371
237
  id,
372
238
  seller,
373
239
  give: offerRec.give,
374
- want: offerRec.want,
240
+ want: { ...offerRec.want, payto },
375
241
  taker: offerRec.taker,
376
242
  bid: offerRec.bid,
377
- status: "open",
243
+ status: typeof servedStatus === "string" ? servedStatus : "open",
378
244
  expiresEpoch: 0,
379
245
  height: blockHeight,
380
246
  feeBps: terms.feeBps
381
247
  };
382
- const plan = fillOutputPlan(provenState, opts.pay);
383
- if (plan.kind === "csd-outputs") {
384
- outputPlan = plan.outputs;
385
- if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
386
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
387
- } else if (plan.kind === "undeliverable") {
388
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
248
+ try {
249
+ const plan = fillOutputPlan(provenState, opts.pay);
250
+ if (plan.kind === "csd-outputs") {
251
+ outputPlan = plan.outputs;
252
+ if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
253
+ sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
254
+ } else if (plan.kind === "undeliverable") {
255
+ sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
256
+ }
257
+ } catch (e) {
258
+ sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
389
259
  }
390
260
  }
391
261
  const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
392
262
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
393
263
  const so = opts.servedOffer;
394
- const servedPayto = so?.want?.payto !== void 0 && so?.want?.payto !== null ? String(so.want.payto).toLowerCase() : "";
395
- if (servedPayto && servedPayto !== payto)
264
+ if (so?.want?.payto === void 0 || so?.want?.payto === null)
265
+ return { ok: false, trust: "verified", reason: "the served offer is missing want.payto (the payment recipient) - pass the resolver's full offer object, or omit servedOffer", ...base };
266
+ if (String(so.want.payto).toLowerCase() !== payto)
396
267
  return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
397
- if (so?.seller !== void 0 && so?.seller !== null && String(so.seller).toLowerCase() !== seller)
268
+ if (so?.seller === void 0 || so?.seller === null)
269
+ return { ok: false, trust: "verified", reason: "the served offer is missing seller (the rebate recipient) - pass the resolver's full offer object, or omit servedOffer", ...base };
270
+ if (String(so.seller).toLowerCase() !== seller)
398
271
  return { ok: false, trust: "verified", reason: "the served seller doesn't match the offer's on-chain author (a lying resolver may be redirecting your payment)", ...base };
399
272
  if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
400
273
  return { ok: false, trust: "verified", reason: "the served terms don't match the offer's on-chain record (fee, rebate, partial sizing, the give, or the want type - a lying resolver could mis-size or re-route the fill, redirecting or burning your payment)", ...base };
274
+ if (isTokenWant(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
275
+ return { ok: false, trust: "verified", reason: "the served want ticker/amount don't match the offer's on-chain record (a lying resolver could bait-and-switch the token price)", ...base };
401
276
  }
402
277
  if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
403
278
  return { ok: true, trust: "verified", ...base };
@@ -408,6 +283,13 @@ var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee14
408
283
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
409
284
  var SPV_HTTP_TIMEOUT_MS = 15e3;
410
285
  var SPV_HTTP_MAX_RETRIES = 4;
286
+ var SPV_MAX_BATCH_BYTES = 2 * 1024 * 1024;
287
+ var SPV_TRANSIENT_RE = /\b(429|50[0-9]|timeout|timed out|abort|aborted|headers|non-dense|failed to fetch|networkerror|load failed)\b/i;
288
+ var SPV_TRANSIENT_JSON_RE = /unexpected (token|end)|json/i;
289
+ var spvIsTransient = (e) => {
290
+ const msg = String(e?.message || e);
291
+ return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
292
+ };
411
293
  var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
412
294
  var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
413
295
  var Cairn = class {
@@ -423,7 +305,10 @@ var Cairn = class {
423
305
  cairnHttp;
424
306
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
425
307
  _spvLight = null;
426
- _spvSeeded = false;
308
+ // MF-14: the in-flight seed promise. Concurrent first callers share ONE seed instead of racing two
309
+ // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
310
+ // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
311
+ _spvSeedInflight = null;
427
312
  _spvCp;
428
313
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
429
314
  _cairnBase;
@@ -448,7 +333,7 @@ var Cairn = class {
448
333
  headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
449
334
  });
450
335
  this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
451
- this.registry = new RegistryClient({ baseUrl: indexer, fetch });
336
+ this.registry = new RegistryClient(indexerHttp);
452
337
  this.names = new NamesClient(this.cairnHttp);
453
338
  }
454
339
  /**
@@ -458,44 +343,90 @@ var Cairn = class {
458
343
  * checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
459
344
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
460
345
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
346
+ * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
347
+ * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
348
+ * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
349
+ * read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
461
350
  */
462
351
  async verifiedHeaderMerkleAt(height) {
463
352
  if (!Number.isInteger(height) || height < this._spvCp.height)
464
353
  throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
354
+ return this.withSpvReseed(async (lc) => {
355
+ const haveTo = lc.baseHeight + lc.chain.length - 1;
356
+ if (haveTo < height) await lc.sync(height);
357
+ const vh = lc.chain[height - lc.baseHeight];
358
+ if (!vh) throw new Error(`light client has no verified header at ${height}`);
359
+ return String(vh.header.merkle);
360
+ });
361
+ }
362
+ // MF-03: run an SPV read; on a STRUCTURAL failure (a reorg orphaned our cached tip: broken
363
+ // prev-link / bad PoW / hash mismatch) reseed ONCE from the pinned checkpoint into a LOCAL fresh
364
+ // client, retry the read on it, and commit the fresh client ONLY when both succeed (never null
365
+ // this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
366
+ // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
367
+ // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
368
+ // failure propagates fail-closed; there is no loop.
369
+ // RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
370
+ // branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
371
+ // for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
372
+ // the permanent-brick leg only.
373
+ async withSpvReseed(op) {
465
374
  const lc = await this.seededSpvLight();
466
- const haveTo = lc.baseHeight + lc.chain.length - 1;
467
- if (haveTo < height) await lc.sync(height);
468
- const vh = lc.chain[height - lc.baseHeight];
469
- if (!vh) throw new Error(`light client has no verified header at ${height}`);
470
- return String(vh.header.merkle);
375
+ try {
376
+ return await op(lc);
377
+ } catch (e) {
378
+ if (spvIsTransient(e)) throw e;
379
+ const fresh = await this.seedFreshSpvLight();
380
+ const out = await op(fresh);
381
+ this._spvLight = fresh;
382
+ return out;
383
+ }
471
384
  }
472
- /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
473
- * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
474
- async seededSpvLight() {
475
- if (!this._spvLight) this._spvLight = new LightClient({
385
+ // Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
386
+ // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
387
+ // dirty the shared field while an attempt is in flight).
388
+ async seedFreshSpvLight() {
389
+ const lc = new LightClient({
476
390
  client: this.chain.client,
477
391
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
478
392
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
479
393
  });
480
- if (!this._spvSeeded) {
481
- await this._spvLight.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
482
- try {
483
- const tipHeight = Number((await this.chain.tip()).height);
484
- const have = this._spvLight.baseHeight + this._spvLight.chain.length - 1;
485
- if (Number.isFinite(tipHeight) && tipHeight > have) await this._spvLight.sync(tipHeight);
486
- } catch {
487
- }
488
- this._spvSeeded = true;
394
+ await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
395
+ try {
396
+ const tipHeight = Number((await this.chain.tip()).height);
397
+ const have = lc.baseHeight + lc.chain.length - 1;
398
+ if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
399
+ } catch {
400
+ }
401
+ return lc;
402
+ }
403
+ /** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
404
+ * (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
405
+ * the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
406
+ * the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
407
+ * fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
408
+ * structural (reorg) break is detected, see withSpvReseed. */
409
+ seededSpvLight() {
410
+ if (this._spvLight) return Promise.resolve(this._spvLight);
411
+ if (!this._spvSeedInflight) {
412
+ this._spvSeedInflight = this.seedFreshSpvLight().then((lc) => {
413
+ this._spvLight = lc;
414
+ return lc;
415
+ }).finally(() => {
416
+ this._spvSeedInflight = null;
417
+ });
489
418
  }
490
- return this._spvLight;
419
+ return this._spvSeedInflight;
491
420
  }
492
421
  /**
493
422
  * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
494
423
  * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
495
424
  * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
496
425
  * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
497
- * server's 10s deadline) is a transient data-availability hiccup; back off (honouring Retry-After) a
498
- * bounded number of times, then THROW so `sync()` fails closed. A non-dense range is rejected.
426
+ * server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
427
+ * SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
428
+ * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
429
+ * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
499
430
  *
500
431
  * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
501
432
  * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
@@ -510,10 +441,14 @@ var Cairn = class {
510
441
  const url = `${this._cairnBase}/api/headers/${from}/${count}`;
511
442
  for (let attempt = 0; ; attempt++) {
512
443
  let res;
444
+ let parsed;
513
445
  const ctrl = new AbortController();
514
446
  const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
515
447
  try {
516
448
  res = await doFetch(url, { signal: ctrl.signal });
449
+ if (res.ok) {
450
+ parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
451
+ }
517
452
  } catch (e) {
518
453
  if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
519
454
  await spvSleep(spvRetryDelayMs(attempt, 0));
@@ -522,7 +457,7 @@ var Cairn = class {
522
457
  clearTimeout(timer);
523
458
  }
524
459
  if (res.ok) {
525
- const rows = (await res.json())?.headers;
460
+ const rows = parsed?.headers;
526
461
  if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
527
462
  return rows.map((r) => ({ header: r.header, hash: r.hash }));
528
463
  }
@@ -546,10 +481,14 @@ var Cairn = class {
546
481
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
547
482
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
548
483
  * before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
484
+ *
485
+ * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
486
+ * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
487
+ * EXACTLY the outputPlan, never resolver-served outputs.
549
488
  */
550
- async verifyOfferForFill(offerId, servedOffer) {
551
- const light = await this.seededSpvLight();
552
- return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer });
489
+ async verifyOfferForFill(offerId, servedOffer, opts) {
490
+ const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
491
+ return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
553
492
  }
554
493
  /**
555
494
  * Detect + connect the Cairn Wallet (browser only). Wires the wallet into