@inversealtruism/cairn-sdk 0.4.5 → 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.5";
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. */
@@ -153,7 +152,7 @@ var SubmitInFlightError = class extends CairnError {
153
152
  }
154
153
  };
155
154
  var NATIVE_CODES = /* @__PURE__ */ new Set(["FIRST_PARTY_ONLY", "RATE_LIMITED", "FORBIDDEN", "INTERNAL"]);
156
- var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN"]);
155
+ var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN", "WALLET_UNAVAILABLE", "REVIEW_REQUIRED"]);
157
156
  var NESTED_TERMINAL = /* @__PURE__ */ new Set([
158
157
  "FILL_UNSAFE",
159
158
  "FILL_WRONG_TARGET",
@@ -171,7 +170,11 @@ var NESTED_TERMINAL = /* @__PURE__ */ new Set([
171
170
  "FEE_TOO_LOW",
172
171
  "ACCOUNT_CHANGED",
173
172
  "GHOST_INPUTS_SKIPPED",
174
- "NOTHING_TO_CONSOLIDATE"
173
+ "NOTHING_TO_CONSOLIDATE",
174
+ "OFFER_SOURCE_CONFLICT",
175
+ "FILL_LEGACY_SUNSET",
176
+ "VERIFY_HORIZON",
177
+ "VERIFY_UNREPRESENTABLE"
175
178
  ]);
176
179
  function mapProviderError(error, code) {
177
180
  if (code === "USER_REJECTED" || code === "APPROVAL_CLOSED") return new UserRejectedError(error);
@@ -201,6 +204,26 @@ function errorCode(err) {
201
204
  // src/http.ts
202
205
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
203
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;
204
227
  async function readCapped(res, maxBytes) {
205
228
  const cl = Number(res.headers.get("content-length"));
206
229
  if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
@@ -249,9 +272,10 @@ var Http = class {
249
272
  if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
250
273
  this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
251
274
  this.timeoutMs = opts.timeoutMs ?? 15e3;
252
- this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
275
+ this.maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 1 ? Math.floor(opts.maxBytes) : DEFAULT_MAX_BYTES;
253
276
  this.headers = opts.headers ?? {};
254
- 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)));
255
279
  }
256
280
  /** Build an absolute URL from a path + optional query params. */
257
281
  url(path, query) {
@@ -300,11 +324,16 @@ var Http = class {
300
324
  }
301
325
  async getJson(path, query) {
302
326
  const url = this.url(path, query);
327
+ const budget = new RetryWaitBudget(HTTP_RETRY_WAIT_BUDGET_MS);
303
328
  for (let attempt = 0; ; attempt++) {
329
+ let serverWaitMs = 0;
330
+ let last;
304
331
  try {
305
332
  const { res, body } = await this.raw("GET", path, { query });
306
333
  if (!res.ok) {
307
- 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);
308
337
  } else {
309
338
  return this.parseJson(res, new TextDecoder().decode(body), url);
310
339
  }
@@ -312,7 +341,10 @@ var Http = class {
312
341
  if (e instanceof HttpError && e.status < 500) throw e;
313
342
  if (attempt >= this.retries) throw e;
314
343
  }
315
- 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));
316
348
  }
317
349
  }
318
350
  async postJson(path, body, headers) {
@@ -399,164 +431,8 @@ var Chain = class {
399
431
  }
400
432
  };
401
433
 
402
- // src/board.ts
403
- var import_csd_codec2 = require("@inversealtruism/csd-codec");
404
- var import_csd_codec3 = require("@inversealtruism/csd-codec");
405
- var BoardClient = class {
406
- http;
407
- wallet;
408
- constructor(http, wallet) {
409
- this.http = http;
410
- this.wallet = wallet;
411
- }
412
- requireWallet() {
413
- if (!this.wallet) {
414
- throw new CairnError("This action needs a connected wallet. Construct Cairn with a wallet, or call cairn.connect() first.");
415
- }
416
- return this.wallet;
417
- }
418
- // ---- reads (public) ----------------------------------------------------
419
- /** Ranked board for a domain + time window. */
420
- board(opts = {}) {
421
- return this.http.getJson("/api/board", { domain: opts.domain ?? "all", window: opts.window ?? "all" });
422
- }
423
- /** Alias for `board()` — the top items. */
424
- top(opts = {}) {
425
- return this.board(opts);
426
- }
427
- /** A single item with its supports + pledges + integrity flag. */
428
- item(id) {
429
- return this.http.getJson(`/api/item/${encodeURIComponent(id)}`);
430
- }
431
- /** Known + discovered domains. */
432
- domains() {
433
- return this.http.getJson("/api/domains");
434
- }
435
- /** All quests (work-graph items). */
436
- quests() {
437
- return this.http.getJson("/api/quests");
438
- }
439
- /** A single quest with claims/submissions/payouts. */
440
- quest(id) {
441
- return this.http.getJson(`/api/quest/${encodeURIComponent(id)}`);
442
- }
443
- /** An address's on-chain-derived profile + identity + reputation. */
444
- profile(addr, opts = {}) {
445
- return this.http.getJson(`/api/profile/${encodeURIComponent(addr)}`, opts.live ? { live: 1 } : void 0);
446
- }
447
- /** Reputation leaderboard (top 50). */
448
- leaderboard() {
449
- return this.http.getJson("/api/leaderboard");
450
- }
451
- /** Network dashboard snapshot. */
452
- network() {
453
- return this.http.getJson("/api/network");
454
- }
455
- /** Network time-series for charts. */
456
- networkSeries(range = "24h") {
457
- return this.http.getJson("/api/network/series", { range });
458
- }
459
- /** Recent blocks with miner/reward. */
460
- networkBlocks(limit = 15) {
461
- return this.http.getJson("/api/network/blocks", { limit });
462
- }
463
- /** Per-miner hashrate estimate. */
464
- miner(addr) {
465
- return this.http.getJson(`/api/miner/${encodeURIComponent(addr)}`);
466
- }
467
- /** Recent activity feed. */
468
- activity() {
469
- return this.http.getJson("/api/activity");
470
- }
471
- /** The gamified cairn wall. */
472
- wall() {
473
- return this.http.getJson("/api/wall");
474
- }
475
- /** Server health + chain reachability. */
476
- health() {
477
- return this.http.getJson("/api/health");
478
- }
479
- // ---- writes (wallet-signed) -------------------------------------------
480
- /**
481
- * Publish a board item: compute the canonical payload hash, have the WALLET
482
- * build/sign/submit the Propose (clear-signed approval), then register the
483
- * off-chain content. The payload hash is byte-identical to what the cairn
484
- * server verifies (csd-codec.payloadHash over { v:1, domain, title, body, links }).
485
- */
486
- async propose(input) {
487
- const wallet = this.requireWallet();
488
- const content = {
489
- v: 1,
490
- domain: input.domain,
491
- title: input.title,
492
- body: input.body ?? "",
493
- links: input.links ?? []
494
- };
495
- const ph = (0, import_csd_codec2.payloadHash)(content);
496
- const uri = `cairn:v1:${ph.slice(2, 14)}`;
497
- const lifetime = input.lifetimeEpochs ?? 720;
498
- const height = await this.tipHeight();
499
- const expiresEpoch = Math.floor(height / import_csd_codec3.EPOCH_LEN) + lifetime;
500
- const fee = input.fee ?? import_csd_codec3.MIN_FEE_PROPOSE;
501
- const r = await wallet.propose({ domain: content.domain, payloadHash: ph, uri, expiresEpoch, fee });
502
- const txid3 = String(r.txid ?? "");
503
- if (!txid3) throw new CairnError("Propose submitted but no txid was returned by the wallet.");
504
- const reg = await this.tryRegister(content, txid3);
505
- return { txid: txid3, payloadHash: ph, content, registered: reg.ok, registerError: reg.error };
506
- }
507
- /**
508
- * Register off-chain content for an already-submitted proposal txid. Idempotent.
509
- * Returns { ok } — the cairn server verifies sha256(canonical) == on-chain payload_hash.
510
- */
511
- async registerContent(content, txid3) {
512
- try {
513
- const res = await this.http.postJson("/api/content", {
514
- domain: content.domain,
515
- title: content.title,
516
- body: content.body,
517
- links: content.links,
518
- txid: txid3
519
- });
520
- return { ok: !!res.ok, error: res.ok ? void 0 : res.error };
521
- } catch (e) {
522
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
523
- }
524
- }
525
- /** Support/attest a proposal (wallet-signed; always clear-signed). */
526
- async support(proposalId, opts = {}) {
527
- const wallet = this.requireWallet();
528
- return wallet.attest({
529
- proposalId,
530
- score: opts.score ?? 80,
531
- confidence: opts.confidence ?? 70,
532
- fee: opts.fee ?? import_csd_codec3.MIN_FEE_ATTEST
533
- });
534
- }
535
- // ---- internal ----------------------------------------------------------
536
- async tipHeight() {
537
- const tip = await this.http.getJson("/api/rpc/tip");
538
- const h = Number(tip?.height);
539
- if (!Number.isFinite(h)) throw new CairnError("Could not read chain tip height for expiry computation.");
540
- return h;
541
- }
542
- async tryRegister(content, txid3) {
543
- const delays = [0, 1500, 3e3, 5e3];
544
- let last = { ok: false, error: "not attempted" };
545
- for (const d of delays) {
546
- if (d) await sleep(d);
547
- last = await this.registerContent(content, txid3);
548
- if (last.ok) return last;
549
- if (!/not found yet|not found/i.test(last.error ?? "")) return last;
550
- }
551
- return last;
552
- }
553
- };
554
- function sleep(ms) {
555
- return new Promise((r) => setTimeout(r, ms));
556
- }
557
-
558
434
  // src/indexer.ts
559
- var import_csd_codec4 = require("@inversealtruism/csd-codec");
435
+ var import_csd_codec2 = require("@inversealtruism/csd-codec");
560
436
  var HEX64 = /^0x[0-9a-fA-F]{64}$/;
561
437
  var MAX_SSE_BUF = 1024 * 1024;
562
438
  function isValidMerkleProof(p) {
@@ -663,24 +539,34 @@ var IndexerClient = class {
663
539
  }
664
540
  let folds = false;
665
541
  try {
666
- 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);
667
543
  } catch {
668
544
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "malformed merkle proof" };
669
545
  }
