@inversealtruism/cairn-sdk 0.3.0 → 0.4.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.d.ts CHANGED
@@ -10,6 +10,8 @@ export { ContentClientOptions, PreparedContent } from './content.js';
10
10
  import { RegistryClient } from './registry.js';
11
11
  import { WalletConnection, DetectOptions } from './connect.js';
12
12
  export { AttestParams, CairnProvider, ConnectResult, FillParams, ProposeParams, ProviderDetail, ProviderInfo, SealClaimParams, SendParams, SignInResult, SiwcParams, SiwcResult, TxResult, WalletCapabilities, WalletPermission, connect, detectProvider, discoverProviders, getWallet, isInstalled } from './connect.js';
13
+ import { ProvenOfferTerms } from '@inversealtruism/cairnx-core';
14
+ export { FillEndorsement, FillOutputPlan, FillPreview, FillSafety, ProvenOfferTerms, bindOfferTerms, feeBpsAt, fillEndorsement, fillIsSafe, fillOutputPlan, previewFill, requiredFillOutputs } from '@inversealtruism/cairnx-core';
13
15
  import { InclusionResult } from '@inversealtruism/csd-light';
14
16
  export { CairnController, CairnControllerOptions, CairnState } from './controller.js';
15
17
  import '@inversealtruism/csd-client';
@@ -104,15 +106,6 @@ declare class NamesClient {
104
106
  state(): Promise<unknown>;
105
107
  }
106
108
 
107
- /** The fee/rebate-relevant fields of an offer, derived from the MERKLE-PROVEN offer (never a served object). */
108
- interface ProvenOfferTerms {
109
- height: number;
110
- feeBps: number;
111
- value?: string;
112
- taker?: string;
113
- bid?: string;
114
- min?: string;
115
- }
116
109
  /** The trust-labeled verdict. `ok:false` + `transient:true` = a retryable chain-catching-up soft-fail (NOT a
117
110
  * proven mismatch). `payto`/`seller`/`terms` are populated whenever they could be merkle-derived. */
118
111
  interface OfferFillCheck {
@@ -126,12 +119,17 @@ interface OfferFillCheck {
126
119
  seller?: string;
127
120
  terms?: ProvenOfferTerms;
128
121
  blockHeight?: number;
122
+ /** B7b (REBIND W3/M1): the PROVEN CSD outputs to build for `pay` (a WHOLE fill of a non-partial offer),
123
+ * sized from the merkle-proven offer by the single-sourced `fillOutputPlan`. Present only when `pay` was
124
+ * passed and the offer could be sized. Build EXACTLY these; never the resolver-served outputs a lying
125
+ * resolver could redirect. A partial (min-bearing) offer is left unsized (its running paid/delivered is
126
+ * not provable at this corroboration layer; the wallet's fill-SPV is the payment-grade sizer). */
127
+ outputPlan?: {
128
+ to: string;
129
+ value: bigint;
130
+ }[];
129
131
  }
130
- /** The treasury fee rate stamped on an offer at its creation height (resolve.ts: v11 ? (v16 ? 150 : 100) : 0). */
131
- declare const feeBpsAt: (height: number) => number;
132
- /** true iff any fee/rebate/partial-sizing field of the SERVED offer diverges from the merkle-proven terms `t`
133
- * (fail-closed: any divergence => the caller refuses). Byte-identical to cairnx-core `bindOfferTerms`. */
134
- declare function bindOfferTerms(servedOffer: unknown, t: ProvenOfferTerms): boolean;
132
+
135
133
  /**
136
134
  * Pre-verify an offer on-chain before building a `fillOffer` payment. `light` is a PoW-verifying light client
137
135
  * (its `verifyTxInclusion` surfaces the merkle-proven tx + committed appPayloadHash); `client` reads source txs
@@ -147,11 +145,20 @@ declare function preverifyOffer(opts: {
147
145
  };
148
146
  offerId: string;
149
147
  servedOffer?: unknown;
148
+ /** B7b (REBIND W3/M1): the CSD base units you intend to pay. When given, the result carries `outputPlan`
149
+ * (the PROVEN CSD outputs to build for a WHOLE fill of a non-partial offer, sized by the single-sourced
150
+ * `fillOutputPlan`) and, if you also pass `plannedOutputs`, binds them EXACTLY to that proven plan.
151
+ * A partial (min-bearing) offer is left unsized here; the wallet's fill-SPV is its payment-grade sizer. */
152
+ pay?: bigint | string | number;
153
+ /** B7b: your planned per-address CSD sums (the outputs you WILL build), bound both ways to `outputPlan`.
154
+ * Requires `pay`. A missing/low leg is a doomed underpay; an extra/high leg is a W3 overpay or an N26
155
+ * smuggled output - both refused. */
156
+ plannedOutputs?: Record<string, bigint | string | number>;
150
157
  }): Promise<OfferFillCheck>;
