@inversealtruism/cairn-sdk 0.4.6 → 0.6.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.js CHANGED
@@ -1,24 +1,16 @@
1
- import {
2
- Http,
3
- RegistryClient,
4
- readCapped
5
- } from "./chunk-2HP3ZXBO.js";
6
- import {
7
- BoardClient
8
- } from "./chunk-E2MA75S3.js";
9
1
  import {
10
2
  Chain,
11
3
  LightClient
12
4
  } from "./chunk-C452UODV.js";
13
5
  import {
14
6
  ContentClient
15
- } from "./chunk-E4YQRWZZ.js";
7
+ } from "./chunk-QEYBWBXC.js";
16
8
  import {
17
9
  IndexerClient
18
- } from "./chunk-J5J4JEJ6.js";
10
+ } from "./chunk-K5MGEYEU.js";
19
11
  import {
20
12
  CairnController
21
- } from "./chunk-OB33SSGA.js";
13
+ } from "./chunk-MLNW2RQB.js";
22
14
  import {
23
15
  WalletConnection,
24
16
  connect,
@@ -26,7 +18,14 @@ import {
26
18
  discoverProviders,
27
19
  getWallet,
28
20
  isInstalled
29
- } from "./chunk-FWTPXLGV.js";
21
+ } from "./chunk-BMH2MACB.js";
22
+ import {
23
+ Http,
24
+ RegistryClient,
25
+ RetryWaitBudget,
26
+ readCapped,
27
+ retryAfterMs
28
+ } from "./chunk-67QBDZPW.js";
30
29
  import {
31
30
  CairnError,
32
31
  ContentVerificationError,
@@ -40,16 +39,51 @@ import {
40
39
  errorCode,
41
40
  mapProviderError,
42
41
  mapSubmitResultError
43
- } from "./chunk-ZFVE6MU7.js";
42
+ } from "./chunk-2A4AJBO5.js";
44
43
 
45
44
  // src/names.ts
46
- import { NAME_RE, RESERVED_NAMES } from "@inversealtruism/cairnx-core";
45
+ import { NAME_RE, RESERVED_NAMES, TICKER_RE, AMOUNT_RE, HASH_RE } from "@inversealtruism/cairnx-core";
47
46
  var nameOk = (n) => typeof n === "string" && NAME_RE.test(n) && !RESERVED_NAMES.has(n);
