@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.cjs CHANGED
@@ -46,16 +46,21 @@ __export(index_exports, {
46
46
  discoverProviders: () => discoverProviders,
47
47
  errorCode: () => errorCode,
48
48
  feeBpsAt: () => import_cairnx_core2.feeBpsAt,
49
+ fillEndorsement: () => import_cairnx_core2.fillEndorsement,
50
+ fillIsSafe: () => import_cairnx_core2.fillIsSafe,
51
+ fillOutputPlan: () => import_cairnx_core2.fillOutputPlan,
49
52
  getWallet: () => getWallet,
50
53
  isInstalled: () => isInstalled,
51
54
  mapProviderError: () => mapProviderError,
52
55
  mapSubmitResultError: () => mapSubmitResultError,
53
- preverifyOffer: () => preverifyOffer
56
+ preverifyOffer: () => preverifyOffer,
57
+ previewFill: () => import_cairnx_core2.previewFill,
58
+ requiredFillOutputs: () => import_cairnx_core2.requiredFillOutputs
54
59
  });
55
60
  module.exports = __toCommonJS(index_exports);
56
61
 
57
62
  // src/errors.ts
58
- var SDK_VERSION = "0.3.2";
63
+ var SDK_VERSION = "0.4.1";
59
64
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
60
65
  var CairnError = class extends Error {
61
66
  /** Stable machine code for programmatic branching. */
@@ -147,7 +152,7 @@ var SubmitInFlightError = class extends CairnError {
147
152
  this.txid = txid3;
148
153
  }
149
154
  };
150
- var NATIVE_CODES = /* @__PURE__ */ new Set(["ACCOUNT_CHANGED", "FIRST_PARTY_ONLY", "RATE_LIMITED", "APPROVAL_CLOSED", "FORBIDDEN", "INTERNAL"]);
155
+ var NATIVE_CODES = /* @__PURE__ */ new Set(["FIRST_PARTY_ONLY", "RATE_LIMITED", "FORBIDDEN", "INTERNAL"]);
151
156
  var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN"]);
