@inversealtruism/cairn-sdk 0.8.0 → 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.0";
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 = {}) {
@@ -1172,6 +1235,7 @@ async function connect(opts = {}) {
1172
1235
  // src/fillverify.ts
1173
1236
  var import_csd_client3 = require("@inversealtruism/csd-client");
1174
1237
  var import_csd_codec4 = require("@inversealtruism/csd-codec");
1238
+ var import_csd_crypto3 = require("@inversealtruism/csd-crypto");
1175
1239
  var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
1176
1240
  var ADDR = /^0x[0-9a-f]{40}$/;
1177
1241
  var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
@@ -1212,13 +1276,116 @@ async function provenAuthor(client, provenTx) {
1212
1276
  return null;
1213
1277
  }
1214
1278
  if (String((0, import_csd_codec4.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
1215
- const out = src.outputs?.[Number(in0.vout)];
1216
- const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
1217
- return spk && ADDR.test(spk) ? spk : null;
1279
+ return (0, import_csd_crypto3.authorOf)(provenTx, src.outputs?.[Number(in0.vout)]?.scriptPubkey);
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;
1218
1383
  }
1219
1384
  async function preverifyOffer(opts) {
1220
1385
  const id = String(opts.offerId).toLowerCase();
1221
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 };
1222
1389
  let incl;
1223
1390
  try {
1224
1391
  incl = await opts.light.verifyTxInclusion(id);
@@ -1242,8 +1409,6 @@ async function preverifyOffer(opts) {
1242
1409
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
1243
1410
  const app = incl.tx.app;
1244
1411
  if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
1245
- if (String(app.domain) !== import_cairnx_core2.DOMAIN)
1246
- return { ok: false, trust: "unverified", reason: `the offer tx is not a ${import_cairnx_core2.DOMAIN} Propose (wrong settlement domain)` };
1247
1412
  let rec;
1248
1413
  try {
1249
1414
  rec = (0, import_cairnx_core2.parseRecord)(app.uri, incl.appPayloadHash);
@@ -1256,60 +1421,23 @@ async function preverifyOffer(opts) {
1256
1421
  const seller = await provenAuthor(opts.client, provenTx);
1257
1422
  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" };
1258
1423
  const offerRec = rec;
1424
+ const ee = app.expires_epoch;
1425
+ const refusal = (0, import_cairnx_core2.provenOfferRefusal)(offerRec, { domain: String(app.domain), height: blockHeight, expiresEpoch: typeof ee === "bigint" ? ee : Number(ee), seller });
1426
+ if (refusal) return { ok: false, trust: "verified", blockHeight, reason: `CairnX rejects this offer (${refusal}) - a fill would move CSD for nothing` };
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
- if (payto === import_cairnx_core2.TREASURY_ADDR)
1263
- return { ok: false, trust: "verified", blockHeight, reason: "the offer's payment recipient is the protocol treasury - the chain rejects it, and a fill would move CSD for nothing" };
1264
- const provenExpiresEpoch = Number(app.expires_epoch);
1265
- if (!Number.isSafeInteger(provenExpiresEpoch) || (0, import_cairnx_core2.epochOf)(blockHeight) > provenExpiresEpoch)
1266
- return { ok: false, trust: "verified", blockHeight, reason: "the offer was already expired when it was mined - the chain rejects it, and a fill would move CSD for nothing" };
1267
- let outputPlan;
1268
- let sumsMismatch;
1269
- let planReason;
1270
- let planBound = false;
1271
- const has = (v) => v !== void 0 && v !== null;
1272
- const servedStatus = opts.servedOffer?.status;
1273
- if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
1274
- else if ((0, import_cairnx_core2.isTokenWant)(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
1275
- 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)";
1276
- 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)";
1277
- else {
1278
- const provenState = {
1279
- id,
1280
- seller,
1281
- give: offerRec.give,
1282
- want: { ...offerRec.want, payto },
1283
- taker: offerRec.taker,
1284
- bid: offerRec.bid,
1285
- status: servedStatus,
1286
- expiresEpoch: 0,
1287
- height: blockHeight,
1288
- feeBps: terms.feeBps
1289
- };
1290
- try {
1291
- const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1292
- if (plan.kind === "csd-outputs") {
1293
- outputPlan = plan.outputs;
1294
- if (has(opts.plannedOutputs)) {
1295
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1296
- planBound = true;
1297
- }
1298
- } else if (plan.kind === "undeliverable") {
1299
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1300
- } else {
1301
- planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
1302
- }
1303
- } catch (e) {
1304
- sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
1305
- }
1306
- }
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);
1307
1433
  const planFields = {
1308
- ...outputPlan ? { outputPlan } : {},
1309
- ...has(opts.plannedOutputs) ? { planBound } : {},
1310
- ...!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 } : {}
1311
1439
  };
1312
- const base = { payto, seller, terms, blockHeight, ...planFields };
1440
+ const base = { payto, seller, terms, blockHeight, expiresEpoch, expiresAt, ...planFields };
1313
1441
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1314
1442
  const so = opts.servedOffer;
1315
1443
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -1320,12 +1448,10 @@ async function preverifyOffer(opts) {
1320
1448
  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 };
1321
1449
  if (String(so.seller).toLowerCase() !== seller)
1322
1450
  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 };
1323
- if ((0, import_cairnx_core2.isTokenWant)(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
1324
- 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 };
1325
1451
  if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms, { give: true, wantType: true }))
1326
- 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 };
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 };
1327
1453
  }
1328
- if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
1454
+ if (j.refusal) return { ok: false, trust: "verified", reason: j.refusal, ...base };
1329
1455
  return { ok: true, trust: "verified", ...base };
1330
1456
  }
