@inversealtruism/cairn-sdk 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/board.cjs CHANGED
@@ -27,7 +27,7 @@ var import_csd_codec = require("@inversealtruism/csd-codec");
27
27
  var import_csd_codec2 = require("@inversealtruism/csd-codec");
28
28
 
29
29
  // src/errors.ts
30
- var SDK_VERSION = "0.4.0";
30
+ var SDK_VERSION = "0.4.2";
31
31
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
32
32
  var CairnError = class extends Error {
33
33
  /** Stable machine code for programmatic branching. */
package/dist/board.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  BoardClient
3
- } from "./chunk-2ZML7IKV.js";
4
- import "./chunk-MREZG2HN.js";
3
+ } from "./chunk-XPTP5W75.js";
4
+ import "./chunk-REHDMKBF.js";
5
5
  export {
6
6
  BoardClient
7
7
  };
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  getWallet
3
- } from "./chunk-RKR3JMIA.js";
3
+ } from "./chunk-VGKXFRHY.js";
4
4
 
5
5
  // src/controller.ts
6
6
  var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error: null });
7
7
  var CairnController = class {
8
8
  state = DISCONNECTED;
9
9
  conn = null;
10
+ // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
11
+ // the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
12
+ epoch = 0;
10
13
  listeners = /* @__PURE__ */ new Set();
11
14
  getWalletFn;
12
15
  detectOpts;
@@ -59,34 +62,43 @@ var CairnController = class {
59
62
  }
60
63
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
61
64
  connect = async () => {
65
+ const epoch = this.epoch;
62
66
  this.setState({ status: "connecting", error: null });
63
67
  try {
64
- if (!this.conn) {
65
- this.conn = await this.getWalletFn(this.detectOpts);
66
- this.conn.on("accountsChanged", this.onAccounts);
67
- this.conn.on("disconnect", this.onDisconnect);
68
+ let conn = this.conn;
69
+ if (!conn) {
70
+ conn = await this.getWalletFn(this.detectOpts);
71
+ if (epoch !== this.epoch) throw new Error("connection was torn down while connecting - call connect() again");
72
+ conn.on("accountsChanged", this.onAccounts);
73
+ conn.on("disconnect", this.onDisconnect);
74
+ this.conn = conn;
68
75
  }
69
- const addr = await this.conn.connect();
76
+ const addr = await conn.connect();
77
+ if (epoch !== this.epoch) throw new Error("connection was torn down while awaiting approval - call connect() again");
70
78
  this.setState({ status: "connected", account: addr, error: null });
71
79
  return addr;
72
80
  } catch (e) {
73
- this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
81
+ if (epoch === this.epoch) this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
74
82
  throw e;
75
83
  }
76
84
  };
77
- /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
85
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
86
+ * Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
87
+ * provider can never hold a torn-down session in "connected". */
78
88
  disconnect = async () => {
79
- try {
80
- await this.conn?.revokePermissions();
81
- } catch {
82
- }
89
+ this.epoch++;
90
+ const conn = this.conn;
83
91
  this.detach();
84
92
  this.conn = null;
85
93
  this.setState({ status: "disconnected", account: null, error: null });
94
+ try {
95
+ await conn?.revokePermissions();
96
+ } catch {
97
+ }
86
98
  };
87
99
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
88
100
  signInWithCsd = (params) => {
89
- if (!this.conn) return Promise.reject(new Error("call connect() before signInWithCsd()"));
101
+ if (!this.conn || this.state.status !== "connected") return Promise.reject(new Error("call connect() before signInWithCsd()"));
90
102
  return this.conn.signInWithCsd(params);
91
103
  };
92
104
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ContentVerificationError
3
- } from "./chunk-MREZG2HN.js";
3
+ } from "./chunk-REHDMKBF.js";
4
4
 
5
5
  // src/content.ts
6
6
  import { payloadHash, canonicalJson, verifyContentBytes } from "@inversealtruism/csd-codec";
@@ -1,5 +1,5 @@
1
1
  // src/errors.ts