152
157
  var NESTED_TERMINAL = /* @__PURE__ */ new Set([
153
158
  "FILL_UNSAFE",
@@ -889,17 +894,20 @@ function normalizeHash(hash) {
889
894
  var import_csd_registry = require("@inversealtruism/csd-registry");
890
895
  var import_csd_registry2 = require("@inversealtruism/csd-registry");
891
896
  var RegistryClient = class {
892
- src;
893
- constructor(opts) {
894
- 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 });
895
903
  }
896
904
  /** Discover ranked content/pin gateways (GET /registry/gateways). */
897
905
  gateways() {
898
- return (0, import_csd_registry.discoverGateways)(this.src);
906
+ return this.http.getJson("/registry/gateways");
899
907
  }
900
908
  /** Discover ranked libp2p peers (GET /registry/peers). */
901
909
  peers() {
902
- return (0, import_csd_registry.discoverPeers)(this.src);
910
+ return this.http.getJson("/registry/peers");
903
911
  }
904
912
  /**
905
913
  * Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved.
@@ -911,12 +919,22 @@ var RegistryClient = class {
911
919
  * source (the wallet's namespv union cure). Treat `verified` here as a hint, not a guarantee.
912
920
  */
913
921
  resolveName(handle) {
914
- return (0, import_csd_registry.resolveName)(this.src, handle);
922
+ return this.getOr404Null(`/identity/${encodeURIComponent(handle)}`);
915
923
  }
916
924
  /** Reverse-resolve an address → identity (GET /address/:addr/identity).
917
925
  * ⚠ Same indexer-trust caveat as resolveName — `verified` is the indexer's claim, not a client proof. */
918
926
  reverseName(address) {
919
- 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
+ }
920
938
  }
921
939
  /** The deterministic, client-side resolvers (recompute from raw ChainRecord[]). */
922
940
  get fromRecords() {
@@ -1015,6 +1033,7 @@ var NamesClient = class {
1015
1033
  };
1016
1034
 
1017
1035
  // src/connect.ts
1036
+ var DEFAULT_PROVIDER_TIMEOUT_MS = 10 * 6e4;
1018
1037
  function discoverProviders(opts = {}) {
1019
1038
  const timeoutMs = opts.timeoutMs ?? 1e3;
1020
1039
  return new Promise((resolve) => {
@@ -1094,8 +1113,27 @@ function unwrapWrite(reply) {
1094
1113
  var WalletConnection = class {
1095
1114
  provider;
1096
1115
  _addr = null;
1097
- constructor(provider) {
1116
+ /** B7c: per-call provider timeout (ms). GENEROUS by default so it never rejects an in-flight human
1117
+ * approval; 0 disables it. Every provider call (writes especially) races against it. */
1118
+ _timeoutMs;
1119
+ constructor(provider, opts = {}) {
1098
1120
  this.provider = provider;
1121
+ this._timeoutMs = opts.timeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
1122
+ }
1123
+ // B7c: race a provider call against the per-call timeout. On timeout, throw a fail-closed CairnError
1124
+ // (retryable:false: a write may already have reached the wallet, so never invite a blind auto-retry). The
1125
+ // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
1126
+ call(op, label) {
1127
+ const ms = this._timeoutMs;
1128
+ const invoke = () => new Promise((resolve) => {
1129
+ resolve(op());
1130
+ });
1131
+ if (!Number.isFinite(ms) || ms <= 0) return invoke();
1132
+ let timer;
1133
+ const timeout = new Promise((_, reject) => {
1134
+ timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
1135
+ });
1136
+ return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
1099
1137
  }
1100
1138
  /** The wallet's version string (e.g. "0.2.x"). */
1101
1139
  get version() {
@@ -1107,13 +1145,13 @@ var WalletConnection = class {
1107
1145
  }
1108
1146
  /** Request connection. First time per origin prompts the user; afterwards may be silent. */
1109
1147
  async connect() {
1110
- const { addr } = unwrap(await this.provider.connect());
1148
+ const { addr } = unwrap(await this.call(() => this.provider.connect(), "connect"));
1111
1149
  this._addr = addr;
1112
1150
  return addr;
1113
1151
  }
1114
1152
  /** Get the connected address (does not force a new connection prompt if already consented). */
1115
1153
  async getAddress() {
1116
- const { addr } = unwrap(await this.provider.getAddress());
1154
+ const { addr } = unwrap(await this.call(() => this.provider.getAddress(), "getAddress"));
1117
1155
  this._addr = addr;
1118
1156
  return addr;
1119
1157
  }
@@ -1126,7 +1164,7 @@ var WalletConnection = class {
1126
1164
  if (typeof this.provider.signIn !== "function") {
1127
1165
  return Promise.reject(new UnsupportedMethodError("this wallet does not expose signIn()"));
1128
1166
  }
1129
- return Promise.resolve(this.provider.signIn()).then(unwrap);
1167
+ return this.call(() => this.provider.signIn(), "signIn").then(unwrap);
1130
1168
  }
1131
1169
  /**
1132
1170
  * Audience-bound "Sign in with CSD" (SIWC) — the secure, replay-resistant third-party login.
@@ -1142,7 +1180,7 @@ var WalletConnection = class {
1142
1180
  if (typeof this.provider.signInWithCsd !== "function") {
1143
1181
  return Promise.reject(new UnsupportedMethodError("this wallet predates Sign-in-with-CSD \u2014 ask the user to update the Cairn Wallet"));
1144
1182
  }
1145
- return Promise.resolve(this.provider.signInWithCsd(params)).then(unwrap);
1183
+ return this.call(() => this.provider.signInWithCsd(params), "signInWithCsd").then(unwrap);
1146
1184
  }
1147
1185
  /** True if this wallet supports audience-bound SIWC (safe to call `signInWithCsd`). */
1148
1186
  get supportsSiwc() {
@@ -1152,7 +1190,7 @@ var WalletConnection = class {
1152
1190
  async getCapabilities() {
1153
1191
  if (typeof this.provider.getCapabilities !== "function") return null;
1154
1192
  try {
1155
- return await this.provider.getCapabilities();
1193
+ return await this.call(() => this.provider.getCapabilities(), "getCapabilities");
1156
1194
  } catch {
1157
1195
  return null;
1158
1196
  }
@@ -1168,24 +1206,40 @@ var WalletConnection = class {
1168
1206
  if (typeof this.provider.fillOffer !== "function") {
1169
1207
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
1170
1208
  }
1171
- return Promise.resolve(this.provider.fillOffer(params)).then(unwrapWrite);
1209
+ this.adviseFillSpvCapability();
1210
+ return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
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.`);
1172
1226
  }
1173
1227
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
1174
1228
  async getPermissions() {
1175
1229
  if (typeof this.provider.getPermissions !== "function") return [];
1176
- return unwrap(await this.provider.getPermissions());
1230
+ return unwrap(await this.call(() => this.provider.getPermissions(), "getPermissions"));
1177
1231
  }
1178
1232
  /** Explicitly request permission to see the address (prompts, like connect). */
1179
1233
  requestPermissions() {
1180
1234
  if (typeof this.provider.requestPermissions !== "function") {
1181
1235
  return Promise.reject(new UnsupportedMethodError("this wallet predates requestPermissions \u2014 use connect()"));
1182
1236
  }
1183
- return Promise.resolve(this.provider.requestPermissions()).then(unwrap);
1237
+ return this.call(() => this.provider.requestPermissions(), "requestPermissions").then(unwrap);
1184
1238
  }
1185
1239
  /** Revoke THIS origin's own access (silent). Returns { revoked }. Safe no-op on older wallets. */
1186
1240
  async revokePermissions() {
1187
1241
  if (typeof this.provider.revokePermissions !== "function") return { revoked: false };
1188
- return unwrap(await this.provider.revokePermissions());
1242
+ return unwrap(await this.call(() => this.provider.revokePermissions(), "revokePermissions"));
1189
1243
  }
1190
1244
  /**
1191
1245
  * Subscribe to provider events. `accountsChanged` fires with `[]` when this site loses access
@@ -1201,26 +1255,26 @@ var WalletConnection = class {
1201
1255
  }
1202
1256
  /** Send CSD (always prompts with clear-signing; wallet picks inputs + returns change to itself). */
1203
1257
  send(params) {
1204
- return Promise.resolve(this.provider.send(params)).then(unwrapWrite);
1258
+ return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
1205
1259
  }
1206
1260
  /** Low-level Propose (always prompts). Prefer `cairn.board.propose()` which computes the hash/uri/expiry. */
1207
1261
  propose(params) {
1208
- return Promise.resolve(this.provider.propose(params)).then(unwrapWrite);
1262
+ return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
1209
1263
  }
1210
1264
  /** Attest/support a proposal (always prompts). */
1211
1265
  attest(params) {
1212
- return Promise.resolve(this.provider.attest(params)).then(unwrapWrite);
1266
+ return this.call(() => this.provider.attest(params), "attest").then(unwrapWrite);
1213
1267
  }
1214
1268
  /** Seal a commit-reveal claim (always prompts; salt stays inside the wallet). */
1215
1269
  sealClaim(params) {
1216
- return Promise.resolve(this.provider.sealClaim(params)).then(unwrapWrite);
1270
+ return this.call(() => this.provider.sealClaim(params), "sealClaim").then(unwrapWrite);
1217
1271
  }
1218
1272
  /** Reveal a previously sealed claim by its sealing txid (always prompts). */
1219
1273
  revealClaim(txid3) {
1220
1274
  if (typeof txid3 !== "string" || !txid3) {
1221
1275
  return Promise.reject(new UnsupportedMethodError("revealClaim requires the sealing txid"));
1222
1276
  }
1223
- return Promise.resolve(this.provider.revealClaim(txid3)).then(unwrapWrite);
1277
+ return this.call(() => this.provider.revealClaim(txid3), "revealClaim").then(unwrapWrite);
1224
1278
  }
1225
1279
  };
1226
1280
  async function getWallet(opts = {}) {
@@ -1239,6 +1293,23 @@ var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
1239
1293
  var ADDR = /^0x[0-9a-f]{40}$/;
1240
1294
  var HASH = /^0x[0-9a-f]{64}$/;
1241
1295
  var COINBASE = "0x" + "00".repeat(32);
1296
+ function bindPlannedSums(planned, proven) {
1297
+ const provenMap = new Map(proven.map((o) => [o.to.toLowerCase(), o.value]));
1298
+ const plannedMap = /* @__PURE__ */ new Map();
1299
+ try {
1300
+ for (const [a, v] of Object.entries(planned)) {
1301
+ const k = String(a).toLowerCase();
1302
+ plannedMap.set(k, (plannedMap.get(k) ?? 0n) + BigInt(v));
1303
+ }
1304
+ } catch {
1305
+ return "invalid plannedOutputs (amounts must be base-unit integers)";
1306
+ }
1307
+ for (const k of /* @__PURE__ */ new Set([...provenMap.keys(), ...plannedMap.keys()])) {
1308
+ if ((provenMap.get(k) ?? 0n) !== (plannedMap.get(k) ?? 0n))
1309
+ 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)`;
1310
+ }
1311
+ return void 0;
1312
+ }
1242
1313
  async function provenAuthor(client, provenTx) {
1243
1314
  const in0 = provenTx.inputs?.[0];
1244
1315
  if (!in0 || String(in0.prevTxid).toLowerCase() === COINBASE && Number(in0.vout) === 4294967295) return null;
@@ -1283,6 +1354,8 @@ async function preverifyOffer(opts) {
1283
1354
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
1284
1355
  const app = incl.tx.app;
1285
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)` };
1286
1359
  let rec;
1287
1360
  try {
1288
1361
  rec = (0, import_cairnx_core2.parseRecord)(app.uri, incl.appPayloadHash);
@@ -1294,27 +1367,56 @@ async function preverifyOffer(opts) {
1294
1367
  return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
1295
1368
  const seller = await provenAuthor(opts.client, provenTx);
1296
1369
  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" };
1370
+ const offerRec = rec;
1297
1371
  const w = rec.want ?? {};
1298
1372
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
1299
- const terms = {
1300
- height: blockHeight,
1301
- feeBps: (0, import_cairnx_core2.feeBpsAt)(blockHeight),
1302
- value: w.value !== void 0 ? String(w.value) : void 0,
1303
- taker: rec.taker !== void 0 ? String(rec.taker).toLowerCase() : void 0,
1304
- bid: rec.bid !== void 0 ? String(rec.bid).toLowerCase() : void 0,
1305
- min: rec.min !== void 0 ? String(rec.min) : void 0
1306
- };
1307
- const base = { payto, seller, terms, blockHeight };
1373
+ const terms = (0, import_cairnx_core2.provenOfferTerms)(offerRec, blockHeight);
1374
+ let outputPlan;
1375
+ let sumsMismatch;
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;
1378
+ const provenState = {
1379
+ id,
1380
+ seller,
1381
+ give: offerRec.give,
1382
+ want: { ...offerRec.want, payto },
1383
+ taker: offerRec.taker,
1384
+ bid: offerRec.bid,
1385
+ status: typeof servedStatus === "string" ? servedStatus : "open",
1386
+ expiresEpoch: 0,
1387
+ height: blockHeight,
1388
+ feeBps: terms.feeBps
1389
+ };
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`;
1401
+ }
1402
+ }
1403
+ const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
1308
1404
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1309
1405
  const so = opts.servedOffer;
1310
- const servedPayto = so?.want?.payto !== void 0 && so?.want?.payto !== null ? String(so.want.payto).toLowerCase() : "";
1311
- 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)
1312
1409
  return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
1313
- 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)
1314
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 };
1315
- if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms))
1316
- 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 };
1414
+ if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms, { give: true, wantType: true }))
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 };
1317
1418
  }