151
158
 
152
159
  /** Stable machine-readable error codes (branch on these, not the message text). */
153
160
  type CairnErrorCode = "NOT_INSTALLED" | "USER_REJECTED" | "WALLET_LOCKED" | "UNSUPPORTED_METHOD" | "HTTP_ERROR" | "CONTENT_VERIFICATION" | "ACCOUNT_CHANGED" | "FIRST_PARTY_ONLY" | "RATE_LIMITED" | "APPROVAL_CLOSED" | "FORBIDDEN" | "INTERNAL" | "GHOST_INPUTS_SKIPPED" | "VERIFY_UNAVAILABLE" | "VERIFY_TAMPER" | "INSUFFICIENT" | "TOO_MANY_INPUTS" | "FEE_TOO_LOW" | "FEE_CAP" | "BAD_FEE" | "ZERO_ADDR_REFUSED" | "BAD_OUTPUTS" | "NO_OUTPUTS" | "BAD_REQUEST" | "OFFER_UNKNOWN" | "FILL_UNSAFE" | "FILL_WRONG_TARGET" | "SOURCE_DIVERGENCE" | "SUBMIT_REJECTED" | "SUBMIT_MAYBE_INFLIGHT" | "SUBMIT_DUPLICATE" | "NOTHING_TO_CONSOLIDATE" | "UNKNOWN";