670
546
  if (!folds) {
671
547
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
672
548
  }
549
+ let unverified;
673
550
  if (this.opts.headerMerkleAt) {
551
+ let onchain;
674
552
  try {
675
- 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) {
676
559
  if (norm(onchain) !== norm(proof.merkle_root)) {
677
560
  return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
678
561
  }
679
562
  return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
680
- } catch {
681
563
  }
564
+ unverified ??= `header source returned no merkle root for ${proof.block_height}`;
565
+ } else {
566
+ unverified = "no header source configured (headerMerkleAt)";
682
567
  }
683
- 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}` };
684
570
  }
685
571
  // ---- live streams ------------------------------------------------------
686
572
  /** Stream all events (blocks, proposals, attestations, reorgs) via SSE. */
@@ -820,7 +706,7 @@ function norm(h) {
820
706
  }
821
707
 
822
708
  // src/content.ts
823
- var import_csd_codec5 = require("@inversealtruism/csd-codec");
709
+ var import_csd_codec3 = require("@inversealtruism/csd-codec");
824
710
  var ContentClient = class {
825
711
  sources;
826
712
  constructor(opts) {
@@ -834,29 +720,32 @@ var ContentClient = class {
834
720
  * This is byte-identical to what the cairn server and wallet compute.
835
721
  */
836
722
  prepare(obj) {
837
- const canonical = (0, import_csd_codec5.canonicalJson)(obj);
723
+ const canonical = (0, import_csd_codec3.canonicalJson)(obj);
838
724
  return {
839
- payloadHash: (0, import_csd_codec5.payloadHash)(obj),
725
+ payloadHash: (0, import_csd_codec3.payloadHash)(obj),
840
726
  canonical,
841
727
  bytes: new TextEncoder().encode(canonical)
842
728
  };
843
729
  }
844
- /** 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. */
845
731
  put(obj) {
846
732
  return this.prepare(obj);
847
733
  }
848
734
  /** Just the payload hash for an object. */
849
735
  hash(obj) {
850
- return (0, import_csd_codec5.payloadHash)(obj);
736
+ return (0, import_csd_codec3.payloadHash)(obj);
851
737
  }
852
738
  /**
853
739
  * Fetch content by hash and VERIFY it self-certifies (sha256(bytes) == hash).
854
- * Tries each configured source in order. Returns raw bytes, or null if no
855
- * source holds it. Throws ContentVerificationError if a source returns bytes
856
- * 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).
857
745
  */
858
746
  async getBytes(hash) {
859
747
  const h = normalizeHash(hash);
748
+ let mismatched = false;
860
749
  for (const src of this.sources) {
861
750
  let got = null;
862
751
  try {
@@ -865,22 +754,29 @@ var ContentClient = class {
865
754
  continue;
866
755
  }
867
756
  if (!got) continue;
868
- if (!(0, import_csd_codec5.verifyContentBytes)(got.bytes, h)) {
869
- throw new ContentVerificationError(h);
757
+ if (!(0, import_csd_codec3.verifyContentBytes)(got.bytes, h)) {
758
+ mismatched = true;
759
+ continue;
870
760
  }
871
761
  return got.bytes;
872
762
  }
763
+ if (mismatched) throw new ContentVerificationError(h);
873
764
  return null;
874
765
  }
875
- /** 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. */
876
768
  async get(hash) {
877
769
  const bytes = await this.getBytes(hash);
878
770
  if (!bytes) return null;
879
- 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
+ }
880
776
  }
881
777
  /** Verify already-fetched bytes against a hash (no network). */
882
778
  verify(bytes, hash) {
883
- return (0, import_csd_codec5.verifyContentBytes)(bytes, normalizeHash(hash));
779
+ return (0, import_csd_codec3.verifyContentBytes)(bytes, normalizeHash(hash));
884
780
  }
885
781
  };
886
782
  function normalizeHash(hash) {
@@ -944,11 +840,46 @@ var RegistryClient = class {
944
840
  // src/names.ts
945
841
  var import_cairnx_core = require("@inversealtruism/cairnx-core");
946
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
+ }
947
870
  var NamesClient = class {
948
871
  constructor(http) {
949
872
  this.http = http;
950
873
  }
951
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
+ }
952
883
  /** Resolve a .csd name for DISPLAY (server-trusted; see the module trust note). null = unknown/invalid name. */
953
884
  async resolve(name) {
954
885
  if (!nameOk(name)) return null;
@@ -969,15 +900,21 @@ var NamesClient = class {
969
900
  throw e;
970
901
  }
971
902
  }
972
- /** 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. */
973
905
  async nameHistory(name) {
974
906
  if (!nameOk(name)) return null;
907
+ const path = `/trade/api/cairnx/name-history/${encodeURIComponent(name)}`;
908
+ let h;
975
909
  try {
976
- return await this.http.getJson(`/trade/api/cairnx/name-history/${encodeURIComponent(name)}`);
910
+ h = await this.http.getJson(path);
977
911
  } catch (e) {
978
912
  if (e?.status === 404) return null;
979
913
  throw e;
980
914
  }
915
+ const p = nameHistoryProblem(h, name);
916
+ if (p) throw this.malformed(path, p);
917
+ return h;
981
918
  }
982
919
  /** Reverse lookup: an address's chosen primary .csd name (null name = none set). */
983
920
  async primary(addr) {
@@ -1001,29 +938,40 @@ var NamesClient = class {
1001
938
  }
1002
939
  /** All registered names (display list; short-edge-cached server-side). */
1003
940
  names() {
1004
- return this.http.getJson(`/trade/api/cairnx/names`);
1005
- }
1006
- /** All deployed tokens. */
1007
- tokens() {
1008
- 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;
1009
952
  }
1010
- /** 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. */
1011
954
  async token(ticker) {
1012
- 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;
1013
958
  try {
1014
- return await this.http.getJson(`/trade/api/cairnx/token/${ticker}`);
959
+ t = await this.http.getJson(path);
1015
960
  } catch (e) {
1016
961
  if (e?.status === 404) return null;
1017
962
  throw e;
1018
963
  }
964
+ const p = tokenProblem(t);
965
+ if (p) throw this.malformed(path, p);
966
+ return t;
1019
967
  }
1020
968
  /** Open offers (the market's display list). */
1021
969
  offers() {
1022
- return this.http.getJson(`/trade/api/cairnx/offers`);
970
+ return this.list(`/trade/api/cairnx/offers`);
1023
971
  }
1024
972
  /** Open bids. */
1025
973
  bids() {
1026
- return this.http.getJson(`/trade/api/cairnx/bids`);
974
+ return this.list(`/trade/api/cairnx/bids`);
1027
975
  }
1028
976
  /** The full canonical CairnX state (large; prefer the scoped reads above). */
1029
977
  state() {
@@ -1256,7 +1204,7 @@ var WalletConnection = class {
1256
1204
  send(params) {
1257
1205
  return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
1258
1206
  }
1259
- /** 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. */
1260
1208
  propose(params) {
1261
1209
  return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
1262
1210
  }
@@ -1287,9 +1235,10 @@ async function connect(opts = {}) {
1287
1235
 
1288
1236
  // src/fillverify.ts
1289
1237
  var import_csd_client3 = require("@inversealtruism/csd-client");
1290
- var import_csd_codec6 = require("@inversealtruism/csd-codec");
1238
+ var import_csd_codec4 = require("@inversealtruism/csd-codec");
1291
1239
  var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
1292
1240
  var ADDR = /^0x[0-9a-f]{40}$/;
1241
+ var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
1293
1242
  var HASH = /^0x[0-9a-f]{64}$/;
1294
1243
  var COINBASE = "0x" + "00".repeat(32);
1295
1244
  function bindPlannedSums(planned, proven) {
@@ -1326,7 +1275,7 @@ async function provenAuthor(client, provenTx) {
1326
1275
  } catch {
1327
1276
  return null;
1328
1277
  }
1329
- 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;
1330
1279
  const out = src.outputs?.[Number(in0.vout)];
1331
1280
  const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
1332
1281
  return spk && ADDR.test(spk) ? spk : null;
@@ -1340,13 +1289,17 @@ async function preverifyOffer(opts) {
1340
1289
  } catch (e) {
1341
1290
  return { ok: false, trust: "unverified", transient: true, reason: `couldn't reach the chain to verify the offer (${e?.message ?? e})` };
