@inversealtruism/cairn-sdk 0.4.6 → 0.5.0

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
@@ -20,7 +20,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- BoardClient: () => BoardClient,
24
23
  Cairn: () => Cairn,
25
24
  CairnController: () => CairnController,
26
25
  CairnError: () => CairnError,
@@ -60,7 +59,7 @@ __export(index_exports, {
60
59
  module.exports = __toCommonJS(index_exports);
61
60
 
62
61
  // src/errors.ts
63
- var SDK_VERSION = "0.4.6";
62
+ var SDK_VERSION = "0.5.0";
64
63
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
65
64
  var CairnError = class extends Error {
66
65
  /** Stable machine code for programmatic branching. */
@@ -205,6 +204,26 @@ function errorCode(err) {
205
204
  // src/http.ts
206
205
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
207
206
  var ERR_TEXT_CAP = 8 * 1024;
207
+ var MAX_RETRY_AFTER_MS = 6e4;
208
+ function retryAfterMs(res) {
209
+ const secs = Number(res.headers.get("retry-after"));
210
+ return Number.isFinite(secs) && secs > 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : 0;
211
+ }
212
+ var RetryWaitBudget = class {
213
+ left;
214
+ constructor(totalMs) {
215
+ this.left = totalMs;
216
+ }
217
+ /** The ms to sleep before the next attempt (`ownMs` is the caller's backoff, clipped to what is left), or
218
+ * null when the server's hint exceeds the remaining budget. */
219
+ take(ownMs, serverMs) {
220
+ if (serverMs > this.left) return null;
221
+ const ms = Math.min(Math.max(ownMs, serverMs), this.left);
222
+ this.left -= ms;
223
+ return ms;
224
+ }
225
+ };
226
+ var HTTP_RETRY_WAIT_BUDGET_MS = 3e4;
208
227
  async function readCapped(res, maxBytes) {
209
228
  const cl = Number(res.headers.get("content-length"));
210
229
  if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
@@ -253,9 +272,10 @@ var Http = class {
253
272
  if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
254
273
  this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
255
274
  this.timeoutMs = opts.timeoutMs ?? 15e3;
256
- this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
275
+ this.maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 1 ? Math.floor(opts.maxBytes) : DEFAULT_MAX_BYTES;
257
276
  this.headers = opts.headers ?? {};
258
- this.retries = Math.min(10, Math.max(0, Math.floor(opts.retries ?? 0)));
277
+ const retries = Number(opts.retries ?? 0);
278
+ this.retries = Number.isNaN(retries) ? 0 : Math.min(10, Math.max(0, Math.floor(retries)));
259
279
  }
260
280
  /** Build an absolute URL from a path + optional query params. */
261
281
  url(path, query) {
@@ -304,11 +324,16 @@ var Http = class {
304
324
  }
305
325
  async getJson(path, query) {
306
326
  const url = this.url(path, query);
327
+ const budget = new RetryWaitBudget(HTTP_RETRY_WAIT_BUDGET_MS);
307
328
  for (let attempt = 0; ; attempt++) {
329
+ let serverWaitMs = 0;
330
+ let last;
308
331
  try {
309
332
  const { res, body } = await this.raw("GET", path, { query });
310
333
  if (!res.ok) {
311
- if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, errText(body));
334
+ last = new HttpError(res.status, url, errText(body));
335
+ if (!(res.status >= 500 && attempt < this.retries)) throw last;
336
+ serverWaitMs = retryAfterMs(res);
312
337
  } else {
313
338
  return this.parseJson(res, new TextDecoder().decode(body), url);
314
339
  }
@@ -316,7 +341,10 @@ var Http = class {
316
341
  if (e instanceof HttpError && e.status < 500) throw e;
317
342
  if (attempt >= this.retries) throw e;
318
343
  }
319
- await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt))));
344
+ const jitterMs = Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt));
345
+ const waitMs = budget.take(jitterMs, serverWaitMs);
346
+ if (waitMs === null) throw last ?? new HttpError(0, url, "retry wait budget exhausted");
347
+ await new Promise((r) => setTimeout(r, waitMs));
320
348
  }
321
349
  }
322
350
  async postJson(path, body, headers) {
@@ -403,164 +431,8 @@ var Chain = class {
403
431
  }
404
432
  };
405
433
 
