@inversealtruism/cairn-sdk 0.3.2 → 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.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-CZWDA3A5.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-JKC5K253.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-AGU6K5EZ.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-BN3PFJDY.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-FMXNJQWL.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";
@@ -273,10 +135,39 @@ var NamesClient = class {
273
135
  // src/fillverify.ts
274
136
  import { rpcTxToTx } from "@inversealtruism/csd-client";
275
137
  import { txid, payloadHash } from "@inversealtruism/csd-codec";
276
- import { parseRecord, feeBpsAt, bindOfferTerms } from "@inversealtruism/cairnx-core";
138
+ import {
139
+ DOMAIN,
140
+ parseRecord,
141
+ feeBpsAt,
142
+ bindOfferTerms,
143
+ provenOfferTerms,
144
+ fillOutputPlan,
145
+ isTokenWant,
146
+ fillEndorsement,
147
+ fillIsSafe,
148
+ requiredFillOutputs,
149
+ previewFill
150
+ } from "@inversealtruism/cairnx-core";
277
151
  var ADDR = /^0x[0-9a-f]{40}$/;
278
152
  var HASH = /^0x[0-9a-f]{64}$/;
279
153
  var COINBASE = "0x" + "00".repeat(32);
154
+ function bindPlannedSums(planned, proven) {
155
+ const provenMap = new Map(proven.map((o) => [o.to.toLowerCase(), o.value]));
156
+ const plannedMap = /* @__PURE__ */ new Map();
157
+ try {
158
+ for (const [a, v] of Object.entries(planned)) {
159
+ const k = String(a).toLowerCase();
160
+ plannedMap.set(k, (plannedMap.get(k) ?? 0n) + BigInt(v));
161
+ }
162
+ } catch {
163
+ return "invalid plannedOutputs (amounts must be base-unit integers)";
164
+ }
165
+ for (const k of /* @__PURE__ */ new Set([...provenMap.keys(), ...plannedMap.keys()])) {
166
+ if ((provenMap.get(k) ?? 0n) !== (plannedMap.get(k) ?? 0n))
167
+ return `your planned payment doesn't match the proven fill outputs (${k}: planned ${(plannedMap.get(k) ?? 0n).toString()}, proven ${(provenMap.get(k) ?? 0n).toString()}) - refusing (an overpay past the proven amount is unrecoverable, and a missing or low leg is a doomed underpay)`;
168
+ }
169
+ return void 0;
170
+ }
280
171
  async function provenAuthor(client, provenTx) {
281
172
  const in0 = provenTx.inputs?.[0];
282
173
  if (!in0 || String(in0.prevTxid).toLowerCase() === COINBASE && Number(in0.vout) === 4294967295) return null;
@@ -321,6 +212,8 @@ async function preverifyOffer(opts) {
321
212
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
322
213
  const app = incl.tx.app;
323
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)` };
324
217
  let rec;
325
218
  try {
326
219
  rec = parseRecord(app.uri, incl.appPayloadHash);
@@ -332,33 +225,73 @@ async function preverifyOffer(opts) {
332
225
  return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
333
226
  const seller = await provenAuthor(opts.client, provenTx);
334
227
  if (!seller) return { ok: false, trust: "verified", transient: true, blockHeight, reason: "couldn't prove the offer's on-chain author yet; the chain view may be catching up, try again" };
228
+ const offerRec = rec;
335
229
  const w = rec.want ?? {};
336
230
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
337
- const terms = {
338
- height: blockHeight,
339
- feeBps: feeBpsAt(blockHeight),
340
- value: w.value !== void 0 ? String(w.value) : void 0,
341
- taker: rec.taker !== void 0 ? String(rec.taker).toLowerCase() : void 0,
342
- bid: rec.bid !== void 0 ? String(rec.bid).toLowerCase() : void 0,
343
- min: rec.min !== void 0 ? String(rec.min) : void 0
344
- };
345
- const base = { payto, seller, terms, blockHeight };
231
+ const terms = provenOfferTerms(offerRec, blockHeight);
232
+ let outputPlan;
233
+ let sumsMismatch;
234
+ if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
235
+ const servedStatus = opts.servedOffer?.status;
236
+ const provenState = {
237
+ id,
238
+ seller,
239
+ give: offerRec.give,
240
+ want: { ...offerRec.want, payto },
241
+ taker: offerRec.taker,
242
+ bid: offerRec.bid,
243
+ status: typeof servedStatus === "string" ? servedStatus : "open",
244
+ expiresEpoch: 0,
245
+ height: blockHeight,
246
+ feeBps: terms.feeBps
247
+ };
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`;
259
+ }
260
+ }
261
+ const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
346
262
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
347
263
  const so = opts.servedOffer;
348
- const servedPayto = so?.want?.payto !== void 0 && so?.want?.payto !== null ? String(so.want.payto).toLowerCase() : "";
349
- 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)
350
267
  return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
351
- 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)
352
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 };
353
- if (bindOfferTerms(opts.servedOffer, terms))
354
- return { ok: false, trust: "verified", reason: "the served fee/rebate/partial terms don't match the offer's on-chain record (a lying resolver could mis-size the fill, causing a rejected fill after your payment)", ...base };
272
+ if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
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 };
355
276
  }
277
+ if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
356
278
  return { ok: true, trust: "verified", ...base };
