@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.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  Chain,
3
3
  LightClient
4
- } from "./chunk-C452UODV.js";
4
+ } from "./chunk-IPBMMJ3Z.js";
5
5
  import {
6
6
  ContentClient
7
- } from "./chunk-DTPADO7N.js";
7
+ } from "./chunk-2RR4ZJPQ.js";
8
8
  import {
9
9
  IndexerClient
10
- } from "./chunk-CI7YODPP.js";
10
+ } from "./chunk-NRCTL7OI.js";
11
11
  import {
12
12
  CairnController
13
- } from "./chunk-JXBO4LV6.js";
13
+ } from "./chunk-AHPXOYCC.js";
14
14
  import {
15
15
  WalletConnection,
16
16
  connect,
@@ -18,7 +18,7 @@ import {
18
18
  discoverProviders,
19
19
  getWallet,
20
20
  isInstalled
21
- } from "./chunk-WWO3FXHG.js";
21
+ } from "./chunk-EVEBQSDK.js";
22
22
  import {
23
23
  CairnError,
24
24
  ContentVerificationError,
@@ -32,7 +32,7 @@ import {
32
32
  errorCode,
33
33
  mapProviderError,
34
34
  mapSubmitResultError
35
- } from "./chunk-UAPWZZQK.js";
35
+ } from "./chunk-37OHCH7R.js";
36
36
 
37
37
  // src/http.ts