406
- // src/board.ts
407
- var import_csd_codec2 = require("@inversealtruism/csd-codec");
408
- var import_csd_codec3 = require("@inversealtruism/csd-codec");
409
- var BoardClient = class {
410
- http;
411
- wallet;
412
- constructor(http, wallet) {
413
- this.http = http;
414
- this.wallet = wallet;
415
- }
416
- requireWallet() {
417
- if (!this.wallet) {
418
- throw new CairnError("This action needs a connected wallet. Construct Cairn with a wallet, or call cairn.connect() first.");
419
- }
420
- return this.wallet;
421
- }
422
- // ---- reads (public) ----------------------------------------------------
423
- /** Ranked board for a domain + time window. */
424
- board(opts = {}) {
425
- return this.http.getJson("/api/board", { domain: opts.domain ?? "all", window: opts.window ?? "all" });
426
- }
427
- /** Alias for `board()` — the top items. */
428
- top(opts = {}) {
429
- return this.board(opts);
430
- }
431
- /** A single item with its supports + pledges + integrity flag. */
432
- item(id) {
433
- return this.http.getJson(`/api/item/${encodeURIComponent(id)}`);
434
- }
435
- /** Known + discovered domains. */
436
- domains() {
437
- return this.http.getJson("/api/domains");
438
- }
439
- /** All quests (work-graph items). */
440
- quests() {
441
- return this.http.getJson("/api/quests");
442
- }
443
- /** A single quest with claims/submissions/payouts. */
444
- quest(id) {
445
- return this.http.getJson(`/api/quest/${encodeURIComponent(id)}`);
446
- }
447
- /** An address's on-chain-derived profile + identity + reputation. */
448
- profile(addr, opts = {}) {
449
- return this.http.getJson(`/api/profile/${encodeURIComponent(addr)}`, opts.live ? { live: 1 } : void 0);
450
- }
451
- /** Reputation leaderboard (top 50). */
452
- leaderboard() {
453
- return this.http.getJson("/api/leaderboard");
454
- }
455
- /** Network dashboard snapshot. */
456
- network() {
457
- return this.http.getJson("/api/network");
458
- }
459
- /** Network time-series for charts. */
460
- networkSeries(range = "24h") {
461
- return this.http.getJson("/api/network/series", { range });
462
- }
463
- /** Recent blocks with miner/reward. */
464
- networkBlocks(limit = 15) {
465
- return this.http.getJson("/api/network/blocks", { limit });
466
- }
467
- /** Per-miner hashrate estimate. */
468
- miner(addr) {
469
- return this.http.getJson(`/api/miner/${encodeURIComponent(addr)}`);
470
- }
471
- /** Recent activity feed. */
472
- activity() {
473
- return this.http.getJson("/api/activity");
474
- }
475
- /** The gamified cairn wall. */
476
- wall() {
477
- return this.http.getJson("/api/wall");
478
- }
479
- /** Server health + chain reachability. */
480
- health() {
481
- return this.http.getJson("/api/health");
482
- }
483
- // ---- writes (wallet-signed) -------------------------------------------
484
- /**
485
- * Publish a board item: compute the canonical payload hash, have the WALLET
486
- * build/sign/submit the Propose (clear-signed approval), then register the
487
- * off-chain content. The payload hash is byte-identical to what the cairn
488
- * server verifies (csd-codec.payloadHash over { v:1, domain, title, body, links }).
489
- */
490
- async propose(input) {
491
- const wallet = this.requireWallet();
492
- const content = {
493
- v: 1,
494
- domain: input.domain,
495
- title: input.title,
496
- body: input.body ?? "",
497
- links: input.links ?? []
498
- };
499
- const ph = (0, import_csd_codec2.payloadHash)(content);
500
- const uri = `cairn:v1:${ph.slice(2, 14)}`;
501
- const lifetime = input.lifetimeEpochs ?? 720;
502
- const height = await this.tipHeight();
503
- const expiresEpoch = Math.floor(height / import_csd_codec3.EPOCH_LEN) + lifetime;
504
- const fee = input.fee ?? import_csd_codec3.MIN_FEE_PROPOSE;
505
- const r = await wallet.propose({ domain: content.domain, payloadHash: ph, uri, expiresEpoch, fee });
506
- const txid3 = String(r.txid ?? "");
507
- if (!txid3) throw new CairnError("Propose submitted but no txid was returned by the wallet.");
508
- const reg = await this.tryRegister(content, txid3);
509
- return { txid: txid3, payloadHash: ph, content, registered: reg.ok, registerError: reg.error };
510
- }
511
- /**
512
- * Register off-chain content for an already-submitted proposal txid. Idempotent.
513
- * Returns { ok } — the cairn server verifies sha256(canonical) == on-chain payload_hash.
514
- */
515
- async registerContent(content, txid3) {
516
- try {
517
- const res = await this.http.postJson("/api/content", {
518
- domain: content.domain,
519
- title: content.title,
520
- body: content.body,
521
- links: content.links,
522
- txid: txid3
523
- });
524
- return { ok: !!res.ok, error: res.ok ? void 0 : res.error };
525
- } catch (e) {
526
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
527
- }
528
- }
529
- /** Support/attest a proposal (wallet-signed; always clear-signed). */
530
- async support(proposalId, opts = {}) {
531
- const wallet = this.requireWallet();
532
- return wallet.attest({
533
- proposalId,
534
- score: opts.score ?? 80,
535
- confidence: opts.confidence ?? 70,
536
- fee: opts.fee ?? import_csd_codec3.MIN_FEE_ATTEST
537
- });
538
- }
539
- // ---- internal ----------------------------------------------------------
540
- async tipHeight() {
541
- const tip = await this.http.getJson("/api/rpc/tip");
542
- const h = Number(tip?.height);
543
- if (!Number.isFinite(h)) throw new CairnError("Could not read chain tip height for expiry computation.");
544
- return h;
545
- }
546
- async tryRegister(content, txid3) {
547
- const delays = [0, 1500, 3e3, 5e3];
548
- let last = { ok: false, error: "not attempted" };
549
- for (const d of delays) {
550
- if (d) await sleep(d);
551
- last = await this.registerContent(content, txid3);
552
- if (last.ok) return last;
553
- if (!/not found yet|not found/i.test(last.error ?? "")) return last;
554
- }
555
- return last;
556
- }
557
- };
558
- function sleep(ms) {
559
- return new Promise((r) => setTimeout(r, ms));
560
- }
561
-
562
434
  // src/indexer.ts
