@inversealtruism/cairn-sdk 0.1.2 → 0.1.4

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.
Files changed (49) hide show
  1. package/dist/board.cjs +33 -2
  2. package/dist/board.d.cts +1 -1
  3. package/dist/board.d.ts +1 -1
  4. package/dist/board.js +2 -2
  5. package/dist/chain.cjs +29 -0
  6. package/dist/chain.d.cts +24 -3
  7. package/dist/chain.d.ts +24 -3
  8. package/dist/chain.js +5 -1
  9. package/dist/{chunk-G7FABIF4.js → chunk-BGJZF7S2.js} +11 -2
  10. package/dist/{chunk-6VWLWF7R.js → chunk-C452UODV.js} +31 -2
  11. package/dist/{chunk-OGP2W7DL.js → chunk-FSQZVNEM.js} +1 -1
  12. package/dist/chunk-K7SJOW3O.js +213 -0
  13. package/dist/chunk-MBSPHWZG.js +96 -0
  14. package/dist/{chunk-BZVJVL4Q.js → chunk-MVX7VQT4.js} +4 -3
  15. package/dist/{chunk-FNBE2FPE.js → chunk-OG4KA3LS.js} +49 -17
  16. package/dist/chunk-RELP23CH.js +99 -0
  17. package/dist/connect.cjs +145 -18
  18. package/dist/connect.d.cts +140 -2
  19. package/dist/connect.d.ts +140 -2
  20. package/dist/connect.js +4 -2
  21. package/dist/content.cjs +37 -5
  22. package/dist/content.d.cts +1 -1
  23. package/dist/content.d.ts +1 -1
  24. package/dist/content.js +2 -2
  25. package/dist/controller.cjs +355 -0
  26. package/dist/controller.d.cts +37 -0
  27. package/dist/controller.d.ts +37 -0
  28. package/dist/controller.js +8 -0
  29. package/dist/{http-D2CzfOsm.d.cts → http-CQsNL8w9.d.cts} +5 -0
  30. package/dist/{http-D2CzfOsm.d.ts → http-CQsNL8w9.d.ts} +5 -0
  31. package/dist/index.cjs +386 -48
  32. package/dist/index.d.cts +39 -6
  33. package/dist/index.d.ts +39 -6
  34. package/dist/index.js +75 -20
  35. package/dist/indexer.cjs +49 -17
  36. package/dist/indexer.d.cts +15 -4
  37. package/dist/indexer.d.ts +15 -4
  38. package/dist/indexer.js +1 -1
  39. package/dist/react.cjs +371 -0
  40. package/dist/react.d.cts +30 -0
  41. package/dist/react.d.ts +30 -0
  42. package/dist/react.js +23 -0
  43. package/dist/registry.cjs +11 -2
  44. package/dist/registry.d.cts +12 -3
  45. package/dist/registry.d.ts +12 -3
  46. package/dist/registry.js +1 -1
  47. package/package.json +21 -10
  48. package/dist/chunk-PHMU3CG5.js +0 -63
  49. package/dist/chunk-SMAPCKVG.js +0 -118
package/dist/index.cjs CHANGED
@@ -22,51 +22,88 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  BoardClient: () => BoardClient,
24
24
  Cairn: () => Cairn,
25
+ CairnController: () => CairnController,
25
26
  CairnError: () => CairnError,
26
27
  Chain: () => Chain,
27
28
  ContentClient: () => ContentClient,
28
29
  ContentVerificationError: () => ContentVerificationError,
30
+ DEFAULT_SPV_CHECKPOINT: () => DEFAULT_SPV_CHECKPOINT,
29
31
  Http: () => Http,
30
32
  HttpError: () => HttpError,
31
33
  IndexerClient: () => IndexerClient,
32
34
  NotInstalledError: () => NotInstalledError,
33
35
  RegistryClient: () => RegistryClient,
36
+ SDK_VERSION: () => SDK_VERSION,
34
37
  UnsupportedMethodError: () => UnsupportedMethodError,
35
38
  UserRejectedError: () => UserRejectedError,
36
39
  WalletConnection: () => WalletConnection,
37
40
  WalletLockedError: () => WalletLockedError,
38
41
  connect: () => connect,
39
42
  detectProvider: () => detectProvider,
43
+ discoverProviders: () => discoverProviders,
44
+ errorCode: () => errorCode,
40
45
  getWallet: () => getWallet,
41
- isInstalled: () => isInstalled
46
+ isInstalled: () => isInstalled,
47
+ mapProviderError: () => mapProviderError
42
48
  });
43
49
  module.exports = __toCommonJS(index_exports);
44
50
 
45
51
  // src/errors.ts