2
- var SDK_VERSION = "0.4.0";
2
+ var SDK_VERSION = "0.4.2";
3
3
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
4
4
  var CairnError = class extends Error {
5
5
  /** Stable machine code for programmatic branching. */
@@ -4,7 +4,7 @@ import {
4
4
  UnsupportedMethodError,
5
5
  mapProviderError,
6
6
  mapSubmitResultError
7
- } from "./chunk-MREZG2HN.js";
7
+ } from "./chunk-REHDMKBF.js";
8
8
 
9
9
  // src/connect.ts
10
10
  var DEFAULT_PROVIDER_TIMEOUT_MS = 10 * 6e4;
@@ -99,12 +99,15 @@ var WalletConnection = class {
99
99
  // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
100
100
  call(op, label) {
101
101
  const ms = this._timeoutMs;
102
- if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(op());
102
+ const invoke = () => new Promise((resolve) => {
103
+ resolve(op());
104
+ });
105
+ if (!Number.isFinite(ms) || ms <= 0) return invoke();
103
106
  let timer;
104
107
  const timeout = new Promise((_, reject) => {
105
108
  timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
106
109
  });
107
- return Promise.race([Promise.resolve(op()), timeout]).finally(() => clearTimeout(timer));
110
+ return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
108
111
  }
109
112
  /** The wallet's version string (e.g. "0.2.x"). */
110
113
  get version() {
@@ -177,8 +180,24 @@ var WalletConnection = class {
177
180
  if (typeof this.provider.fillOffer !== "function") {
178
181
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
179
182
  }
183
+ this.adviseFillSpvCapability();
180
184
  return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
181
185
  }
186
+ // P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
187
+ // payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
188
+ // and STILL RELAY. WARN, NEVER BLOCK: a hard version gate would decline honest fills (version strings are
189
+ // spoofable and unparseable forks exist), and the wallet itself is the enforcement point. Reads only the
190
+ // sync `provider.version`; never an awaited getCapabilities() round-trip on the fill hot path. An
191
+ // unparseable version never warns (no false alarms).
192
+ _fillSpvAdvised = false;
193
+ adviseFillSpvCapability() {
194
+ if (this._fillSpvAdvised) return;
195
+ this._fillSpvAdvised = true;
196
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(this.provider?.version ?? ""));
197
+ if (!m) return;
198
+ const below = Number(m[1]) === 0 && (Number(m[2]) < 2 || Number(m[2]) === 2 && Number(m[3]) < 60);
199
+ if (below) console.warn(`[cairn-sdk] this Cairn Wallet (v${m[0]}) predates the on-device fill-SPV boundary (cairn-wallet 0.2.60+). The fill proceeds; suggest a wallet update for payment-grade offer verification.`);
200
+ }
182
201
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
183
202
  async getPermissions() {
184
203
  if (typeof this.provider.getPermissions !== "function") return [];
@@ -0,0 +1,224 @@
1
+ import {
2
+ HttpError
3
+ } from "./chunk-REHDMKBF.js";
4
+
5
+ // src/registry.ts
6
+ import { fromRecords } from "@inversealtruism/csd-registry";
7
+
8
+ // src/http.ts
9
+ var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
10
+ var ERR_TEXT_CAP = 8 * 1024;
11
+ async function readCapped(res, maxBytes) {
12
+ const cl = Number(res.headers.get("content-length"));
13
+ if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
14
+ const body = res.body;
15
+ if (!body || typeof body.getReader !== "function") {
16
+ const buf = new Uint8Array(await res.arrayBuffer());
17
+ if (buf.byteLength > maxBytes) throw new HttpError(res.status, res.url, `response too large (${buf.byteLength} > ${maxBytes})`);
18
+ return buf;
19
+ }
20
+ const reader = body.getReader();
21
+ const chunks = [];
22
+ let total = 0;
23
+ for (; ; ) {
24
+ const { done, value } = await reader.read();
25
+ if (done) break;
26
+ if (value) {
27
+ total += value.byteLength;
28
+ if (total > maxBytes) {
29
+ try {
30
+ await reader.cancel();
31
+ } catch {
32
+ }
33
+ throw new HttpError(res.status, res.url, `response exceeded ${maxBytes} bytes`);
34
+ }
35
+ chunks.push(value);
36
+ }
37
+ }
38
+ const out = new Uint8Array(total);
39
+ let off = 0;
40
+ for (const c of chunks) {
41
+ out.set(c, off);
42
+ off += c.byteLength;
43
+ }
44
+ return out;
45
+ }
46
+ var Http = class {
47
+ baseUrl;
48
+ _fetch;
49
+ timeoutMs;
50
+ maxBytes;
51
+ headers;
52
+ retries;
53
+ constructor(opts) {
54
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
55
+ const f = opts.fetch ?? globalThis.fetch;
56
+ if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
57
+ this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
58
+ this.timeoutMs = opts.timeoutMs ?? 15e3;
59
+ this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
60
+ this.headers = opts.headers ?? {};
61
+ this.retries = Math.min(10, Math.max(0, Math.floor(opts.retries ?? 0)));
62
+ }
63
+ /** Build an absolute URL from a path + optional query params. */
64
+ url(path, query) {
65
+ const p = path.startsWith("/") ? path : `/${path}`;
66
+ const u = new URL(this.baseUrl + p);
67
+ const basePath = new URL(this.baseUrl + "/").pathname;
68
+ if (basePath !== "/" && !(u.pathname + "/").startsWith(basePath)) {
69
+ throw new Error(`illegal path '${path}' \u2014 escapes base ${basePath}`);
70
+ }
71
+ if (query) {
72
+ for (const [k, v] of Object.entries(query)) {
73
+ if (v !== void 0 && v !== null) u.searchParams.set(k, String(v));
74
+ }
75
+ }
76
+ return u.toString();
77
+ }
78
+ async raw(method, path, opts) {
79
+ const url = this.url(path, opts?.query);
80
+ const ctrl = new AbortController();
81
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
82
+ try {
83
+ const res = await this._fetch(url, {
84
+ method,
85
+ signal: ctrl.signal,
86
+ headers: {
87
+ ...opts?.body !== void 0 ? { "content-type": "application/json" } : {},
88
+ ...this.headers,
89
+ ...opts?.headers ?? {}
90
+ },
91
+ body: opts?.body !== void 0 ? JSON.stringify(opts.body) : void 0
92
+ });
93
+ return res;
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+ // A malformed 2xx body must surface as a TYPED HttpError, not a leaked SyntaxError (B10: dApp
99
+ // code branches on `instanceof CairnError`; a raw parse error escaped that contract).
100
+ parseJson(res, text, url) {
101
+ try {
102
+ return JSON.parse(text);
103
+ } catch (e) {
104
+ throw new HttpError(res.status, url, `invalid JSON body: ${text.slice(0, 120)}`);
105
+ }
106
+ }
107
+ async getJson(path, query) {
108
+ const url = this.url(path, query);
109
+ for (let attempt = 0; ; attempt++) {
110
+ try {
111
+ const res = await this.raw("GET", path, { query });
112
+ if (!res.ok) {
113
+ if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, await safeText(res));
114
+ res.body?.cancel().catch(() => {
115
+ });
116
+ } else {
117
+ return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
118
+ }
119
+ } catch (e) {
120
+ if (e instanceof HttpError && e.status < 500) throw e;
121
+ if (attempt >= this.retries) throw e;
122
+ }
123
+ await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt))));
124
+ }
125
+ }
126
+ async postJson(path, body, headers) {
127
+ const url = this.url(path);
128
+ const res = await this.raw("POST", path, { body, headers });
129
+ if (!res.ok) throw new HttpError(res.status, url, await safeText(res));
130
+ return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
131
+ }
132
+ /** GET raw bytes (for content retrieval). Returns null on 404. */
133
+ async getBytes(path) {
134
+ const res = await this.raw("GET", path);
135
+ if (res.status === 404) return null;
136
+ if (!res.ok) throw new HttpError(res.status, this.url(path), await safeText(res));
137
+ return { bytes: await readCapped(res, this.maxBytes), headers: res.headers };
138
+ }
139
+ };
140
+ async function safeText(res) {
141
+ try {
142
+ return new TextDecoder().decode(await readCapped(res, ERR_TEXT_CAP));
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+
148
+ // src/registry.ts
149
+ import {
150
+ fromRecords as fromRecords2,
151
+ verifyPeer,
152
+ verifyGateway,
153
+ verifyIdentitySig,
154
+ DOMAINS,
155
+ buildPeerRecord,
156
+ buildGatewayRecord,
157
+ buildIdentityCommit,
158
+ buildIdentityReveal,
159
+ epochOf
160
+ } from "@inversealtruism/csd-registry";
161
+ var RegistryClient = class {
162
+ http;
163
+ /** Accepts the SDK's hardened Http directly (the Cairn facade path) or the legacy
164
+ * { baseUrl, fetch } options (kept for direct constructors; they now get the same
165
+ * hardened transport, built internally). */
166
+ constructor(src) {
167
+ this.http = src instanceof Http ? src : new Http({ baseUrl: src.baseUrl, fetch: src.fetch });
168
+ }
169
+ /** Discover ranked content/pin gateways (GET /registry/gateways). */
170
+ gateways() {
171
+ return this.http.getJson("/registry/gateways");
172
+ }
173
+ /** Discover ranked libp2p peers (GET /registry/peers). */
174
+ peers() {
175
+ return this.http.getJson("/registry/peers");
176
+ }
177
+ /**
178
+ * Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved.
179
+ *
180
+ * ⚠ TRUST (CAIRN-SDK-RESOLVE-VERIFIED-FOOTGUN): the returned `verified` flag is the INDEXER's assertion,
181
+ * NOT a client-side proof. A hostile/compromised indexer (or a MITM of the read proxy) can return
182
+ * `verified:true` for a wrong address. Do NOT use this directly as a payee/login target without either (a)
183
+ * recomputing from raw records via `fromRecords.name(...)`, or (b) cross-checking a second independent
184
+ * source (the wallet's namespv union cure). Treat `verified` here as a hint, not a guarantee.
185
+ */
186
+ resolveName(handle) {
187
+ return this.getOr404Null(`/identity/${encodeURIComponent(handle)}`);
188
+ }
189
+ /** Reverse-resolve an address → identity (GET /address/:addr/identity).
190
+ * ⚠ Same indexer-trust caveat as resolveName — `verified` is the indexer's claim, not a client proof. */
191
+ reverseName(address) {
192
+ return this.getOr404Null(`/address/${encodeURIComponent(address)}/identity`);
193
+ }
194
+ // MF-12: 404 → null (the preserved csd-registry contract for both name resolvers); every other
195
+ // failure rethrows the typed HttpError/CairnError the hardened Http raises.
196
+ async getOr404Null(path) {
197
+ try {
198
+ return await this.http.getJson(path);
199
+ } catch (e) {
200
+ if (e instanceof HttpError && e.status === 404) return null;
201
+ return Promise.reject(e);
202
+ }
203
+ }
204
+ /** The deterministic, client-side resolvers (recompute from raw ChainRecord[]). */
205
+ get fromRecords() {
206
+ return fromRecords;
207
+ }
208
+ };
209
+
210
+ export {
211
+ readCapped,
212
+ Http,
213
+ RegistryClient,
214
+ fromRecords2 as fromRecords,
215
+ verifyPeer,
216
+ verifyGateway,
217
+ verifyIdentitySig,
218
+ DOMAINS,
219
+ buildPeerRecord,
220
+ buildGatewayRecord,
221
+ buildIdentityCommit,
222
+ buildIdentityReveal,
223
+ epochOf
224
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  CairnError
3
- } from "./chunk-MREZG2HN.js";
3
+ } from "./chunk-REHDMKBF.js";
4
4
 
5
5
  // src/board.ts
6
6
  import { payloadHash } from "@inversealtruism/csd-codec";
package/dist/connect.cjs CHANGED
@@ -30,7 +30,7 @@ __export(connect_exports, {
30
30
  module.exports = __toCommonJS(connect_exports);
31
31
 
32
32
  // src/errors.ts
33
- var SDK_VERSION = "0.4.0";
33
+ var SDK_VERSION = "0.4.2";
34
34
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
35
35
  var CairnError = class extends Error {
36
36
  /** Stable machine code for programmatic branching. */
@@ -240,12 +240,15 @@ var WalletConnection = class {
240
240
  // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
241
241
  call(op, label) {
242
242
  const ms = this._timeoutMs;
243
- if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(op());
243
+ const invoke = () => new Promise((resolve) => {
244
+ resolve(op());
245
+ });
246
+ if (!Number.isFinite(ms) || ms <= 0) return invoke();
244
247
  let timer;
245
248
  const timeout = new Promise((_, reject) => {
246
249
  timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
247
250
  });
248
- return Promise.race([Promise.resolve(op()), timeout]).finally(() => clearTimeout(timer));
251
+ return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
249
252
  }
250
253
  /** The wallet's version string (e.g. "0.2.x"). */
251
254
  get version() {
@@ -318,8 +321,24 @@ var WalletConnection = class {
318
321
  if (typeof this.provider.fillOffer !== "function") {
319
322
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
320
323
  }
324
+ this.adviseFillSpvCapability();
321
325
  return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
322
326
  }
327
+ // P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
328
+ // payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
329
+ // and STILL RELAY. WARN, NEVER BLOCK: a hard version gate would decline honest fills (version strings are
330
+ // spoofable and unparseable forks exist), and the wallet itself is the enforcement point. Reads only the
331
+ // sync `provider.version`; never an awaited getCapabilities() round-trip on the fill hot path. An
332
+ // unparseable version never warns (no false alarms).
333
+ _fillSpvAdvised = false;
334
+ adviseFillSpvCapability() {
335
+ if (this._fillSpvAdvised) return;
336
+ this._fillSpvAdvised = true;
337
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(this.provider?.version ?? ""));
338
+ if (!m) return;
339
+ const below = Number(m[1]) === 0 && (Number(m[2]) < 2 || Number(m[2]) === 2 && Number(m[3]) < 60);
340
+ if (below) console.warn(`[cairn-sdk] this Cairn Wallet (v${m[0]}) predates the on-device fill-SPV boundary (cairn-wallet 0.2.60+). The fill proceeds; suggest a wallet update for payment-grade offer verification.`);
341
+ }
323
342
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
324
343
  async getPermissions() {
325
344
  if (typeof this.provider.getPermissions !== "function") return [];
@@ -57,7 +57,8 @@ interface SealClaimParams {
57
57
  * so a dApp settling real value MUST merkle-prove the offer and derive the recipient from its on-chain author
58
58
  * before building `outputs`. Payment-grade offer verification is the wallet's on-device fill-SPV path (F13;
59
59
  * added in cairn-wallet 0.2.60, which binds the fill payment/rebate/fee legs to the merkle-proven offer; see
60
- * docs/SDK-GUIDE.md verifies-vs-trusts). An SPV pre-verify helper in this SDK is roadmapped.
60
+ * docs/SDK-GUIDE.md verifies-vs-trusts). The SDK's SPV pre-verify SHIPPED: call `preverifyOffer` or
61
+ * `cairn.verifyOfferForFill(offerId, servedOffer, { pay })` before building `outputs`.
61
62
  */
62
63
  interface FillParams {
63
64
  proposalId: string;
@@ -249,6 +250,8 @@ declare class WalletConnection {
249
250
  * 0.2.60+, which binds payment/rebate/fee to the merkle-proven offer) is the payment-grade check; see FillParams.
250
251
  */
251
252
  fillOffer(params: FillParams): Promise<TxResult>;
253
+ private _fillSpvAdvised;
254
+ private adviseFillSpvCapability;
252
255
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
253
256
  getPermissions(): Promise<WalletPermission[]>;
254
257
  /** Explicitly request permission to see the address (prompts, like connect). */
package/dist/connect.d.ts CHANGED
@@ -57,7 +57,8 @@ interface SealClaimParams {
57
57
  * so a dApp settling real value MUST merkle-prove the offer and derive the recipient from its on-chain author
58
58
  * before building `outputs`. Payment-grade offer verification is the wallet's on-device fill-SPV path (F13;
59
59
  * added in cairn-wallet 0.2.60, which binds the fill payment/rebate/fee legs to the merkle-proven offer; see
60
- * docs/SDK-GUIDE.md verifies-vs-trusts). An SPV pre-verify helper in this SDK is roadmapped.
60
+ * docs/SDK-GUIDE.md verifies-vs-trusts). The SDK's SPV pre-verify SHIPPED: call `preverifyOffer` or
61
+ * `cairn.verifyOfferForFill(offerId, servedOffer, { pay })` before building `outputs`.
61
62
  */
62
63
  interface FillParams {
63
64
  proposalId: string;
@@ -249,6 +250,8 @@ declare class WalletConnection {
249
250
  * 0.2.60+, which binds payment/rebate/fee to the merkle-proven offer) is the payment-grade check; see FillParams.
250
251
  */
251
252
  fillOffer(params: FillParams): Promise<TxResult>;
253
+ private _fillSpvAdvised;
254
+ private adviseFillSpvCapability;
252
255
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
253
256
  getPermissions(): Promise<WalletPermission[]>;
254
257
  /** Explicitly request permission to see the address (prompts, like connect). */
package/dist/connect.js CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  discoverProviders,
6
6
  getWallet,
7
7
  isInstalled
8
- } from "./chunk-RKR3JMIA.js";
9
- import "./chunk-MREZG2HN.js";
8
+ } from "./chunk-VGKXFRHY.js";
9
+ import "./chunk-REHDMKBF.js";
10
10
  export {
11
11
  WalletConnection,
12
12
  connect,
package/dist/content.cjs CHANGED
@@ -26,7 +26,7 @@ module.exports = __toCommonJS(content_exports);
26
26
  var import_csd_codec = require("@inversealtruism/csd-codec");
27
27
 
28
28
  // src/errors.ts
29
- var SDK_VERSION = "0.4.0";
29
+ var SDK_VERSION = "0.4.2";
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. */
package/dist/content.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ContentClient
3
- } from "./chunk-2L5QNDAE.js";
4
- import "./chunk-MREZG2HN.js";
3
+ } from "./chunk-PZEQ4YHR.js";
4
+ import "./chunk-REHDMKBF.js";
5
5
  export {
6
6
  ContentClient
7
7
  };
@@ -25,7 +25,7 @@ __export(controller_exports, {
25
25
  module.exports = __toCommonJS(controller_exports);
26
26
 
27
27
  // src/errors.ts
28
- var SDK_VERSION = "0.4.0";
28
+ var SDK_VERSION = "0.4.2";
29
29
  var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
30
30
  var CairnError = class extends Error {
31
31
  /** Stable machine code for programmatic branching. */
@@ -212,12 +212,15 @@ var WalletConnection = class {
212
212
  // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
213
213
  call(op, label) {
214
214
  const ms = this._timeoutMs;
215
- if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(op());
215
+ const invoke = () => new Promise((resolve) => {
216
+ resolve(op());
217
+ });
218
+ if (!Number.isFinite(ms) || ms <= 0) return invoke();
216
219
  let timer;
217
220
  const timeout = new Promise((_, reject) => {
218
221
  timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
219
222
  });
220
- return Promise.race([Promise.resolve(op()), timeout]).finally(() => clearTimeout(timer));
223
+ return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
221
224
  }
222
225
  /** The wallet's version string (e.g. "0.2.x"). */
223
226
  get version() {
@@ -290,8 +293,24 @@ var WalletConnection = class {
290
293
  if (typeof this.provider.fillOffer !== "function") {
291
294
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
292
295
  }
296
+ this.adviseFillSpvCapability();
293
297
  return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
294
298
  }
299
+ // P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
300
+ // payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
301
+ // and STILL RELAY. WARN, NEVER BLOCK: a hard version gate would decline honest fills (version strings are
302
+ // spoofable and unparseable forks exist), and the wallet itself is the enforcement point. Reads only the
303
+ // sync `provider.version`; never an awaited getCapabilities() round-trip on the fill hot path. An
304
+ // unparseable version never warns (no false alarms).
305
+ _fillSpvAdvised = false;
306
+ adviseFillSpvCapability() {
307
+ if (this._fillSpvAdvised) return;
308
+ this._fillSpvAdvised = true;
309
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(this.provider?.version ?? ""));
310
+ if (!m) return;
311
+ const below = Number(m[1]) === 0 && (Number(m[2]) < 2 || Number(m[2]) === 2 && Number(m[3]) < 60);
312
+ if (below) console.warn(`[cairn-sdk] this Cairn Wallet (v${m[0]}) predates the on-device fill-SPV boundary (cairn-wallet 0.2.60+). The fill proceeds; suggest a wallet update for payment-grade offer verification.`);
313
+ }
295
314
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
296
315
  async getPermissions() {
297
316
  if (typeof this.provider.getPermissions !== "function") return [];
@@ -354,6 +373,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
354
373
  var CairnController = class {
355
374
  state = DISCONNECTED;
356
375
  conn = null;
376
+ // MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
377
+ // the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
378
+ epoch = 0;
357
379
  listeners = /* @__PURE__ */ new Set();
358
380
  getWalletFn;
359
381
  detectOpts;
@@ -406,34 +428,43 @@ var CairnController = class {
406
428
  }
407
429
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
408
430
  connect = async () => {
431
+ const epoch = this.epoch;
409
432
  this.setState({ status: "connecting", error: null });
410
433
  try {
411
- if (!this.conn) {
412
- this.conn = await this.getWalletFn(this.detectOpts);
413
- this.conn.on("accountsChanged", this.onAccounts);
414
- this.conn.on("disconnect", this.onDisconnect);
434
+ let conn = this.conn;
435
+ if (!conn) {
436
+ conn = await this.getWalletFn(this.detectOpts);
437
+ if (epoch !== this.epoch) throw new Error("connection was torn down while connecting - call connect() again");
438
+ conn.on("accountsChanged", this.onAccounts);
439
+ conn.on("disconnect", this.onDisconnect);
440
+ this.conn = conn;
415
441
  }
416
- const addr = await this.conn.connect();
442
+ const addr = await conn.connect();
443
+ if (epoch !== this.epoch) throw new Error("connection was torn down while awaiting approval - call connect() again");
417
444
  this.setState({ status: "connected", account: addr, error: null });
418
445
  return addr;
419
446
  } catch (e) {
420
- this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
447
+ if (epoch === this.epoch) this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
421
448
  throw e;
422
449
  }
423
450
  };
424
- /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
451
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
452
+ * Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
453
+ * provider can never hold a torn-down session in "connected". */
425
454
  disconnect = async () => {
426
- try {
427
- await this.conn?.revokePermissions();
428
- } catch {
429
- }
455
+ this.epoch++;
456
+ const conn = this.conn;
430
457
  this.detach();
431
458
  this.conn = null;
432
459
  this.setState({ status: "disconnected", account: null, error: null });
460
+ try {
461
+ await conn?.revokePermissions();
462
+ } catch {
463
+ }
433
464
  };
434
465
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
435
466
  signInWithCsd = (params) => {
436
- if (!this.conn) return Promise.reject(new Error("call connect() before signInWithCsd()"));
467
+ if (!this.conn || this.state.status !== "connected") return Promise.reject(new Error("call connect() before signInWithCsd()"));
437
468
  return this.conn.signInWithCsd(params);
438
469
  };
439
470
  };
@@ -12,6 +12,7 @@ interface CairnControllerOptions extends DetectOptions {
12
12
  declare class CairnController {
13
13
  private state;
14
14
  private conn;
15
+ private epoch;
15
16
  private readonly listeners;
16
17
  private readonly getWalletFn;
17
18
  private readonly detectOpts;
@@ -28,7 +29,9 @@ declare class CairnController {
28
29
  private detach;
29
30
  /** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
30
31
  connect: () => Promise<string>;
31
- /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort). */
32
+ /** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
33
+ * Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
34
+ * provider can never hold a torn-down session in "connected". */
32
35
  disconnect: () => Promise<void>;
33
36
  /** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
34
37
  signInWithCsd: (params: SiwcParams) => Promise<SiwcResult>;