357
279
  }
358
280
 
359
281
  // src/index.ts
360
282
  var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee1457a40833f2fcb4de44291b9d373e50f17b97232" };
361
283
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
284
+ var SPV_HTTP_TIMEOUT_MS = 15e3;
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
+ };
293
+ var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
294
+ var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
362
295
  var Cairn = class {
363
296
  config;
364
297
  chain;
@@ -372,8 +305,13 @@ var Cairn = class {
372
305
  cairnHttp;
373
306
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
374
307
  _spvLight = null;
375
- _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;
376
312
  _spvCp;
313
+ // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
314
+ _cairnBase;
377
315
  constructor(config = {}) {
378
316
  this.config = config;
379
317
  const fetch = config.fetch;
@@ -382,6 +320,7 @@ var Cairn = class {
382
320
  const rpc = config.baseUrls?.rpc ?? `${cairn}/api/rpc`;
383
321
  const indexer = (config.baseUrls?.indexer ?? `${cairn}/explorer/api`).replace(/\/+$/, "");
384
322
  this.cairnHttp = new Http({ baseUrl: cairn, fetch, timeoutMs });
323
+ this._cairnBase = cairn;
385
324
  const indexerHttp = new Http({ baseUrl: indexer, fetch, timeoutMs });
386
325
  const swarmHttp = config.baseUrls?.swarm ? new Http({ baseUrl: config.baseUrls.swarm.replace(/\/+$/, ""), fetch, timeoutMs }) : void 0;
387
326
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
@@ -394,7 +333,7 @@ var Cairn = class {
394
333
  headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
395
334
  });
396
335
  this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
397
- this.registry = new RegistryClient({ baseUrl: indexer, fetch });
336
+ this.registry = new RegistryClient(indexerHttp);
398
337
  this.names = new NamesClient(this.cairnHttp);
399
338
  }
400
339
  /**
@@ -404,26 +343,132 @@ var Cairn = class {
404
343
  * checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
405
344
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
406
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.
407
350
  */
408
351
  async verifiedHeaderMerkleAt(height) {
409
352
  if (!Number.isInteger(height) || height < this._spvCp.height)
410
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) {
411
374
  const lc = await this.seededSpvLight();
412
- const haveTo = lc.baseHeight + lc.chain.length - 1;
413
- if (haveTo < height) await lc.sync(height);
414
- const vh = lc.chain[height - lc.baseHeight];
415
- if (!vh) throw new Error(`light client has no verified header at ${height}`);
416
- 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
+ }
417
384
  }
418
- /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
419
- * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
420
- async seededSpvLight() {
421
- if (!this._spvLight) this._spvLight = new LightClient({ client: this.chain.client, checkpoints: { [this._spvCp.height]: this._spvCp.hash } });
422
- if (!this._spvSeeded) {
423
- await this._spvLight.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
424
- this._spvSeeded = true;
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({
390
+ client: this.chain.client,
391
+ checkpoints: { [this._spvCp.height]: this._spvCp.hash },
392
+ headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
393
+ });
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
+ });
418
+ }
419
+ return this._spvSeedInflight;
420
+ }
421
+ /**
422
+ * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
423
+ * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
424
+ * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
425
+ * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
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).
430
+ *
431
+ * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
432
+ * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
433
+ * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
434
+ * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
435
+ * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
436
+ */
437
+ async fetchHeadersBatch(from, count) {
438
+ const g = this.config.fetch ?? globalThis.fetch;
439
+ if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
440
+ const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
441
+ const url = `${this._cairnBase}/api/headers/${from}/${count}`;
442
+ for (let attempt = 0; ; attempt++) {
443
+ let res;
444
+ let parsed;
445
+ const ctrl = new AbortController();
446
+ const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
447
+ try {
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
+ }
452
+ } catch (e) {
453
+ if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
454
+ await spvSleep(spvRetryDelayMs(attempt, 0));
455
+ continue;
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ if (res.ok) {
460
+ const rows = parsed?.headers;
461
+ if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
462
+ return rows.map((r) => ({ header: r.header, hash: r.hash }));
463
+ }
464
+ if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
465
+ res.body?.cancel?.().catch(() => {
466
+ });
467
+ await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
468
+ continue;
469
+ }
470
+ throw new Error(`/api/headers ${res.status}`);
425
471
  }
426
- return this._spvLight;
427
472
  }
428
473
  /**
429
474
  * F13 — corroborate an offer ON-CHAIN before building a `fillOffer` payment's `outputs`. Merkle-proves the
@@ -436,10 +481,14 @@ var Cairn = class {
436
481
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
437
482
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
438
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.
439
488
  */
440
- async verifyOfferForFill(offerId, servedOffer) {
441
- const light = await this.seededSpvLight();
442
- 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 });
443
492
  }
444
493
  /**
445
494
  * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
@@ -487,9 +536,14 @@ export {
487
536
  discoverProviders,
488
537
  errorCode,
489
538
  feeBpsAt,
539
+ fillEndorsement,
540
+ fillIsSafe,
541
+ fillOutputPlan,
490
542
  getWallet,
491
543
  isInstalled,
492
544
  mapProviderError,
493
545
  mapSubmitResultError,
494
- preverifyOffer
546
+ preverifyOffer,
547
+ previewFill,
548
+ requiredFillOutputs
495
549
  };