1419
+ if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
1318
1420
  return { ok: true, trust: "verified", ...base };
1319
1421
  }
1320
1422
 
@@ -1323,6 +1425,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
1323
1425
  var CairnController = class {
1324
1426
  state = DISCONNECTED;
1325
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;
1326
1431
  listeners = /* @__PURE__ */ new Set();
1327
1432
  getWalletFn;
1328
1433
  detectOpts;
@@ -1375,34 +1480,43 @@ var CairnController = class {
1375
1480
  }
1376
1481
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
1377
1482
  connect = async () => {
1483
+ const epoch = this.epoch;
1378
1484
  this.setState({ status: "connecting", error: null });
1379
1485
  try {
1380
- if (!this.conn) {
1381
- this.conn = await this.getWalletFn(this.detectOpts);
1382
- this.conn.on("accountsChanged", this.onAccounts);
1383
- 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;
1384
1493
  }
1385
- 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");
1386
1496
  this.setState({ status: "connected", account: addr, error: null });
1387
1497
  return addr;
1388
1498
  } catch (e) {
1389
- 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) });
1390
1500
  throw e;
1391
1501
  }
1392
1502
  };
1393
- /** 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". */
1394
1506
  disconnect = async () => {
1395
- try {
1396
- await this.conn?.revokePermissions();
1397
- } catch {
1398
- }
1507
+ this.epoch++;
1508
+ const conn = this.conn;
1399
1509
  this.detach();
1400
1510
  this.conn = null;
1401
1511
  this.setState({ status: "disconnected", account: null, error: null });
1512
+ try {
1513
+ await conn?.revokePermissions();
1514
+ } catch {
1515
+ }
1402
1516
  };