47
+ var amountOk = (v) => typeof v === "string" && AMOUNT_RE.test(v);
48
+ var heightOk = (v) => Number.isSafeInteger(v) && v >= 0;
49
+ function nameHistoryProblem(h, name) {
50
+ if (!h || typeof h !== "object" || Array.isArray(h)) return "not an object";
51
+ const r = h;
52
+ if (r.name !== name) return `name is ${JSON.stringify(r.name)}, expected ${JSON.stringify(name)}`;
53
+ if (!heightOk(r.tipHeight)) return "bad tipHeight";
54
+ if (!r.resolve || typeof r.resolve !== "object") return "resolve is not an object";
55
+ if (typeof r.scopedReplaySufficient !== "boolean") return "scopedReplaySufficient is not a boolean";
56
+ if (!Array.isArray(r.events)) return "events is not an array";
57
+ for (const [i, e] of r.events.entries()) {
58
+ const ev = e;
59
+ if (!ev || typeof ev.txid !== "string" || !HASH_RE.test(ev.txid) || !heightOk(ev.height) || !heightOk(ev.pos) || typeof ev.kind !== "string")
60
+ return `event ${i} is malformed`;
61
+ }
62
+ return null;
63
+ }
64
+ function tokenProblem(t) {
65
+ if (!t || typeof t !== "object") return "not an object";
66
+ const r = t;
67
+ if (typeof r.ticker !== "string" || !TICKER_RE.test(r.ticker)) return "bad ticker";
68
+ if (!Number.isSafeInteger(r.decimals) || r.decimals < 0) return `bad decimals (${String(r.decimals)})`;
69
+ if (!amountOk(r.supply)) return "supply is not a base-unit integer string";
70
+ if (!amountOk(r.minted)) return "minted is not a base-unit integer string";
71
+ if (r.mintLimit !== void 0 && !amountOk(r.mintLimit)) return "mintLimit is not a base-unit integer string";
72
+ return null;
73
+ }
48
74
  var NamesClient = class {
49
75
  constructor(http) {
50
76
  this.http = http;
51
77
  }
52
78
  http;
79
+ malformed(path, what) {
80
+ return new HttpError(200, this.http.url(path), `malformed response: ${what}`);
81
+ }
82
+ async list(path) {
83
+ const body = await this.http.getJson(path);
84
+ if (!Array.isArray(body)) throw this.malformed(path, "expected an array");
85
+ return body;
86
+ }
53
87
  /** Resolve a .csd name for DISPLAY (server-trusted; see the module trust note). null = unknown/invalid name. */
54
88
  async resolve(name) {
55
89
  if (!nameOk(name)) return null;
@@ -70,15 +104,21 @@ var NamesClient = class {
70
104
  throw e;
71
105
  }
72
106
  }
73
- /** The event history of one name (registrations, transfers, sets, renewals). null = invalid name. */
107
+ /** The event history of one name (registrations, transfers, sets, renewals) with the server's current
108
+ * claim. null = invalid or unregistered name. Throws a typed HttpError on a malformed body. */
74
109
  async nameHistory(name) {
75
110
  if (!nameOk(name)) return null;
111
+ const path = `/trade/api/cairnx/name-history/${encodeURIComponent(name)}`;
112
+ let h;
76
113
  try {
77
- return await this.http.getJson(`/trade/api/cairnx/name-history/${encodeURIComponent(name)}`);
114
+ h = await this.http.getJson(path);
78
115
  } catch (e) {
79
116
  if (e?.status === 404) return null;
80
117
  throw e;
81
118
  }
119
+ const p = nameHistoryProblem(h, name);
120
+ if (p) throw this.malformed(path, p);
121
+ return h;
82
122
  }
83
123
  /** Reverse lookup: an address's chosen primary .csd name (null name = none set). */
84
124
  async primary(addr) {
@@ -102,29 +142,40 @@ var NamesClient = class {
102
142
  }
103
143
  /** All registered names (display list; short-edge-cached server-side). */
104
144
  names() {
105
- return this.http.getJson(`/trade/api/cairnx/names`);
145
+ return this.list(`/trade/api/cairnx/names`);
106
146
  }
107
- /** All deployed tokens. */
108
- tokens() {
109
- return this.http.getJson(`/trade/api/cairnx/tokens`);
147
+ /** All deployed tokens. Throws a typed HttpError if any row has a malformed ticker, decimals or amount. */
148
+ async tokens() {
149
+ const path = `/trade/api/cairnx/tokens`;
150
+ const rows = await this.list(path);
151
+ rows.forEach((t, i) => {
152
+ const p = tokenProblem(t);
153
+ if (p) throw this.malformed(path, `token row ${i}: ${p}`);
154
+ });
155
+ return rows;
110
156
  }
111
- /** One token by ticker. null = unknown/invalid ticker. */
157
+ /** One token by ticker. null = unknown/invalid ticker. Throws a typed HttpError on a malformed body. */
112
158
  async token(ticker) {
113
- if (typeof ticker !== "string" || !/^[A-Z][A-Z0-9]{2,11}$/.test(ticker)) return null;
159
+ if (typeof ticker !== "string" || !TICKER_RE.test(ticker)) return null;
160
+ const path = `/trade/api/cairnx/token/${ticker}`;
161
+ let t;
114
162
  try {
115
- return await this.http.getJson(`/trade/api/cairnx/token/${ticker}`);
163
+ t = await this.http.getJson(path);
116
164
  } catch (e) {
117
165
  if (e?.status === 404) return null;
118
166
  throw e;
119
167
  }
168
+ const p = tokenProblem(t);
169
+ if (p) throw this.malformed(path, p);
170
+ return t;
120
171
  }
121
172
  /** Open offers (the market's display list). */
122
173
  offers() {
123
- return this.http.getJson(`/trade/api/cairnx/offers`);
174
+ return this.list(`/trade/api/cairnx/offers`);
124
175
  }
125
176
  /** Open bids. */
126
177
  bids() {
127
- return this.http.getJson(`/trade/api/cairnx/bids`);
178
+ return this.list(`/trade/api/cairnx/bids`);
128
179
  }
129
180
  /** The full canonical CairnX state (large; prefer the scoped reads above). */
130
181
  state() {
@@ -151,6 +202,7 @@ import {
151
202
  epochOf
152
203
  } from "@inversealtruism/cairnx-core";
153
204
  var ADDR = /^0x[0-9a-f]{40}$/;
205
+ var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
154
206
  var HASH = /^0x[0-9a-f]{64}$/;
155
207
  var COINBASE = "0x" + "00".repeat(32);
156
208
  function bindPlannedSums(planned, proven) {
@@ -201,8 +253,12 @@ async function preverifyOffer(opts) {
201
253
  } catch (e) {
202
254
  return { ok: false, trust: "unverified", transient: true, reason: `couldn't reach the chain to verify the offer (${e?.message ?? e})` };
203
255
  }
204
- if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash)
256
+ if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash) {
257
+ const below = BELOW_BASE.exec(incl.reason ?? "");
258
+ if (below)
259
+ 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` };
205
260
  return { ok: false, trust: "unverified", transient: true, blockHeight: incl.blockHeight, reason: incl.reason ?? `offer not merkle-proven on-chain (${incl.trustLevel})` };
261
+ }
206
262
  const blockHeight = Number(incl.blockHeight);
207
263
  let provenTx, provenId;
208
264
  try {
@@ -238,8 +294,15 @@ async function preverifyOffer(opts) {
238
294
  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" };
239
295
  let outputPlan;
240
296
  let sumsMismatch;
241
- if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
242
- const servedStatus = opts.servedOffer?.status;
297
+ let planReason;
298
+ let planBound = false;
299
+ const has = (v) => v !== void 0 && v !== null;
300
+ const servedStatus = opts.servedOffer?.status;
301
+ if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
302
+ else if (isTokenWant(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
303
+ 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)";
304
+ 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)";
305
+ else {
243
306
  const provenState = {
244
307
  id,
245
308
  seller,
@@ -247,7 +310,7 @@ async function preverifyOffer(opts) {
247
310
  want: { ...offerRec.want, payto },
248
311
  taker: offerRec.taker,
249
312
  bid: offerRec.bid,
250
- status: typeof servedStatus === "string" ? servedStatus : "open",
313
+ status: servedStatus,
251
314
  expiresEpoch: 0,
252
315
  height: blockHeight,
253
316
  feeBps: terms.feeBps
@@ -256,16 +319,25 @@ async function preverifyOffer(opts) {
256
319
  const plan = fillOutputPlan(provenState, opts.pay);
257
320
  if (plan.kind === "csd-outputs") {
258
321
  outputPlan = plan.outputs;
259
- if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
322
+ if (has(opts.plannedOutputs)) {
260
323
  sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
324
+ planBound = true;
325
+ }
261
326
  } else if (plan.kind === "undeliverable") {
262
327
  sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
328
+ } else {
329
+ planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
263
330
  }
264
331
  } catch (e) {
265
332
  sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
266
333
  }
267
334
  }
268
- const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
335
+ const planFields = {
336
+ ...outputPlan ? { outputPlan } : {},
337
+ ...has(opts.plannedOutputs) ? { planBound } : {},
338
+ ...!outputPlan && planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason } : {}
339
+ };
340
+ const base = { payto, seller, terms, blockHeight, ...planFields };
269
341
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
270
342
  const so = opts.servedOffer;
271
343
  if (so?.want?.payto === void 0 || so?.want?.payto === null)
@@ -286,7 +358,7 @@ async function preverifyOffer(opts) {
286
358
  }
287
359
 
288
360
  // src/index.ts
289
- var DEFAULT_SPV_CHECKPOINT = { height: 101998, hash: "0x0000000000085295c9e13bbf276a11affb5470059235d1ea85d6334c8b43c49c" };
361
+ var DEFAULT_SPV_CHECKPOINT = { height: 115403, hash: "0x00000000000052e29166597d598827b137af1dea7542123b848637fee0b67347" };
290
362
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
291
363
  var SPV_HTTP_TIMEOUT_MS = 15e3;
292
364
  var SPV_HTTP_MAX_RETRIES = 4;
@@ -298,7 +370,10 @@ var spvIsTransient = (e) => {
298
370
  return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
299
371
  };
300
372
  var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
301
- var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
373
+ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
374
+ var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
375
+ var SPV_RECHECK_DEPTH = 12;
376
+ var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
302
377
  var Cairn = class {
303
378
  config;
304
379
  chain;
@@ -307,7 +382,6 @@ var Cairn = class {
307
382
  registry;
308
383
  /** .csd names + CairnX market READS (server-trusted display reads; see names.ts trust note). */
309
384
  names;
310
- board;
311
385
  wallet;
312
386
  cairnHttp;
313
387
  // Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
@@ -333,7 +407,6 @@ var Cairn = class {
333
407
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
334
408
  this.chain = new Chain({ rpcUrl: rpc, fetch, timeoutMs });
335
409
  this.wallet = config.wallet;
336
- this.board = new BoardClient(this.cairnHttp, this.wallet);
337
410
  this.index = new IndexerClient(indexerHttp, {
338
411
  fetch,
339
412
  WebSocketImpl: config.WebSocketImpl,
@@ -351,16 +424,17 @@ var Cairn = class {
351
424
  * to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
352
425
  * header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
353
426
  * MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
354
- * withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
355
- * NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
356
- * read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
427
+ * withSpvReseed. A height within SPV_RECHECK_DEPTH of the cached tip is re-checked against the served
428
+ * chain first (recheckCachedHeader), so a block orphaned after it was cached stops backing
429
+ * "verified-inclusion". Deeper heights answer from the cache with no network touch.
357
430
  */
358
431
  async verifiedHeaderMerkleAt(height) {
359
432
  if (!Number.isInteger(height) || height < this._spvCp.height)
360
433
  throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
361
434
  return this.withSpvReseed(async (lc) => {
362
- const haveTo = lc.baseHeight + lc.chain.length - 1;
435
+ const haveTo = spvTipOf(lc);
363
436
  if (haveTo < height) await lc.sync(height);
437
+ else await this.recheckCachedHeader(lc, height, haveTo);
364
438
  const vh = lc.chain[height - lc.baseHeight];
365
439
  if (!vh) throw new Error(`light client has no verified header at ${height}`);
366
440
  return String(vh.header.merkle);
@@ -372,11 +446,8 @@ var Cairn = class {
372
446
  // this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
373
447
  // (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
374
448
  // on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
375
- // failure propagates fail-closed; there is no loop.
376
- // RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
377
- // branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
378
- // for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
379
- // the permanent-brick leg only.
449
+ // failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
450
+ // cached header that the served chain no longer carries is a structural failure.
380
451
  async withSpvReseed(op) {
381
452
  const lc = await this.seededSpvLight();
382
453
  try {
@@ -389,6 +460,16 @@ var Cairn = class {
389
460
  return out;
390
461
  }
391
462
  }
463
+ // A near-tip cached header the served chain no longer carries is a reorg: throw a structural error so
464
+ // withSpvReseed rebuilds. The message must not match SPV_TRANSIENT_RE, or it would be retried on the stale cache.
465
+ async recheckCachedHeader(lc, height, haveTo) {
466
+ if (height <= haveTo - SPV_RECHECK_DEPTH) return;
467
+ const cached = lc.chain[height - lc.baseHeight];
468
+ if (!cached) return;
469
+ const [served] = await this.fetchHeadersBatch(height, 1);
470
+ if (String(served?.hash ?? "").toLowerCase() !== String(cached.hash).toLowerCase())
471
+ throw new Error(`SPV cached block at height ${height} is no longer on the served chain (reorg); reseeding`);
472
+ }
392
473
  // Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
393
474
  // the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
394
475
  // dirty the shared field while an attempt is in flight).
@@ -401,7 +482,7 @@ var Cairn = class {
401
482
  await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
402
483
  try {
403
484
  const tipHeight = Number((await this.chain.tip()).height);
404
- const have = lc.baseHeight + lc.chain.length - 1;
485
+ const have = spvTipOf(lc);
405
486
  if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
406
487
  } catch {
407
488
  }
@@ -446,6 +527,7 @@ var Cairn = class {
446
527
  if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
447
528
  const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
448
529
  const url = `${this._cairnBase}/api/headers/${from}/${count}`;
530
+ const budget = new RetryWaitBudget(SPV_RETRY_WAIT_BUDGET_MS);
449
531
  for (let attempt = 0; ; attempt++) {
450
532
  let res;
451
533
  let parsed;
@@ -457,8 +539,9 @@ var Cairn = class {
457
539
  parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
458
540
  }
459
541
  } catch (e) {
460
- if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
461
- await spvSleep(spvRetryDelayMs(attempt, 0));
542
+ const waitMs = attempt < SPV_HTTP_MAX_RETRIES ? budget.take(spvBackoffMs(attempt), 0) : null;
543
+ if (waitMs === null) throw e;
544
+ await spvSleep(waitMs);
462
545
  continue;
463
546
  } finally {
464
547
  clearTimeout(timer);
@@ -471,7 +554,9 @@ var Cairn = class {
471
554
  if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
472
555
  res.body?.cancel?.().catch(() => {
473
556
  });
474
- await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
557
+ const waitMs = budget.take(spvBackoffMs(attempt), retryAfterMs(res));
558
+ if (waitMs === null) throw new Error(`/api/headers ${res.status}: Retry-After exceeds the remaining retry budget`);
559
+ await spvSleep(waitMs);
475
560
  continue;
476
561
  }
477
562
  throw new Error(`/api/headers ${res.status}`);
@@ -494,12 +579,20 @@ var Cairn = class {
494
579
  * EXACTLY the outputPlan, never resolver-served outputs.
495
580
  */
496
581
  async verifyOfferForFill(offerId, servedOffer, opts) {
497
- const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
582
+ const light = {
583
+ verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
584
+ const haveTo = spvTipOf(lc);
585
+ const r = await lc.verifyTxInclusion(txidHex);
586
+ if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
587
+ await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
588
+ return r;
589
+ })
590
+ };
498
591
  return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
499
592
  }
500
593
  /**
501
- * Detect + connect the Cairn Wallet (browser only). Wires the wallet into
502
- * `cairn.board` for signed writes. Resolves to the connected address.
594
+ * Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
595
+ * Resolves to the connected address.
503
596
  */
504
597
  async connect(opts = {}) {
505
598
  const { wallet, address } = await connect(opts);
@@ -509,7 +602,6 @@ var Cairn = class {
509
602
  /** Attach an already-connected wallet (e.g. one you constructed yourself). */
510
603
  attachWallet(wallet) {
511
604
  this.wallet = wallet;
512
- this.board = new BoardClient(this.cairnHttp, wallet);
513
605
  }
514
606
  /** True if a wallet is attached. */
515
607
  get connected() {
@@ -517,7 +609,6 @@ var Cairn = class {
517
609
  }
518
610
  };
519
611
  export {
520
- BoardClient,
521
612
  Cairn,
522
613
  CairnController,
523
614
  CairnError,
package/dist/indexer.cjs CHANGED
@@ -137,17 +137,27 @@ var IndexerClient = class {
137
137
  if (!folds) {
138
138
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
139
139
  }
140
+ let unverified;
140
141
  if (this.opts.headerMerkleAt) {
142
+ let onchain;
141
143
  try {
142
- const onchain = await this.opts.headerMerkleAt(proof.block_height);
144
+ onchain = await this.opts.headerMerkleAt(proof.block_height);
145
+ } catch (e) {
146
+ onchain = "";
147
+ unverified = `header at ${proof.block_height} not verified (${e?.message ?? String(e)})`;
148
+ }
149
+ if (onchain) {
143
150
  if (norm(onchain) !== norm(proof.merkle_root)) {
144
151
  return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
145
152
  }
146
153
  return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
147
- } catch {
148
154
  }
155
+ unverified ??= `header source returned no merkle root for ${proof.block_height}`;
156
+ } else {
157
+ unverified = "no header source configured (headerMerkleAt)";
149
158
  }
150
- return { included: true, trustLevel: "proof-consistent", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
159
+ const trivial = proof.merkle.length === 0 && norm(proof.merkle_root) === norm(txid) ? "; empty branch (root equals the txid)" : "";
160
+ return { included: false, trustLevel: "proof-consistent", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root, reason: `indexer proof only, ${unverified}${trivial}` };
151
161
  }
152
162
  // ---- live streams ------------------------------------------------------
153
163
  /** Stream all events (blocks, proposals, attestations, reorgs) via SSE. */
@@ -8,6 +8,9 @@ interface MerkleProof {
8
8
  }
9
9
  type TrustLevel = "verified-inclusion" | "proof-consistent" | "not-found";
10
10
  interface InclusionResult {
11
+ /** True only when the proof root matched a header the `headerMerkleAt` source verified (trustLevel
12
+ * "verified-inclusion"). A proof that merely folds is "proof-consistent" with `included: false` and a
13
+ * `reason`: the indexer alone vouches for it, and an empty branch whose root is the txid folds trivially. */
11
14
  included: boolean;
12
15
  trustLevel: TrustLevel;
13
16
  blockHeight?: number;
package/dist/indexer.d.ts CHANGED
@@ -8,6 +8,9 @@ interface MerkleProof {
8
8
  }
9
9
  type TrustLevel = "verified-inclusion" | "proof-consistent" | "not-found";
10
10
  interface InclusionResult {
11
+ /** True only when the proof root matched a header the `headerMerkleAt` source verified (trustLevel
12
+ * "verified-inclusion"). A proof that merely folds is "proof-consistent" with `included: false` and a
13
+ * `reason`: the indexer alone vouches for it, and an empty branch whose root is the txid folds trivially. */
11
14
  included: boolean;
12
15
  trustLevel: TrustLevel;
13
16
  blockHeight?: number;
package/dist/indexer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IndexerClient
3
- } from "./chunk-J5J4JEJ6.js";
3
+ } from "./chunk-K5MGEYEU.js";
4
4
  export {
5
5
  IndexerClient
6
6
  };
package/dist/react.cjs CHANGED
@@ -26,7 +26,7 @@ __export(react_exports, {
26
26
  module.exports = __toCommonJS(react_exports);
27
27
 
28
28
  // src/errors.ts
29
- var SDK_VERSION = "0.4.6";
29
+ var SDK_VERSION = "0.6.0";
30
30
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
31
31
  var CairnError = class extends Error {
32
32
  /** Stable machine code for programmatic branching. */
@@ -349,7 +349,7 @@ var WalletConnection = class {
349
349
  send(params) {
350
350
  return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
351
351
  }
352
- /** Low-level Propose (always prompts). Prefer `cairn.board.propose()` which computes the hash/uri/expiry. */
352
+ /** Low-level Propose (always prompts). The caller computes the payload hash, uri and expiry epoch. */
353
353
  propose(params) {
354
354
  return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
355
355
  }
@@ -378,8 +378,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
378
378
  var CairnController = class {
379
379
  state = DISCONNECTED;
380
380
  conn = null;
381
- // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
382
- // the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
381
+ // Connect/disconnect generation token. disconnect(), the wallet's `disconnect` event and an empty
382
+ // accountsChanged (lock, account switch, revoke) bump it; a connect() whose await straddled the bump must
383
+ // never commit "connected" over the torn-down session.
383
384
  epoch = 0;
384
385
  listeners = /* @__PURE__ */ new Set();
385
386
  getWalletFn;
@@ -410,6 +411,7 @@ var CairnController = class {
410
411
  }
411
412
  onAccounts = (accounts) => {
412
413
  if (!Array.isArray(accounts) || accounts.length === 0) {
414
+ this.epoch++;
413
415
  this.setState({ status: "disconnected", account: null });
414
416
  return;
415
417
  }
@@ -421,6 +423,7 @@ var CairnController = class {
421
423
  }
422
424
  };
423
425
  onDisconnect = () => {
426
+ this.epoch++;
424
427
  this.setState({ status: "disconnected", account: null });
425
428
  };
426
429
  // Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
@@ -476,7 +479,7 @@ var CairnController = class {
476
479
 
477
480
  // src/react.ts
478
481
  function createCairnHooks(React, controllerOrOpts) {
479
- const controller = controllerOrOpts instanceof CairnController ? controllerOrOpts : new CairnController(controllerOrOpts);
482
+ const controller = isController(controllerOrOpts) ? controllerOrOpts : new CairnController(controllerOrOpts);
480
483
  const useCairnState = () => React.useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
481
484
  const useCairnAccount = () => useCairnState().account;
482
485
  const useCairn = () => ({
@@ -487,6 +490,10 @@ function createCairnHooks(React, controllerOrOpts) {
487
490
  });
488
491
  return { controller, useCairnState, useCairnAccount, useCairn };
489
492
  }
493
+ function isController(v) {
494
+ const c = v;
495
+ return !!c && typeof c.subscribe === "function" && typeof c.getSnapshot === "function" && typeof c.connect === "function" && typeof c.disconnect === "function" && typeof c.signInWithCsd === "function";
496
+ }
490
497
  // Annotate the CommonJS export names for ESM import in node:
491
498
  0 && (module.exports = {
492
499
  CairnController,
package/dist/react.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  CairnController
3
- } from "./chunk-OB33SSGA.js";
4
- import "./chunk-FWTPXLGV.js";
5
- import "./chunk-ZFVE6MU7.js";
3
+ } from "./chunk-MLNW2RQB.js";
4
+ import "./chunk-BMH2MACB.js";
5
+ import "./chunk-2A4AJBO5.js";
6
6
 
7
7
  // src/react.ts
8
8
  function createCairnHooks(React, controllerOrOpts) {
9
- const controller = controllerOrOpts instanceof CairnController ? controllerOrOpts : new CairnController(controllerOrOpts);
9
+ const controller = isController(controllerOrOpts) ? controllerOrOpts : new CairnController(controllerOrOpts);
10
10
  const useCairnState = () => React.useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
11
11
  const useCairnAccount = () => useCairnState().account;
12
12
  const useCairn = () => ({
@@ -17,6 +17,10 @@ function createCairnHooks(React, controllerOrOpts) {
17
17
  });
18
18
  return { controller, useCairnState, useCairnAccount, useCairn };
19
19
  }
20
+ function isController(v) {
21
+ const c = v;
22
+ return !!c && typeof c.subscribe === "function" && typeof c.getSnapshot === "function" && typeof c.connect === "function" && typeof c.disconnect === "function" && typeof c.signInWithCsd === "function";
23
+ }
20
24
  export {
21
25
  CairnController,
22
26
  createCairnHooks
package/dist/registry.cjs CHANGED
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(registry_exports);
36
36
  var import_csd_registry = require("@inversealtruism/csd-registry");
37
37
 
38
38
  // src/errors.ts
39
- var SDK_VERSION = "0.4.6";
39
+ var SDK_VERSION = "0.6.0";
40
40
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
41
41
  var CairnError = class extends Error {
42
42
  /** Stable machine code for programmatic branching. */
@@ -95,6 +95,26 @@ var HttpError = class extends CairnError {
95
95
  // src/http.ts
96
96
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
97
97
  var ERR_TEXT_CAP = 8 * 1024;
98
+ var MAX_RETRY_AFTER_MS = 6e4;
99
+ function retryAfterMs(res) {
100
+ const secs = Number(res.headers.get("retry-after"));
101
+ return Number.isFinite(secs) && secs > 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : 0;
102
+ }
103
+ var RetryWaitBudget = class {
104
+ left;
105
+ constructor(totalMs) {
106
+ this.left = totalMs;
107
+ }
108
+ /** The ms to sleep before the next attempt (`ownMs` is the caller's backoff, clipped to what is left), or
109
+ * null when the server's hint exceeds the remaining budget. */
110
+ take(ownMs, serverMs) {
111
+ if (serverMs > this.left) return null;
112
+ const ms = Math.min(Math.max(ownMs, serverMs), this.left);
113
+ this.left -= ms;
114
+ return ms;
115
+ }
116
+ };
117
+ var HTTP_RETRY_WAIT_BUDGET_MS = 3e4;
98
118
  async function readCapped(res, maxBytes) {
99
119
  const cl = Number(res.headers.get("content-length"));
100
120
  if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
@@ -143,9 +163,10 @@ var Http = class {
143
163
  if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
144
164
  this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
145
165
  this.timeoutMs = opts.timeoutMs ?? 15e3;
146
- this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
166
+ this.maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 1 ? Math.floor(opts.maxBytes) : DEFAULT_MAX_BYTES;
147
167
  this.headers = opts.headers ?? {};
148
- this.retries = Math.min(10, Math.max(0, Math.floor(opts.retries ?? 0)));
168
+ const retries = Number(opts.retries ?? 0);
169
+ this.retries = Number.isNaN(retries) ? 0 : Math.min(10, Math.max(0, Math.floor(retries)));
149
170
  }
150
171
  /** Build an absolute URL from a path + optional query params. */
151
172
  url(path, query) {
@@ -194,11 +215,16 @@ var Http = class {
194
215
  }
195
216
  async getJson(path, query) {
196
217
  const url = this.url(path, query);
218
+ const budget = new RetryWaitBudget(HTTP_RETRY_WAIT_BUDGET_MS);
197
219
  for (let attempt = 0; ; attempt++) {
220
+ let serverWaitMs = 0;
221
+ let last;
198
222
  try {
199
223
  const { res, body } = await this.raw("GET", path, { query });
200
224
  if (!res.ok) {
201
- if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, errText(body));
225
+ last = new HttpError(res.status, url, errText(body));
226
+ if (!(res.status >= 500 && attempt < this.retries)) throw last;
227
+ serverWaitMs = retryAfterMs(res);
202
228
  } else {
203
229
  return this.parseJson(res, new TextDecoder().decode(body), url);
204
230
  }
@@ -206,7 +232,10 @@ var Http = class {
206
232
  if (e instanceof HttpError && e.status < 500) throw e;
207
233
  if (attempt >= this.retries) throw e;
208
234
  }
209
- await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt))));
235
+ const jitterMs = Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt));
236
+ const waitMs = budget.take(jitterMs, serverWaitMs);
237
+ if (waitMs === null) throw last ?? new HttpError(0, url, "retry wait budget exhausted");
238
+ await new Promise((r) => setTimeout(r, waitMs));
210
239
  }
211
240
  }
212
241
  async postJson(path, body, headers) {
package/dist/registry.js CHANGED
@@ -10,8 +10,8 @@ import {
10
10
  verifyGateway,
11
11
  verifyIdentitySig,
12
12
  verifyPeer
13
- } from "./chunk-2HP3ZXBO.js";
14
- import "./chunk-ZFVE6MU7.js";
13
+ } from "./chunk-67QBDZPW.js";
14
+ import "./chunk-2A4AJBO5.js";
15
15
  export {
16
16
  DOMAINS,
17
17
  RegistryClient,