@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.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-23ATXZKM.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-QWGR5J5D.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-F3KJPQXM.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-X7J6ECNV.js";
35
+ } from "./chunk-37OHCH7R.js";
36
36
 
37
37
  // src/http.ts
38
38
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
@@ -370,7 +370,11 @@ import {
370
370
  fillEndorsement,
371
371
  fillIsSafe,
372
372
  requiredFillOutputs,
373
- previewFill
373
+ previewFill,
374
+ offerExpiryHeightOf,
375
+ offerFillTooLate,
376
+ FILL_TIP_MARGIN,
377
+ fillTargetId as fillTargetOf
374
378
  } from "@inversealtruism/cairnx-core";
375
379
  var ADDR = /^0x[0-9a-f]{40}$/;
376
380
  var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
@@ -413,9 +417,114 @@ async function provenAuthor(client, provenTx) {
413
417
  if (String(txid(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
414
418
  return authorOf(provenTx, src.outputs?.[Number(in0.vout)]?.scriptPubkey);
415
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;
522
+ }
416
523
  async function preverifyOffer(opts) {
417
524
  const id = String(opts.offerId).toLowerCase();
418
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 };
419
528
  let incl;
420
529
  try {
421
530
  incl = await opts.light.verifyTxInclusion(id);
@@ -457,52 +566,17 @@ async function preverifyOffer(opts) {
457
566
  const w = rec.want ?? {};
458
567
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
459
568
  const terms = provenOfferTerms(offerRec, blockHeight);
460
- let outputPlan;
461
- let sumsMismatch;
462
- let planReason;
463
- let planBound = false;
464
- const has = (v) => v !== void 0 && v !== null;
465
- const servedStatus = opts.servedOffer?.status;
466
- if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
467
- else if (isTokenWant(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
468
- 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)";
469
- 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)";
470
- else {
471
- const provenState = {
472
- id,
473
- seller,
474
- give: offerRec.give,
475
- want: { ...offerRec.want, payto },
476
- taker: offerRec.taker,
477
- bid: offerRec.bid,
478
- status: servedStatus,
479
- expiresEpoch: 0,
480
- height: blockHeight,
481
- feeBps: terms.feeBps
482
- };
483
- try {
484
- const plan = fillOutputPlan(provenState, opts.pay);
485
- if (plan.kind === "csd-outputs") {
486
- outputPlan = plan.outputs;
487
- if (has(opts.plannedOutputs)) {
488
- sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
489
- planBound = true;
490
- }
491
- } else if (plan.kind === "undeliverable") {
492
- sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
493
- } else {
494
- planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
495
- }
496
- } catch (e) {
497
- sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
498
- }
499
- }
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);
500
572
  const planFields = {
501
- ...outputPlan ? { outputPlan } : {},
502
- ...has(opts.plannedOutputs) ? { planBound } : {},
503
- ...!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 } : {}
504
578
  };
505
- const base = { payto, seller, terms, blockHeight, ...planFields };
579
+ const base = { payto, seller, terms, blockHeight, expiresEpoch, expiresAt, ...planFields };
506
580
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
507
581
  const so = opts.servedOffer;
508
582
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -516,7 +590,7 @@ async function preverifyOffer(opts) {
516
590
  if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
517
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 };
518
592
  }
519
- if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
593
+ if (j.refusal) return { ok: false, trust: "verified", reason: j.refusal, ...base };
520
594
  return { ok: true, trust: "verified", ...base };
521
595
  }
522
596
 
@@ -537,6 +611,20 @@ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
537
611
  var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
538
612
  var SPV_RECHECK_DEPTH = 12;
539
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
+ };
540
628
  var Cairn = class {
541
629
  config;
542
630
  chain;
@@ -552,6 +640,9 @@ var Cairn = class {
552
640
  // syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
553
641
  // fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
554
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;
555
646
  _spvCp;
556
647
  // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
557
648
  _cairnBase;
@@ -607,14 +698,18 @@ var Cairn = class {
607
698
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
608
699
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
609
700
  // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
610
- // 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.
611
703
  async withSpvReseed(op) {
612
704
  const lc = await this.seededSpvLight();
613
705
  try {
614
706
  return await op(lc);
615
707
  } catch (e) {
616
708
  if (spvIsTransient(e)) throw e;
617
- const fresh = await this.seedFreshSpvLight();
709
+ this._spvReseedInflight ??= this.seedFreshSpvLight().finally(() => {
710
+ this._spvReseedInflight = null;
711
+ });
712
+ const fresh = await this._spvReseedInflight;
618
713
  const out = await op(fresh);
619
714
  this._spvLight = fresh;
620
715
  return out;
@@ -634,7 +729,7 @@ var Cairn = class {
634
729
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
635
730
  // dirty the shared field while an attempt is in flight).
636
731
  async seedFreshSpvLight() {
637
- const lc = new LightClient({
732
+ const lc = new SpvLightClient({
638
733
  client: this.chain.client,
639
734
  checkpoints: { [this._spvCp.height]: this._spvCp.hash },
640
735
  headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
@@ -676,11 +771,7 @@ var Cairn = class {
676
771
  * data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
677
772
  * `sync()` fails closed. A non-dense range is rejected terminally (never retried).
678
773
  *
679
- * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
680
- * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
681
- * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
682
- * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
683
- * 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).
684
775
  */
685
776
  async fetchHeadersBatch(from, count) {
686
777
  const g = this.config.fetch ?? globalThis.fetch;
@@ -728,7 +819,8 @@ var Cairn = class {
728
819
  * commitment, derives the payment recipient + seller from the offer's on-chain AUTHOR (the funding input's
729
820
  * prevout owner, txid-committed, NOT the malleable scriptSig), and, if you pass the resolver-SERVED `offer`
730
821
  * object, binds its payto/seller + fee/rebate/partial terms to the proven ones. Returns a TRUST-LABELED result
731
- * (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).
732
824
  *
733
825
  * This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
734
826
  * fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
@@ -736,19 +828,44 @@ var Cairn = class {
736
828
  *
737
829
  * Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
738
830
  * non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
739
- * 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.
740
834
  */
741
835
  async verifyOfferForFill(offerId, servedOffer, opts) {
742
836
  const light = {
743
837
  verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
744
838
  const haveTo = spvTipOf(lc);
745
- const r = await lc.verifyTxInclusion(txidHex);
746
- if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
747
- await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
748
- 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 };
749
856
  })
750
857
  };
751
- 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
+ });
752
869
  }
753
870
  /**
754
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
  };