563
- var import_csd_codec4 = require("@inversealtruism/csd-codec");
435
+ var import_csd_codec2 = require("@inversealtruism/csd-codec");
564
436
  var HEX64 = /^0x[0-9a-fA-F]{64}$/;
565
437
  var MAX_SSE_BUF = 1024 * 1024;
566
438
  function isValidMerkleProof(p) {
@@ -667,24 +539,34 @@ var IndexerClient = class {
667
539
  }
668
540
  let folds = false;
669
541
  try {
670
- folds = (0, import_csd_codec4.verifyMerkleProof)(txid3, proof.pos, proof.merkle, proof.merkle_root);
542
+ folds = (0, import_csd_codec2.verifyMerkleProof)(txid3, proof.pos, proof.merkle, proof.merkle_root);
671
543
  } catch {
672
544
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "malformed merkle proof" };
673
545
  }
674
546
  if (!folds) {
675
547
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
676
548
  }
549
+ let unverified;
677
550
  if (this.opts.headerMerkleAt) {
551
+ let onchain;
678
552
  try {
679
- const onchain = await this.opts.headerMerkleAt(proof.block_height);
553
+ onchain = await this.opts.headerMerkleAt(proof.block_height);
554
+ } catch (e) {
555
+ onchain = "";
556
+ unverified = `header at ${proof.block_height} not verified (${e?.message ?? String(e)})`;
557
+ }
558
+ if (onchain) {
680
559
  if (norm(onchain) !== norm(proof.merkle_root)) {
681
560
  return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
682
561
  }
683
562
  return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
684
- } catch {
685
563
  }
564
+ unverified ??= `header source returned no merkle root for ${proof.block_height}`;
565
+ } else {
566
+ unverified = "no header source configured (headerMerkleAt)";
686
567
  }
687
- return { included: true, trustLevel: "proof-consistent", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
568
+ const trivial = proof.merkle.length === 0 && norm(proof.merkle_root) === norm(txid3) ? "; empty branch (root equals the txid)" : "";
569
+ return { included: false, trustLevel: "proof-consistent", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root, reason: `indexer proof only, ${unverified}${trivial}` };
688
570
  }
689
571
  // ---- live streams ------------------------------------------------------
690
572
  /** Stream all events (blocks, proposals, attestations, reorgs) via SSE. */
@@ -824,7 +706,7 @@ function norm(h) {
824
706
  }
825
707
 
826
708
  // src/content.ts