1331
1457
 
@@ -1450,6 +1576,20 @@ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
1450
1576
  var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
1451
1577
  var SPV_RECHECK_DEPTH = 12;
1452
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
+ };
1453
1593
  var Cairn = class {
1454
1594
  config;
1455
1595
  chain;
@@ -1465,6 +1605,9 @@ var Cairn = class {
1465
1605
  // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
1466
1606
  // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
1467
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;
1468
1611
  _spvCp;
1469
1612
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
1470
1613
  _cairnBase;
@@ -1520,14 +1663,18 @@ var Cairn = class {
1520
1663
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
1521
1664
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
1522
1665
  // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
1523
- // 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.
1524
1668
  async withSpvReseed(op) {
1525
1669
  const lc = await this.seededSpvLight();
1526
1670
  try {
1527
1671
  return await op(lc);
1528
1672
  } catch (e) {
1529
1673
  if (spvIsTransient(e)) throw e;
1530
- const fresh = await this.seedFreshSpvLight();
1674
+ this._spvReseedInflight ??= this.seedFreshSpvLight().finally(() => {
1675
+ this._spvReseedInflight = null;
1676
+ });
1677
+ const fresh = await this._spvReseedInflight;
1531
1678
  const out = await op(fresh);
1532
1679
  this._spvLight = fresh;
1533
1680
  return out;
@@ -1547,7 +1694,7 @@ var Cairn = class {
1547
1694
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
1548
1695
  // dirty the shared field while an attempt is in flight).
1549
1696
  async seedFreshSpvLight() {
1550
- const lc = new import_csd_light.LightClient({
1697
+ const lc = new SpvLightClient({
1551
1698
  client: this.chain.client,
1552
1699
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
1553
1700
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
@@ -1589,11 +1736,7 @@ var Cairn = class {
1589
1736
  * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
1590
1737
  * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
1591
1738
  *
1592
- * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
1593
- * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
1594
- * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
1595
- * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
1596
- * 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).
1597
1740
  */
1598
1741
  async fetchHeadersBatch(from, count) {
1599
1742
  const g = this.config.fetch ?? globalThis.fetch;
@@ -1641,7 +1784,8 @@ var Cairn = class {
1641
1784
  * commitment, derives the payment recipient + seller from the offer's on-chain AUTHOR (the funding input's
1642
1785
  * prevout owner, txid-committed, NOT the malleable scriptSig), and, if you pass the resolver-SERVED `offer`
1643
1786
  * object, binds its payto/seller + fee/rebate/partial terms to the proven ones. Returns a TRUST-LABELED result
1644
- * (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).
1645
1789
  *
1646
1790
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
1647
1791
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
@@ -1649,19 +1793,44 @@ var Cairn = class {
1649
1793
  *
1650
1794
  * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
1651
1795
  * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
1652
- * 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.
1653
1799
  */
1654
1800
  async verifyOfferForFill(offerId, servedOffer, opts) {
1655
1801
  const light = {
1656
1802
  verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
1657
1803
  const haveTo = spvTipOf(lc);
1658
- const r = await lc.verifyTxInclusion(txidHex);
1659
- if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
1660
- await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
1661
- 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 };
1662
1821
  })
1663
1822
  };
1664
- 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
+ });
1665
1834
  }
1666
1835
  /**
1667
1836
  * Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.