1403
1517
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
1404
1518
  signInWithCsd = (params) => {
1405
- 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()"));
1406
1520
  return this.conn.signInWithCsd(params);
1407
1521
  };
1408
1522
  };
@@ -1410,6 +1524,17 @@ var CairnController = class {
1410
1524
  // src/index.ts
1411
1525
  var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee1457a40833f2fcb4de44291b9d373e50f17b97232" };
1412
1526
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
1527
+ var SPV_HTTP_TIMEOUT_MS = 15e3;
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
+ };
1536
+ var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1537
+ var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
1413
1538
  var Cairn = class {
1414
1539
  config;
1415
1540
  chain;
@@ -1423,8 +1548,13 @@ var Cairn = class {
1423
1548
  cairnHttp;
1424
1549
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
1425
1550
  _spvLight = null;
1426
- _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;
1427
1555
  _spvCp;
1556
+ // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
1557
+ _cairnBase;
1428
1558
  constructor(config = {}) {
1429
1559
  this.config = config;
1430
1560
  const fetch = config.fetch;
@@ -1433,6 +1563,7 @@ var Cairn = class {
1433
1563
  const rpc = config.baseUrls?.rpc ?? `${cairn}/api/rpc`;
1434
1564
  const indexer = (config.baseUrls?.indexer ?? `${cairn}/explorer/api`).replace(/\/+$/, "");
1435
1565
  this.cairnHttp = new Http({ baseUrl: cairn, fetch, timeoutMs });
1566
+ this._cairnBase = cairn;
1436
1567
  const indexerHttp = new Http({ baseUrl: indexer, fetch, timeoutMs });
1437
1568
  const swarmHttp = config.baseUrls?.swarm ? new Http({ baseUrl: config.baseUrls.swarm.replace(/\/+$/, ""), fetch, timeoutMs }) : void 0;
1438
1569
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
@@ -1445,7 +1576,7 @@ var Cairn = class {
1445
1576
  headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
1446
1577
  });
1447
1578
  this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
1448
- this.registry = new RegistryClient({ baseUrl: indexer, fetch });
1579
+ this.registry = new RegistryClient(indexerHttp);
1449
1580
  this.names = new NamesClient(this.cairnHttp);
1450
1581
  }
1451
1582
  /**
@@ -1455,26 +1586,132 @@ var Cairn = class {
1455
1586
  * checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
1456
1587
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
1457
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.
1458
1593
  */
1459
1594
  async verifiedHeaderMerkleAt(height) {
1460
1595
  if (!Number.isInteger(height) || height < this._spvCp.height)
1461
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) {
1462
1617
  const lc = await this.seededSpvLight();
1463
- const haveTo = lc.baseHeight + lc.chain.length - 1;
1464
- if (haveTo < height) await lc.sync(height);
1465
- const vh = lc.chain[height - lc.baseHeight];
1466
- if (!vh) throw new Error(`light client has no verified header at ${height}`);
1467
- return String(vh.header.merkle);
1468
- }
1469
- /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
1470
- * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
1471
- async seededSpvLight() {
1472
- if (!this._spvLight) this._spvLight = new import_csd_light.LightClient({ client: this.chain.client, checkpoints: { [this._spvCp.height]: this._spvCp.hash } });
1473
- if (!this._spvSeeded) {
1474
- await this._spvLight.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
1475
- this._spvSeeded = true;
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({
1633
+ client: this.chain.client,
1634
+ checkpoints: { [this._spvCp.height]: this._spvCp.hash },
1635
+ headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
1636
+ });
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 {
1643
+ }
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;
1663
+ }
1664
+ /**
1665
+ * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
1666
+ * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
1667
+ * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
1668
+ * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
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).
1673
+ *
1674
+ * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
1675
+ * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
1676
+ * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
1677
+ * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
1678
+ * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
1679
+ */
1680
+ async fetchHeadersBatch(from, count) {
1681
+ const g = this.config.fetch ?? globalThis.fetch;
1682
+ if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
1683
+ const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
1684
+ const url = `${this._cairnBase}/api/headers/${from}/${count}`;
1685
+ for (let attempt = 0; ; attempt++) {
1686
+ let res;
1687
+ let parsed;
1688
+ const ctrl = new AbortController();
1689
+ const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
1690
+ try {
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
+ }
1695
+ } catch (e) {
1696
+ if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
1697
+ await spvSleep(spvRetryDelayMs(attempt, 0));
1698
+ continue;
1699
+ } finally {
1700
+ clearTimeout(timer);
1701
+ }
1702
+ if (res.ok) {
1703
+ const rows = parsed?.headers;
1704
+ if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
1705
+ return rows.map((r) => ({ header: r.header, hash: r.hash }));
1706
+ }
1707
+ if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
1708
+ res.body?.cancel?.().catch(() => {
1709
+ });
1710
+ await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
1711
+ continue;
1712
+ }
1713
+ throw new Error(`/api/headers ${res.status}`);
1476
1714
  }
1477
- return this._spvLight;
1478
1715
  }
1479
1716
  /**
1480
1717
  * F13 — corroborate an offer ON-CHAIN before building a `fillOffer` payment's `outputs`. Merkle-proves the
@@ -1487,10 +1724,14 @@ var Cairn = class {
1487
1724
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
1488
1725
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
1489
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.
1490
1731
  */
1491
- async verifyOfferForFill(offerId, servedOffer) {
1492
- const light = await this.seededSpvLight();
1493
- 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 });
1494
1735
  }
1495
1736
  /**
1496
1737
  * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
@@ -1539,9 +1780,14 @@ var Cairn = class {
1539
1780
  discoverProviders,
1540
1781
  errorCode,
1541
1782
  feeBpsAt,
1783
+ fillEndorsement,
1784
+ fillIsSafe,
1785
+ fillOutputPlan,
1542
1786
  getWallet,
1543
1787
  isInstalled,
1544
1788
  mapProviderError,
1545
1789
  mapSubmitResultError,
1546
- preverifyOffer
1790
+ preverifyOffer,
1791
+ previewFill,
1792
+ requiredFillOutputs
1547
1793
  });