154
- declare const SDK_VERSION = "0.3.0";
161
+ declare const SDK_VERSION = "0.4.0";
155
162
  interface CairnErrorOptions {
156
163
  code?: CairnErrorCode;
157
164
  docsPath?: string;
@@ -171,7 +178,7 @@ declare class CairnError extends Error {
171
178
  /** Relative docs path (under the SDK docs base), if any. */
172
179
  readonly docsPath?: string;
173
180
  /** The cairn-sdk version that produced this error. */
174
- readonly version = "0.3.0";
181
+ readonly version = "0.4.0";
175
182
  /**
176
183
  * Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
177
184
  * declines (the default); `true` only for provably transient infra faults where nothing was signed.
@@ -312,6 +319,7 @@ declare class Cairn {
312
319
  private _spvLight;
313
320
  private _spvSeeded;
314
321
  private readonly _spvCp;
322
+ private readonly _cairnBase;
315
323
  constructor(config?: CairnConfig);
316
324
  /**
317
325
  * PoW-VERIFIED header merkle at a height (audit M3 — the no-PoW half). Lazily seeds a light
@@ -325,6 +333,21 @@ declare class Cairn {
325
333
  /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
326
334
  * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
327
335
  private seededSpvLight;
336
+ /**
337
+ * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
338
+ * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
339
+ * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
340
+ * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
341
+ * server's 10s deadline) is a transient data-availability hiccup; back off (honouring Retry-After) a
342
+ * bounded number of times, then THROW so `sync()` fails closed. A non-dense range is rejected.
343
+ *
344
+ * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
345
+ * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
346
+ * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
347
+ * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
348
+ * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
349
+ */
350
+ private fetchHeadersBatch;
328
351
  /**
329
352
  * F13 — corroborate an offer ON-CHAIN before building a `fillOffer` payment's `outputs`. Merkle-proves the
330
353
  * offer into the PoW-verified header chain (from the pinned SPV checkpoint), binds the record to its on-chain
@@ -349,4 +372,4 @@ declare class Cairn {
349
372
  get connected(): boolean;
350
373
  }
351
374
 
352
- export { BoardClient, Cairn, type CairnBaseUrls, type CairnConfig, CairnError, type CairnErrorCode, type CairnErrorOptions, Chain, ContentClient, ContentVerificationError, DEFAULT_SPV_CHECKPOINT, DetectOptions, FetchLike, Http, HttpError, IndexerClient, type NameDetail, type NameResolution, NamesClient, NotInstalledError, type OfferFillCheck, type PrimaryName, type ProvenOfferTerms, RegistryClient, SDK_VERSION, SubmitInFlightError, type TokenInfo, UnsupportedMethodError, UserRejectedError, WalletConnection, WalletLockedError, bindOfferTerms, errorCode, feeBpsAt, mapProviderError, mapSubmitResultError, preverifyOffer };
375
+ export { BoardClient, Cairn, type CairnBaseUrls, type CairnConfig, CairnError, type CairnErrorCode, type CairnErrorOptions, Chain, ContentClient, ContentVerificationError, DEFAULT_SPV_CHECKPOINT, DetectOptions, FetchLike, Http, HttpError, IndexerClient, type NameDetail, type NameResolution, NamesClient, NotInstalledError, type OfferFillCheck, type PrimaryName, RegistryClient, SDK_VERSION, SubmitInFlightError, type TokenInfo, UnsupportedMethodError, UserRejectedError, WalletConnection, WalletLockedError, errorCode, mapProviderError, mapSubmitResultError, preverifyOffer };
package/dist/index.js CHANGED
@@ -3,20 +3,20 @@ import {
3
3
  } from "./chunk-BGJZF7S2.js";
4
4
  import {
5
5
  BoardClient
6
- } from "./chunk-NLCF5T2F.js";
6
+ } from "./chunk-2ZML7IKV.js";
7
7
  import {
8
8
  Chain,
9
9
  LightClient
10
10
  } from "./chunk-C452UODV.js";
11
11
  import {
12
12
  ContentClient
13
- } from "./chunk-4RDXEW35.js";
13
+ } from "./chunk-2L5QNDAE.js";
14
14
  import {
15
15
  IndexerClient
16
16
  } from "./chunk-J5J4JEJ6.js";
17
17
  import {
18
18
  CairnController
19
- } from "./chunk-UMXLWUEF.js";
19
+ } from "./chunk-GTTLMWGY.js";
20
20
  import {
21
21
  WalletConnection,
22
22
  connect,
@@ -24,7 +24,7 @@ import {
24
24
  discoverProviders,
25
25
  getWallet,
26
26
  isInstalled
27
- } from "./chunk-QV2GIXO7.js";
27
+ } from "./chunk-RKR3JMIA.js";
28
28
  import {
29
29
  CairnError,
30
30
  ContentVerificationError,
@@ -38,7 +38,7 @@ import {
38
38
  errorCode,
39
39
  mapProviderError,
40
40
  mapSubmitResultError
41
- } from "./chunk-E2Z3XSCW.js";
41
+ } from "./chunk-MREZG2HN.js";
42
42
 
43
43
  // src/http.ts
44
44
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
@@ -145,8 +145,9 @@ var Http = class {
145
145
  try {
146
146
  const res = await this.raw("GET", path, { query });
147
147
  if (!res.ok) {
148
- const err = new HttpError(res.status, url, await safeText(res));
149
- if (!(res.status >= 500 && attempt < this.retries)) throw err;
148
+ if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, await safeText(res));
149
+ res.body?.cancel().catch(() => {
150
+ });
150
151
  } else {
151
152
  return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
152
153
  }
@@ -191,7 +192,7 @@ var NamesClient = class {
191
192
  async resolve(name) {
192
193
  if (!nameOk(name)) return null;
193
194
  try {
194
- return await this.http.getJson(`/trade/api/cairnx/resolve/${name}`);
195
+ return await this.http.getJson(`/trade/api/cairnx/resolve/${encodeURIComponent(name)}`);
195
196
  } catch (e) {
196
197
  if (e?.status === 404) return null;
197
198
  throw e;
@@ -201,7 +202,7 @@ var NamesClient = class {
201
202
  async name(name) {
202
203
  if (!nameOk(name)) return null;
203
204
  try {
204
- return await this.http.getJson(`/trade/api/cairnx/name/${name}`);
205
+ return await this.http.getJson(`/trade/api/cairnx/name/${encodeURIComponent(name)}`);
205
206
  } catch (e) {
206
207
  if (e?.status === 404) return null;
207
208
  throw e;
@@ -211,7 +212,7 @@ var NamesClient = class {
211
212
  async nameHistory(name) {
212
213
  if (!nameOk(name)) return null;
213
214
  try {
214
- return await this.http.getJson(`/trade/api/cairnx/name-history/${name}`);
215
+ return await this.http.getJson(`/trade/api/cairnx/name-history/${encodeURIComponent(name)}`);
215
216
  } catch (e) {
216
217
  if (e?.status === 404) return null;
217
218
  throw e;
@@ -272,23 +273,37 @@ var NamesClient = class {
272
273
  // src/fillverify.ts
273
274
  import { rpcTxToTx } from "@inversealtruism/csd-client";
274
275
  import { txid, payloadHash } from "@inversealtruism/csd-codec";
275
- import { parseRecord, V11_HEIGHT, V16_HEIGHT, FEE_BPS, FEE_BPS_V16 } from "@inversealtruism/cairnx-core";
276
+ import {
277
+ parseRecord,
278
+ feeBpsAt,
279
+ bindOfferTerms,
280
+ provenOfferTerms,
281
+ fillOutputPlan,
282
+ isTokenWant,
283
+ fillEndorsement,
284
+ fillIsSafe,
285
+ requiredFillOutputs,
286
+ previewFill
287
+ } from "@inversealtruism/cairnx-core";
276
288
  var ADDR = /^0x[0-9a-f]{40}$/;
277
289
  var HASH = /^0x[0-9a-f]{64}$/;
278
290
  var COINBASE = "0x" + "00".repeat(32);
279
- var feeBpsAt = (height) => height >= V11_HEIGHT ? height >= V16_HEIGHT ? FEE_BPS_V16 : FEE_BPS : 0;
280
- function bindOfferTerms(servedOffer, t) {
281
- const o = servedOffer;
282
- const s = (v) => v === void 0 || v === null ? "" : String(v).toLowerCase();
283
- if (Number(o?.height) !== t.height) return true;
284
- if (Number(o?.feeBps) !== t.feeBps) return true;
285
- if (t.value !== void 0 && String(o?.want?.value) !== t.value) return true;
286
- if (s(o?.taker) !== s(t.taker)) return true;
287
- if (s(o?.bid) !== s(t.bid)) return true;
288
- const om = o?.min;
289
- if ((om !== void 0 && om !== null) !== (t.min !== void 0)) return true;
290
- if (t.min !== void 0 && String(om) !== t.min) return true;
291
- return false;
291
+ function bindPlannedSums(planned, proven) {
292
+ const provenMap = new Map(proven.map((o) => [o.to.toLowerCase(), o.value]));
293
+ const plannedMap = /* @__PURE__ */ new Map();
294
+ try {
295
+ for (const [a, v] of Object.entries(planned)) {
296
+ const k = String(a).toLowerCase();
297
+ plannedMap.set(k, (plannedMap.get(k) ?? 0n) + BigInt(v));
298
+ }
299
+ } catch {
300
+ return "invalid plannedOutputs (amounts must be base-unit integers)";
301
+ }
302
+ for (const k of /* @__PURE__ */ new Set([...provenMap.keys(), ...plannedMap.keys()])) {
303
+ if ((provenMap.get(k) ?? 0n) !== (plannedMap.get(k) ?? 0n))
304
+ return `your planned payment doesn't match the proven fill outputs (${k}: planned ${(plannedMap.get(k) ?? 0n).toString()}, proven ${(provenMap.get(k) ?? 0n).toString()}) - refusing (an overpay past the proven amount is unrecoverable, and a missing or low leg is a doomed underpay)`;
305
+ }
306
+ return void 0;
292
307
  }
293
308
  async function provenAuthor(client, provenTx) {
294
309
  const in0 = provenTx.inputs?.[0];
@@ -345,17 +360,35 @@ async function preverifyOffer(opts) {
345
360
  return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
346
361
  const seller = await provenAuthor(opts.client, provenTx);
347
362
  if (!seller) return { ok: false, trust: "verified", transient: true, blockHeight, reason: "couldn't prove the offer's on-chain author yet; the chain view may be catching up, try again" };
363
+ const offerRec = rec;
348
364
  const w = rec.want ?? {};
349
365
  const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
350
- const terms = {
351
- height: blockHeight,
352
- feeBps: feeBpsAt(blockHeight),
353
- value: w.value !== void 0 ? String(w.value) : void 0,
354
- taker: rec.taker !== void 0 ? String(rec.taker).toLowerCase() : void 0,
355
- bid: rec.bid !== void 0 ? String(rec.bid).toLowerCase() : void 0,
356
- min: rec.min !== void 0 ? String(rec.min) : void 0
357
- };
358
- const base = { payto, seller, terms, blockHeight };
366
+ const terms = provenOfferTerms(offerRec, blockHeight);
367
+ let outputPlan;
368
+ let sumsMismatch;
369
+ if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
370
+ const provenState = {
371
+ id,
372
+ seller,
373
+ give: offerRec.give,
374
+ want: offerRec.want,
375
+ taker: offerRec.taker,
376
+ bid: offerRec.bid,
377
+ status: "open",
378
+ expiresEpoch: 0,
379
+ height: blockHeight,
380
+ feeBps: terms.feeBps
381
+ };
382
+ const plan = fillOutputPlan(provenState, opts.pay);
383
+ if (plan.kind === "csd-outputs") {
384
+ outputPlan = plan.outputs;
385
+ if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
386
+ sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
387
+ } else if (plan.kind === "undeliverable") {
388
+ sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
389
+ }
390
+ }
391
+ const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
359
392
  if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
360
393
  const so = opts.servedOffer;
361
394
  const servedPayto = so?.want?.payto !== void 0 && so?.want?.payto !== null ? String(so.want.payto).toLowerCase() : "";
@@ -363,15 +396,20 @@ async function preverifyOffer(opts) {
363
396
  return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
364
397
  if (so?.seller !== void 0 && so?.seller !== null && String(so.seller).toLowerCase() !== seller)
365
398
  return { ok: false, trust: "verified", reason: "the served seller doesn't match the offer's on-chain author (a lying resolver may be redirecting your payment)", ...base };
366
- if (bindOfferTerms(opts.servedOffer, terms))
367
- return { ok: false, trust: "verified", reason: "the served fee/rebate/partial terms don't match the offer's on-chain record (a lying resolver could mis-size the fill, causing a rejected fill after your payment)", ...base };
399
+ if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
400
+ return { ok: false, trust: "verified", reason: "the served terms don't match the offer's on-chain record (fee, rebate, partial sizing, the give, or the want type - a lying resolver could mis-size or re-route the fill, redirecting or burning your payment)", ...base };
368
401
  }
402
+ if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
369
403
  return { ok: true, trust: "verified", ...base };
370
404
  }
371
405
 
372
406
  // src/index.ts
373
407
  var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee1457a40833f2fcb4de44291b9d373e50f17b97232" };
374
408
  var DEFAULT_CAIRN = "https://cairn-substrate.com";
409
+ var SPV_HTTP_TIMEOUT_MS = 15e3;
410
+ var SPV_HTTP_MAX_RETRIES = 4;
411
+ var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
412
+ var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
375
413
  var Cairn = class {
376
414
  config;
377
415
  chain;
@@ -387,6 +425,8 @@ var Cairn = class {
387
425
  _spvLight = null;
388
426
  _spvSeeded = false;
389
427
  _spvCp;
428
+ // W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
429
+ _cairnBase;
390
430
  constructor(config = {}) {
391
431
  this.config = config;
392
432
  const fetch = config.fetch;
@@ -395,6 +435,7 @@ var Cairn = class {
395
435
  const rpc = config.baseUrls?.rpc ?? `${cairn}/api/rpc`;
396
436
  const indexer = (config.baseUrls?.indexer ?? `${cairn}/explorer/api`).replace(/\/+$/, "");
397
437
  this.cairnHttp = new Http({ baseUrl: cairn, fetch, timeoutMs });
438
+ this._cairnBase = cairn;
398
439
  const indexerHttp = new Http({ baseUrl: indexer, fetch, timeoutMs });
399
440
  const swarmHttp = config.baseUrls?.swarm ? new Http({ baseUrl: config.baseUrls.swarm.replace(/\/+$/, ""), fetch, timeoutMs }) : void 0;
400
441
  this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
@@ -431,13 +472,69 @@ var Cairn = class {
431
472
  /** The PoW-verifying SPV light client, seeded ONCE from the pinned checkpoint (shared by the M3 header-merkle
432
473
  * bind and the F13 offer pre-verify). Reused across calls; the first call near tip syncs the post-checkpoint span. */
433
474
  async seededSpvLight() {
434
- if (!this._spvLight) this._spvLight = new LightClient({ client: this.chain.client, checkpoints: { [this._spvCp.height]: this._spvCp.hash } });
475
+ if (!this._spvLight) this._spvLight = new LightClient({
476
+ client: this.chain.client,
477
+ checkpoints: { [this._spvCp.height]: this._spvCp.hash },
478
+ headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
479
+ });
435
480
  if (!this._spvSeeded) {
436
481
  await this._spvLight.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
482
+ try {
483
+ const tipHeight = Number((await this.chain.tip()).height);
484
+ const have = this._spvLight.baseHeight + this._spvLight.chain.length - 1;
485
+ if (Number.isFinite(tipHeight) && tipHeight > have) await this._spvLight.sync(tipHeight);
486
+ } catch {
487
+ }
437
488
  this._spvSeeded = true;
438
489
  }
439
490
  return this._spvLight;
440
491
  }
492
+ /**
493
+ * W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
494
+ * `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
495
+ * prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
496
+ * Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
497
+ * server's 10s deadline) is a transient data-availability hiccup; back off (honouring Retry-After) a
498
+ * bounded number of times, then THROW so `sync()` fails closed. A non-dense range is rejected.
499
+ *
500
+ * B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
501
+ * mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
502
+ * cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
503
+ * restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
504
+ * once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
505
+ */
506
+ async fetchHeadersBatch(from, count) {
507
+ const g = this.config.fetch ?? globalThis.fetch;
508
+ if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
509
+ const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
510
+ const url = `${this._cairnBase}/api/headers/${from}/${count}`;
511
+ for (let attempt = 0; ; attempt++) {
512
+ let res;
513
+ const ctrl = new AbortController();
514
+ const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
515
+ try {
516
+ res = await doFetch(url, { signal: ctrl.signal });
517
+ } catch (e) {
518
+ if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
519
+ await spvSleep(spvRetryDelayMs(attempt, 0));
520
+ continue;
521
+ } finally {
522
+ clearTimeout(timer);
523
+ }
524
+ if (res.ok) {
525
+ const rows = (await res.json())?.headers;
526
+ if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
527
+ return rows.map((r) => ({ header: r.header, hash: r.hash }));
528
+ }
529
+ if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
530
+ res.body?.cancel?.().catch(() => {
531
+ });
532
+ await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
533
+ continue;
534
+ }
535
+ throw new Error(`/api/headers ${res.status}`);
536
+ }
537
+ }
441
538
  /**
442
539
  * F13 — corroborate an offer ON-CHAIN before building a `fillOffer` payment's `outputs`. Merkle-proves the
443
540
  * offer into the PoW-verified header chain (from the pinned SPV checkpoint), binds the record to its on-chain
@@ -500,9 +597,14 @@ export {
500
597
  discoverProviders,
501
598
  errorCode,
502
599
  feeBpsAt,
600
+ fillEndorsement,
601
+ fillIsSafe,
602
+ fillOutputPlan,
503
603
  getWallet,
504
604
  isInstalled,
505
605
  mapProviderError,
506
606
  mapSubmitResultError,
507
- preverifyOffer
607
+ preverifyOffer,
608
+ previewFill,
609
+ requiredFillOutputs
508
610
  };
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.3.0";
29
+ var SDK_VERSION = "0.4.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. */
@@ -100,7 +100,7 @@ var SubmitInFlightError = class extends CairnError {
100
100
  this.txid = txid;
101
101
  }
102
102
  };
103
- var NATIVE_CODES = /* @__PURE__ */ new Set(["ACCOUNT_CHANGED", "FIRST_PARTY_ONLY", "RATE_LIMITED", "APPROVAL_CLOSED", "FORBIDDEN", "INTERNAL"]);
103
+ var NATIVE_CODES = /* @__PURE__ */ new Set(["FIRST_PARTY_ONLY", "RATE_LIMITED", "FORBIDDEN", "INTERNAL"]);
104
104
  var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN"]);
105
105
  var NESTED_TERMINAL = /* @__PURE__ */ new Set([
106
106
  "FILL_UNSAFE",
@@ -144,6 +144,7 @@ function mapSubmitResultError(result) {
144
144
  }
145
145
 
146
146
  // src/connect.ts
147
+ var DEFAULT_PROVIDER_TIMEOUT_MS = 10 * 6e4;
147
148
  function detectProvider(opts = {}) {
148
149
  const timeoutMs = opts.timeoutMs ?? 3e3;
149
150
  return new Promise((resolve, reject) => {
@@ -200,8 +201,24 @@ function unwrapWrite(reply) {
200
201
  var WalletConnection = class {
201
202
  provider;
202
203
  _addr = null;
203
- constructor(provider) {
204
+ /** B7c: per-call provider timeout (ms). GENEROUS by default so it never rejects an in-flight human
205
+ * approval; 0 disables it. Every provider call (writes especially) races against it. */
206
+ _timeoutMs;
207
+ constructor(provider, opts = {}) {
204
208
  this.provider = provider;
209
+ this._timeoutMs = opts.timeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
210
+ }
211
+ // B7c: race a provider call against the per-call timeout. On timeout, throw a fail-closed CairnError
212
+ // (retryable:false: a write may already have reached the wallet, so never invite a blind auto-retry). The
213
+ // genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
214
+ call(op, label) {
215
+ const ms = this._timeoutMs;
216
+ if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(op());
217
+ let timer;
218
+ const timeout = new Promise((_, reject) => {
219
+ timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
220
+ });
221
+ return Promise.race([Promise.resolve(op()), timeout]).finally(() => clearTimeout(timer));
205
222
  }
206
223
  /** The wallet's version string (e.g. "0.2.x"). */
207
224
  get version() {
@@ -213,13 +230,13 @@ var WalletConnection = class {
213
230
  }
214
231
  /** Request connection. First time per origin prompts the user; afterwards may be silent. */
215
232
  async connect() {
216
- const { addr } = unwrap(await this.provider.connect());
233
+ const { addr } = unwrap(await this.call(() => this.provider.connect(), "connect"));
217
234
  this._addr = addr;
218
235
  return addr;
219
236
  }
220
237
  /** Get the connected address (does not force a new connection prompt if already consented). */
221
238
  async getAddress() {
222
- const { addr } = unwrap(await this.provider.getAddress());
239
+ const { addr } = unwrap(await this.call(() => this.provider.getAddress(), "getAddress"));
223
240
  this._addr = addr;
224
241
  return addr;
225
242
  }
@@ -232,7 +249,7 @@ var WalletConnection = class {
232
249
  if (typeof this.provider.signIn !== "function") {
233
250
  return Promise.reject(new UnsupportedMethodError("this wallet does not expose signIn()"));
234
251
  }
235
- return Promise.resolve(this.provider.signIn()).then(unwrap);
252
+ return this.call(() => this.provider.signIn(), "signIn").then(unwrap);
236
253
  }
237
254
  /**
238
255
  * Audience-bound "Sign in with CSD" (SIWC) — the secure, replay-resistant third-party login.
@@ -248,7 +265,7 @@ var WalletConnection = class {
248
265
  if (typeof this.provider.signInWithCsd !== "function") {
249
266
  return Promise.reject(new UnsupportedMethodError("this wallet predates Sign-in-with-CSD \u2014 ask the user to update the Cairn Wallet"));
250
267
  }
251
- return Promise.resolve(this.provider.signInWithCsd(params)).then(unwrap);
268
+ return this.call(() => this.provider.signInWithCsd(params), "signInWithCsd").then(unwrap);
252
269
  }
253
270
  /** True if this wallet supports audience-bound SIWC (safe to call `signInWithCsd`). */
254
271
  get supportsSiwc() {
@@ -258,7 +275,7 @@ var WalletConnection = class {
258
275
  async getCapabilities() {
259
276
  if (typeof this.provider.getCapabilities !== "function") return null;
260
277
  try {
261
- return await this.provider.getCapabilities();
278
+ return await this.call(() => this.provider.getCapabilities(), "getCapabilities");
262
279
  } catch {
263
280
  return null;
264
281
  }
@@ -274,24 +291,24 @@ var WalletConnection = class {
274
291
  if (typeof this.provider.fillOffer !== "function") {
275
292
  return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
276
293
  }
277
- return Promise.resolve(this.provider.fillOffer(params)).then(unwrapWrite);
294
+ return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
278
295
  }
279
296
  /** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
280
297
  async getPermissions() {
281
298
  if (typeof this.provider.getPermissions !== "function") return [];
282
- return unwrap(await this.provider.getPermissions());
299
+ return unwrap(await this.call(() => this.provider.getPermissions(), "getPermissions"));
283
300
  }
284
301
  /** Explicitly request permission to see the address (prompts, like connect). */
285
302
  requestPermissions() {
286
303
  if (typeof this.provider.requestPermissions !== "function") {
287
304
  return Promise.reject(new UnsupportedMethodError("this wallet predates requestPermissions \u2014 use connect()"));
288
305
  }
289
- return Promise.resolve(this.provider.requestPermissions()).then(unwrap);
306
+ return this.call(() => this.provider.requestPermissions(), "requestPermissions").then(unwrap);
290
307
  }
291
308
  /** Revoke THIS origin's own access (silent). Returns { revoked }. Safe no-op on older wallets. */
292
309
  async revokePermissions() {
293
310
  if (typeof this.provider.revokePermissions !== "function") return { revoked: false };
294
- return unwrap(await this.provider.revokePermissions());
311
+ return unwrap(await this.call(() => this.provider.revokePermissions(), "revokePermissions"));
295
312
  }
296
313
  /**
297
314
  * Subscribe to provider events. `accountsChanged` fires with `[]` when this site loses access
@@ -307,26 +324,26 @@ var WalletConnection = class {
307
324
  }
308
325
  /** Send CSD (always prompts with clear-signing; wallet picks inputs + returns change to itself). */
309
326
  send(params) {
310
- return Promise.resolve(this.provider.send(params)).then(unwrapWrite);
327
+ return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
311
328
  }
312
329
  /** Low-level Propose (always prompts). Prefer `cairn.board.propose()` which computes the hash/uri/expiry. */
313
330
  propose(params) {
314
- return Promise.resolve(this.provider.propose(params)).then(unwrapWrite);
331
+ return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
315
332
  }
316
333
  /** Attest/support a proposal (always prompts). */
317
334
  attest(params) {
318
- return Promise.resolve(this.provider.attest(params)).then(unwrapWrite);
335
+ return this.call(() => this.provider.attest(params), "attest").then(unwrapWrite);
319
336
  }
320
337
  /** Seal a commit-reveal claim (always prompts; salt stays inside the wallet). */
321
338
  sealClaim(params) {
322
- return Promise.resolve(this.provider.sealClaim(params)).then(unwrapWrite);
339
+ return this.call(() => this.provider.sealClaim(params), "sealClaim").then(unwrapWrite);
323
340
  }
324
341
  /** Reveal a previously sealed claim by its sealing txid (always prompts). */
325
342
  revealClaim(txid) {
326
343
  if (typeof txid !== "string" || !txid) {
327
344
  return Promise.reject(new UnsupportedMethodError("revealClaim requires the sealing txid"));
328
345
  }
329
- return Promise.resolve(this.provider.revealClaim(txid)).then(unwrapWrite);
346
+ return this.call(() => this.provider.revealClaim(txid), "revealClaim").then(unwrapWrite);
330
347
  }
331
348
  };
332
349
  async function getWallet(opts = {}) {
package/dist/react.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  CairnController
3
- } from "./chunk-UMXLWUEF.js";
4
- import "./chunk-QV2GIXO7.js";
5
- import "./chunk-E2Z3XSCW.js";
3
+ } from "./chunk-GTTLMWGY.js";
4
+ import "./chunk-RKR3JMIA.js";
5
+ import "./chunk-MREZG2HN.js";
6
6
 
7
7
  // src/react.ts
8
8
  function createCairnHooks(React, controllerOrOpts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inversealtruism/cairn-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Compute Substrate dApp kit — one cohesive toolkit to connect the Cairn Wallet, read the chain/board, publish self-certifying content, and resolve registries. Composes the published csd-* primitives.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -58,21 +58,8 @@
58
58
  "LICENSE",
59
59
  "README.md"
60
60
  ],
61
- "scripts": {
62
- "build": "tsup src/index.ts src/connect.ts src/board.ts src/indexer.ts src/content.ts src/registry.ts src/chain.ts src/controller.ts src/react.ts --format esm,cjs --dts --clean",
63
- "test": "tsx test/sdk.test.ts && tsx test/connect.test.ts && tsx test/errors.test.ts && tsx test/controller.test.ts && tsx test/react.test.ts && tsx test/hardening.test.ts && tsx test/spv-checkpoint.test.ts && tsx test/names-b10.test.ts && tsx test/fillverify.test.ts",
64
- "test:live": "node examples/read-only.mjs",
65
- "build:example": "node scripts/build-example.mjs",
66
- "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
67
- "test:e2e:read": "node test/e2e/e2e-read.mjs",
68
- "test:e2e:wallet": "xvfb-run -a node test/e2e/e2e-wallet-connect.mjs",
69
- "test:e2e:write": "node test/e2e/e2e-write.mjs",
70
- "test:e2e": "node test/e2e/e2e-read.mjs && xvfb-run -a node test/e2e/e2e-wallet-connect.mjs",
71
- "test:all": "pnpm test && pnpm test:e2e",
72
- "prepublishOnly": "npm run build && npm test"
73
- },
74
61
  "dependencies": {
75
- "@inversealtruism/cairnx-core": "0.1.38",
62
+ "@inversealtruism/cairnx-core": "0.1.40",
76
63
  "@inversealtruism/csd-client": "0.1.15",
77
64
  "@inversealtruism/csd-codec": "0.1.15",
78
65
  "@inversealtruism/csd-crypto": "0.1.15",
@@ -81,20 +68,14 @@
81
68
  "@inversealtruism/csd-tx": "0.1.17"
82
69
  },
83
70
  "devDependencies": {
71
+ "@inversealtruism/csd-siwc": "0.1.15",
84
72
  "esbuild": "^0.27.0",
85
73
  "playwright-core": "^1.49.0",
86
74
  "tsup": "^8.5.1",
87
75
  "tsx": "^4.19.2",
88
- "typescript": "^5.6.0",
89
- "@inversealtruism/csd-siwc": "0.1.15"
76
+ "typescript": "^5.6.0"
90
77
  },
91
78
  "sideEffects": false,
92
- "packageManager": "pnpm@10.32.1",
93
- "pnpm": {
94
- "onlyBuiltDependencies": [
95
- "esbuild"
96
- ]
97
- },
98
79
  "license": "MIT",
99
80
  "publishConfig": {
100
81
  "access": "public"
@@ -112,5 +93,17 @@
112
93
  "dapp",
113
94
  "sdk",
114
95
  "blockchain"
115
- ]
116
- }
96
+ ],
97
+ "scripts": {
98
+ "build": "tsup src/index.ts src/connect.ts src/board.ts src/indexer.ts src/content.ts src/registry.ts src/chain.ts src/controller.ts src/react.ts --format esm,cjs --dts --clean",
99
+ "test": "node test/run.mjs",
100
+ "test:live": "node examples/read-only.mjs",
101
+ "build:example": "node scripts/build-example.mjs",
102
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
103
+ "test:e2e:read": "node test/e2e/e2e-read.mjs",
104
+ "test:e2e:wallet": "xvfb-run -a node test/e2e/e2e-wallet-connect.mjs",
105
+ "test:e2e:write": "node test/e2e/e2e-write.mjs",
106
+ "test:e2e": "node test/e2e/e2e-read.mjs && xvfb-run -a node test/e2e/e2e-wallet-connect.mjs",
107
+ "test:all": "pnpm test && pnpm test:e2e"
108
+ }
109
+ }