@inversealtruism/cairn-sdk 0.8.1 → 0.8.2

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
@@ -58,7 +58,7 @@ __export(index_exports, {
58
58
  module.exports = __toCommonJS(index_exports);
59
59
 
60
60
  // src/errors.ts
61
- var SDK_VERSION = "0.8.1";
61
+ var SDK_VERSION = "0.8.2";
62
62
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
63
63
  var CairnError = class extends Error {
64
64
  /** Stable machine code for programmatic branching. */
@@ -141,17 +141,22 @@ var ContentVerificationError = class extends CairnError {
141
141
  }
142
142
  };
143
143
  var SubmitInFlightError = class extends CairnError {
144
- /** The locally-computed consensus txid to reconcile against the chain (may be undefined if the wallet omitted it). */
144
+ /** The locally-computed consensus txid to reconcile against the chain (undefined when it is not known). */
145
145
  txid;
146
- /** Always true: the tx MAY be in the mempool. Reconcile via `txid`; never blind-retry. */
146
+ /** The signed transaction, set only when `Chain.send` threw this (a wallet write keeps it in the wallet).
147
+ * `chain.submit(nodeJson)` resends it unchanged: the same coins under the same txid, so it cannot pay twice,
148
+ * where a new send may select other coins. */
149
+ nodeJson;
150
+ /** Always true: the tx MAY be in the mempool. Reconcile via `txid` or the balance; never blind-retry. */
147
151
  maybeSent = true;
148
- constructor(message, code, txid3) {
149
- super(message, { code, docsPath: "/errors#submit-inflight", retryable: false });
152
+ constructor(message, code, txid3, cause, nodeJson) {
153
+ super(message, { code, docsPath: "/errors#submit-inflight", retryable: false, cause });
150
154
  this.txid = txid3;
155
+ this.nodeJson = nodeJson;
151
156
  }
152
157
  };
153
158
  var NATIVE_CODES = /* @__PURE__ */ new Set(["FIRST_PARTY_ONLY", "RATE_LIMITED", "FORBIDDEN", "INTERNAL"]);
154
- var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN", "WALLET_UNAVAILABLE", "REVIEW_REQUIRED"]);
159
+ var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN", "WALLET_UNAVAILABLE", "REVIEW_REQUIRED", "SEAL_NOT_MINED"]);
155
160
  var NESTED_TERMINAL = /* @__PURE__ */ new Set([
156
161
  "FILL_UNSAFE",
157
162
  "FILL_WRONG_TARGET",
@@ -173,7 +178,9 @@ var NESTED_TERMINAL = /* @__PURE__ */ new Set([
173
178
  "OFFER_SOURCE_CONFLICT",
174
179
  "FILL_LEGACY_SUNSET",
175
180
  "VERIFY_HORIZON",
176
- "VERIFY_UNREPRESENTABLE"
181
+ "VERIFY_UNREPRESENTABLE",
182
+ "RESERVATION_DENIED",
183
+ "RPC_BLOCK_MISMATCH"
177
184
  ]);
178
185
  function mapProviderError(error, code) {
179
186
  if (code === "USER_REJECTED" || code === "APPROVAL_CLOSED") return new UserRejectedError(error);
@@ -191,11 +198,15 @@ function mapProviderError(error, code) {
191
198
  function mapSubmitResultError(result) {
192
199
  const code = result?.code;
193
200
  const msg = typeof result?.error === "string" && result.error || code || "The wallet refused the transaction.";
194
- if (code === "SUBMIT_MAYBE_INFLIGHT" || code === "SUBMIT_DUPLICATE") {
201
+ if (code === "SUBMIT_MAYBE_INFLIGHT" || code === "SUBMIT_DUPLICATE" || code === "WALLET_UNAVAILABLE") {
195
202
  return new SubmitInFlightError(msg, code, typeof result?.txid === "string" ? result.txid : void 0);
196
203
  }
197
204
  return mapProviderError(msg, code);
198
205
  }
206
+ function mapWriteProviderError(error, code) {
207
+ if (code === "WALLET_UNAVAILABLE") return new SubmitInFlightError(error || code, code);
208
+ return mapProviderError(error, code);
209
+ }
199
210
  function errorCode(err) {
200
211
  return err instanceof CairnError ? err.code : "UNKNOWN";
201
212
  }
@@ -404,7 +415,11 @@ var Chain = class {
404
415
  * chain (`verifyInputValues` recomputes each source tx's txid, so a lying RPC can't under-report) and
405
416
  * computes change from the VERIFIED total — closing the UTXO-VALUE-1 implicit-fee-burn. Fail-closed:
406
417
  * if any source can't be verified it signs nothing. Prefer this over the pure `buildSend`. Pass
407
- * `broadcast:false` to get the signed tx back without submitting.
418
+ * `broadcast:false` to get the signed tx back without submitting. A submit request that fails after signing
419
+ * throws a `SubmitInFlightError` carrying the signed `txid` and `nodeJson`: the node may have taken it, so check
420
+ * before resending, and resend that `nodeJson` with `submit` rather than a new send. A 4xx answer (a proxy's
421
+ * body cap or rate limit, or the node refusing the request) broadcast nothing and throws a final `CairnError`
422
+ * with code `SUBMIT_REJECTED`.
408
423
  */
409
424
  async send(p) {
410
425
  const from = (0, import_csd_crypto2.addrFromPriv)(p.priv);
@@ -418,10 +433,19 @@ var Chain = class {
418
433
  verify: (inputs) => (0, import_csd_client2.verifyInputValues)(this.client, inputs)
419
434
  });
420
435
  if (!built.ok || p.broadcast === false) return built;
421
- const submitResult = await this.client.submit(built.nodeJson);
436
+ let submitResult;
437
+ try {
438
+ submitResult = await this.client.submit(built.nodeJson);
439
+ } catch (e) {
440
+ const what = e?.message ?? String(e);
441
+ if (e?.terminal === true)
442
+ throw new CairnError(`the submit request was refused (${what}), so nothing was broadcast; a 400 or 413 from a proxy is usually its request body cap (a transaction with many inputs), a 429 its rate limit`, { code: "SUBMIT_REJECTED", cause: e });
443
+ throw new SubmitInFlightError(`the submit request failed after signing (${what}); the node may have taken the transaction, so check ${built.txid} before sending again`, "SUBMIT_MAYBE_INFLIGHT", built.txid, e, built.nodeJson);
444
+ }
422
445
  return { ...built, ok: built.ok && submitResult.ok !== false, submitResult };
423
446
  }
424
- /** A verifying light client over the same RPC (built once, reused). */
447
+ /** A light client over the same RPC (built once, reused), with no checkpoint and no batch header provider: it proves
448
+ * nothing until seeded. The facade's seeded client backs `cairn.index.verifyInclusion` and `verifyOfferForFill`. */
425
449
  light() {
426
450
  if (!this._light) {
427
451
  this._light = new import_csd_light2.LightClient({ client: this.client });
@@ -434,6 +458,20 @@ var Chain = class {
434
458
  var import_csd_codec2 = require("@inversealtruism/csd-codec");
435
459
  var HEX64 = /^0x[0-9a-fA-F]{64}$/;
436
460
  var MAX_SSE_BUF = 1024 * 1024;
461
+ var UTXO_PAGE = 1e3;
462
+ var UTXO_MAX_PAGES = 100;
463
+ var UTXO_CURSOR = /^\d{1,16}:0x[0-9a-fA-F]{64}:\d{1,10}$/;
464
+ function utxoCursor(row) {
465
+ const r = row;
466
+ const c = `${String(r?.status?.block_height)}:${String(r?.txid)}:${String(r?.vout)}`;
467
+ return UTXO_CURSOR.test(c) ? c : null;
468
+ }
469
+ function cursorAfter(a, b) {
470
+ const [ha, ta, va] = a.split(":"), [hb2, tb, vb] = b.split(":");
471
+ if (Number(ha) !== Number(hb2)) return Number(ha) > Number(hb2);
472
+ if (ta.toLowerCase() !== tb.toLowerCase()) return ta.toLowerCase() > tb.toLowerCase();
473
+ return Number(va) > Number(vb);
474
+ }
437
475
  function isValidMerkleProof(p) {
438
476
  if (!p || typeof p !== "object") return false;
439
477
  const m = p;
@@ -483,8 +521,29 @@ var IndexerClient = class {
483
521
  addressTxs(a) {
484
522
  return this.http.getJson(`/address/${encodeURIComponent(a)}/txs`);
485
523
  }
486
- addressUtxo(a) {
487
- return this.http.getJson(`/address/${encodeURIComponent(a)}/utxo`);
524
+ /** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
525
+ * `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
526
+ * outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
527
+ async addressUtxo(a) {
528
+ const path = `/address/${encodeURIComponent(a)}/utxo`;
529
+ const first = await this.http.getJson(path);
530
+ if (!Array.isArray(first) || first.length !== UTXO_PAGE) return first;
531
+ const out = [...first];
532
+ let page = first;
533
+ let prev = null;
534
+ for (let pages = 1; page.length === UTXO_PAGE; pages++) {
535
+ if (pages === UTXO_MAX_PAGES)
536
+ throw new HttpError(200, this.http.url(path), `too many unspent outputs: addressUtxo reads at most ${UTXO_MAX_PAGES} pages of ${UTXO_PAGE} rows, and page ${UTXO_MAX_PAGES} is full`);
537
+ const after = utxoCursor(page[UTXO_PAGE - 1]);
538
+ if (!after) throw new HttpError(200, this.http.url(path), "malformed response: a utxo row has no block_height, txid and vout to page from");
539
+ if (prev !== null && !cursorAfter(after, prev)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: the utxo pages do not advance");
540
+ page = await this.http.getJson(path, { after });
541
+ if (!Array.isArray(page)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: a utxo page is not an array");
542
+ if (prev === null && page.length > 0 && utxoCursor(page[0]) === utxoCursor(first[0])) return first;
543
+ prev = after;
544
+ out.push(...page);
545
+ }
546
+ return out;
488
547
  }
489
548
  reputation(a) {
490
549
  return this.http.getJson(`/address/${encodeURIComponent(a)}/reputation`);
@@ -503,7 +562,8 @@ var IndexerClient = class {
503
562
  }
504
563
  // ---- trust-minimized inclusion ----------------------------------------
505
564
  /**
506
- * Verify a tx is included in a block by folding its merkle branch to the root
565
+ * Verify that `txid` is a node of a block's merkle tree (an inner node or the root folds too, so pass an id you
566
+ * hashed from a transaction body you hold) by folding its merkle branch to the root
507
567
  * with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
508
568
  * proof's root is cross-checked against the header merkle at that height →
509
569
  * trustLevel "verified-inclusion" (only as trustworthy as that header source —
@@ -987,7 +1047,8 @@ function unwrap(reply) {
987
1047
  throw mapProviderError(reply?.error ?? "Unknown wallet error", reply?.code);
988
1048
  }
989
1049
  function unwrapWrite(reply) {
990
- const result = unwrap(reply);
1050
+ if (!reply || !reply.ok) throw mapWriteProviderError(reply?.error ?? "Unknown wallet error", reply?.code);
1051
+ const result = reply.result;
991
1052
  if (result && typeof result === "object" && result.ok === false) {
992
1053
  throw mapSubmitResultError(result);
993
1054
  }
@@ -1003,10 +1064,12 @@ var WalletConnection = class {
1003
1064
  this.provider = provider;
1004
1065
  this._timeoutMs = opts.timeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
1005
1066
  }
1006
- // B7c: race a provider call against the per-call timeout. On timeout, throw a fail-closed CairnError
1007
- // (retryable:false: a write may already have reached the wallet, so never invite a blind auto-retry). The
1008
- // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
1009
- call(op, label) {
1067
+ // B7c: race a provider call against the per-call timeout. On timeout, throw a fail-closed error
1068
+ // (retryable:false). A write's request stays open in the wallet and may still be approved and sent, so its
1069
+ // timeout is a maybe-sent SubmitInFlightError (WALLET_TIMEOUT, no txid); while that request stays open, a resend
1070
+ // opens a second one, and approving both sends twice. The genuine wallet resolves quickly
1071
+ // once the user acts; this only fires on a truly hung/absent response.
1072
+ call(op, label, write = false) {
1010
1073
  const ms = this._timeoutMs;
1011
1074
  const invoke = () => new Promise((resolve) => {
1012
1075
  resolve(op());
@@ -1014,7 +1077,7 @@ var WalletConnection = class {
1014
1077
  if (!Number.isFinite(ms) || ms <= 0) return invoke();
1015
1078
  let timer;
1016
1079
  const timeout = new Promise((_, reject) => {
1017
- timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
1080
+ timer = setTimeout(() => reject(write ? new SubmitInFlightError(`the wallet did not respond to ${label} within ${ms}ms; the request may still be open in the wallet and be sent if approved, so reject it in the wallet and check the chain before sending it again`, "WALLET_TIMEOUT") : new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
1018
1081
  });
1019
1082
  return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
1020
1083
  }
@@ -1090,7 +1153,7 @@ var WalletConnection = class {
1090
1153
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
1091
1154
  }
1092
1155
  this.adviseFillSpvCapability();
1093
- return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
1156
+ return this.call(() => this.provider.fillOffer(params), "fillOffer", true).then(unwrapWrite);
1094
1157
  }
1095
1158
  // P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
1096
1159
  // payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
@@ -1138,26 +1201,26 @@ var WalletConnection = class {
1138
1201
  }
1139
1202
  /** Send CSD (always prompts with clear-signing; wallet picks inputs + returns change to itself). */
1140
1203
  send(params) {
1141
- return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
1204
+ return this.call(() => this.provider.send(params), "send", true).then(unwrapWrite);
1142
1205
  }
1143
1206
  /** Low-level Propose (always prompts). The caller computes the payload hash, uri and expiry epoch. */
1144
1207
  propose(params) {
1145
- return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
1208
+ return this.call(() => this.provider.propose(params), "propose", true).then(unwrapWrite);
1146
1209
  }
1147
1210
  /** Attest/support a proposal (always prompts). */
1148
1211
  attest(params) {
1149
- return this.call(() => this.provider.attest(params), "attest").then(unwrapWrite);
1212
+ return this.call(() => this.provider.attest(params), "attest", true).then(unwrapWrite);
1150
1213
  }
1151
1214
  /** Seal a commit-reveal claim (always prompts; salt stays inside the wallet). */
1152
1215
  sealClaim(params) {
1153
- return this.call(() => this.provider.sealClaim(params), "sealClaim").then(unwrapWrite);
1216
+ return this.call(() => this.provider.sealClaim(params), "sealClaim", true).then(unwrapWrite);
1154
1217
  }
1155
1218
  /** Reveal a previously sealed claim by its sealing txid (always prompts). */
1156
1219
  revealClaim(txid3) {
1157
1220
  if (typeof txid3 !== "string" || !txid3) {
1158
1221
  return Promise.reject(new UnsupportedMethodError("revealClaim requires the sealing txid"));
1159
1222
  }
1160
- return this.call(() => this.provider.revealClaim(txid3), "revealClaim").then(unwrapWrite);
1223
+ return this.call(() => this.provider.revealClaim(txid3), "revealClaim", true).then(unwrapWrite);
1161
1224
  }
1162
1225
  };
1163
1226
  async function getWallet(opts = {}) {
@@ -1215,9 +1278,114 @@ async function provenAuthor(client, provenTx) {
1215
1278
  if (String((0, import_csd_codec4.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
1216
1279
  return (0, import_csd_crypto3.authorOf)(provenTx, src.outputs?.[Number(in0.vout)]?.scriptPubkey);
1217
1280
  }
1281
+ var has = (v) => v !== void 0 && v !== null;
1282
+ function fillInputs(o) {
1283
+ const base = { pay: o.pay, plannedOutputs: o.plannedOutputs };
1284
+ if (!has(o.me) && !has(o.tip) && !has(o.fillTargetId)) return base;
1285
+ const me = typeof o.me === "string" ? o.me.toLowerCase() : "";
1286
+ const target = has(o.fillTargetId) ? String(o.fillTargetId).toLowerCase() : void 0;
1287
+ if (!ADDR.test(me) || !Number.isSafeInteger(o.tip) || o.tip < 0 || target !== void 0 && !HASH.test(target) || !has(o.pay))
1288
+ return "invalid fill inputs: pass me (your 0x address), tip (the chain tip height) and pay together; fillTargetId, when given, is a 0x txid";
1289
+ return { ...base, me, tip: o.tip, fillTargetId: target };
1290
+ }
1291
+ function judgeFill(rec, proven, served, f) {
1292
+ const str = (v) => typeof v === "string" ? v : void 0;
1293
+ const status = served?.status;
1294
+ const terms = proven.terms;
1295
+ const state = {
1296
+ id: proven.id,
1297
+ seller: proven.seller,
1298
+ give: rec.give,
1299
+ want: { ...rec.want, payto: proven.payto },
1300
+ taker: rec.taker,
1301
+ bid: rec.bid,
1302
+ min: rec.min,
1303
+ status,
1304
+ expiresEpoch: proven.expiresEpoch,
1305
+ height: terms.height,
1306
+ feeBps: terms.feeBps,
1307
+ paid: str(served?.paid),
1308
+ delivered: str(served?.delivered),
1309
+ claimedBy: str(served?.claimedBy),
1310
+ claimUntilHeight: typeof served?.claimUntilHeight === "number" ? served.claimUntilHeight : void 0,
1311
+ claimTxid: str(served?.claimTxid)
1312
+ };
1313
+ const j = { planBound: false };
1314
+ const err = (e) => e?.message ?? String(e);
1315
+ const token = (0, import_cairnx_core2.isTokenWant)(rec.want);
1316
+ const progress = state.paid !== void 0 && state.delivered !== void 0;
1317
+ if (typeof status === "string" && status !== "open")
1318
+ j.refusal = `the resolver serves this offer as ${status}, not open - refusing (CairnX does not apply a fill of it)`;
1319
+ if (token && has(f.plannedOutputs)) {
1320
+ const legs = bindPlannedSums(f.plannedOutputs, []);
1321
+ if (legs) j.refusal ??= `the offer is token-priced, so a fill moves no CSD: ${legs}`;
1322
+ }
1323
+ if (!has(f.pay)) j.planReason = "no `pay` was given, so no output plan was sized";
1324
+ else if (j.refusal) j.planReason = "the fill is refused, so no output plan was sized";
1325
+ else if (token) j.planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
1326
+ else if (rec.min !== void 0) {
1327
+ j.planReason = "the offer is partially fillable: its running paid/delivered is not provable here (the wallet's fill-SPV sizes it)";
1328
+ if (typeof status === "string" && progress) {
1329
+ try {
1330
+ const p = (0, import_cairnx_core2.previewFill)(state, f.pay);
1331
+ if (!p.deliverable) j.refusal = `this pay would not deliver against the offer's served progress (${p.reason}) - refusing (the CSD would be lost)`;
1332
+ } catch (e) {
1333
+ j.refusal = `couldn't size the fill against the offer (${err(e)}) - refusing`;
1334
+ }
1335
+ }
1336
+ } else if (typeof status !== "string") j.planReason = "no served offer status: liveness is not provable here, so no output plan was sized (pass the resolver's offer object)";
1337
+ else {
1338
+ try {
1339
+ const plan = (0, import_cairnx_core2.fillOutputPlan)(state, f.pay);
1340
+ if (plan.kind === "csd-outputs") {
1341
+ j.outputPlan = plan.outputs;
1342
+ if (has(f.plannedOutputs)) {
1343
+ j.refusal = bindPlannedSums(f.plannedOutputs, plan.outputs);
1344
+ j.planBound = true;
1345
+ }
1346
+ } else if (plan.kind === "undeliverable") {
1347
+ j.refusal = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1348
+ } else {
1349
+ j.planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
1350
+ }
1351
+ } catch (e) {
1352
+ j.refusal = `couldn't size the fill against the proven offer (${err(e)}) - refusing rather than sizing from served fields`;
1353
+ }
1354
+ }
1355
+ if (f.me !== void 0 && f.tip !== void 0 && has(f.pay)) {
1356
+ const unplan = () => {
1357
+ delete j.outputPlan;
1358
+ j.planBound = false;
1359
+ j.planReason = "the fill is refused, so no output plan was sized";
1360
+ };
1361
+ if ((0, import_cairnx_core2.offerFillTooLate)(proven.expiresAt, f.tip)) {
1362
+ j.refusal ??= `the offer's last live block ${proven.expiresAt - 1} leaves less than the ${import_cairnx_core2.FILL_TIP_MARGIN}-block cushion above tip ${f.tip}, so the fill is too close to the offer's expiry - do not sign it`;
1363
+ unplan();
1364
+ }
1365
+ if (typeof status !== "string") j.refusal ??= "the fill endorsement needs the resolver's offer object (its status, claim and progress) - pass servedOffer";
1366
+ else if (rec.min !== void 0 && !progress) j.refusal ??= "the served offer is missing paid or delivered (a partial offer's progress) - pass the resolver's full offer object";
1367
+ else {
1368
+ try {
1369
+ const e = (0, import_cairnx_core2.fillEndorsement)(state, f.me, f.pay, f.tip, f.fillTargetId !== void 0 ? { fillTargetId: f.fillTargetId } : void 0);
1370
+ j.endorsement = { verdict: e.verdict, reason: e.reason };
1371
+ j.fillTarget = (0, import_cairnx_core2.fillTargetId)(state, f.tip);
1372
+ if (e.verdict === "refused") {
1373
+ j.refusal ??= `CairnX would refuse this fill (${e.reason}) - do not sign it`;
1374
+ unplan();
1375
+ }
1376
+ } catch (e) {
1377
+ j.refusal ??= `couldn't judge the fill against the offer (${err(e)}) - refusing`;
1378
+ unplan();
1379
+ }
1380
+ }
1381
+ }
1382
+ return j;
1383
+ }
1218
1384
  async function preverifyOffer(opts) {
1219
1385
  const id = String(opts.offerId).toLowerCase();
1220
1386
  if (!HASH.test(id)) return { ok: false, trust: "unverified", reason: "invalid offer id" };
1387
+ const fill = fillInputs(opts);
1388
+ if (typeof fill === "string") return { ok: false, trust: "unverified", reason: fill };
1221
1389
  let incl;
1222
1390
  try {
1223
1391
  incl = await opts.light.verifyTxInclusion(id);
@@ -1259,52 +1427,17 @@ async function preverifyOffer(opts) {
1259
1427
  const w = rec.want ?? {};
1260
1428
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
1261
1429
  const terms = (0, import_cairnx_core2.provenOfferTerms)(offerRec, blockHeight);
1262
- let outputPlan;
1263
- let sumsMismatch;
1264
- let planReason;
1265
- let planBound = false;
1266
- const has = (v) => v !== void 0 && v !== null;
1267
- const servedStatus = opts.servedOffer?.status;
1268
- if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
1269
- else if ((0, import_cairnx_core2.isTokenWant)(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
1270
- else if (offerRec.min !== void 0) planReason = "the offer is partially fillable: its running paid/delivered is not provable here (the wallet's fill-SPV sizes it)";
1271
- else if (typeof servedStatus !== "string") planReason = "no served offer status: liveness is not provable here, so no output plan was sized (pass the resolver's offer object)";
1272
- else {
1273
- const provenState = {
1274
- id,
1275
- seller,
1276
- give: offerRec.give,
1277
- want: { ...offerRec.want, payto },
1278
- taker: offerRec.taker,
1279
- bid: offerRec.bid,
1280
- status: servedStatus,
1281
- expiresEpoch: 0,
1282
- height: blockHeight,
1283
- feeBps: terms.feeBps
1284
- };
1285
- try {
1286
- const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1287
- if (plan.kind === "csd-outputs") {
1288
- outputPlan = plan.outputs;
1289
- if (has(opts.plannedOutputs)) {
1290
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1291
- planBound = true;
1292
- }
1293
- } else if (plan.kind === "undeliverable") {
1294
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1295
- } else {
1296
- planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
1297
- }
1298
- } catch (e) {
1299
- sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
1300
- }
1301
- }
1430
+ const expiresEpoch = Number(ee);
1431
+ const expiresAt = (0, import_cairnx_core2.offerExpiryHeightOf)(expiresEpoch, blockHeight);
1432
+ const j = judgeFill(offerRec, { id, seller, payto, terms, expiresEpoch, expiresAt }, opts.servedOffer, fill);
1302
1433
  const planFields = {
1303
- ...outputPlan ? { outputPlan } : {},
1304
- ...has(opts.plannedOutputs) ? { planBound } : {},
1305
- ...!outputPlan && planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason } : {}
1434
+ ...j.outputPlan ? { outputPlan: j.outputPlan } : {},
1435
+ ...has(opts.pay) ? { sized: j.outputPlan !== void 0 } : {},
1436
+ ...has(opts.plannedOutputs) ? { planBound: j.planBound } : {},
1437
+ ...!j.outputPlan && j.planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason: j.planReason } : {},
1438
+ ...j.endorsement ? { endorsement: j.endorsement, fillTarget: j.fillTarget } : {}
1306
1439
  };
1307
- const base = { payto, seller, terms, blockHeight, ...planFields };
1440
+ const base = { payto, seller, terms, blockHeight, expiresEpoch, expiresAt, ...planFields };
1308
1441
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1309
1442
  const so = opts.servedOffer;
1310
1443
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -1318,7 +1451,7 @@ async function preverifyOffer(opts) {
1318
1451
  if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms, { give: true, wantType: true }))
1319
1452
  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's type or token price - a lying resolver could mis-size, re-price or re-route the fill, redirecting or burning your payment)", ...base };
1320
1453
  }
1321
- if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
1454
+ if (j.refusal) return { ok: false, trust: "verified", reason: j.refusal, ...base };
1322
1455
  return { ok: true, trust: "verified", ...base };
1323
1456
  }
1324
1457
 
@@ -1443,6 +1576,20 @@ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
1443
1576
  var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
1444
1577
  var SPV_RECHECK_DEPTH = 12;
1445
1578
  var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
1579
+ var SPV_BEYOND_TIP = /^tx at \d+ is \d+ blocks beyond tip; sync\(\d+\) first$/;
1580
+ var SpvLightClient = class extends import_csd_light.LightClient {
1581
+ syncInFlight = null;
1582
+ async sync(to, from) {
1583
+ while (this.syncInFlight) await this.syncInFlight;
1584
+ const run = super.sync(to, from);
1585
+ this.syncInFlight = run;
1586
+ try {
1587
+ return await run;
1588
+ } finally {
1589
+ if (this.syncInFlight === run) this.syncInFlight = null;
1590
+ }
1591
+ }
1592
+ };
1446
1593
  var Cairn = class {
1447
1594
  config;
1448
1595
  chain;
@@ -1458,6 +1605,9 @@ var Cairn = class {
1458
1605
  // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
1459
1606
  // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
1460
1607
  _spvSeedInflight = null;
1608
+ // The in-flight reseed of withSpvReseed, shared by callers whose structural failures overlap. Cleared when it
1609
+ // settles; each caller still commits the fresh client only after its own retry succeeds.
1610
+ _spvReseedInflight = null;
1461
1611
  _spvCp;
1462
1612
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
1463
1613
  _cairnBase;
@@ -1513,14 +1663,18 @@ var Cairn = class {
1513
1663
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
1514
1664
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
1515
1665
  // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
1516
- // cached header that the served chain no longer carries is a structural failure.
1666
+ // cached header that the served chain no longer carries is a structural failure. Callers whose failures overlap
1667
+ // (waiters on one shared sync) share one reseed.
1517
1668
  async withSpvReseed(op) {
1518
1669
  const lc = await this.seededSpvLight();
1519
1670
  try {
1520
1671
  return await op(lc);
1521
1672
  } catch (e) {
1522
1673
  if (spvIsTransient(e)) throw e;
1523
- const fresh = await this.seedFreshSpvLight();
1674
+ this._spvReseedInflight ??= this.seedFreshSpvLight().finally(() => {
1675
+ this._spvReseedInflight = null;
1676
+ });
1677
+ const fresh = await this._spvReseedInflight;
1524
1678
  const out = await op(fresh);
1525
1679
  this._spvLight = fresh;
1526
1680
  return out;
@@ -1540,7 +1694,7 @@ var Cairn = class {
1540
1694
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
1541
1695
  // dirty the shared field while an attempt is in flight).
1542
1696
  async seedFreshSpvLight() {
1543
- const lc = new import_csd_light.LightClient({
1697
+ const lc = new SpvLightClient({
1544
1698
  client: this.chain.client,
1545
1699
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
1546
1700
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
@@ -1582,11 +1736,7 @@ var Cairn = class {
1582
1736
  * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
1583
1737
  * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
1584
1738
  *
1585
- * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
1586
- * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
1587
- * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
1588
- * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
1589
- * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
1739
+ * Cross-origin reach depends on the server's CORS mount on `/api/headers` (the cairn server mounts one).
1590
1740
  */
1591
1741
  async fetchHeadersBatch(from, count) {
1592
1742
  const g = this.config.fetch ?? globalThis.fetch;
@@ -1634,7 +1784,8 @@ var Cairn = class {
1634
1784
  * commitment, derives the payment recipient + seller from the offer's on-chain AUTHOR (the funding input's
1635
1785
  * prevout owner, txid-committed, NOT the malleable scriptSig), and, if you pass the resolver-SERVED `offer`
1636
1786
  * object, binds its payto/seller + fee/rebate/partial terms to the proven ones. Returns a TRUST-LABELED result
1637
- * (fail-closed on a positive mismatch, fail-soft `transient` on a lagging / below-checkpoint chain view).
1787
+ * (fail-closed on a positive mismatch; `transient` on a lagging chain view; final, naming the anchor, for an
1788
+ * offer mined below the SPV checkpoint).
1638
1789
  *
1639
1790
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
1640
1791
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
@@ -1642,19 +1793,44 @@ var Cairn = class {
1642
1793
  *
1643
1794
  * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
1644
1795
  * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
1645
- * EXACTLY the outputPlan, never resolver-served outputs.
1796
+ * EXACTLY the outputPlan, never resolver-served outputs. Pass opts.me (your address) and opts.tip (the chain tip
1797
+ * height) with pay to have cairnx-core's fillEndorsement and offerFillTooLate judge the fill for you at that tip; without them `ok`
1798
+ * says the offer record is proven, not that you can fill it now.
1646
1799
  */
1647
1800
  async verifyOfferForFill(offerId, servedOffer, opts) {
1648
1801
  const light = {
1649
1802
  verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
1650
1803
  const haveTo = spvTipOf(lc);
1651
- const r = await lc.verifyTxInclusion(txidHex);
1652
- if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
1653
- await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
1654
- return r;
1804
+ let r = await lc.verifyTxInclusion(txidHex);
1805
+ if (r.trustLevel === "rpc-trusted" && SPV_BEYOND_TIP.test(r.reason ?? "")) {
1806
+ let tipHeight;
1807
+ try {
1808
+ tipHeight = Number((await this.chain.tip()).height);
1809
+ } catch (e) {
1810
+ return { ...r, reason: `${r.reason} (the node tip read failed: ${e?.message ?? e})` };
1811
+ }
1812
+ if (Number.isSafeInteger(tipHeight) && tipHeight > spvTipOf(lc)) await lc.sync(tipHeight);
1813
+ r = await lc.verifyTxInclusion(txidHex);
1814
+ }
1815
+ if (r.trustLevel !== "verified-inclusion") return r;
1816
+ const height = Number(r.blockHeight);
1817
+ if (!Number.isSafeInteger(height))
1818
+ return { trustLevel: "rpc-trusted", included: false, reason: `the node's containing height for this tx is not a block height (${String(r.blockHeight)})` };
1819
+ if (height <= haveTo) await this.recheckCachedHeader(lc, height, haveTo);
1820
+ return { ...r, blockHeight: height };
1655
1821
  })
1656
1822
  };
1657
- return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
1823
+ return preverifyOffer({
1824
+ light,
1825
+ client: this.chain.client,
1826
+ offerId,
1827
+ servedOffer,
1828
+ pay: opts?.pay,
1829
+ plannedOutputs: opts?.plannedOutputs,
1830
+ me: opts?.me,
1831
+ tip: opts?.tip,
1832
+ fillTargetId: opts?.fillTargetId
1833
+ });
1658
1834
  }
1659
1835
  /**
1660
1836
  * Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.