@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.
@@ -12,6 +12,7 @@ interface CairnControllerOptions extends DetectOptions {
12
12
  declare class CairnController {
13
13
  private state;
14
14
  private conn;
15
+ private epoch;
15
16
  private readonly listeners;
16
17
  private readonly getWalletFn;
17
18
  private readonly detectOpts;
@@ -28,7 +29,9 @@ declare class CairnController {
28
29
  private detach;
29
30
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
30
31
  connect: () => Promise<string>;
31
- /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
32
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
33
+ * Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
34
+ * provider can never hold a torn-down session in "connected". */
32
35
  disconnect: () => Promise<void>;
33
36
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
34
37
  signInWithCsd: (params: SiwcParams) => Promise<SiwcResult>;
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  CairnController
3
- } from "./chunk-GTTLMWGY.js";
4
- import "./chunk-RKR3JMIA.js";
5
- import "./chunk-MREZG2HN.js";
3
+ } from "./chunk-QATE6AIP.js";
4
+ import "./chunk-PIPN6FSA.js";
5
+ import "./chunk-ERBT4DFD.js";
6
6
  export {
7
7
  CairnController
8
8
  };
package/dist/index.cjs CHANGED
@@ -60,7 +60,7 @@ __export(index_exports, {
60
60
  module.exports = __toCommonJS(index_exports);
61
61
 
62
62
  // src/errors.ts
63
- var SDK_VERSION = "0.4.0";
63
+ var SDK_VERSION = "0.4.1";
64
64
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
65
65
  var CairnError = class extends Error {
66
66
  /** Stable machine code for programmatic branching. */
@@ -894,17 +894,20 @@ function normalizeHash(hash) {
894
894
  var import_csd_registry = require("@inversealtruism/csd-registry");
895
895
  var import_csd_registry2 = require("@inversealtruism/csd-registry");
896
896
  var RegistryClient = class {
897
- src;
898
- constructor(opts) {
899
- this.src = { baseUrl: opts.baseUrl.replace(/\/+$/, ""), fetch: opts.fetch };
897
+ http;
898
+ /** Accepts the SDK's hardened Http directly (the Cairn facade path) or the legacy
899
+ * { baseUrl, fetch } options (kept for direct constructors; they now get the same
900
+ * hardened transport, built internally). */
901
+ constructor(src) {
902
+ this.http = src instanceof Http ? src : new Http({ baseUrl: src.baseUrl, fetch: src.fetch });
900
903
  }
901
904
  /** Discover ranked content/pin gateways (GET /registry/gateways). */
902
905
  gateways() {
903
- return (0, import_csd_registry.discoverGateways)(this.src);
906
+ return this.http.getJson("/registry/gateways");
904
907
  }
905
908
  /** Discover ranked libp2p peers (GET /registry/peers). */
906
909
  peers() {
907
- return (0, import_csd_registry.discoverPeers)(this.src);
910
+ return this.http.getJson("/registry/peers");
908
911
  }
909
912
  /**
910
913
  * Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved.
@@ -916,12 +919,22 @@ var RegistryClient = class {
916
919
  * source (the wallet's namespv union cure). Treat `verified` here as a hint, not a guarantee.
917
920
  */
918
921
  resolveName(handle) {
919
- return (0, import_csd_registry.resolveName)(this.src, handle);
922
+ return this.getOr404Null(`/identity/${encodeURIComponent(handle)}`);
920
923
  }
921
924
  /** Reverse-resolve an address → identity (GET /address/:addr/identity).
922
925
  * ⚠ Same indexer-trust caveat as resolveName — `verified` is the indexer's claim, not a client proof. */
923
926
  reverseName(address) {
924
- return (0, import_csd_registry.reverseName)(this.src, address);
927
+ return this.getOr404Null(`/address/${encodeURIComponent(address)}/identity`);
928
+ }
929
+ // MF-12: 404 → null (the preserved csd-registry contract for both name resolvers); every other
930
+ // failure rethrows the typed HttpError/CairnError the hardened Http raises.
931
+ async getOr404Null(path) {
932
+ try {
933
+ return await this.http.getJson(path);
934
+ } catch (e) {
935
+ if (e instanceof HttpError && e.status === 404) return null;
936
+ return Promise.reject(e);
937
+ }
925
938
  }
926
939
  /** The deterministic, client-side resolvers (recompute from raw ChainRecord[]). */
927
940
  get fromRecords() {
@@ -1112,12 +1125,15 @@ var WalletConnection = class {
1112
1125
  // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
1113
1126
  call(op, label) {
1114
1127
  const ms = this._timeoutMs;
1115
- if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(op());
1128
+ const invoke = () => new Promise((resolve) => {
1129
+ resolve(op());
1130
+ });
1131
+ if (!Number.isFinite(ms) || ms <= 0) return invoke();
1116
1132
  let timer;
1117
1133
  const timeout = new Promise((_, reject) => {
1118
1134
  timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
1119
1135
  });
1120
- return Promise.race([Promise.resolve(op()), timeout]).finally(() => clearTimeout(timer));
1136
+ return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
1121
1137
  }
1122
1138
  /** The wallet's version string (e.g. "0.2.x"). */
1123
1139
  get version() {
@@ -1190,8 +1206,24 @@ var WalletConnection = class {
1190
1206
  if (typeof this.provider.fillOffer !== "function") {
1191
1207
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
1192
1208
  }
1209
+ this.adviseFillSpvCapability();
1193
1210
  return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
1194
1211
  }
1212
+ // P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
1213
+ // payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
1214
+ // and STILL RELAY. WARN, NEVER BLOCK: a hard version gate would decline honest fills (version strings are
1215
+ // spoofable and unparseable forks exist), and the wallet itself is the enforcement point. Reads only the
1216
+ // sync `provider.version`; never an awaited getCapabilities() round-trip on the fill hot path. An
1217
+ // unparseable version never warns (no false alarms).
1218
+ _fillSpvAdvised = false;
1219
+ adviseFillSpvCapability() {
1220
+ if (this._fillSpvAdvised) return;
1221
+ this._fillSpvAdvised = true;
1222
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(this.provider?.version ?? ""));
1223
+ if (!m) return;
1224
+ const below = Number(m[1]) === 0 && (Number(m[2]) < 2 || Number(m[2]) === 2 && Number(m[3]) < 60);
1225
+ if (below) console.warn(`[cairn-sdk] this Cairn Wallet (v${m[0]}) predates the on-device fill-SPV boundary (cairn-wallet 0.2.60+). The fill proceeds; suggest a wallet update for payment-grade offer verification.`);
1226
+ }
1195
1227
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
1196
1228
  async getPermissions() {
1197
1229
  if (typeof this.provider.getPermissions !== "function") return [];
@@ -1322,6 +1354,8 @@ async function preverifyOffer(opts) {
1322
1354
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
1323
1355
  const app = incl.tx.app;
1324
1356
  if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
1357
+ if (String(app.domain) !== import_cairnx_core2.DOMAIN)
1358
+ return { ok: false, trust: "unverified", reason: `the offer tx is not a ${import_cairnx_core2.DOMAIN} Propose (wrong settlement domain)` };
1325
1359
  let rec;
1326
1360
  try {
1327
1361
  rec = (0, import_cairnx_core2.parseRecord)(app.uri, incl.appPayloadHash);
@@ -1340,37 +1374,47 @@ async function preverifyOffer(opts) {
1340
1374
  let outputPlan;
1341
1375
  let sumsMismatch;
1342
1376
  if (opts.pay !== void 0 && opts.pay !== null && !(0, import_cairnx_core2.isTokenWant)(offerRec.want) && offerRec.min === void 0) {
1377
+ const servedStatus = opts.servedOffer?.status;
1343
1378
  const provenState = {
1344
1379
  id,
1345
1380
  seller,
1346
1381
  give: offerRec.give,
1347
- want: offerRec.want,
1382
+ want: { ...offerRec.want, payto },
1348
1383
  taker: offerRec.taker,
1349
1384
  bid: offerRec.bid,
1350
- status: "open",
1385
+ status: typeof servedStatus === "string" ? servedStatus : "open",
1351
1386
  expiresEpoch: 0,
1352
1387
  height: blockHeight,
1353
1388
  feeBps: terms.feeBps
1354
1389
  };
1355
- const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1356
- if (plan.kind === "csd-outputs") {
1357
- outputPlan = plan.outputs;
1358
- if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
1359
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1360
- } else if (plan.kind === "undeliverable") {
1361
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1390
+ try {
1391
+ const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1392
+ if (plan.kind === "csd-outputs") {
1393
+ outputPlan = plan.outputs;
1394
+ if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
1395
+ sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1396
+ } else if (plan.kind === "undeliverable") {
1397
+ sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1398
+ }
1399
+ } catch (e) {
1400
+ sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
1362
1401
  }
1363
1402
  }
1364
1403
  const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
1365
1404
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1366
1405
  const so = opts.servedOffer;
1367
- const servedPayto = so?.want?.payto !== void 0 && so?.want?.payto !== null ? String(so.want.payto).toLowerCase() : "";
1368
- if (servedPayto && servedPayto !== payto)
1406
+ if (so?.want?.payto === void 0 || so?.want?.payto === null)
1407
+ 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 };
1408
+ if (String(so.want.payto).toLowerCase() !== payto)
1369
1409
  return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
1370
- if (so?.seller !== void 0 && so?.seller !== null && String(so.seller).toLowerCase() !== seller)
1410
+ if (so?.seller === void 0 || so?.seller === null)
1411
+ 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 };
1412
+ if (String(so.seller).toLowerCase() !== seller)
1371
1413
  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 };
1372
1414
  if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms, { give: true, wantType: true }))
1373
1415
  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 };
1416
+ if ((0, import_cairnx_core2.isTokenWant)(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
1417
+ 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 };
1374
1418
  }
1375
1419
  if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
1376
1420
  return { ok: true, trust: "verified", ...base };
@@ -1381,6 +1425,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
1381
1425
  var CairnController = class {
1382
1426
  state = DISCONNECTED;
1383
1427
  conn = null;
1428
+ // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
1429
+ // the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
1430
+ epoch = 0;
1384
1431
  listeners = /* @__PURE__ */ new Set();
1385
1432
  getWalletFn;
1386
1433
  detectOpts;
@@ -1433,34 +1480,43 @@ var CairnController = class {
1433
1480
  }
1434
1481
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
1435
1482
  connect = async () => {
1483
+ const epoch = this.epoch;
1436
1484
  this.setState({ status: "connecting", error: null });
1437
1485
  try {
1438
- if (!this.conn) {
1439
- this.conn = await this.getWalletFn(this.detectOpts);
1440
- this.conn.on("accountsChanged", this.onAccounts);
1441
- this.conn.on("disconnect", this.onDisconnect);
1486
+ let conn = this.conn;
1487
+ if (!conn) {
1488
+ conn = await this.getWalletFn(this.detectOpts);
1489
+ if (epoch !== this.epoch) throw new Error("connection was torn down while connecting - call connect() again");
1490
+ conn.on("accountsChanged", this.onAccounts);
1491
+ conn.on("disconnect", this.onDisconnect);
1492
+ this.conn = conn;
1442
1493
  }
1443
- const addr = await this.conn.connect();
1494
+ const addr = await conn.connect();
1495
+ if (epoch !== this.epoch) throw new Error("connection was torn down while awaiting approval - call connect() again");
1444
1496
  this.setState({ status: "connected", account: addr, error: null });
1445
1497
  return addr;
1446
1498
  } catch (e) {
1447
- this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
1499
+ if (epoch === this.epoch) this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
1448
1500
  throw e;
1449
1501
  }
1450
1502
  };
1451
- /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
1503
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
1504
+ * Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
1505
+ * provider can never hold a torn-down session in "connected". */
1452
1506
  disconnect = async () => {
1453
- try {
1454
- await this.conn?.revokePermissions();
1455
- } catch {
1456
- }
1507
+ this.epoch++;
1508
+ const conn = this.conn;
1457
1509
  this.detach();
1458
1510
  this.conn = null;
1459
1511
  this.setState({ status: "disconnected", account: null, error: null });
1512
+ try {
1513
+ await conn?.revokePermissions();
1514
+ } catch {
1515
+ }
1460
1516
  };
1461
1517
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
1462
1518
  signInWithCsd = (params) => {
1463
- if (!this.conn) return Promise.reject(new Error("call connect() before signInWithCsd()"));
1519
+ if (!this.conn || this.state.status !== "connected") return Promise.reject(new Error("call connect() before signInWithCsd()"));
1464
1520
  return this.conn.signInWithCsd(params);
1465
1521
  };
1466
1522
  };
@@ -1470,6 +1526,13 @@ var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee14
1470
1526
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
1471
1527
  var SPV_HTTP_TIMEOUT_MS = 15e3;
1472
1528
  var SPV_HTTP_MAX_RETRIES = 4;
1529
+ var SPV_MAX_BATCH_BYTES = 2 * 1024 * 1024;
1530
+ 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;
1531
+ var SPV_TRANSIENT_JSON_RE = /unexpected (token|end)|json/i;
1532
+ var spvIsTransient = (e) => {
1533
+ const msg = String(e?.message || e);
1534
+ return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
1535
+ };
1473
1536
  var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1474
1537
  var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
1475
1538
  var Cairn = class {
@@ -1485,7 +1548,10 @@ var Cairn = class {
1485
1548
  cairnHttp;
1486
1549
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
1487
1550
  _spvLight = null;
1488
- _spvSeeded = false;
1551
+ // MF-14: the in-flight seed promise. Concurrent first callers share ONE seed instead of racing two
1552
+ // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
1553
+ // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
1554
+ _spvSeedInflight = null;
1489
1555
  _spvCp;
1490
1556
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
1491
1557
  _cairnBase;
@@ -1510,7 +1576,7 @@ var Cairn = class {
1510
1576
  headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
1511
1577
  });
1512
1578
  this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
1513
- this.registry = new RegistryClient({ baseUrl: indexer, fetch });
1579
+ this.registry = new RegistryClient(indexerHttp);
1514
1580
  this.names = new NamesClient(this.cairnHttp);
1515
1581
  }
1516
1582
  /**
@@ -1520,44 +1586,90 @@ var Cairn = class {
1520
1586
  * checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
1521
1587
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
1522
1588
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
1589
+ * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
1590
+ * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
1591
+ * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
1592
+ * read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
1523
1593
  */
1524
1594
  async verifiedHeaderMerkleAt(height) {
1525
1595
  if (!Number.isInteger(height) || height < this._spvCp.height)
1526
1596
  throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
1597
+ return this.withSpvReseed(async (lc) => {
1598
+ const haveTo = lc.baseHeight + lc.chain.length - 1;
1599
+ if (haveTo < height) await lc.sync(height);
1600
+ const vh = lc.chain[height - lc.baseHeight];
1601
+ if (!vh) throw new Error(`light client has no verified header at ${height}`);
1602
+ return String(vh.header.merkle);
1603
+ });
1604
+ }
1605
+ // MF-03: run an SPV read; on a STRUCTURAL failure (a reorg orphaned our cached tip: broken
1606
+ // prev-link / bad PoW / hash mismatch) reseed ONCE from the pinned checkpoint into a LOCAL fresh
1607
+ // client, retry the read on it, and commit the fresh client ONLY when both succeed (never null
1608
+ // this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
1609
+ // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
1610
+ // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
1611
+ // failure propagates fail-closed; there is no loop.
1612
+ // RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
1613
+ // branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
1614
+ // for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
1615
+ // the permanent-brick leg only.
1616
+ async withSpvReseed(op) {
1527
1617
  const lc = await this.seededSpvLight();
1528
- const haveTo = lc.baseHeight + lc.chain.length - 1;
1529
- if (haveTo < height) await lc.sync(height);
1530
- const vh = lc.chain[height - lc.baseHeight];
1531
- if (!vh) throw new Error(`light client has no verified header at ${height}`);
1532
- return String(vh.header.merkle);
1533
- }
1534
- /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
1535
- * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
1536
- async seededSpvLight() {
1537
- if (!this._spvLight) this._spvLight = new import_csd_light.LightClient({
1618
+ try {
1619
+ return await op(lc);
1620
+ } catch (e) {
1621
+ if (spvIsTransient(e)) throw e;
1622
+ const fresh = await this.seedFreshSpvLight();
1623
+ const out = await op(fresh);
1624
+ this._spvLight = fresh;
1625
+ return out;
1626
+ }
1627
+ }
1628
+ // Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
1629
+ // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
1630
+ // dirty the shared field while an attempt is in flight).
1631
+ async seedFreshSpvLight() {
1632
+ const lc = new import_csd_light.LightClient({
1538
1633
  client: this.chain.client,
1539
1634
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
1540
1635
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
1541
1636
  });
1542
- if (!this._spvSeeded) {
1543
- await this._spvLight.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
1544
- try {
1545
- const tipHeight = Number((await this.chain.tip()).height);
1546
- const have = this._spvLight.baseHeight + this._spvLight.chain.length - 1;
1547
- if (Number.isFinite(tipHeight) && tipHeight > have) await this._spvLight.sync(tipHeight);
1548
- } catch {
1549
- }
1550
- this._spvSeeded = true;
1637
+ await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
1638
+ try {
1639
+ const tipHeight = Number((await this.chain.tip()).height);
1640
+ const have = lc.baseHeight + lc.chain.length - 1;
1641
+ if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
1642
+ } catch {
1551
1643
  }
1552
- return this._spvLight;
1644
+ return lc;
1645
+ }
1646
+ /** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
1647
+ * (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
1648
+ * the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
1649
+ * the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
1650
+ * fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
1651
+ * structural (reorg) break is detected, see withSpvReseed. */
1652
+ seededSpvLight() {
1653
+ if (this._spvLight) return Promise.resolve(this._spvLight);
1654
+ if (!this._spvSeedInflight) {
1655
+ this._spvSeedInflight = this.seedFreshSpvLight().then((lc) => {
1656
+ this._spvLight = lc;
1657
+ return lc;
1658
+ }).finally(() => {
1659
+ this._spvSeedInflight = null;
1660
+ });
1661
+ }
1662
+ return this._spvSeedInflight;
1553
1663
  }
1554
1664
  /**
1555
1665
  * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
1556
1666
  * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
1557
1667
  * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
1558
1668
  * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
1559
- * server's 10s deadline) is a transient data-availability hiccup; back off (honouring Retry-After) a
1560
- * bounded number of times, then THROW so `sync()` fails closed. A non-dense range is rejected.
1669
+ * server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
1670
+ * SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
1671
+ * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
1672
+ * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
1561
1673
  *
1562
1674
  * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
1563
1675
  * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
@@ -1572,10 +1684,14 @@ var Cairn = class {
1572
1684
  const url = `${this._cairnBase}/api/headers/${from}/${count}`;
1573
1685
  for (let attempt = 0; ; attempt++) {
1574
1686
  let res;
1687
+ let parsed;
1575
1688
  const ctrl = new AbortController();
1576
1689
  const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
1577
1690
  try {
1578
1691
  res = await doFetch(url, { signal: ctrl.signal });
1692
+ if (res.ok) {
1693
+ parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
1694
+ }
1579
1695
  } catch (e) {
1580
1696
  if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
1581
1697
  await spvSleep(spvRetryDelayMs(attempt, 0));
@@ -1584,7 +1700,7 @@ var Cairn = class {
1584
1700
  clearTimeout(timer);
1585
1701
  }
1586
1702
  if (res.ok) {
1587
- const rows = (await res.json())?.headers;
1703
+ const rows = parsed?.headers;
1588
1704
  if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
1589
1705
  return rows.map((r) => ({ header: r.header, hash: r.hash }));
1590
1706
  }
@@ -1608,10 +1724,14 @@ var Cairn = class {
1608
1724
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
1609
1725
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
1610
1726
  * before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
1727
+ *
1728
+ * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
1729
+ * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
1730
+ * EXACTLY the outputPlan, never resolver-served outputs.
1611
1731
  */
1612
- async verifyOfferForFill(offerId, servedOffer) {
1613
- const light = await this.seededSpvLight();
1614
- return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer });
1732
+ async verifyOfferForFill(offerId, servedOffer, opts) {
1733
+ const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
1734
+ return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
1615
1735
  }
1616
1736
  /**
1617
1737
  * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
package/dist/index.d.cts 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.