@inversealtruism/cairn-sdk 0.4.0 → 0.4.1
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 +1 -1
- package/dist/board.js +2 -2
- package/dist/chunk-7UOTIAYK.js +224 -0
- package/dist/{chunk-2ZML7IKV.js → chunk-DUTVUIQF.js} +1 -1
- package/dist/{chunk-MREZG2HN.js → chunk-ERBT4DFD.js} +1 -1
- package/dist/{chunk-2L5QNDAE.js → chunk-OCEHXK6A.js} +1 -1
- package/dist/{chunk-RKR3JMIA.js → chunk-PIPN6FSA.js} +22 -3
- package/dist/{chunk-GTTLMWGY.js → chunk-QATE6AIP.js} +25 -13
- package/dist/connect.cjs +22 -3
- package/dist/connect.d.cts +4 -1
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +2 -2
- package/dist/content.cjs +1 -1
- package/dist/content.js +2 -2
- package/dist/controller.cjs +46 -15
- package/dist/controller.d.cts +4 -1
- package/dist/controller.d.ts +4 -1
- package/dist/controller.js +3 -3
- package/dist/index.cjs +182 -62
- package/dist/index.d.cts +30 -9
- package/dist/index.d.ts +30 -9
- package/dist/index.js +125 -186
- package/dist/react.cjs +46 -15
- package/dist/react.js +3 -3
- package/dist/registry.cjs +219 -7
- package/dist/registry.d.cts +16 -11
- package/dist/registry.d.ts +16 -11
- package/dist/registry.js +2 -1
- package/package.json +6 -6
- package/dist/chunk-BGJZF7S2.js +0 -69
package/dist/index.d.ts
CHANGED
|
@@ -134,7 +134,9 @@ interface OfferFillCheck {
|
|
|
134
134
|
* Pre-verify an offer on-chain before building a `fillOffer` payment. `light` is a PoW-verifying light client
|
|
135
135
|
* (its `verifyTxInclusion` surfaces the merkle-proven tx + committed appPayloadHash); `client` reads source txs
|
|
136
136
|
* for the prevout-owner author bind. Pass `servedOffer` (the resolver's offer object) to also bind its
|
|
137
|
-
* payto/seller/terms to the proven values.
|
|
137
|
+
* payto/seller/terms to the proven values. The FULL object is required: a served offer missing want.payto
|
|
138
|
+
* or seller is refused (P75-5). A token-priced offer's served want.ticker/want.amount are bound verbatim to
|
|
139
|
+
* the record. See the module header for the trust posture.
|
|
138
140
|
*/
|
|
139
141
|
declare function preverifyOffer(opts: {
|
|
140
142
|
light: {
|
|
@@ -158,7 +160,7 @@ declare function preverifyOffer(opts: {
|
|
|
158
160
|
|
|
159
161
|
/** Stable machine-readable error codes (branch on these, not the message text). */
|
|
160
162
|
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";
|
|
161
|
-
declare const SDK_VERSION = "0.4.
|
|
163
|
+
declare const SDK_VERSION = "0.4.1";
|
|
162
164
|
interface CairnErrorOptions {
|
|
163
165
|
code?: CairnErrorCode;
|
|
164
166
|
docsPath?: string;
|
|
@@ -178,7 +180,7 @@ declare class CairnError extends Error {
|
|
|
178
180
|
/** Relative docs path (under the SDK docs base), if any. */
|
|
179
181
|
readonly docsPath?: string;
|
|
180
182
|
/** The cairn-sdk version that produced this error. */
|
|
181
|
-
readonly version = "0.4.
|
|
183
|
+
readonly version = "0.4.1";
|
|
182
184
|
/**
|
|
183
185
|
* Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
|
|
184
186
|
* declines (the default); `true` only for provably transient infra faults where nothing was signed.
|
|
@@ -317,7 +319,7 @@ declare class Cairn {
|
|
|
317
319
|
wallet?: WalletConnection;
|
|
318
320
|
private readonly cairnHttp;
|
|
319
321
|
private _spvLight;
|
|
320
|
-
private
|
|
322
|
+
private _spvSeedInflight;
|
|
321
323
|
private readonly _spvCp;
|
|
322
324
|
private readonly _cairnBase;
|
|
323
325
|
constructor(config?: CairnConfig);
|
|
@@ -328,18 +330,30 @@ declare class Cairn {
|
|
|
328
330
|
* checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
|
|
329
331
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
330
332
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
333
|
+
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
334
|
+
* withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
|
|
335
|
+
* NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
|
|
336
|
+
* read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
|
|
331
337
|
*/
|
|
332
338
|
private verifiedHeaderMerkleAt;
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
private withSpvReseed;
|
|
340
|
+
private seedFreshSpvLight;
|
|
341
|
+
/** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
|
|
342
|
+
* (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
|
|
343
|
+
* the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
|
|
344
|
+
* the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
|
|
345
|
+
* fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
|
|
346
|
+
* structural (reorg) break is detected, see withSpvReseed. */
|
|
335
347
|
private seededSpvLight;
|
|
336
348
|
/**
|
|
337
349
|
* W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
|
|
338
350
|
* `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
|
|
339
351
|
* prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
|
|
340
352
|
* Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
|
|
341
|
-
* server's 10s deadline)
|
|
342
|
-
*
|
|
353
|
+
* server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
|
|
354
|
+
* SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
|
|
355
|
+
* data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
|
|
356
|
+
* `sync()` fails closed. A non-dense range is rejected terminally (never retried).
|
|
343
357
|
*
|
|
344
358
|
* B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
|
|
345
359
|
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
@@ -359,8 +373,15 @@ declare class Cairn {
|
|
|
359
373
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
360
374
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
361
375
|
* before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
|
|
376
|
+
*
|
|
377
|
+
* Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
|
|
378
|
+
* non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
|
|
379
|
+
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
362
380
|
*/
|
|
363
|
-
verifyOfferForFill(offerId: string, servedOffer?: unknown
|
|
381
|
+
verifyOfferForFill(offerId: string, servedOffer?: unknown, opts?: {
|
|
382
|
+
pay?: bigint | string | number;
|
|
383
|
+
plannedOutputs?: Record<string, bigint | string | number>;
|
|
384
|
+
}): Promise<OfferFillCheck>;
|
|
364
385
|
/**
|
|
365
386
|
* Detect + connect the Cairn Wallet (browser only). Wires the wallet into
|
|
366
387
|
* `cairn.board` for signed writes. Resolves to the connected address.
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Http,
|
|
3
|
+
RegistryClient,
|
|
4
|
+
readCapped
|
|
5
|
+
} from "./chunk-7UOTIAYK.js";
|
|
4
6
|
import {
|
|
5
7
|
BoardClient
|
|
6
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-DUTVUIQF.js";
|
|
7
9
|
import {
|
|
8
10
|
Chain,
|
|
9
11
|
LightClient
|
|
10
12
|
} from "./chunk-C452UODV.js";
|
|
11
13
|
import {
|
|
12
14
|
ContentClient
|
|
13
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-OCEHXK6A.js";
|
|
14
16
|
import {
|
|
15
17
|
IndexerClient
|
|
16
18
|
} from "./chunk-J5J4JEJ6.js";
|
|
17
19
|
import {
|
|
18
20
|
CairnController
|
|
19
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-QATE6AIP.js";
|
|
20
22
|
import {
|
|
21
23
|
WalletConnection,
|
|
22
24
|
connect,
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
discoverProviders,
|
|
25
27
|
getWallet,
|
|
26
28
|
isInstalled
|
|
27
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-PIPN6FSA.js";
|
|
28
30
|
import {
|
|
29
31
|
CairnError,
|
|
30
32
|
ContentVerificationError,
|
|
@@ -38,147 +40,7 @@ import {
|
|
|
38
40
|
errorCode,
|
|
39
41
|
mapProviderError,
|
|
40
42
|
mapSubmitResultError
|
|
41
|
-
} from "./chunk-
|
|
42
|
-
|
|
43
|
-
// src/http.ts
|
|
44
|
-
var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
45
|
-
var ERR_TEXT_CAP = 8 * 1024;
|
|
46
|
-
async function readCapped(res, maxBytes) {
|
|
47
|
-
const cl = Number(res.headers.get("content-length"));
|
|
48
|
-
if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
|
|
49
|
-
const body = res.body;
|
|
50
|
-
if (!body || typeof body.getReader !== "function") {
|
|
51
|
-
const buf = new Uint8Array(await res.arrayBuffer());
|
|
52
|
-
if (buf.byteLength > maxBytes) throw new HttpError(res.status, res.url, `response too large (${buf.byteLength} > ${maxBytes})`);
|
|
53
|
-
return buf;
|
|
54
|
-
}
|
|
55
|
-
const reader = body.getReader();
|
|
56
|
-
const chunks = [];
|
|
57
|
-
let total = 0;
|
|
58
|
-
for (; ; ) {
|
|
59
|
-
const { done, value } = await reader.read();
|
|
60
|
-
if (done) break;
|
|
61
|
-
if (value) {
|
|
62
|
-
total += value.byteLength;
|
|
63
|
-
if (total > maxBytes) {
|
|
64
|
-
try {
|
|
65
|
-
await reader.cancel();
|
|
66
|
-
} catch {
|
|
67
|
-
}
|
|
68
|
-
throw new HttpError(res.status, res.url, `response exceeded ${maxBytes} bytes`);
|
|
69
|
-
}
|
|
70
|
-
chunks.push(value);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const out = new Uint8Array(total);
|
|
74
|
-
let off = 0;
|
|
75
|
-
for (const c of chunks) {
|
|
76
|
-
out.set(c, off);
|
|
77
|
-
off += c.byteLength;
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
var Http = class {
|
|
82
|
-
baseUrl;
|
|
83
|
-
_fetch;
|
|
84
|
-
timeoutMs;
|
|
85
|
-
maxBytes;
|
|
86
|
-
headers;
|
|
87
|
-
retries;
|
|
88
|
-
constructor(opts) {
|
|
89
|
-
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
90
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
91
|
-
if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
|
|
92
|
-
this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
|
|
93
|
-
this.timeoutMs = opts.timeoutMs ?? 15e3;
|
|
94
|
-
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
95
|
-
this.headers = opts.headers ?? {};
|
|
96
|
-
this.retries = Math.min(10, Math.max(0, Math.floor(opts.retries ?? 0)));
|
|
97
|
-
}
|
|
98
|
-
/** Build an absolute URL from a path + optional query params. */
|
|
99
|
-
url(path, query) {
|
|
100
|
-
const p = path.startsWith("/") ? path : `/${path}`;
|
|
101
|
-
const u = new URL(this.baseUrl + p);
|
|
102
|
-
const basePath = new URL(this.baseUrl + "/").pathname;
|
|
103
|
-
if (basePath !== "/" && !(u.pathname + "/").startsWith(basePath)) {
|
|
104
|
-
throw new Error(`illegal path '${path}' \u2014 escapes base ${basePath}`);
|
|
105
|
-
}
|
|
106
|
-
if (query) {
|
|
107
|
-
for (const [k, v] of Object.entries(query)) {
|
|
108
|
-
if (v !== void 0 && v !== null) u.searchParams.set(k, String(v));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return u.toString();
|
|
112
|
-
}
|
|
113
|
-
async raw(method, path, opts) {
|
|
114
|
-
const url = this.url(path, opts?.query);
|
|
115
|
-
const ctrl = new AbortController();
|
|
116
|
-
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
117
|
-
try {
|
|
118
|
-
const res = await this._fetch(url, {
|
|
119
|
-
method,
|
|
120
|
-
signal: ctrl.signal,
|
|
121
|
-
headers: {
|
|
122
|
-
...opts?.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
123
|
-
...this.headers,
|
|
124
|
-
...opts?.headers ?? {}
|
|
125
|
-
},
|
|
126
|
-
body: opts?.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
|
127
|
-
});
|
|
128
|
-
return res;
|
|
129
|
-
} finally {
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
// A malformed 2xx body must surface as a TYPED HttpError, not a leaked SyntaxError (B10: dApp
|
|
134
|
-
// code branches on `instanceof CairnError`; a raw parse error escaped that contract).
|
|
135
|
-
parseJson(res, text, url) {
|
|
136
|
-
try {
|
|
137
|
-
return JSON.parse(text);
|
|
138
|
-
} catch (e) {
|
|
139
|
-
throw new HttpError(res.status, url, `invalid JSON body: ${text.slice(0, 120)}`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
async getJson(path, query) {
|
|
143
|
-
const url = this.url(path, query);
|
|
144
|
-
for (let attempt = 0; ; attempt++) {
|
|
145
|
-
try {
|
|
146
|
-
const res = await this.raw("GET", path, { query });
|
|
147
|
-
if (!res.ok) {
|
|
148
|
-
if (!(res.status >= 500 && attempt < this.retries)) throw new HttpError(res.status, url, await safeText(res));
|
|
149
|
-
res.body?.cancel().catch(() => {
|
|
150
|
-
});
|
|
151
|
-
} else {
|
|
152
|
-
return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
|
|
153
|
-
}
|
|
154
|
-
} catch (e) {
|
|
155
|
-
if (e instanceof HttpError && e.status < 500) throw e;
|
|
156
|
-
if (attempt >= this.retries) throw e;
|
|
157
|
-
}
|
|
158
|
-
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt))));
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
async postJson(path, body, headers) {
|
|
162
|
-
const url = this.url(path);
|
|
163
|
-
const res = await this.raw("POST", path, { body, headers });
|
|
164
|
-
if (!res.ok) throw new HttpError(res.status, url, await safeText(res));
|
|
165
|
-
return this.parseJson(res, new TextDecoder().decode(await readCapped(res, this.maxBytes)), url);
|
|
166
|
-
}
|
|
167
|
-
/** GET raw bytes (for content retrieval). Returns null on 404. */
|
|
168
|
-
async getBytes(path) {
|
|
169
|
-
const res = await this.raw("GET", path);
|
|
170
|
-
if (res.status === 404) return null;
|
|
171
|
-
if (!res.ok) throw new HttpError(res.status, this.url(path), await safeText(res));
|
|
172
|
-
return { bytes: await readCapped(res, this.maxBytes), headers: res.headers };
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
async function safeText(res) {
|
|
176
|
-
try {
|
|
177
|
-
return new TextDecoder().decode(await readCapped(res, ERR_TEXT_CAP));
|
|
178
|
-
} catch {
|
|
179
|
-
return "";
|
|
180
|
-
}
|
|
181
|
-
}
|
|
43
|
+
} from "./chunk-ERBT4DFD.js";
|
|
182
44
|
|
|
183
45
|
// src/names.ts
|
|
184
46
|
import { NAME_RE, RESERVED_NAMES } from "@inversealtruism/cairnx-core";
|
|
@@ -274,6 +136,7 @@ var NamesClient = class {
|
|
|
274
136
|
import { rpcTxToTx } from "@inversealtruism/csd-client";
|
|
275
137
|
import { txid, payloadHash } from "@inversealtruism/csd-codec";
|
|
276
138
|
import {
|
|
139
|
+
DOMAIN,
|
|
277
140
|
parseRecord,
|
|
278
141
|
feeBpsAt,
|
|
279
142
|
bindOfferTerms,
|
|
@@ -349,6 +212,8 @@ async function preverifyOffer(opts) {
|
|
|
349
212
|
if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
|
|
350
213
|
const app = incl.tx.app;
|
|
351
214
|
if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
|
|
215
|
+
if (String(app.domain) !== DOMAIN)
|
|
216
|
+
return { ok: false, trust: "unverified", reason: `the offer tx is not a ${DOMAIN} Propose (wrong settlement domain)` };
|
|
352
217
|
let rec;
|
|
353
218
|
try {
|
|
354
219
|
rec = parseRecord(app.uri, incl.appPayloadHash);
|
|
@@ -367,37 +232,47 @@ async function preverifyOffer(opts) {
|
|
|
367
232
|
let outputPlan;
|
|
368
233
|
let sumsMismatch;
|
|
369
234
|
if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
|
|
235
|
+
const servedStatus = opts.servedOffer?.status;
|
|
370
236
|
const provenState = {
|
|
371
237
|
id,
|
|
372
238
|
seller,
|
|
373
239
|
give: offerRec.give,
|
|
374
|
-
want: offerRec.want,
|
|
240
|
+
want: { ...offerRec.want, payto },
|
|
375
241
|
taker: offerRec.taker,
|
|
376
242
|
bid: offerRec.bid,
|
|
377
|
-
status: "open",
|
|
243
|
+
status: typeof servedStatus === "string" ? servedStatus : "open",
|
|
378
244
|
expiresEpoch: 0,
|
|
379
245
|
height: blockHeight,
|
|
380
246
|
feeBps: terms.feeBps
|
|
381
247
|
};
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
248
|
+
try {
|
|
249
|
+
const plan = fillOutputPlan(provenState, opts.pay);
|
|
250
|
+
if (plan.kind === "csd-outputs") {
|
|
251
|
+
outputPlan = plan.outputs;
|
|
252
|
+
if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
|
|
253
|
+
sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
|
|
254
|
+
} else if (plan.kind === "undeliverable") {
|
|
255
|
+
sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
|
|
389
259
|
}
|
|
390
260
|
}
|
|
391
261
|
const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
|
|
392
262
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
393
263
|
const so = opts.servedOffer;
|
|
394
|
-
|
|
395
|
-
|
|
264
|
+
if (so?.want?.payto === void 0 || so?.want?.payto === null)
|
|
265
|
+
return { ok: false, trust: "verified", reason: "the served offer is missing want.payto (the payment recipient) - pass the resolver's full offer object, or omit servedOffer", ...base };
|
|
266
|
+
if (String(so.want.payto).toLowerCase() !== payto)
|
|
396
267
|
return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
|
|
397
|
-
if (so?.seller
|
|
268
|
+
if (so?.seller === void 0 || so?.seller === null)
|
|
269
|
+
return { ok: false, trust: "verified", reason: "the served offer is missing seller (the rebate recipient) - pass the resolver's full offer object, or omit servedOffer", ...base };
|
|
270
|
+
if (String(so.seller).toLowerCase() !== seller)
|
|
398
271
|
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 };
|
|
399
272
|
if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
|
|
400
273
|
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 };
|
|
274
|
+
if (isTokenWant(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
|
|
275
|
+
return { ok: false, trust: "verified", reason: "the served want ticker/amount don't match the offer's on-chain record (a lying resolver could bait-and-switch the token price)", ...base };
|
|
401
276
|
}
|
|
402
277
|
if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
|
|
403
278
|
return { ok: true, trust: "verified", ...base };
|
|
@@ -408,6 +283,13 @@ var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee14
|
|
|
408
283
|
var DEFAULT_CAIRN = "https://cairn-substrate.com";
|
|
409
284
|
var SPV_HTTP_TIMEOUT_MS = 15e3;
|
|
410
285
|
var SPV_HTTP_MAX_RETRIES = 4;
|
|
286
|
+
var SPV_MAX_BATCH_BYTES = 2 * 1024 * 1024;
|
|
287
|
+
var SPV_TRANSIENT_RE = /\b(429|50[0-9]|timeout|timed out|abort|aborted|headers|non-dense|failed to fetch|networkerror|load failed)\b/i;
|
|
288
|
+
var SPV_TRANSIENT_JSON_RE = /unexpected (token|end)|json/i;
|
|
289
|
+
var spvIsTransient = (e) => {
|
|
290
|
+
const msg = String(e?.message || e);
|
|
291
|
+
return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
|
|
292
|
+
};
|
|
411
293
|
var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
412
294
|
var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
|
|
413
295
|
var Cairn = class {
|
|
@@ -423,7 +305,10 @@ var Cairn = class {
|
|
|
423
305
|
cairnHttp;
|
|
424
306
|
// Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
|
|
425
307
|
_spvLight = null;
|
|
426
|
-
|
|
308
|
+
// MF-14: the in-flight seed promise. Concurrent first callers share ONE seed instead of racing two
|
|
309
|
+
// syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
|
|
310
|
+
// fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
|
|
311
|
+
_spvSeedInflight = null;
|
|
427
312
|
_spvCp;
|
|
428
313
|
// W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
|
|
429
314
|
_cairnBase;
|
|
@@ -448,7 +333,7 @@ var Cairn = class {
|
|
|
448
333
|
headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
|
|
449
334
|
});
|
|
450
335
|
this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
|
|
451
|
-
this.registry = new RegistryClient(
|
|
336
|
+
this.registry = new RegistryClient(indexerHttp);
|
|
452
337
|
this.names = new NamesClient(this.cairnHttp);
|
|
453
338
|
}
|
|
454
339
|
/**
|
|
@@ -458,44 +343,90 @@ var Cairn = class {
|
|
|
458
343
|
* checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
|
|
459
344
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
460
345
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
346
|
+
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
347
|
+
* withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
|
|
348
|
+
* NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
|
|
349
|
+
* read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
|
|
461
350
|
*/
|
|
462
351
|
async verifiedHeaderMerkleAt(height) {
|
|
463
352
|
if (!Number.isInteger(height) || height < this._spvCp.height)
|
|
464
353
|
throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
|
|
354
|
+
return this.withSpvReseed(async (lc) => {
|
|
355
|
+
const haveTo = lc.baseHeight + lc.chain.length - 1;
|
|
356
|
+
if (haveTo < height) await lc.sync(height);
|
|
357
|
+
const vh = lc.chain[height - lc.baseHeight];
|
|
358
|
+
if (!vh) throw new Error(`light client has no verified header at ${height}`);
|
|
359
|
+
return String(vh.header.merkle);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// MF-03: run an SPV read; on a STRUCTURAL failure (a reorg orphaned our cached tip: broken
|
|
363
|
+
// prev-link / bad PoW / hash mismatch) reseed ONCE from the pinned checkpoint into a LOCAL fresh
|
|
364
|
+
// client, retry the read on it, and commit the fresh client ONLY when both succeed (never null
|
|
365
|
+
// this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
|
|
366
|
+
// (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
|
|
367
|
+
// on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
|
|
368
|
+
// failure propagates fail-closed; there is no loop.
|
|
369
|
+
// RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
|
|
370
|
+
// branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
|
|
371
|
+
// for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
|
|
372
|
+
// the permanent-brick leg only.
|
|
373
|
+
async withSpvReseed(op) {
|
|
465
374
|
const lc = await this.seededSpvLight();
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
375
|
+
try {
|
|
376
|
+
return await op(lc);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (spvIsTransient(e)) throw e;
|
|
379
|
+
const fresh = await this.seedFreshSpvLight();
|
|
380
|
+
const out = await op(fresh);
|
|
381
|
+
this._spvLight = fresh;
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
471
384
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
385
|
+
// Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
|
|
386
|
+
// the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
|
|
387
|
+
// dirty the shared field while an attempt is in flight).
|
|
388
|
+
async seedFreshSpvLight() {
|
|
389
|
+
const lc = new LightClient({
|
|
476
390
|
client: this.chain.client,
|
|
477
391
|
checkpoints: { [this._spvCp.height]: this._spvCp.hash },
|
|
478
392
|
headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
|
|
479
393
|
});
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
394
|
+
await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
|
|
395
|
+
try {
|
|
396
|
+
const tipHeight = Number((await this.chain.tip()).height);
|
|
397
|
+
const have = lc.baseHeight + lc.chain.length - 1;
|
|
398
|
+
if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
return lc;
|
|
402
|
+
}
|
|
403
|
+
/** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
|
|
404
|
+
* (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
|
|
405
|
+
* the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
|
|
406
|
+
* the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
|
|
407
|
+
* fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
|
|
408
|
+
* structural (reorg) break is detected, see withSpvReseed. */
|
|
409
|
+
seededSpvLight() {
|
|
410
|
+
if (this._spvLight) return Promise.resolve(this._spvLight);
|
|
411
|
+
if (!this._spvSeedInflight) {
|
|
412
|
+
this._spvSeedInflight = this.seedFreshSpvLight().then((lc) => {
|
|
413
|
+
this._spvLight = lc;
|
|
414
|
+
return lc;
|
|
415
|
+
}).finally(() => {
|
|
416
|
+
this._spvSeedInflight = null;
|
|
417
|
+
});
|
|
489
418
|
}
|
|
490
|
-
return this.
|
|
419
|
+
return this._spvSeedInflight;
|
|
491
420
|
}
|
|
492
421
|
/**
|
|
493
422
|
* W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
|
|
494
423
|
* `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
|
|
495
424
|
* prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
|
|
496
425
|
* Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
|
|
497
|
-
* server's 10s deadline)
|
|
498
|
-
*
|
|
426
|
+
* server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
|
|
427
|
+
* SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
|
|
428
|
+
* data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
|
|
429
|
+
* `sync()` fails closed. A non-dense range is rejected terminally (never retried).
|
|
499
430
|
*
|
|
500
431
|
* B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
|
|
501
432
|
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
@@ -510,10 +441,14 @@ var Cairn = class {
|
|
|
510
441
|
const url = `${this._cairnBase}/api/headers/${from}/${count}`;
|
|
511
442
|
for (let attempt = 0; ; attempt++) {
|
|
512
443
|
let res;
|
|
444
|
+
let parsed;
|
|
513
445
|
const ctrl = new AbortController();
|
|
514
446
|
const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
|
|
515
447
|
try {
|
|
516
448
|
res = await doFetch(url, { signal: ctrl.signal });
|
|
449
|
+
if (res.ok) {
|
|
450
|
+
parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
|
|
451
|
+
}
|
|
517
452
|
} catch (e) {
|
|
518
453
|
if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
|
|
519
454
|
await spvSleep(spvRetryDelayMs(attempt, 0));
|
|
@@ -522,7 +457,7 @@ var Cairn = class {
|
|
|
522
457
|
clearTimeout(timer);
|
|
523
458
|
}
|
|
524
459
|
if (res.ok) {
|
|
525
|
-
const rows =
|
|
460
|
+
const rows = parsed?.headers;
|
|
526
461
|
if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
|
|
527
462
|
return rows.map((r) => ({ header: r.header, hash: r.hash }));
|
|
528
463
|
}
|
|
@@ -546,10 +481,14 @@ var Cairn = class {
|
|
|
546
481
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
547
482
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
548
483
|
* before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
|
|
484
|
+
*
|
|
485
|
+
* Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
|
|
486
|
+
* non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
|
|
487
|
+
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
549
488
|
*/
|
|
550
|
-
async verifyOfferForFill(offerId, servedOffer) {
|
|
551
|
-
const light =
|
|
552
|
-
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer });
|
|
489
|
+
async verifyOfferForFill(offerId, servedOffer, opts) {
|
|
490
|
+
const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
|
|
491
|
+
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
|
|
553
492
|
}
|
|
554
493
|
/**
|
|
555
494
|
* Detect + connect the Cairn Wallet (browser only). Wires the wallet into
|