52
+ var SDK_VERSION = "0.1.4";
53
+ var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
46
54
  var CairnError = class extends Error {
47
- constructor(message) {
48
- super(message);
55
+ /** Stable machine code for programmatic branching. */
56
+ code;
57
+ /** The human message without the appended docs/version footer. */
58
+ shortMessage;
59
+ /** Relative docs path (under the SDK docs base), if any. */
60
+ docsPath;
61
+ /** The cairn-sdk version that produced this error. */
62
+ version = SDK_VERSION;
63
+ constructor(shortMessage, opts = {}) {
64
+ const docs = opts.docsPath;
65
+ super(
66
+ [shortMessage, docs ? `Docs: ${DOCS_BASE}${docs}` : "", `Version: cairn-sdk@${SDK_VERSION}`].filter(Boolean).join("\n"),
67
+ { cause: opts.cause }
68
+ );
49
69
  this.name = new.target.name;
70
+ this.code = opts.code ?? "UNKNOWN";
71
+ this.shortMessage = shortMessage;
72
+ this.docsPath = docs;
73
+ }
74
+ /**
75
+ * Walk the `cause` chain. With a predicate, returns the FIRST matching error (or null). Without one,
76
+ * returns the DEEPEST cause (the root). Mirrors viem's `BaseError.walk()`.
77
+ */
78
+ walk(fn) {
79
+ let current = this;
80
+ let last = this;
81
+ while (current != null) {
82
+ if (fn && fn(current)) return current;
83
+ last = current;
84
+ current = current?.cause;
85
+ }
86
+ return fn ? null : last;
50
87
  }
51
88
  };
52
89
  var NotInstalledError = class extends CairnError {
53
90
  constructor(message = "Cairn Wallet not detected. Install it from cairn-substrate.com.") {
54
- super(message);
91
+ super(message, { code: "NOT_INSTALLED", docsPath: "/install" });
55
92
  }
56
93
  };
57
94
  var UserRejectedError = class extends CairnError {
58
95
  constructor(message = "Request rejected by user.") {
59
- super(message);
96
+ super(message, { code: "USER_REJECTED", docsPath: "/errors#user-rejected" });
60
97
  }
61
98
  };
62
99
  var WalletLockedError = class extends CairnError {
63
100
  constructor(message = "Wallet is locked.") {
64
- super(message);
101
+ super(message, { code: "WALLET_LOCKED", docsPath: "/errors#wallet-locked" });
65
102
  }
66
103
  };
67
104
  var UnsupportedMethodError = class extends CairnError {
68
105
  constructor(message = "Method not supported by the wallet.") {
69
- super(message);
106
+ super(message, { code: "UNSUPPORTED_METHOD", docsPath: "/errors#unsupported-method" });
70
107
  }
71
108
  };
72
109
  var HttpError = class extends CairnError {
@@ -74,7 +111,7 @@ var HttpError = class extends CairnError {
74
111
  url;
75
112
  body;
76
113
  constructor(status, url, body) {
77
- super(`HTTP ${status} for ${url}${body ? `: ${String(body).slice(0, 200)}` : ""}`);
114
+ super(`HTTP ${status} for ${url}${body ? `: ${String(body).slice(0, 200)}` : ""}`, { code: "HTTP_ERROR" });
78
115
  this.status = status;
79
116
  this.url = url;
80
117
  this.body = body;
@@ -83,7 +120,7 @@ var HttpError = class extends CairnError {
83
120
  var ContentVerificationError = class extends CairnError {
84
121
  expected;
85
122
  constructor(expected) {
86
- super(`Content does not match its payload hash ${expected} \u2014 bytes were tampered with in transit.`);
123
+ super(`Content does not match its payload hash ${expected} \u2014 bytes were tampered with in transit.`, { code: "CONTENT_VERIFICATION", docsPath: "/content#verification" });
87
124
  this.expected = expected;
88
125
  }
89
126
  };
@@ -91,15 +128,56 @@ function mapProviderError(error) {
91
128
  const e = String(error || "").toLowerCase();
92
129
  if (e.includes("rejected by user")) return new UserRejectedError(error);
93
130
  if (e.includes("wallet locked")) return new WalletLockedError(error);
94
- if (e.includes("unsupported dapp method")) return new UnsupportedMethodError(error);
131
+ if (e.includes("unsupported dapp method") || e.includes("predates") || e.includes("too old")) return new UnsupportedMethodError(error);
95
132
  return new CairnError(error || "Unknown wallet error");
96
133
  }
134
+ function errorCode(err) {
135
+ return err instanceof CairnError ? err.code : "UNKNOWN";
136
+ }
97
137
 
98
138
  // src/http.ts
139
+ var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
140
+ var ERR_TEXT_CAP = 8 * 1024;
141
+ async function readCapped(res, maxBytes) {
142
+ const cl = Number(res.headers.get("content-length"));
143
+ if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
144
+ const body = res.body;
145
+ if (!body || typeof body.getReader !== "function") {
146
+ const buf = new Uint8Array(await res.arrayBuffer());
147
+ if (buf.byteLength > maxBytes) throw new HttpError(res.status, res.url, `response too large (${buf.byteLength} > ${maxBytes})`);
148
+ return buf;
149
+ }
150
+ const reader = body.getReader();
151
+ const chunks = [];
152
+ let total = 0;
153
+ for (; ; ) {
154
+ const { done, value } = await reader.read();
155
+ if (done) break;
156
+ if (value) {
157
+ total += value.byteLength;
158
+ if (total > maxBytes) {
159
+ try {
160
+ await reader.cancel();
161
+ } catch {
162
+ }
163
+ throw new HttpError(res.status, res.url, `response exceeded ${maxBytes} bytes`);
164
+ }
165
+ chunks.push(value);
166
+ }
167
+ }
168
+ const out = new Uint8Array(total);
169
+ let off = 0;
170
+ for (const c of chunks) {
171
+ out.set(c, off);
172
+ off += c.byteLength;
173
+ }
174
+ return out;
175
+ }
99
176
  var Http = class {
100
177
  baseUrl;
101
178
  _fetch;
102
179
  timeoutMs;
180
+ maxBytes;
103
181
  headers;
104
182
  constructor(opts) {
105
183
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
@@ -107,12 +185,17 @@ var Http = class {
107
185
  if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
108
186
  this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
109
187
  this.timeoutMs = opts.timeoutMs ?? 15e3;
188
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
110
189
  this.headers = opts.headers ?? {};
111
190
  }
112
191
  /** Build an absolute URL from a path + optional query params. */
113
192
  url(path, query) {
114
193
  const p = path.startsWith("/") ? path : `/${path}`;
115
194
  const u = new URL(this.baseUrl + p);
195
+ const basePath = new URL(this.baseUrl + "/").pathname;
196
+ if (basePath !== "/" && !(u.pathname + "/").startsWith(basePath)) {
197
+ throw new Error(`illegal path '${path}' \u2014 escapes base ${basePath}`);
198
+ }
116
199
  if (query) {
117
200
  for (const [k, v] of Object.entries(query)) {
118
201
  if (v !== void 0 && v !== null) u.searchParams.set(k, String(v));
@@ -143,25 +226,24 @@ var Http = class {
143
226
  async getJson(path, query) {
144
227
  const res = await this.raw("GET", path, { query });
145
228
  if (!res.ok) throw new HttpError(res.status, this.url(path, query), await safeText(res));
146
- return await res.json();
229
+ return JSON.parse(new TextDecoder().decode(await readCapped(res, this.maxBytes)));
147
230
  }
148
231
  async postJson(path, body, headers) {
149
232
  const res = await this.raw("POST", path, { body, headers });
150
233
  if (!res.ok) throw new HttpError(res.status, this.url(path), await safeText(res));
151
- return await res.json();
234
+ return JSON.parse(new TextDecoder().decode(await readCapped(res, this.maxBytes)));
152
235
  }
153
236
  /** GET raw bytes (for content retrieval). Returns null on 404. */
154
237
  async getBytes(path) {
155
238
  const res = await this.raw("GET", path);
156
239
  if (res.status === 404) return null;
157
240
  if (!res.ok) throw new HttpError(res.status, this.url(path), await safeText(res));
158
- const buf = new Uint8Array(await res.arrayBuffer());
159
- return { bytes: buf, headers: res.headers };
241
+ return { bytes: await readCapped(res, this.maxBytes), headers: res.headers };
160
242
  }
161
243
  };
162
244
  async function safeText(res) {
163
245
  try {
164
- return await res.text();
246
+ return new TextDecoder().decode(await readCapped(res, ERR_TEXT_CAP));
165
247
  } catch {
166
248
  return "";
167
249
  }
@@ -175,6 +257,8 @@ var import_csd_crypto = require("@inversealtruism/csd-crypto");
175
257
  var import_csd_light = require("@inversealtruism/csd-light");
176
258
  var import_csd_client2 = require("@inversealtruism/csd-client");
177
259
  var import_csd_light2 = require("@inversealtruism/csd-light");
260
+ var import_csd_tx2 = require("@inversealtruism/csd-tx");
261
+ var import_csd_crypto2 = require("@inversealtruism/csd-crypto");
178
262
  var Chain = class {
179
263
  client;
180
264
  opts;
@@ -195,6 +279,29 @@ var Chain = class {
195
279
  submit(nodeJsonTx) {
196
280
  return this.client.submit(nodeJsonTx);
197
281
  }
282
+ /**
283
+ * Build + sign (+ optionally broadcast) a transfer with INPUT-VALUE VERIFICATION (the H2-safe send).
284
+ * Fetches the signer's UTXOs, selects, then re-verifies each selected input's REAL value against the
285
+ * chain (`verifyInputValues` recomputes each source tx's txid, so a lying RPC can't under-report) and
286
+ * computes change from the VERIFIED total — closing the UTXO-VALUE-1 implicit-fee-burn. Fail-closed:
287
+ * if any source can't be verified it signs nothing. Prefer this over the pure `buildSend`. Pass
288
+ * `broadcast:false` to get the signed tx back without submitting.
289
+ */
290
+ async send(p) {
291
+ const from = (0, import_csd_crypto2.addrFromPriv)(p.priv);
292
+ const u = await this.client.utxos(from);
293
+ const built = await (0, import_csd_tx2.buildSendVerified)({
294
+ outputs: p.outputs,
295
+ fee: p.fee,
296
+ utxos: u.utxos,
297
+ priv: p.priv,
298
+ maxFee: p.maxFee,
299
+ verify: (inputs) => (0, import_csd_client2.verifyInputValues)(this.client, inputs)
300
+ });
301
+ if (!built.ok || p.broadcast === false) return built;
302
+ const submitResult = await this.client.submit(built.nodeJson);
303
+ return { ...built, ok: built.ok && submitResult.ok !== false, submitResult };
304
+ }
198
305
  /** A verifying light client over the same RPC (built once, reused). */
199
306
  light() {
200
307
  if (!this._light) {
@@ -362,6 +469,13 @@ function sleep(ms) {
362
469
 
363
470
  // src/indexer.ts
364
471
  var import_csd_codec4 = require("@inversealtruism/csd-codec");
472
+ var HEX64 = /^0x[0-9a-fA-F]{64}$/;
473
+ var MAX_SSE_BUF = 1024 * 1024;
474
+ function isValidMerkleProof(p) {
475
+ if (!p || typeof p !== "object") return false;
476
+ const m = p;
477
+ return Number.isInteger(m.block_height) && m.block_height >= 0 && Number.isInteger(m.pos) && m.pos >= 0 && typeof m.merkle_root === "string" && HEX64.test(m.merkle_root) && Array.isArray(m.merkle) && m.merkle.every((x) => typeof x === "string" && HEX64.test(x));
478
+ }
365
479
  var IndexerClient = class {
366
480
  http;
367
481
  opts;
@@ -382,32 +496,35 @@ var IndexerClient = class {
382
496
  blockHashAtHeight(h) {
383
497
  return this.http.getJson(`/block-height/${h}`);
384
498
  }
499
+ // CAIRNSDK-CONTENT-HASH-PATH-1: encode every caller-supplied id segment. Raw interpolation let an id like
500
+ // `../../api/rpc/tip` walk `new URL` to a DIFFERENT same-origin endpoint whose body was then returned
501
+ // verbatim as the requested object (worse than the content case, which discards the walked-to response).
385
502
  block(hash) {
386
- return this.http.getJson(`/block/${hash}`);
503
+ return this.http.getJson(`/block/${encodeURIComponent(hash)}`);
387
504
  }
388
505
  blockTxids(hash) {
389
- return this.http.getJson(`/block/${hash}/txids`);
506
+ return this.http.getJson(`/block/${encodeURIComponent(hash)}/txids`);
390
507
  }
391
508
  tx(id) {
392
- return this.http.getJson(`/tx/${id}`);
509
+ return this.http.getJson(`/tx/${encodeURIComponent(id)}`);
393
510
  }
394
511
  txStatus(id) {
395
- return this.http.getJson(`/tx/${id}/status`);
512
+ return this.http.getJson(`/tx/${encodeURIComponent(id)}/status`);
396
513
  }
397
514
  txMerkleProof(id) {
398
- return this.http.getJson(`/tx/${id}/merkle-proof`);
515
+ return this.http.getJson(`/tx/${encodeURIComponent(id)}/merkle-proof`);
399
516
  }
400
517
  address(a) {
401
- return this.http.getJson(`/address/${a}`);
518
+ return this.http.getJson(`/address/${encodeURIComponent(a)}`);
402
519
  }
403
520
  addressTxs(a) {
404
- return this.http.getJson(`/address/${a}/txs`);
521
+ return this.http.getJson(`/address/${encodeURIComponent(a)}/txs`);
405
522
  }
406
523
  addressUtxo(a) {
407
- return this.http.getJson(`/address/${a}/utxo`);
524
+ return this.http.getJson(`/address/${encodeURIComponent(a)}/utxo`);
408
525
  }
409
526
  reputation(a) {
410
- return this.http.getJson(`/address/${a}/reputation`);
527
+ return this.http.getJson(`/address/${encodeURIComponent(a)}/reputation`);
411
528
  }
412
529
  domains() {
413
530
  return this.http.getJson("/domains");
@@ -416,10 +533,10 @@ var IndexerClient = class {
416
533
  return this.http.getJson(`/domain/${encodeURIComponent(d)}/proposals`);
417
534
  }
418
535
  proposal(id) {
419
- return this.http.getJson(`/proposal/${id}`);
536
+ return this.http.getJson(`/proposal/${encodeURIComponent(id)}`);
420
537
  }
421
538
  attestations(id) {
422
- return this.http.getJson(`/proposal/${id}/attestations`);
539
+ return this.http.getJson(`/proposal/${encodeURIComponent(id)}/attestations`);
423
540
  }
424
541
  registryPeers() {
425
542
  return this.http.getJson("/registry/peers");
@@ -431,7 +548,7 @@ var IndexerClient = class {
431
548
  return this.http.getJson(`/identity/${encodeURIComponent(handle)}`);
432
549
  }
433
550
  reverseIdentity(addr) {
434
- return this.http.getJson(`/address/${addr}/identity`);
551
+ return this.http.getJson(`/address/${encodeURIComponent(addr)}/identity`);
435
552
  }
436
553
  // ---- trust-minimized inclusion ----------------------------------------
437
554
  /**
@@ -440,9 +557,11 @@ var IndexerClient = class {
440
557
  * proof's root is cross-checked against the header merkle at that height →
441
558
  * trustLevel "verified-inclusion" (only as trustworthy as that header source —
442
559
  * see headerMerkleAt). Otherwise the proof is only internally consistent →
443
- * "proof-consistent" (still useful, but the indexer is trusted). The Cairn
444
- * facade wires headerMerkleAt ONLY when the node RPC is an independent origin
445
- * from the indexer, so the default same-origin setup stays at "proof-consistent".
560
+ * "proof-consistent" (still useful, but the indexer is trusted). INDEXER-DOC-STALE-1: the Cairn facade now
561
+ * wires headerMerkleAt UNCONDITIONALLY via a PoW-verifying checkpoint light client (index.ts), so the
562
+ * default reaches "verified-inclusion" when the proof root matches the PoW-verified header — what makes a
563
+ * same-origin RPC safe is the forward PoW re-verification, not an origin gate (which no longer exists).
564
+ * Fails closed: a malformed proof or a chain-disagreement returns "not-found" (the latter with equivocation:true).
446
565
  */
447
566
  async verifyInclusion(txid2) {
448
567
  let proof;
@@ -451,7 +570,15 @@ var IndexerClient = class {
451
570
  } catch {
452
571
  return { included: false, trustLevel: "not-found", reason: "no merkle proof (tx not indexed)" };
453
572
  }
454
- const folds = (0, import_csd_codec4.verifyMerkleProof)(txid2, proof.pos, proof.merkle, proof.merkle_root);
573
+ if (!isValidMerkleProof(proof)) {
574
+ return { included: false, trustLevel: "not-found", reason: "malformed merkle proof" };
575
+ }
576
+ let folds = false;
577
+ try {
578
+ folds = (0, import_csd_codec4.verifyMerkleProof)(txid2, proof.pos, proof.merkle, proof.merkle_root);
579
+ } catch {
580
+ return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "malformed merkle proof" };
581
+ }
455
582
  if (!folds) {
456
583
  return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
457
584
  }
@@ -459,7 +586,7 @@ var IndexerClient = class {
459
586
  try {
460
587
  const onchain = await this.opts.headerMerkleAt(proof.block_height);
461
588
  if (norm(onchain) !== norm(proof.merkle_root)) {
462
- return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
589
+ return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
463
590
  }
464
591
  return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
465
592
  } catch {
@@ -483,6 +610,11 @@ var IndexerClient = class {
483
610
  /**
484
611
  * Subscribe over WebSocket with selective tracking. Returns a handle with
485
612
  * `.close()`. `onEvent` receives every matching IndexEvent (incl. reorgs).
613
+ *
614
+ * WS-NORECONNECT-1: unlike the SSE feeds (streamAll/streamBlocks/streamDomain, which auto-reconnect with
615
+ * backoff), this WebSocket subscription does NOT auto-reconnect — it surfaces a terminal error via
616
+ * `onError` (when provided) and stops. A consumer tracking reorgs over WS MUST re-`subscribe()` on error
617
+ * to avoid a silently-stale view; for built-in reconnect prefer the SSE `stream*` methods.
486
618
  */
487
619
  subscribe(sub, onEvent, onError) {
488
620
  const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
@@ -545,6 +677,13 @@ var IndexerClient = class {
545
677
  buf = buf.slice(idx + 2);
546
678
  dispatchFrame(frame, handlers);
547
679
  }
680
+ if (buf.length > MAX_SSE_BUF) {
681
+ try {
682
+ await reader.cancel();
683
+ } catch {
684
+ }
685
+ throw new Error(`SSE buffer exceeded ${MAX_SSE_BUF} bytes with no frame delimiter \u2014 aborting`);
686
+ }
548
687
  }
549
688
  } catch (err) {
550
689
  if (closed) return;
@@ -657,8 +796,9 @@ var ContentClient = class {
657
796
  }
658
797
  };
659
798
  function normalizeHash(hash) {
660
- const h = hash.startsWith("0x") || hash.startsWith("0X") ? hash : `0x${hash}`;
661
- return h.toLowerCase();
799
+ const h = (hash.startsWith("0x") || hash.startsWith("0X") ? hash : `0x${hash}`).toLowerCase();
800
+ if (!/^0x[0-9a-f]{64}$/.test(h)) throw new Error(`invalid content hash: ${hash}`);
801
+ return h;
662
802
  }
663
803
 
664
804
  // src/registry.ts
@@ -677,11 +817,20 @@ var RegistryClient = class {
677
817
  peers() {
678
818
  return (0, import_csd_registry.discoverPeers)(this.src);
679
819
  }
680
- /** Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved. */
820
+ /**
821
+ * Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved.
822
+ *
823
+ * ⚠ TRUST (CAIRN-SDK-RESOLVE-VERIFIED-FOOTGUN): the returned `verified` flag is the INDEXER's assertion,
824
+ * NOT a client-side proof. A hostile/compromised indexer (or a MITM of the read proxy) can return
825
+ * `verified:true` for a wrong address. Do NOT use this directly as a payee/login target without either (a)
826
+ * recomputing from raw records via `fromRecords.name(...)`, or (b) cross-checking a second independent
827
+ * source (the wallet's namespv union cure). Treat `verified` here as a hint, not a guarantee.
828
+ */
681
829
  resolveName(handle) {
682
830
  return (0, import_csd_registry.resolveName)(this.src, handle);
683
831
  }
684
- /** Reverse-resolve an address → identity (GET /address/:addr/identity). */
832
+ /** Reverse-resolve an address → identity (GET /address/:addr/identity).
833
+ * ⚠ Same indexer-trust caveat as resolveName — `verified` is the indexer's claim, not a client proof. */
685
834
  reverseName(address) {
686
835
  return (0, import_csd_registry.reverseName)(this.src, address);
687
836
  }
@@ -692,6 +841,26 @@ var RegistryClient = class {
692
841
  };
693
842
 
694
843
  // src/connect.ts
844
+ function discoverProviders(opts = {}) {
845
+ const timeoutMs = opts.timeoutMs ?? 1e3;
846
+ return new Promise((resolve) => {
847
+ if (typeof window === "undefined") {
848
+ resolve([]);
849
+ return;
850
+ }
851
+ const found = /* @__PURE__ */ new Map();
852
+ const onAnnounce = (ev) => {
853
+ const d = ev?.detail;
854
+ if (d?.info?.uuid && d?.provider?.isCairn) found.set(d.info.uuid, d);
855
+ };
856
+ window.addEventListener("csd:announceProvider", onAnnounce);
857
+ window.dispatchEvent(new CustomEvent("csd:requestProvider"));
858
+ setTimeout(() => {
859
+ window.removeEventListener("csd:announceProvider", onAnnounce);
860
+ resolve([...found.values()]);
861
+ }, timeoutMs);
862
+ });
863
+ }
695
864
  function detectProvider(opts = {}) {
696
865
  const timeoutMs = opts.timeoutMs ?? 3e3;
697
866
  return new Promise((resolve, reject) => {
@@ -699,18 +868,19 @@ function detectProvider(opts = {}) {
699
868
  reject(new NotInstalledError("Not running in a browser \u2014 `window.cairn` is unavailable."));
700
869
  return;
701
870
  }
702
- if (window.cairn?.isCairn) {
703
- resolve(window.cairn);
704
- return;
705
- }
706
871
  let done = false;
707
- const onInit = () => {
872
+ const finish = (p) => {
708
873
  if (done) return;
709
- if (window.cairn?.isCairn) {
710
- done = true;
711
- cleanup();
712
- resolve(window.cairn);
713
- }
874
+ done = true;
875
+ cleanup();
876
+ resolve(p);
877
+ };
878
+ const onInit = () => {
879
+ if (!done && window.cairn?.isCairn) finish(window.cairn);
880
+ };
881
+ const onAnnounce = (ev) => {
882
+ const p = ev?.detail?.provider;
883
+ if (p?.isCairn) finish(p);
714
884
  };
715
885
  const timer = setTimeout(() => {
716
886
  if (done) return;
@@ -721,8 +891,15 @@ function detectProvider(opts = {}) {
721
891
  const cleanup = () => {
722
892
  clearTimeout(timer);
723
893
  window.removeEventListener("cairn#initialized", onInit);
894
+ window.removeEventListener("csd:announceProvider", onAnnounce);
724
895
  };
725
896
  window.addEventListener("cairn#initialized", onInit);
897
+ window.addEventListener("csd:announceProvider", onAnnounce);
898
+ if (window.cairn?.isCairn) {
899
+ finish(window.cairn);
900
+ return;
901
+ }
902
+ window.dispatchEvent(new CustomEvent("csd:requestProvider"));
726
903
  onInit();
727
904
  });
728
905
  }
@@ -759,10 +936,76 @@ var WalletConnection = class {
759
936
  this._addr = addr;
760
937
  return addr;
761
938
  }
762
- /** Passwordless sign-in: a signature over a login nonce (always prompts; structurally cannot sign a tx). */
939
+ /**
940
+ * @deprecated First-party-only legacy login (it authenticates against the wallet's own configured
941
+ * server and is rejected from third-party origins). Use {@link signInWithCsd} for any site.
942
+ * Passwordless sign-in: a signature over a login nonce (always prompts; structurally cannot sign a tx).
943
+ */
763
944
  signIn() {
764
945
  return Promise.resolve(this.provider.signIn()).then(unwrap);
765
946
  }
947
+ /**
948
+ * Audience-bound "Sign in with CSD" (SIWC) — the secure, replay-resistant third-party login.
949
+ * The wallet binds the REAL requesting origin + your single-use server `nonce` into the signed
950
+ * message and returns the artifact; it never mints a session. VERIFY the result SERVER-SIDE with
951
+ * `verifySiwc` from `@inversealtruism/csd-siwc` (domain/nonce/chain/time/signature/account), then
952
+ * issue your OWN session. The signature is proof-of-control, NOT a bearer token. Always prompts.
953
+ */
954
+ signInWithCsd(params) {
955
+ if (!params || typeof params.nonce !== "string" || !params.nonce) {
956
+ return Promise.reject(new UnsupportedMethodError("signInWithCsd requires a server-issued { nonce }"));
957
+ }
958
+ if (typeof this.provider.signInWithCsd !== "function") {
959
+ return Promise.reject(new UnsupportedMethodError("this wallet predates Sign-in-with-CSD \u2014 ask the user to update the Cairn Wallet"));
960
+ }
961
+ return Promise.resolve(this.provider.signInWithCsd(params)).then(unwrap);
962
+ }
963
+ /** True if this wallet supports audience-bound SIWC (safe to call `signInWithCsd`). */
964
+ get supportsSiwc() {
965
+ return typeof this.provider.signInWithCsd === "function";
966
+ }
967
+ /** Feature/capability detection. Returns null if the wallet predates `getCapabilities`. */
968
+ async getCapabilities() {
969
+ if (typeof this.provider.getCapabilities !== "function") return null;
970
+ try {
971
+ return await this.provider.getCapabilities();
972
+ } catch {
973
+ return null;
974
+ }
975
+ }
976
+ /** Atomic fill (CairnX DvP): pay + attest in ONE tx. Always prompts with clear-signing. */
977
+ fillOffer(params) {
978
+ return Promise.resolve(this.provider.fillOffer(params)).then(unwrap);
979
+ }
980
+ /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
981
+ async getPermissions() {
982
+ if (typeof this.provider.getPermissions !== "function") return [];
983
+ return unwrap(await this.provider.getPermissions());
984
+ }
985
+ /** Explicitly request permission to see the address (prompts, like connect). */
986
+ requestPermissions() {
987
+ if (typeof this.provider.requestPermissions !== "function") {
988
+ return Promise.reject(new UnsupportedMethodError("this wallet predates requestPermissions \u2014 use connect()"));
989
+ }
990
+ return Promise.resolve(this.provider.requestPermissions()).then(unwrap);
991
+ }
992
+ /** Revoke THIS origin's own access (silent). Returns { revoked }. Safe no-op on older wallets. */
993
+ async revokePermissions() {
994
+ if (typeof this.provider.revokePermissions !== "function") return { revoked: false };
995
+ return unwrap(await this.provider.revokePermissions());
996
+ }
997
+ /**
998
+ * Subscribe to provider events. `accountsChanged` fires with `[]` when this site loses access
999
+ * (wallet lock / account switch / revoke) — re-call connect() to (re)share; `disconnect` likewise.
1000
+ * No-op if the wallet predates events. Use the SAME handler reference with off() to unsubscribe.
1001
+ */
1002
+ on(event, handler) {
1003
+ this.provider.on?.(event, handler);
1004
+ }
1005
+ /** Remove a previously-added event handler (must be the same reference passed to on()). */
1006
+ off(event, handler) {
1007
+ this.provider.removeListener?.(event, handler);
1008
+ }
766
1009
  /** Send CSD (always prompts with clear-signing; wallet picks inputs + returns change to itself). */
767
1010
  send(params) {
768
1011
  return Promise.resolve(this.provider.send(params)).then(unwrap);
@@ -796,6 +1039,95 @@ async function connect(opts = {}) {
796
1039
  return { wallet, address };
797
1040
  }
798
1041
 
1042
+ // src/controller.ts
1043
+ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error: null });
1044
+ var CairnController = class {
1045
+ state = DISCONNECTED;
1046
+ conn = null;
1047
+ listeners = /* @__PURE__ */ new Set();
1048
+ getWalletFn;
1049
+ detectOpts;
1050
+ constructor(opts = {}) {
1051
+ const { getWallet: gw, ...detect } = opts;
1052
+ this.getWalletFn = gw ?? getWallet;
1053
+ this.detectOpts = detect;
1054
+ }
1055
+ /** useSyncExternalStore-compatible. Returns an unsubscribe fn. */
1056
+ subscribe = (cb) => {
1057
+ this.listeners.add(cb);
1058
+ return () => {
1059
+ this.listeners.delete(cb);
1060
+ };
1061
+ };
1062
+ /** useSyncExternalStore-compatible snapshot — stable reference until the state actually changes. */
1063
+ getSnapshot = () => this.state;
1064
+ /** The underlying WalletConnection (for advanced use: send/propose/permissions/…). Null until connect(). */
1065
+ get connection() {
1066
+ return this.conn;
1067
+ }
1068
+ setState(patch) {
1069
+ const next = { ...this.state, ...patch };
1070
+ if (next.status === this.state.status && next.account === this.state.account && next.error === this.state.error) return;
1071
+ this.state = Object.freeze(next);
1072
+ for (const l of [...this.listeners]) l();
1073
+ }
1074
+ onAccounts = (accounts) => {
1075
+ if (!Array.isArray(accounts) || accounts.length === 0) {
1076
+ this.setState({ status: "disconnected", account: null });
1077
+ return;
1078
+ }
1079
+ const next = String(accounts[0]);
1080
+ if (this.state.status === "connected" && this.state.account && next.toLowerCase() === this.state.account.toLowerCase()) {
1081
+ this.setState({ status: "connected", account: next, error: null });
1082
+ } else {
1083
+ this.setState({ status: "disconnected", account: null });
1084
+ }
1085
+ };
1086
+ onDisconnect = () => {
1087
+ this.setState({ status: "disconnected", account: null });
1088
+ };
1089
+ // Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
1090
+ detach() {
1091
+ try {
1092
+ this.conn?.off?.("accountsChanged", this.onAccounts);
1093
+ this.conn?.off?.("disconnect", this.onDisconnect);
1094
+ } catch {
1095
+ }
1096
+ }
1097
+ /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
1098
+ connect = async () => {
1099
+ this.setState({ status: "connecting", error: null });
1100
+ try {
1101
+ if (!this.conn) {
1102
+ this.conn = await this.getWalletFn(this.detectOpts);
1103
+ this.conn.on("accountsChanged", this.onAccounts);
1104
+ this.conn.on("disconnect", this.onDisconnect);
1105
+ }
1106
+ const addr = await this.conn.connect();
1107
+ this.setState({ status: "connected", account: addr, error: null });
1108
+ return addr;
1109
+ } catch (e) {
1110
+ this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
1111
+ throw e;
1112
+ }
1113
+ };
1114
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
1115
+ disconnect = async () => {
1116
+ try {
1117
+ await this.conn?.revokePermissions();
1118
+ } catch {
1119
+ }
1120
+ this.detach();
1121
+ this.conn = null;
1122
+ this.setState({ status: "disconnected", account: null, error: null });
1123
+ };
1124
+ /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
1125
+ signInWithCsd = (params) => {
1126
+ if (!this.conn) return Promise.reject(new Error("call connect() before signInWithCsd()"));
1127
+ return this.conn.signInWithCsd(params);
1128
+ };
1129
+ };
1130
+
799
1131
  // src/index.ts
800
1132
  var DEFAULT_SPV_CHECKPOINT = { height: 31310, hash: "0x000000000000138bd3eee4b8d2fbacb8d5433ac3040ddbf1c45a5e3e0cc9e814" };
801
1133
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
@@ -880,21 +1212,27 @@ var Cairn = class {
880
1212
  0 && (module.exports = {
881
1213
  BoardClient,
882
1214
  Cairn,
1215
+ CairnController,
883
1216
  CairnError,
884
1217
  Chain,
885
1218
  ContentClient,
886
1219
  ContentVerificationError,
1220
+ DEFAULT_SPV_CHECKPOINT,
887
1221
  Http,
888
1222
  HttpError,
889
1223
  IndexerClient,
890
1224
  NotInstalledError,
891
1225
  RegistryClient,
1226
+ SDK_VERSION,
892
1227
  UnsupportedMethodError,
893
1228
  UserRejectedError,
894
1229
  WalletConnection,
895
1230
  WalletLockedError,
896
1231
  connect,
897
1232
  detectProvider,
1233
+ discoverProviders,
1234
+ errorCode,
898
1235
  getWallet,
899
- isInstalled
1236
+ isInstalled,
1237
+ mapProviderError
900
1238
  });