1342
1291
  }
1343
- 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` };
1344
1296
  return { ok: false, trust: "unverified", transient: true, blockHeight: incl.blockHeight, reason: incl.reason ?? `offer not merkle-proven on-chain (${incl.trustLevel})` };
1297
+ }
1345
1298
  const blockHeight = Number(incl.blockHeight);
1346
1299
  let provenTx, provenId;
1347
1300
  try {
1348
1301
  provenTx = (0, import_csd_client3.rpcTxToTx)(incl.tx);
1349
- provenId = (0, import_csd_codec6.txid)(provenTx);
1302
+ provenId = (0, import_csd_codec4.txid)(provenTx);
1350
1303
  } catch {
1351
1304
  return { ok: false, trust: "unverified", reason: "the on-chain offer tx couldn't be re-derived" };
1352
1305
  }
@@ -1362,7 +1315,7 @@ async function preverifyOffer(opts) {
1362
1315
  rec = null;
1363
1316
  }
1364
1317
  if (!rec || rec.t !== "offer") return { ok: false, trust: "unverified", reason: "the offer record doesn't bind to its on-chain commitment" };
1365
- 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())
1366
1319
  return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
1367
1320
  const seller = await provenAuthor(opts.client, provenTx);
1368
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" };
@@ -1377,8 +1330,15 @@ async function preverifyOffer(opts) {
1377
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" };
1378
1331
  let outputPlan;
1379
1332
  let sumsMismatch;
1380
- if (opts.pay !== void 0 && opts.pay !== null && !(0, import_cairnx_core2.isTokenWant)(offerRec.want) && offerRec.min === void 0) {
1381
- 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 {
1382
1342
  const provenState = {
1383
1343
  id,
1384
1344
  seller,
@@ -1386,7 +1346,7 @@ async function preverifyOffer(opts) {
1386
1346
  want: { ...offerRec.want, payto },
1387
1347
  taker: offerRec.taker,
1388
1348
  bid: offerRec.bid,
1389
- status: typeof servedStatus === "string" ? servedStatus : "open",
1349
+ status: servedStatus,
1390
1350
  expiresEpoch: 0,
1391
1351
  height: blockHeight,
1392
1352
  feeBps: terms.feeBps
@@ -1395,16 +1355,25 @@ async function preverifyOffer(opts) {
1395
1355
  const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
1396
1356
  if (plan.kind === "csd-outputs") {
1397
1357
  outputPlan = plan.outputs;
1398
- if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
1358
+ if (has(opts.plannedOutputs)) {
1399
1359
  sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
1360
+ planBound = true;
1361
+ }
1400
1362
  } else if (plan.kind === "undeliverable") {
1401
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})`;
1402
1366
  }