38
38
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
@@ -358,20 +358,23 @@ var NamesClient = class {
358
358
  // src/fillverify.ts
359
359
  import { rpcTxToTx } from "@inversealtruism/csd-client";
360
360
  import { txid, payloadHash } from "@inversealtruism/csd-codec";
361
+ import { authorOf } from "@inversealtruism/csd-crypto";
361
362
  import {
362
- DOMAIN,
363
363
  parseRecord,
364
364
  feeBpsAt,
365
365
  bindOfferTerms,
366
366
  provenOfferTerms,
367
+ provenOfferRefusal,
367
368
  fillOutputPlan,
368
369
  isTokenWant,
369
370
  fillEndorsement,
370
371
  fillIsSafe,
371
372
  requiredFillOutputs,
372
373
  previewFill,
373
- TREASURY_ADDR,
374
- epochOf
374
+ offerExpiryHeightOf,
375
+ offerFillTooLate,
376
+ FILL_TIP_MARGIN,
377
+ fillTargetId as fillTargetOf
375
378
  } from "@inversealtruism/cairnx-core";
376
379
  var ADDR = /^0x[0-9a-f]{40}$/;
377
380
  var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
@@ -412,13 +415,116 @@ async function provenAuthor(client, provenTx) {
412
415
  return null;
413
416
  }
414
417
  if (String(txid(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
415
- const out = src.outputs?.[Number(in0.vout)];
416
- const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
417
- return spk && ADDR.test(spk) ? spk : null;
418
+ return authorOf(provenTx, src.outputs?.[Number(in0.vout)]?.scriptPubkey);
419
+ }
420
+ var has = (v) => v !== void 0 && v !== null;
421
+ function fillInputs(o) {
422
+ const base = { pay: o.pay, plannedOutputs: o.plannedOutputs };
423
+ if (!has(o.me) && !has(o.tip) && !has(o.fillTargetId)) return base;
424
+ const me = typeof o.me === "string" ? o.me.toLowerCase() : "";
425
+ const target = has(o.fillTargetId) ? String(o.fillTargetId).toLowerCase() : void 0;
426
+ if (!ADDR.test(me) || !Number.isSafeInteger(o.tip) || o.tip < 0 || target !== void 0 && !HASH.test(target) || !has(o.pay))
427
+ return "invalid fill inputs: pass me (your 0x address), tip (the chain tip height) and pay together; fillTargetId, when given, is a 0x txid";
428
+ return { ...base, me, tip: o.tip, fillTargetId: target };
429
+ }
430
+ function judgeFill(rec, proven, served, f) {
431
+ const str = (v) => typeof v === "string" ? v : void 0;
432
+ const status = served?.status;
433
+ const terms = proven.terms;
434
+ const state = {
435
+ id: proven.id,
436
+ seller: proven.seller,
437
+ give: rec.give,
438
+ want: { ...rec.want, payto: proven.payto },
439
+ taker: rec.taker,
440
+ bid: rec.bid,
441
+ min: rec.min,
442
+ status,
443
+ expiresEpoch: proven.expiresEpoch,
444
+ height: terms.height,
445
+ feeBps: terms.feeBps,
446
+ paid: str(served?.paid),
447
+ delivered: str(served?.delivered),
448
+ claimedBy: str(served?.claimedBy),
449
+ claimUntilHeight: typeof served?.claimUntilHeight === "number" ? served.claimUntilHeight : void 0,
450
+ claimTxid: str(served?.claimTxid)
451
+ };
452
+ const j = { planBound: false };
453
+ const err = (e) => e?.message ?? String(e);
454
+ const token = isTokenWant(rec.want);
455
+ const progress = state.paid !== void 0 && state.delivered !== void 0;
456
+ if (typeof status === "string" && status !== "open")
457
+ j.refusal = `the resolver serves this offer as ${status}, not open - refusing (CairnX does not apply a fill of it)`;
458
+ if (token && has(f.plannedOutputs)) {
459
+ const legs = bindPlannedSums(f.plannedOutputs, []);
460
+ if (legs) j.refusal ??= `the offer is token-priced, so a fill moves no CSD: ${legs}`;
461
+ }
462
+ if (!has(f.pay)) j.planReason = "no `pay` was given, so no output plan was sized";
463
+ else if (j.refusal) j.planReason = "the fill is refused, so no output plan was sized";
464
+ else if (token) j.planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
465
+ else if (rec.min !== void 0) {
466
+ j.planReason = "the offer is partially fillable: its running paid/delivered is not provable here (the wallet's fill-SPV sizes it)";
467
+ if (typeof status === "string" && progress) {
468
+ try {
469
+ const p = previewFill(state, f.pay);
470
+ if (!p.deliverable) j.refusal = `this pay would not deliver against the offer's served progress (${p.reason}) - refusing (the CSD would be lost)`;
471
+ } catch (e) {
472
+ j.refusal = `couldn't size the fill against the offer (${err(e)}) - refusing`;
473
+ }
474
+ }
475
+ } 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)";
476
+ else {
477
+ try {
478
+ const plan = fillOutputPlan(state, f.pay);
479
+ if (plan.kind === "csd-outputs") {
480
+ j.outputPlan = plan.outputs;
481
+ if (has(f.plannedOutputs)) {
482
+ j.refusal = bindPlannedSums(f.plannedOutputs, plan.outputs);
483
+ j.planBound = true;
484
+ }
485
+ } else if (plan.kind === "undeliverable") {
486
+ j.refusal = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
487
+ } else {
488
+ j.planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
489
+ }
490
+ } catch (e) {
491
+ j.refusal = `couldn't size the fill against the proven offer (${err(e)}) - refusing rather than sizing from served fields`;
492
+ }
493
+ }
494
+ if (f.me !== void 0 && f.tip !== void 0 && has(f.pay)) {
495
+ const unplan = () => {
496
+ delete j.outputPlan;
497
+ j.planBound = false;
498
+ j.planReason = "the fill is refused, so no output plan was sized";
499
+ };
500
+ if (offerFillTooLate(proven.expiresAt, f.tip)) {
501
+ j.refusal ??= `the offer's last live block ${proven.expiresAt - 1} leaves less than the ${FILL_TIP_MARGIN}-block cushion above tip ${f.tip}, so the fill is too close to the offer's expiry - do not sign it`;
502
+ unplan();
503
+ }
504
+ if (typeof status !== "string") j.refusal ??= "the fill endorsement needs the resolver's offer object (its status, claim and progress) - pass servedOffer";
505
+ 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";
506
+ else {
507
+ try {
508
+ const e = fillEndorsement(state, f.me, f.pay, f.tip, f.fillTargetId !== void 0 ? { fillTargetId: f.fillTargetId } : void 0);
509
+ j.endorsement = { verdict: e.verdict, reason: e.reason };
510
+ j.fillTarget = fillTargetOf(state, f.tip);
511
+ if (e.verdict === "refused") {
512
+ j.refusal ??= `CairnX would refuse this fill (${e.reason}) - do not sign it`;
513
+ unplan();
514
+ }
515
+ } catch (e) {
516
+ j.refusal ??= `couldn't judge the fill against the offer (${err(e)}) - refusing`;
517
+ unplan();
518
+ }
519
+ }
520
+ }
521
+ return j;
418
522
  }
419
523
  async function preverifyOffer(opts) {
420
524
  const id = String(opts.offerId).toLowerCase();
421
525
  if (!HASH.test(id)) return { ok: false, trust: "unverified", reason: "invalid offer id" };
526
+ const fill = fillInputs(opts);
527
+ if (typeof fill === "string") return { ok: false, trust: "unverified", reason: fill };
422
528
  let incl;
423
529
  try {
424
530
  incl = await opts.light.verifyTxInclusion(id);
@@ -442,8 +548,6 @@ async function preverifyOffer(opts) {
442
548
  if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
443
549
  const app = incl.tx.app;
444
550
  if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
445
- if (String(app.domain) !== DOMAIN)
446
- return { ok: false, trust: "unverified", reason: `the offer tx is not a ${DOMAIN} Propose (wrong settlement domain)` };
447
551
  let rec;
448
552
  try {
449
553
  rec = parseRecord(app.uri, incl.appPayloadHash);
@@ -456,60 +560,23 @@ async function preverifyOffer(opts) {
456
560
  const seller = await provenAuthor(opts.client, provenTx);
457
561
  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" };
458
562
  const offerRec = rec;
563
+ const ee = app.expires_epoch;
564
+ const refusal = provenOfferRefusal(offerRec, { domain: String(app.domain), height: blockHeight, expiresEpoch: typeof ee === "bigint" ? ee : Number(ee), seller });
565
+ if (refusal) return { ok: false, trust: "verified", blockHeight, reason: `CairnX rejects this offer (${refusal}) - a fill would move CSD for nothing` };
459
566
  const w = rec.want ?? {};
460
567
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
461
568
  const terms = provenOfferTerms(offerRec, blockHeight);
462
- if (payto === TREASURY_ADDR)
463
- 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" };
464
- const provenExpiresEpoch = Number(app.expires_epoch);
465
- if (!Number.isSafeInteger(provenExpiresEpoch) || epochOf(blockHeight) > provenExpiresEpoch)
466
- 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" };
467
- let outputPlan;
468
- let sumsMismatch;
469
- let planReason;
470
- let planBound = false;
471
- const has = (v) => v !== void 0 && v !== null;
472
- const servedStatus = opts.servedOffer?.status;
473
- if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
474
- else if (isTokenWant(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
475
- 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)";
476
- 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)";
477
- else {
478
- const provenState = {
479
- id,
480
- seller,
481
- give: offerRec.give,
482
- want: { ...offerRec.want, payto },
483
- taker: offerRec.taker,
484
- bid: offerRec.bid,
485
- status: servedStatus,
486
- expiresEpoch: 0,
487
- height: blockHeight,
488
- feeBps: terms.feeBps
489
- };
490
- try {
491
- const plan = fillOutputPlan(provenState, opts.pay);
492
- if (plan.kind === "csd-outputs") {
493
- outputPlan = plan.outputs;
494
- if (has(opts.plannedOutputs)) {
495
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
496
- planBound = true;
497
- }
498
- } else if (plan.kind === "undeliverable") {
499
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
500
- } else {
501
- planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
502
- }
503
- } catch (e) {
504
- sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
505
- }
506
- }
569
+ const expiresEpoch = Number(ee);
570
+ const expiresAt = offerExpiryHeightOf(expiresEpoch, blockHeight);
571
+ const j = judgeFill(offerRec, { id, seller, payto, terms, expiresEpoch, expiresAt }, opts.servedOffer, fill);
507
572
  const planFields = {
508
- ...outputPlan ? { outputPlan } : {},
509
- ...has(opts.plannedOutputs) ? { planBound } : {},
510
- ...!outputPlan && planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason } : {}
573
+ ...j.outputPlan ? { outputPlan: j.outputPlan } : {},
574
+ ...has(opts.pay) ? { sized: j.outputPlan !== void 0 } : {},
575
+ ...has(opts.plannedOutputs) ? { planBound: j.planBound } : {},
576
+ ...!j.outputPlan && j.planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason: j.planReason } : {},
577
+ ...j.endorsement ? { endorsement: j.endorsement, fillTarget: j.fillTarget } : {}
511
578
  };
512
- const base = { payto, seller, terms, blockHeight, ...planFields };
579
+ const base = { payto, seller, terms, blockHeight, expiresEpoch, expiresAt, ...planFields };
513
580
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
514
581
  const so = opts.servedOffer;
515
582
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -520,12 +587,10 @@ async function preverifyOffer(opts) {
520
587
  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 };
521
588
  if (String(so.seller).toLowerCase() !== seller)
522
589
  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 };
523
- if (isTokenWant(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
524
- 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 };
525
590
  if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
526
- 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 };
591
+ 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 };
527
592
  }
528
- if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
593
+ if (j.refusal) return { ok: false, trust: "verified", reason: j.refusal, ...base };
529
594
  return { ok: true, trust: "verified", ...base };
530
595
  }
531
596
 
@@ -546,6 +611,20 @@ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
546
611
  var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
547
612
  var SPV_RECHECK_DEPTH = 12;
548
613
  var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
614
+ var SPV_BEYOND_TIP = /^tx at \d+ is \d+ blocks beyond tip; sync\(\d+\) first$/;
615
+ var SpvLightClient = class extends LightClient {
616
+ syncInFlight = null;
617
+ async sync(to, from) {
618
+ while (this.syncInFlight) await this.syncInFlight;
619
+ const run = super.sync(to, from);
620
+ this.syncInFlight = run;
621
+ try {
622
+ return await run;
623
+ } finally {
624
+ if (this.syncInFlight === run) this.syncInFlight = null;
625
+ }
626
+ }
627
+ };
549
628
  var Cairn = class {
550
629
  config;
551
630
  chain;
@@ -561,6 +640,9 @@ var Cairn = class {
561
640
  // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
562
641
  // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
563
642
  _spvSeedInflight = null;
643
+ // The in-flight reseed of withSpvReseed, shared by callers whose structural failures overlap. Cleared when it
644
+ // settles; each caller still commits the fresh client only after its own retry succeeds.
645
+ _spvReseedInflight = null;
564
646
  _spvCp;
565
647
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
566
648
  _cairnBase;
@@ -616,14 +698,18 @@ var Cairn = class {
616
698
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
617
699
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
618
700
  // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
619
- // cached header that the served chain no longer carries is a structural failure.
701
+ // cached header that the served chain no longer carries is a structural failure. Callers whose failures overlap
702
+ // (waiters on one shared sync) share one reseed.
620
703
  async withSpvReseed(op) {
621
704
  const lc = await this.seededSpvLight();
622
705
  try {
623
706
  return await op(lc);
624
707
  } catch (e) {
625
708
  if (spvIsTransient(e)) throw e;
626
- const fresh = await this.seedFreshSpvLight();
709
+ this._spvReseedInflight ??= this.seedFreshSpvLight().finally(() => {
710
+ this._spvReseedInflight = null;
711
+ });
712
+ const fresh = await this._spvReseedInflight;
627
713
  const out = await op(fresh);
628
714
  this._spvLight = fresh;
629
715
  return out;
@@ -643,7 +729,7 @@ var Cairn = class {
643
729
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
644
730
  // dirty the shared field while an attempt is in flight).
645
731
  async seedFreshSpvLight() {
646
- const lc = new LightClient({
732
+ const lc = new SpvLightClient({
647
733
  client: this.chain.client,
648
734
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
649
735
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
@@ -685,11 +771,7 @@ var Cairn = class {
685
771
  * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
686
772
  * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
687
773
  *
688
- * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
689
- * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
690
- * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
691
- * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
692
- * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
774
+ * Cross-origin reach depends on the server's CORS mount on `/api/headers` (the cairn server mounts one).
693
775
  */
694
776
  async fetchHeadersBatch(from, count) {
695
777
  const g = this.config.fetch ?? globalThis.fetch;
@@ -737,7 +819,8 @@ var Cairn = class {
737
819
  * commitment, derives the payment recipient + seller from the offer's on-chain AUTHOR (the funding input's
738
820
  * prevout owner, txid-committed, NOT the malleable scriptSig), and, if you pass the resolver-SERVED `offer`
739
821
  * object, binds its payto/seller + fee/rebate/partial terms to the proven ones. Returns a TRUST-LABELED result
740
- * (fail-closed on a positive mismatch, fail-soft `transient` on a lagging / below-checkpoint chain view).
822
+ * (fail-closed on a positive mismatch; `transient` on a lagging chain view; final, naming the anchor, for an
823
+ * offer mined below the SPV checkpoint).
741
824
  *
742
825
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
743
826
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
@@ -745,19 +828,44 @@ var Cairn = class {
745
828
  *
746
829
  * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
747
830
  * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
748
- * EXACTLY the outputPlan, never resolver-served outputs.
831
+ * EXACTLY the outputPlan, never resolver-served outputs. Pass opts.me (your address) and opts.tip (the chain tip
832
+ * height) with pay to have cairnx-core's fillEndorsement and offerFillTooLate judge the fill for you at that tip; without them `ok`
833
+ * says the offer record is proven, not that you can fill it now.
749
834
  */
750
835
  async verifyOfferForFill(offerId, servedOffer, opts) {
751
836
  const light = {
752
837
  verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
753
838
  const haveTo = spvTipOf(lc);
754
- const r = await lc.verifyTxInclusion(txidHex);
755
- if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
756
- await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
757
- return r;
839
+ let r = await lc.verifyTxInclusion(txidHex);
840
+ if (r.trustLevel === "rpc-trusted" && SPV_BEYOND_TIP.test(r.reason ?? "")) {
841
+ let tipHeight;
842
+ try {
843
+ tipHeight = Number((await this.chain.tip()).height);
844
+ } catch (e) {
845
+ return { ...r, reason: `${r.reason} (the node tip read failed: ${e?.message ?? e})` };
846
+ }
847
+ if (Number.isSafeInteger(tipHeight) && tipHeight > spvTipOf(lc)) await lc.sync(tipHeight);
848
+ r = await lc.verifyTxInclusion(txidHex);
849
+ }
850
+ if (r.trustLevel !== "verified-inclusion") return r;
851
+ const height = Number(r.blockHeight);
852
+ if (!Number.isSafeInteger(height))
853
+ return { trustLevel: "rpc-trusted", included: false, reason: `the node's containing height for this tx is not a block height (${String(r.blockHeight)})` };
854
+ if (height <= haveTo) await this.recheckCachedHeader(lc, height, haveTo);
855
+ return { ...r, blockHeight: height };
758
856
  })
759
857
  };
760
- return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
858
+ return preverifyOffer({
859
+ light,
860
+ client: this.chain.client,
861
+ offerId,
862
+ servedOffer,
863
+ pay: opts?.pay,
864
+ plannedOutputs: opts?.plannedOutputs,
865
+ me: opts?.me,
866
+ tip: opts?.tip,
867
+ fillTargetId: opts?.fillTargetId
868
+ });
761
869
  }
762
870
  /**
763
871
  * Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
package/dist/indexer.cjs CHANGED
@@ -24,8 +24,81 @@ __export(indexer_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(indexer_exports);
26
26
  var import_csd_codec = require("@inversealtruism/csd-codec");
27
+
28
+ // src/errors.ts
29
+ var SDK_VERSION = "0.8.2";
30
+ var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
31
+ var CairnError = class extends Error {
32
+ /** Stable machine code for programmatic branching. */
33
+ code;
34
+ /** The human message without the appended docs/version footer. */
35
+ shortMessage;
36
+ /** Relative docs path (under the SDK docs base), if any. */
37
+ docsPath;
38
+ /** The cairn-sdk version that produced this error. */
39
+ version = SDK_VERSION;
40
+ /**
41
+ * Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
42
+ * declines (the default); `true` only for provably transient infra faults where nothing was signed.
43
+ * A `SubmitInFlightError` is `false` too: reconcile its `txid`, never blind-retry.
44
+ */
45
+ retryable;
46
+ constructor(shortMessage, opts = {}) {
47
+ const docs = opts.docsPath;
48
+ super(
49
+ [shortMessage, docs ? `Docs: ${DOCS_BASE}${docs}` : "", `Version: cairn-sdk@${SDK_VERSION}`].filter(Boolean).join("\n"),
50
+ { cause: opts.cause }
51
+ );
52
+ this.name = new.target.name;
53
+ this.code = opts.code ?? "UNKNOWN";
54
+ this.shortMessage = shortMessage;
55
+ this.docsPath = docs;
56
+ this.retryable = opts.retryable ?? false;
57
+ }
58
+ /**
59
+ * Walk the `cause` chain. With a predicate, returns the FIRST matching error (or null). Without one,
60
+ * returns the DEEPEST cause (the root). Mirrors viem's `BaseError.walk()`.
61
+ */
62
+ walk(fn) {
63
+ let current = this;
64
+ let last = this;
65
+ while (current != null) {
66
+ if (fn && fn(current)) return current;
67
+ last = current;
68
+ current = current?.cause;
69
+ }
70
+ return fn ? null : last;
71
+ }
72
+ };
73
+ var HttpError = class extends CairnError {
74
+ status;
75
+ url;
76
+ body;
77
+ constructor(status, url, body) {
78
+ super(`HTTP ${status} for ${url}${body ? `: ${String(body).slice(0, 200)}` : ""}`, { code: "HTTP_ERROR" });
79
+ this.status = status;
80
+ this.url = url;
81
+ this.body = body;
82
+ }
83
+ };
84
+
85
+ // src/indexer.ts
27
86
  var HEX64 = /^0x[0-9a-fA-F]{64}$/;
28
87
  var MAX_SSE_BUF = 1024 * 1024;
88
+ var UTXO_PAGE = 1e3;
89
+ var UTXO_MAX_PAGES = 100;
90
+ var UTXO_CURSOR = /^\d{1,16}:0x[0-9a-fA-F]{64}:\d{1,10}$/;
91
+ function utxoCursor(row) {
92
+ const r = row;
93
+ const c = `${String(r?.status?.block_height)}:${String(r?.txid)}:${String(r?.vout)}`;
94
+ return UTXO_CURSOR.test(c) ? c : null;
95
+ }
96
+ function cursorAfter(a, b) {
97
+ const [ha, ta, va] = a.split(":"), [hb, tb, vb] = b.split(":");
98
+ if (Number(ha) !== Number(hb)) return Number(ha) > Number(hb);
99
+ if (ta.toLowerCase() !== tb.toLowerCase()) return ta.toLowerCase() > tb.toLowerCase();
100
+ return Number(va) > Number(vb);
101
+ }
29
102
  function isValidMerkleProof(p) {
30
103
  if (!p || typeof p !== "object") return false;
31
104
  const m = p;
@@ -75,8 +148,29 @@ var IndexerClient = class {
75
148
  addressTxs(a) {
76
149
  return this.http.getJson(`/address/${encodeURIComponent(a)}/txs`);
77
150
  }
78
- addressUtxo(a) {
79
- return this.http.getJson(`/address/${encodeURIComponent(a)}/utxo`);
151
+ /** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
152
+ * `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
153
+ * outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
154
+ async addressUtxo(a) {
155
+ const path = `/address/${encodeURIComponent(a)}/utxo`;
156
+ const first = await this.http.getJson(path);
157
+ if (!Array.isArray(first) || first.length !== UTXO_PAGE) return first;
158
+ const out = [...first];
159
+ let page = first;
160
+ let prev = null;
161
+ for (let pages = 1; page.length === UTXO_PAGE; pages++) {
162
+ if (pages === UTXO_MAX_PAGES)
163
+ 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`);
164
+ const after = utxoCursor(page[UTXO_PAGE - 1]);
165
+ 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");
166
+ if (prev !== null && !cursorAfter(after, prev)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: the utxo pages do not advance");
167
+ page = await this.http.getJson(path, { after });
168
+ if (!Array.isArray(page)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: a utxo page is not an array");
169
+ if (prev === null && page.length > 0 && utxoCursor(page[0]) === utxoCursor(first[0])) return first;
170
+ prev = after;
171
+ out.push(...page);
172
+ }
173
+ return out;
80
174
  }
81
175
  reputation(a) {
82
176
  return this.http.getJson(`/address/${encodeURIComponent(a)}/reputation`);
@@ -95,7 +189,8 @@ var IndexerClient = class {
95
189
  }
96
190
  // ---- trust-minimized inclusion ----------------------------------------
97
191
  /**
98
- * Verify a tx is included in a block by folding its merkle branch to the root
192
+ * 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
193
+ * hashed from a transaction body you hold) by folding its merkle branch to the root
99
194
  * with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
100
195
  * proof's root is cross-checked against the header merkle at that height →
101
196
  * trustLevel "verified-inclusion" (only as trustworthy as that header source —
@@ -62,6 +62,9 @@ declare class IndexerClient {
62
62
  txMerkleProof(id: string): Promise<MerkleProof>;
63
63
  address(a: string): Promise<unknown>;
64
64
  addressTxs(a: string): Promise<unknown[]>;
65
+ /** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
66
+ * `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
67
+ * outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
65
68
  addressUtxo(a: string): Promise<unknown[]>;
66
69
  reputation(a: string): Promise<unknown>;
67
70
  domains(): Promise<unknown[]>;
@@ -69,7 +72,8 @@ declare class IndexerClient {
69
72
  proposal(id: string): Promise<unknown>;
70
73
  attestations(id: string): Promise<unknown[]>;
71
74
  /**
72
- * Verify a tx is included in a block by folding its merkle branch to the root
75
+ * 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
76
+ * hashed from a transaction body you hold) by folding its merkle branch to the root
73
77
  * with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
74
78
  * proof's root is cross-checked against the header merkle at that height →
75
79
  * trustLevel "verified-inclusion" (only as trustworthy as that header source —
package/dist/indexer.d.ts CHANGED
@@ -62,6 +62,9 @@ declare class IndexerClient {
62
62
  txMerkleProof(id: string): Promise<MerkleProof>;
63
63
  address(a: string): Promise<unknown>;
64
64
  addressTxs(a: string): Promise<unknown[]>;
65
+ /** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
66
+ * `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
67
+ * outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
65
68
  addressUtxo(a: string): Promise<unknown[]>;
66
69
  reputation(a: string): Promise<unknown>;
67
70
  domains(): Promise<unknown[]>;
@@ -69,7 +72,8 @@ declare class IndexerClient {
69
72
  proposal(id: string): Promise<unknown>;
70
73
  attestations(id: string): Promise<unknown[]>;
71
74
  /**
72
- * Verify a tx is included in a block by folding its merkle branch to the root
75
+ * 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
76
+ * hashed from a transaction body you hold) by folding its merkle branch to the root
73
77
  * with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
74
78
  * proof's root is cross-checked against the header merkle at that height →
75
79
  * trustLevel "verified-inclusion" (only as trustworthy as that header source —
package/dist/indexer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  IndexerClient
3
- } from "./chunk-CI7YODPP.js";
3
+ } from "./chunk-NRCTL7OI.js";
4
+ import "./chunk-37OHCH7R.js";
4
5
  export {
5
6
  IndexerClient
6
7
  };