827
- var import_csd_codec5 = require("@inversealtruism/csd-codec");
709
+ var import_csd_codec3 = require("@inversealtruism/csd-codec");
828
710
  var ContentClient = class {
829
711
  sources;
830
712
  constructor(opts) {
@@ -838,29 +720,32 @@ var ContentClient = class {
838
720
  * This is byte-identical to what the cairn server and wallet compute.
839
721
  */
840
722
  prepare(obj) {
841
- const canonical = (0, import_csd_codec5.canonicalJson)(obj);
723
+ const canonical = (0, import_csd_codec3.canonicalJson)(obj);
842
724
  return {
843
- payloadHash: (0, import_csd_codec5.payloadHash)(obj),
725
+ payloadHash: (0, import_csd_codec3.payloadHash)(obj),
844
726
  canonical,
845
727
  bytes: new TextEncoder().encode(canonical)
846
728
  };
847
729
  }
848
- /** Alias for `prepare()` — emphasises this does not itself publish (anchor via board.propose). */
730
+ /** Alias for `prepare()`. It does not publish anything; anchor the hash with a Propose. */
849
731
  put(obj) {
850
732
  return this.prepare(obj);
851
733
  }
852
734
  /** Just the payload hash for an object. */
853
735
  hash(obj) {
854
- return (0, import_csd_codec5.payloadHash)(obj);
736
+ return (0, import_csd_codec3.payloadHash)(obj);
855
737
  }
856
738
  /**
857
739
  * Fetch content by hash and VERIFY it self-certifies (sha256(bytes) == hash).
858
- * Tries each configured source in order. Returns raw bytes, or null if no
859
- * source holds it. Throws ContentVerificationError if a source returns bytes
860
- * that don't match the hash (tampering / wrong object).
740
+ * Tries each configured source in order; a source that is unreachable, lacks
741
+ * the content or serves bytes that don't match the hash is skipped. Returns the
742
+ * first verified bytes, or null if no source holds it. Throws
743
+ * ContentVerificationError only when no source served verified bytes and at
744
+ * least one served mismatching bytes (tampering / wrong object).
861
745
  */
862
746
  async getBytes(hash) {
863
747
  const h = normalizeHash(hash);
748
+ let mismatched = false;
864
749
  for (const src of this.sources) {
865
750
  let got = null;
866
751
  try {
@@ -869,22 +754,29 @@ var ContentClient = class {
869
754
  continue;
870
755
  }
871
756
  if (!got) continue;
872
- if (!(0, import_csd_codec5.verifyContentBytes)(got.bytes, h)) {
873
- throw new ContentVerificationError(h);
757
+ if (!(0, import_csd_codec3.verifyContentBytes)(got.bytes, h)) {
758
+ mismatched = true;
759
+ continue;
874
760
  }
875
761
  return got.bytes;
876
762
  }
763
+ if (mismatched) throw new ContentVerificationError(h);
877
764
  return null;
878
765
  }
879
- /** Fetch + verify + parse as UTF-8 JSON. Returns null if not found. */
766
+ /** Fetch + verify + parse as UTF-8 JSON. Returns null if not found. Throws a CairnError with code
767
+ * CONTENT_NOT_JSON when the verified bytes are not JSON. */
880
768
  async get(hash) {
881
769
  const bytes = await this.getBytes(hash);
882
770
  if (!bytes) return null;
883
- return JSON.parse(new TextDecoder().decode(bytes));
771
+ try {
772
+ return JSON.parse(new TextDecoder().decode(bytes));
773
+ } catch (e) {
774
+ throw new CairnError(`Content ${normalizeHash(hash)} verified against its hash but is not JSON; use getBytes() for raw content.`, { code: "CONTENT_NOT_JSON", cause: e });
775
+ }
884
776
  }
885
777
  /** Verify already-fetched bytes against a hash (no network). */
886
778
  verify(bytes, hash) {
887
- return (0, import_csd_codec5.verifyContentBytes)(bytes, normalizeHash(hash));
779
+ return (0, import_csd_codec3.verifyContentBytes)(bytes, normalizeHash(hash));
888
780
  }
889
781
  };
890
782
  function normalizeHash(hash) {
@@ -948,11 +840,46 @@ var RegistryClient = class {
948
840
  // src/names.ts
949
841
  var import_cairnx_core = require("@inversealtruism/cairnx-core");
950
842
  var nameOk = (n) => typeof n === "string" && import_cairnx_core.NAME_RE.test(n) && !import_cairnx_core.RESERVED_NAMES.has(n);
843
+ var amountOk = (v) => typeof v === "string" && import_cairnx_core.AMOUNT_RE.test(v);
844
+ var heightOk = (v) => Number.isSafeInteger(v) && v >= 0;
845
+ function nameHistoryProblem(h, name) {
846
+ if (!h || typeof h !== "object" || Array.isArray(h)) return "not an object";
847
+ const r = h;
848
+ if (r.name !== name) return `name is ${JSON.stringify(r.name)}, expected ${JSON.stringify(name)}`;
849
+ if (!heightOk(r.tipHeight)) return "bad tipHeight";
850
+ if (!r.resolve || typeof r.resolve !== "object") return "resolve is not an object";
851
+ if (typeof r.scopedReplaySufficient !== "boolean") return "scopedReplaySufficient is not a boolean";
852
+ if (!Array.isArray(r.events)) return "events is not an array";
853
+ for (const [i, e] of r.events.entries()) {
854
+ const ev = e;
855
+ if (!ev || typeof ev.txid !== "string" || !import_cairnx_core.HASH_RE.test(ev.txid) || !heightOk(ev.height) || !heightOk(ev.pos) || typeof ev.kind !== "string")
856
+ return `event ${i} is malformed`;
857
+ }
858
+ return null;
859
+ }
860
+ function tokenProblem(t) {
861
+ if (!t || typeof t !== "object") return "not an object";
862
+ const r = t;
863
+ if (typeof r.ticker !== "string" || !import_cairnx_core.TICKER_RE.test(r.ticker)) return "bad ticker";
864
+ if (!Number.isSafeInteger(r.decimals) || r.decimals < 0) return `bad decimals (${String(r.decimals)})`;
865
+ if (!amountOk(r.supply)) return "supply is not a base-unit integer string";
866
+ if (!amountOk(r.minted)) return "minted is not a base-unit integer string";
867
+ if (r.mintLimit !== void 0 && !amountOk(r.mintLimit)) return "mintLimit is not a base-unit integer string";
868
+ return null;
869
+ }
951
870
  var NamesClient = class {
952
871
  constructor(http) {
953
872
  this.http = http;
954
873
  }
955
874
  http;
875
+ malformed(path, what) {
876
+ return new HttpError(200, this.http.url(path), `malformed response: ${what}`);
877
+ }
878
+ async list(path) {
879
+ const body = await this.http.getJson(path);
880
+ if (!Array.isArray(body)) throw this.malformed(path, "expected an array");
881
+ return body;
882
+ }
956
883
  /** Resolve a .csd name for DISPLAY (server-trusted; see the module trust note). null = unknown/invalid name. */
957
884
  async resolve(name) {
958
885
  if (!nameOk(name)) return null;
@@ -973,15 +900,21 @@ var NamesClient = class {
973
900
  throw e;
974
901
  }
975
902
  }
976
- /** The event history of one name (registrations, transfers, sets, renewals). null = invalid name. */
903
+ /** The event history of one name (registrations, transfers, sets, renewals) with the server's current
904
+ * claim. null = invalid or unregistered name. Throws a typed HttpError on a malformed body. */
977
905
  async nameHistory(name) {
978
906
  if (!nameOk(name)) return null;
907
+ const path = `/trade/api/cairnx/name-history/${encodeURIComponent(name)}`;
908
+ let h;
979
909
  try {
980
- return await this.http.getJson(`/trade/api/cairnx/name-history/${encodeURIComponent(name)}`);
910
+ h = await this.http.getJson(path);
981
911
  } catch (e) {
982
912
  if (e?.status === 404) return null;
983
913
  throw e;
984
914
  }
915
+ const p = nameHistoryProblem(h, name);
916
+ if (p) throw this.malformed(path, p);
917
+ return h;
985
918
  }
986
919
  /** Reverse lookup: an address's chosen primary .csd name (null name = none set). */
987
920
  async primary(addr) {
@@ -1005,29 +938,40 @@ var NamesClient = class {
1005
938
  }
1006
939
  /** All registered names (display list; short-edge-cached server-side). */
1007
940
  names() {
1008
- return this.http.getJson(`/trade/api/cairnx/names`);
1009
- }
1010
- /** All deployed tokens. */
1011
- tokens() {
1012
- return this.http.getJson(`/trade/api/cairnx/tokens`);
941
+ return this.list(`/trade/api/cairnx/names`);
942
+ }
943
+ /** All deployed tokens. Throws a typed HttpError if any row has a malformed ticker, decimals or amount. */
944
+ async tokens() {
945
+ const path = `/trade/api/cairnx/tokens`;
946
+ const rows = await this.list(path);
947
+ rows.forEach((t, i) => {
948
+ const p = tokenProblem(t);
949
+ if (p) throw this.malformed(path, `token row ${i}: ${p}`);
950
+ });
951
+ return rows;
1013
952
  }
1014
- /** One token by ticker. null = unknown/invalid ticker. */
953
+ /** One token by ticker. null = unknown/invalid ticker. Throws a typed HttpError on a malformed body. */
1015
954
  async token(ticker) {
1016
- if (typeof ticker !== "string" || !/^[A-Z][A-Z0-9]{2,11}$/.test(ticker)) return null;
955
+ if (typeof ticker !== "string" || !import_cairnx_core.TICKER_RE.test(ticker)) return null;
956
+ const path = `/trade/api/cairnx/token/${ticker}`;
957
+ let t;
1017
958
  try {
1018
- return await this.http.getJson(`/trade/api/cairnx/token/${ticker}`);
959
+ t = await this.http.getJson(path);
1019
960
  } catch (e) {
1020
961
  if (e?.status === 404) return null;
1021
962
  throw e;
1022
963
  }
964
+ const p = tokenProblem(t);
965
+ if (p) throw this.malformed(path, p);
966
+ return t;
1023
967
  }
1024
968
  /** Open offers (the market's display list). */
1025
969
  offers() {
1026
- return this.http.getJson(`/trade/api/cairnx/offers`);
970
+ return this.list(`/trade/api/cairnx/offers`);
1027
971
  }
1028
972
  /** Open bids. */
1029
973
  bids() {
1030
- return this.http.getJson(`/trade/api/cairnx/bids`);
974
+ return this.list(`/trade/api/cairnx/bids`);
1031
975
  }
1032
976
  /** The full canonical CairnX state (large; prefer the scoped reads above). */
1033
977
  state() {
@@ -1260,7 +1204,7 @@ var WalletConnection = class {
1260
1204
  send(params) {
1261
1205
  return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
1262
1206
  }
1263
- /** Low-level Propose (always prompts). Prefer `cairn.board.propose()` which computes the hash/uri/expiry. */
1207
+ /** Low-level Propose (always prompts). The caller computes the payload hash, uri and expiry epoch. */
1264
1208
  propose(params) {
1265
1209
  return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
1266
1210
  }
@@ -1291,9 +1235,10 @@ async function connect(opts = {}) {
1291
1235
 
1292
1236
  // src/fillverify.ts
1293
1237
  var import_csd_client3 = require("@inversealtruism/csd-client");
1294
- var import_csd_codec6 = require("@inversealtruism/csd-codec");
1238
+ var import_csd_codec4 = require("@inversealtruism/csd-codec");
1295
1239
  var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
1296
1240
  var ADDR = /^0x[0-9a-f]{40}$/;
1241
+ var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
1297
1242
  var HASH = /^0x[0-9a-f]{64}$/;
1298
1243
  var COINBASE = "0x" + "00".repeat(32);
1299
1244
  function bindPlannedSums(planned, proven) {
@@ -1330,7 +1275,7 @@ async function provenAuthor(client, provenTx) {
1330
1275
  } catch {
1331
1276
  return null;
1332
1277
  }
1333
- if (String((0, import_csd_codec6.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
1278
+ if (String((0, import_csd_codec4.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
1334
1279
  const out = src.outputs?.[Number(in0.vout)];
1335
1280
  const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
1336
1281
  return spk && ADDR.test(spk) ? spk : null;
@@ -1344,13 +1289,17 @@ async function preverifyOffer(opts) {
1344
1289
  } catch (e) {
1345
1290
  return { ok: false, trust: "unverified", transient: true, reason: `couldn't reach the chain to verify the offer (${e?.message ?? e})` };
1346
1291
  }
1347
- if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash)
1292
+ if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash) {
1293
+ const below = BELOW_BASE.exec(incl.reason ?? "");
1294
+ if (below)
1295
+ return { ok: false, trust: "unverified", blockHeight: incl.blockHeight, reason: `the offer was mined below this SDK's SPV checkpoint window (base ${below[1]}), so it cannot be merkle-proven here and retrying will not change that; pass an older anchor in config.spvCheckpoint to verify it` };
1348
1296
  return { ok: false, trust: "unverified", transient: true, blockHeight: incl.blockHeight, reason: incl.reason ?? `offer not merkle-proven on-chain (${incl.trustLevel})` };
1297
+ }
1349
1298
  const blockHeight = Number(incl.blockHeight);
1350
1299
  let provenTx, provenId;
1351
1300
  try {
1352
1301
  provenTx = (0, import_csd_client3.rpcTxToTx)(incl.tx);
1353
- provenId = (0, import_csd_codec6.txid)(provenTx);
1302
+ provenId = (0, import_csd_codec4.txid)(provenTx);
1354
1303
  } catch {
1355
1304
  return { ok: false, trust: "unverified", reason: "the on-chain offer tx couldn't be re-derived" };
1356
1305
  }
@@ -1366,7 +1315,7 @@ async function preverifyOffer(opts) {
1366
1315
  rec = null;
1367
1316
  }
1368
1317
  if (!rec || rec.t !== "offer") return { ok: false, trust: "unverified", reason: "the offer record doesn't bind to its on-chain commitment" };
1369
- if (String((0, import_csd_codec6.payloadHash)(rec)).toLowerCase() !== String(incl.appPayloadHash).toLowerCase())
1318
+ if (String((0, import_csd_codec4.payloadHash)(rec)).toLowerCase() !== String(incl.appPayloadHash).toLowerCase())
1370
1319
  return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
1371
1320
  const seller = await provenAuthor(opts.client, provenTx);
1372
1321
  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" };
@@ -1381,8 +1330,15 @@ async function preverifyOffer(opts) {
1381
1330
  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" };
1382
1331
  let outputPlan;
1383
1332
  let sumsMismatch;
1384
- if (opts.pay !== void 0 && opts.pay !== null && !(0, import_cairnx_core2.isTokenWant)(offerRec.want) && offerRec.min === void 0) {
1385
- const servedStatus = opts.servedOffer?.status;
1333
+ let planReason;
1334
+ let planBound = false;
1335
+ const has = (v) => v !== void 0 && v !== null;
1336
+ const servedStatus = opts.servedOffer?.status;
1337
+ if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
1338
+ else if ((0, import_cairnx_core2.isTokenWant)(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
1339
+ 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)";
1340
+ 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)";
1341
+ else {
1386
1342
  const provenState = {
1387
1343
  id,
1388
1344
  seller,
@@ -1390,7 +1346,7 @@ async function preverifyOffer(opts) {
1390
1346
  want: { ...offerRec.want, payto },
1391
1347
  taker: offerRec.taker,
1392
1348
  bid: offerRec.bid,
1393
- status: typeof servedStatus === "string" ? servedStatus : "open",
1349
+ status: servedStatus,
1394
1350
  expiresEpoch: 0,
1395
1351
  height: blockHeight,
1396
1352
  feeBps: terms.feeBps
@@ -1399,16 +1355,25 @@ async function preverifyOffer(opts) {
1399
1355
  const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1400
1356
  if (plan.kind === "csd-outputs") {
1401
1357
  outputPlan = plan.outputs;
1402
- if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
1358
+ if (has(opts.plannedOutputs)) {
1403
1359
  sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1360
+ planBound = true;
1361
+ }
1404
1362
  } else if (plan.kind === "undeliverable") {
1405
1363
  sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
1364
+ } else {
1365
+ planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
1406
1366
  }
1407
1367
  } catch (e) {
1408
1368
  sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
1409
1369
  }
1410
1370
  }
1411
- const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
1371
+ const planFields = {
1372
+ ...outputPlan ? { outputPlan } : {},
1373
+ ...has(opts.plannedOutputs) ? { planBound } : {},
1374
+ ...!outputPlan && planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason } : {}
1375
+ };
1376
+ const base = { payto, seller, terms, blockHeight, ...planFields };
1412
1377
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1413
1378
  const so = opts.servedOffer;
1414
1379
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -1433,8 +1398,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
1433
1398
  var CairnController = class {
1434
1399
  state = DISCONNECTED;
1435
1400
  conn = null;
1436
- // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
1437
- // the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
1401
+ // Connect/disconnect generation token. disconnect(), the wallet's `disconnect` event and an empty
1402
+ // accountsChanged (lock, account switch, revoke) bump it; a connect() whose await straddled the bump must
1403
+ // never commit "connected" over the torn-down session.
1438
1404
  epoch = 0;
1439
1405
  listeners = /* @__PURE__ */ new Set();
1440
1406
  getWalletFn;
@@ -1465,6 +1431,7 @@ var CairnController = class {
1465
1431
  }
1466
1432
  onAccounts = (accounts) => {
1467
1433
  if (!Array.isArray(accounts) || accounts.length === 0) {
1434
+ this.epoch++;
1468
1435
  this.setState({ status: "disconnected", account: null });
1469
1436
  return;
1470
1437
  }
@@ -1476,6 +1443,7 @@ var CairnController = class {
1476
1443
  }
1477
1444
  };
1478
1445
  onDisconnect = () => {
1446
+ this.epoch++;
1479
1447
  this.setState({ status: "disconnected", account: null });
1480
1448
  };
1481
1449
  // Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
@@ -1530,7 +1498,7 @@ var CairnController = class {
1530
1498
  };
1531
1499
 
1532
1500
  // src/index.ts
1533
- var DEFAULT_SPV_CHECKPOINT = { height: 101998, hash: "0x0000000000085295c9e13bbf276a11affb5470059235d1ea85d6334c8b43c49c" };
1501
+ var DEFAULT_SPV_CHECKPOINT = { height: 115403, hash: "0x00000000000052e29166597d598827b137af1dea7542123b848637fee0b67347" };
1534
1502
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
1535
1503
  var SPV_HTTP_TIMEOUT_MS = 15e3;
1536
1504
  var SPV_HTTP_MAX_RETRIES = 4;
@@ -1542,7 +1510,10 @@ var spvIsTransient = (e) => {
1542
1510
  return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
1543
1511
  };
1544
1512
  var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1545
- var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
1513
+ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
1514
+ var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
1515
+ var SPV_RECHECK_DEPTH = 12;
1516
+ var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
1546
1517
  var Cairn = class {
1547
1518
  config;
1548
1519
  chain;
@@ -1551,7 +1522,6 @@ var Cairn = class {
1551
1522
  registry;
1552
1523
  /** .csd names + CairnX market READS (server-trusted display reads; see names.ts trust note). */
1553
1524
  names;
1554
- board;
1555
1525
  wallet;
1556
1526
  cairnHttp;
1557
1527
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
@@ -1577,7 +1547,6 @@ var Cairn = class {
1577
1547
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
1578
1548
  this.chain = new Chain({ rpcUrl: rpc, fetch, timeoutMs });
1579
1549
  this.wallet = config.wallet;
1580
- this.board = new BoardClient(this.cairnHttp, this.wallet);
1581
1550
  this.index = new IndexerClient(indexerHttp, {
1582
1551
  fetch,
1583
1552
  WebSocketImpl: config.WebSocketImpl,
@@ -1595,16 +1564,17 @@ var Cairn = class {
1595
1564
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
1596
1565
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
1597
1566
  * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
1598
- * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
1599
- * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
1600
- * read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
1567
+ * withSpvReseed. A height within SPV_RECHECK_DEPTH of the cached tip is re-checked against the served
1568
+ * chain first (recheckCachedHeader), so a block orphaned after it was cached stops backing
1569
+ * "verified-inclusion". Deeper heights answer from the cache with no network touch.
1601
1570
  */
1602
1571
  async verifiedHeaderMerkleAt(height) {
1603
1572
  if (!Number.isInteger(height) || height < this._spvCp.height)
1604
1573
  throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
1605
1574
  return this.withSpvReseed(async (lc) => {
1606
- const haveTo = lc.baseHeight + lc.chain.length - 1;
1575
+ const haveTo = spvTipOf(lc);
1607
1576
  if (haveTo < height) await lc.sync(height);
1577
+ else await this.recheckCachedHeader(lc, height, haveTo);
1608
1578
  const vh = lc.chain[height - lc.baseHeight];
1609
1579
  if (!vh) throw new Error(`light client has no verified header at ${height}`);
1610
1580
  return String(vh.header.merkle);
@@ -1616,11 +1586,8 @@ var Cairn = class {
1616
1586
  // this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
1617
1587
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
1618
1588
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
1619
- // failure propagates fail-closed; there is no loop.
1620
- // RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
1621
- // branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
1622
- // for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
1623
- // the permanent-brick leg only.
1589
+ // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
1590
+ // cached header that the served chain no longer carries is a structural failure.
1624
1591
  async withSpvReseed(op) {
1625
1592
  const lc = await this.seededSpvLight();
1626
1593
  try {
@@ -1633,6 +1600,16 @@ var Cairn = class {
1633
1600
  return out;
1634
1601
  }
1635
1602
  }
1603
+ // A near-tip cached header the served chain no longer carries is a reorg: throw a structural error so
1604
+ // withSpvReseed rebuilds. The message must not match SPV_TRANSIENT_RE, or it would be retried on the stale cache.
1605
+ async recheckCachedHeader(lc, height, haveTo) {
1606
+ if (height <= haveTo - SPV_RECHECK_DEPTH) return;
1607
+ const cached = lc.chain[height - lc.baseHeight];
1608
+ if (!cached) return;
1609
+ const [served] = await this.fetchHeadersBatch(height, 1);
1610
+ if (String(served?.hash ?? "").toLowerCase() !== String(cached.hash).toLowerCase())
1611
+ throw new Error(`SPV cached block at height ${height} is no longer on the served chain (reorg); reseeding`);
1612
+ }
1636
1613
  // Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
1637
1614
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
1638
1615
  // dirty the shared field while an attempt is in flight).
@@ -1645,7 +1622,7 @@ var Cairn = class {
1645
1622
  await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
1646
1623
  try {
1647
1624
  const tipHeight = Number((await this.chain.tip()).height);
1648
- const have = lc.baseHeight + lc.chain.length - 1;
1625
+ const have = spvTipOf(lc);
1649
1626
  if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
1650
1627
  } catch {
1651
1628
  }
@@ -1690,6 +1667,7 @@ var Cairn = class {
1690
1667
  if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
1691
1668
  const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
1692
1669
  const url = `${this._cairnBase}/api/headers/${from}/${count}`;
1670
+ const budget = new RetryWaitBudget(SPV_RETRY_WAIT_BUDGET_MS);
1693
1671
  for (let attempt = 0; ; attempt++) {
1694
1672
  let res;
1695
1673
  let parsed;
@@ -1701,8 +1679,9 @@ var Cairn = class {
1701
1679
  parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
1702
1680
  }
1703
1681
  } catch (e) {
1704
- if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
1705
- await spvSleep(spvRetryDelayMs(attempt, 0));
1682
+ const waitMs = attempt < SPV_HTTP_MAX_RETRIES ? budget.take(spvBackoffMs(attempt), 0) : null;
1683
+ if (waitMs === null) throw e;
1684
+ await spvSleep(waitMs);
1706
1685
  continue;
1707
1686
  } finally {
1708
1687
  clearTimeout(timer);
@@ -1715,7 +1694,9 @@ var Cairn = class {
1715
1694
  if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
1716
1695
  res.body?.cancel?.().catch(() => {
1717
1696
  });
1718
- await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
1697
+ const waitMs = budget.take(spvBackoffMs(attempt), retryAfterMs(res));
1698
+ if (waitMs === null) throw new Error(`/api/headers ${res.status}: Retry-After exceeds the remaining retry budget`);
1699
+ await spvSleep(waitMs);
1719
1700
  continue;
1720
1701
  }
1721
1702
  throw new Error(`/api/headers ${res.status}`);
@@ -1738,12 +1719,20 @@ var Cairn = class {
1738
1719
  * EXACTLY the outputPlan, never resolver-served outputs.
1739
1720
  */
1740
1721
  async verifyOfferForFill(offerId, servedOffer, opts) {
1741
- const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
1722
+ const light = {
1723
+ verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
1724
+ const haveTo = spvTipOf(lc);
1725
+ const r = await lc.verifyTxInclusion(txidHex);
1726
+ if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
1727
+ await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
1728
+ return r;
1729
+ })
1730
+ };
1742
1731
  return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
1743
1732
  }
1744
1733
  /**
1745
- * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
1746
- * `cairn.board` for signed writes. Resolves to the connected address.
1734
+ * Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
1735
+ * Resolves to the connected address.
1747
1736
  */
1748
1737
  async connect(opts = {}) {
1749
1738
  const { wallet, address } = await connect(opts);
@@ -1753,7 +1742,6 @@ var Cairn = class {
1753
1742
  /** Attach an already-connected wallet (e.g. one you constructed yourself). */
1754
1743
  attachWallet(wallet) {
1755
1744
  this.wallet = wallet;
1756
- this.board = new BoardClient(this.cairnHttp, wallet);
1757
1745
  }
1758
1746
  /** True if a wallet is attached. */
1759
1747
  get connected() {
@@ -1762,7 +1750,6 @@ var Cairn = class {
1762
1750
  };
1763
1751
  // Annotate the CommonJS export names for ESM import in node:
1764
1752
  0 && (module.exports = {
1765
- BoardClient,
1766
1753
  Cairn,
1767
1754
  CairnController,
1768
1755
  CairnError,