1403
1367
  } catch (e) {
1404
1368
  sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
1405
1369
  }
1406
1370
  }
1407
- 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 };
1408
1377
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
1409
1378
  const so = opts.servedOffer;
1410
1379
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -1429,8 +1398,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
1429
1398
  var CairnController = class {
1430
1399
  state = DISCONNECTED;
1431
1400
  conn = null;
1432
- // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
1433
- // 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.
1434
1404
  epoch = 0;
1435
1405
  listeners = /* @__PURE__ */ new Set();
1436
1406
  getWalletFn;
@@ -1461,6 +1431,7 @@ var CairnController = class {
1461
1431
  }
1462
1432
  onAccounts = (accounts) => {
1463
1433
  if (!Array.isArray(accounts) || accounts.length === 0) {
1434
+ this.epoch++;
1464
1435
  this.setState({ status: "disconnected", account: null });
1465
1436
  return;
1466
1437
  }
@@ -1472,6 +1443,7 @@ var CairnController = class {
1472
1443
  }
1473
1444
  };
1474
1445
  onDisconnect = () => {
1446
+ this.epoch++;
1475
1447
  this.setState({ status: "disconnected", account: null });
1476
1448
  };
1477
1449
  // Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
@@ -1526,7 +1498,7 @@ var CairnController = class {
1526
1498
  };
1527
1499
 
1528
1500
  // src/index.ts
1529
- var DEFAULT_SPV_CHECKPOINT = { height: 101998, hash: "0x0000000000085295c9e13bbf276a11affb5470059235d1ea85d6334c8b43c49c" };
1501
+ var DEFAULT_SPV_CHECKPOINT = { height: 115403, hash: "0x00000000000052e29166597d598827b137af1dea7542123b848637fee0b67347" };
1530
1502
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
1531
1503
  var SPV_HTTP_TIMEOUT_MS = 15e3;
1532
1504
  var SPV_HTTP_MAX_RETRIES = 4;
@@ -1538,7 +1510,10 @@ var spvIsTransient = (e) => {
1538
1510
  return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
1539
1511
  };
1540
1512
  var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1541
- 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;
1542
1517
  var Cairn = class {
1543
1518
  config;
1544
1519
  chain;
@@ -1547,7 +1522,6 @@ var Cairn = class {
1547
1522
  registry;
1548
1523
  /** .csd names + CairnX market READS (server-trusted display reads; see names.ts trust note). */
1549
1524
  names;
1550
- board;
1551
1525
  wallet;
1552
1526
  cairnHttp;
1553
1527
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
@@ -1573,7 +1547,6 @@ var Cairn = class {
1573
1547
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
1574
1548
  this.chain = new Chain({ rpcUrl: rpc, fetch, timeoutMs });
1575
1549
  this.wallet = config.wallet;
1576
- this.board = new BoardClient(this.cairnHttp, this.wallet);
1577
1550
  this.index = new IndexerClient(indexerHttp, {
1578
1551
  fetch,
1579
1552
  WebSocketImpl: config.WebSocketImpl,
@@ -1591,16 +1564,17 @@ var Cairn = class {
1591
1564
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
1592
1565
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
1593
1566
  * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
1594
- * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
1595
- * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
1596
- * 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.
1597
1570
  */
1598
1571
  async verifiedHeaderMerkleAt(height) {
1599
1572
  if (!Number.isInteger(height) || height < this._spvCp.height)
1600
1573
  throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
1601
1574
  return this.withSpvReseed(async (lc) => {
1602
- const haveTo = lc.baseHeight + lc.chain.length - 1;
1575
+ const haveTo = spvTipOf(lc);
1603
1576
  if (haveTo < height) await lc.sync(height);
1577
+ else await this.recheckCachedHeader(lc, height, haveTo);
1604
1578
  const vh = lc.chain[height - lc.baseHeight];
1605
1579
  if (!vh) throw new Error(`light client has no verified header at ${height}`);
1606
1580
  return String(vh.header.merkle);
@@ -1612,11 +1586,8 @@ var Cairn = class {
1612
1586
  // this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
1613
1587
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
1614
1588
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
1615
- // failure propagates fail-closed; there is no loop.
1616
- // RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
1617
- // branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
1618
- // for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
1619
- // 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.
1620
1591
  async withSpvReseed(op) {
1621
1592
  const lc = await this.seededSpvLight();
1622
1593
  try {
@@ -1629,6 +1600,16 @@ var Cairn = class {
1629
1600
  return out;
1630
1601
  }
1631
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
+ }
1632
1613
  // Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
1633
1614
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
1634
1615
  // dirty the shared field while an attempt is in flight).
@@ -1641,7 +1622,7 @@ var Cairn = class {
1641
1622
  await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
1642
1623
  try {
1643
1624
  const tipHeight = Number((await this.chain.tip()).height);
1644
- const have = lc.baseHeight + lc.chain.length - 1;
1625
+ const have = spvTipOf(lc);
1645
1626
  if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
1646
1627
  } catch {
1647
1628
  }
@@ -1686,6 +1667,7 @@ var Cairn = class {
1686
1667
  if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
1687
1668
  const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
1688
1669
  const url = `${this._cairnBase}/api/headers/${from}/${count}`;
1670
+ const budget = new RetryWaitBudget(SPV_RETRY_WAIT_BUDGET_MS);
1689
1671
  for (let attempt = 0; ; attempt++) {
1690
1672
  let res;
1691
1673
  let parsed;
@@ -1697,8 +1679,9 @@ var Cairn = class {
1697
1679
  parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
1698
1680
  }
1699
1681
  } catch (e) {
1700
- if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
1701
- 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);
1702
1685
  continue;
1703
1686
  } finally {
1704
1687
  clearTimeout(timer);
@@ -1711,7 +1694,9 @@ var Cairn = class {
1711
1694
  if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
1712
1695
  res.body?.cancel?.().catch(() => {
1713
1696
  });
1714
- 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);
1715
1700
  continue;
1716
1701
  }
1717
1702
  throw new Error(`/api/headers ${res.status}`);
@@ -1734,12 +1719,20 @@ var Cairn = class {
1734
1719
  * EXACTLY the outputPlan, never resolver-served outputs.
1735
1720
  */
1736
1721
  async verifyOfferForFill(offerId, servedOffer, opts) {
1737
- 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
+ };
1738
1731
  return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
1739
1732
  }
1740
1733
  /**
1741
- * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
1742
- * `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.
1743
1736
  */
1744
1737
  async connect(opts = {}) {
1745
1738
  const { wallet, address } = await connect(opts);
@@ -1749,7 +1742,6 @@ var Cairn = class {
1749
1742
  /** Attach an already-connected wallet (e.g. one you constructed yourself). */
1750
1743
  attachWallet(wallet) {
1751
1744
  this.wallet = wallet;
1752
- this.board = new BoardClient(this.cairnHttp, wallet);
1753
1745
  }
1754
1746
  /** True if a wallet is attached. */
1755
1747
  get connected() {
@@ -1758,7 +1750,6 @@ var Cairn = class {
1758
1750
  };
1759
1751
  // Annotate the CommonJS export names for ESM import in node:
1760
1752
  0 && (module.exports = {
1761
- BoardClient,
1762
1753
  Cairn,
1763
1754
  CairnController,
1764
1755
  CairnError,