@inversealtruism/cairn-sdk 0.3.2 → 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-CZWDA3A5.js → chunk-DUTVUIQF.js} +1 -1
- package/dist/{chunk-FMXNJQWL.js → chunk-ERBT4DFD.js} +2 -2
- package/dist/{chunk-JKC5K253.js → chunk-OCEHXK6A.js} +1 -1
- package/dist/{chunk-BN3PFJDY.js → chunk-PIPN6FSA.js} +53 -16
- package/dist/{chunk-AGU6K5EZ.js → chunk-QATE6AIP.js} +25 -13
- package/dist/connect.cjs +53 -17
- package/dist/connect.d.cts +11 -2
- package/dist/connect.d.ts +11 -2
- package/dist/connect.js +2 -2
- package/dist/content.cjs +1 -1
- package/dist/content.js +2 -2
- package/dist/controller.cjs +77 -29
- package/dist/controller.d.cts +4 -1
- package/dist/controller.d.ts +4 -1
- package/dist/controller.js +3 -3
- package/dist/index.cjs +317 -71
- package/dist/index.d.cts +65 -18
- package/dist/index.d.ts +65 -18
- package/dist/index.js +235 -181
- package/dist/react.cjs +77 -29
- 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 +21 -28
- package/dist/chunk-BGJZF7S2.js +0 -69
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";
|
|
@@ -273,10 +135,39 @@ var NamesClient = class {
|
|
|
273
135
|
// src/fillverify.ts
|
|
274
136
|
import { rpcTxToTx } from "@inversealtruism/csd-client";
|
|
275
137
|
import { txid, payloadHash } from "@inversealtruism/csd-codec";
|
|
276
|
-
import {
|
|
138
|
+
import {
|
|
139
|
+
DOMAIN,
|
|
140
|
+
parseRecord,
|
|
141
|
+
feeBpsAt,
|
|
142
|
+
bindOfferTerms,
|
|
143
|
+
provenOfferTerms,
|
|
144
|
+
fillOutputPlan,
|
|
145
|
+
isTokenWant,
|
|
146
|
+
fillEndorsement,
|
|
147
|
+
fillIsSafe,
|
|
148
|
+
requiredFillOutputs,
|
|
149
|
+
previewFill
|
|
150
|
+
} from "@inversealtruism/cairnx-core";
|
|
277
151
|
var ADDR = /^0x[0-9a-f]{40}$/;
|
|
278
152
|
var HASH = /^0x[0-9a-f]{64}$/;
|
|
279
153
|
var COINBASE = "0x" + "00".repeat(32);
|
|
154
|
+
function bindPlannedSums(planned, proven) {
|
|
155
|
+
const provenMap = new Map(proven.map((o) => [o.to.toLowerCase(), o.value]));
|
|
156
|
+
const plannedMap = /* @__PURE__ */ new Map();
|
|
157
|
+
try {
|
|
158
|
+
for (const [a, v] of Object.entries(planned)) {
|
|
159
|
+
const k = String(a).toLowerCase();
|
|
160
|
+
plannedMap.set(k, (plannedMap.get(k) ?? 0n) + BigInt(v));
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
return "invalid plannedOutputs (amounts must be base-unit integers)";
|
|
164
|
+
}
|
|
165
|
+
for (const k of /* @__PURE__ */ new Set([...provenMap.keys(), ...plannedMap.keys()])) {
|
|
166
|
+
if ((provenMap.get(k) ?? 0n) !== (plannedMap.get(k) ?? 0n))
|
|
167
|
+
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)`;
|
|
168
|
+
}
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
280
171
|
async function provenAuthor(client, provenTx) {
|
|
281
172
|
const in0 = provenTx.inputs?.[0];
|
|
282
173
|
if (!in0 || String(in0.prevTxid).toLowerCase() === COINBASE && Number(in0.vout) === 4294967295) return null;
|
|
@@ -321,6 +212,8 @@ async function preverifyOffer(opts) {
|
|
|
321
212
|
if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
|
|
322
213
|
const app = incl.tx.app;
|
|
323
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)` };
|
|
324
217
|
let rec;
|
|
325
218
|
try {
|
|
326
219
|
rec = parseRecord(app.uri, incl.appPayloadHash);
|
|
@@ -332,33 +225,73 @@ async function preverifyOffer(opts) {
|
|
|
332
225
|
return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
|
|
333
226
|
const seller = await provenAuthor(opts.client, provenTx);
|
|
334
227
|
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" };
|
|
228
|
+
const offerRec = rec;
|
|
335
229
|
const w = rec.want ?? {};
|
|
336
230
|
const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
|
|
337
|
-
const terms =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
231
|
+
const terms = provenOfferTerms(offerRec, blockHeight);
|
|
232
|
+
let outputPlan;
|
|
233
|
+
let sumsMismatch;
|
|
234
|
+
if (opts.pay !== void 0 && opts.pay !== null && !isTokenWant(offerRec.want) && offerRec.min === void 0) {
|
|
235
|
+
const servedStatus = opts.servedOffer?.status;
|
|
236
|
+
const provenState = {
|
|
237
|
+
id,
|
|
238
|
+
seller,
|
|
239
|
+
give: offerRec.give,
|
|
240
|
+
want: { ...offerRec.want, payto },
|
|
241
|
+
taker: offerRec.taker,
|
|
242
|
+
bid: offerRec.bid,
|
|
243
|
+
status: typeof servedStatus === "string" ? servedStatus : "open",
|
|
244
|
+
expiresEpoch: 0,
|
|
245
|
+
height: blockHeight,
|
|
246
|
+
feeBps: terms.feeBps
|
|
247
|
+
};
|
|
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`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
|
|
346
262
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
347
263
|
const so = opts.servedOffer;
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
350
267
|
return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
|
|
351
|
-
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)
|
|
352
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 };
|
|
353
|
-
if (bindOfferTerms(opts.servedOffer, terms))
|
|
354
|
-
return { ok: false, trust: "verified", reason: "the served
|
|
272
|
+
if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
|
|
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 };
|
|
355
276
|
}
|
|
277
|
+
if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
|
|
356
278
|
return { ok: true, trust: "verified", ...base };
|
|
357
279
|
}
|
|
358
280
|
|
|
359
281
|
// src/index.ts
|
|
360
282
|
var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee1457a40833f2fcb4de44291b9d373e50f17b97232" };
|
|
361
283
|
var DEFAULT_CAIRN = "https://cairn-substrate.com";
|
|
284
|
+
var SPV_HTTP_TIMEOUT_MS = 15e3;
|
|
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
|
+
};
|
|
293
|
+
var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
294
|
+
var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
|
|
362
295
|
var Cairn = class {
|
|
363
296
|
config;
|
|
364
297
|
chain;
|
|
@@ -372,8 +305,13 @@ var Cairn = class {
|
|
|
372
305
|
cairnHttp;
|
|
373
306
|
// Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
|
|
374
307
|
_spvLight = null;
|
|
375
|
-
|
|
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;
|
|
376
312
|
_spvCp;
|
|
313
|
+
// W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
|
|
314
|
+
_cairnBase;
|
|
377
315
|
constructor(config = {}) {
|
|
378
316
|
this.config = config;
|
|
379
317
|
const fetch = config.fetch;
|
|
@@ -382,6 +320,7 @@ var Cairn = class {
|
|
|
382
320
|
const rpc = config.baseUrls?.rpc ?? `${cairn}/api/rpc`;
|
|
383
321
|
const indexer = (config.baseUrls?.indexer ?? `${cairn}/explorer/api`).replace(/\/+$/, "");
|
|
384
322
|
this.cairnHttp = new Http({ baseUrl: cairn, fetch, timeoutMs });
|
|
323
|
+
this._cairnBase = cairn;
|
|
385
324
|
const indexerHttp = new Http({ baseUrl: indexer, fetch, timeoutMs });
|
|
386
325
|
const swarmHttp = config.baseUrls?.swarm ? new Http({ baseUrl: config.baseUrls.swarm.replace(/\/+$/, ""), fetch, timeoutMs }) : void 0;
|
|
387
326
|
this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
|
|
@@ -394,7 +333,7 @@ var Cairn = class {
|
|
|
394
333
|
headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
|
|
395
334
|
});
|
|
396
335
|
this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
|
|
397
|
-
this.registry = new RegistryClient(
|
|
336
|
+
this.registry = new RegistryClient(indexerHttp);
|
|
398
337
|
this.names = new NamesClient(this.cairnHttp);
|
|
399
338
|
}
|
|
400
339
|
/**
|
|
@@ -404,26 +343,132 @@ var Cairn = class {
|
|
|
404
343
|
* checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
|
|
405
344
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
406
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.
|
|
407
350
|
*/
|
|
408
351
|
async verifiedHeaderMerkleAt(height) {
|
|
409
352
|
if (!Number.isInteger(height) || height < this._spvCp.height)
|
|
410
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) {
|
|
411
374
|
const lc = await this.seededSpvLight();
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|
|
417
384
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
this.
|
|
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({
|
|
390
|
+
client: this.chain.client,
|
|
391
|
+
checkpoints: { [this._spvCp.height]: this._spvCp.hash },
|
|
392
|
+
headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
|
|
393
|
+
});
|
|
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
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return this._spvSeedInflight;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
|
|
423
|
+
* `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
|
|
424
|
+
* prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
|
|
425
|
+
* Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
|
|
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).
|
|
430
|
+
*
|
|
431
|
+
* B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
|
|
432
|
+
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
433
|
+
* cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
|
|
434
|
+
* restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
|
|
435
|
+
* once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
|
|
436
|
+
*/
|
|
437
|
+
async fetchHeadersBatch(from, count) {
|
|
438
|
+
const g = this.config.fetch ?? globalThis.fetch;
|
|
439
|
+
if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
|
|
440
|
+
const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
|
|
441
|
+
const url = `${this._cairnBase}/api/headers/${from}/${count}`;
|
|
442
|
+
for (let attempt = 0; ; attempt++) {
|
|
443
|
+
let res;
|
|
444
|
+
let parsed;
|
|
445
|
+
const ctrl = new AbortController();
|
|
446
|
+
const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
|
|
447
|
+
try {
|
|
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
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
|
|
454
|
+
await spvSleep(spvRetryDelayMs(attempt, 0));
|
|
455
|
+
continue;
|
|
456
|
+
} finally {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
}
|
|
459
|
+
if (res.ok) {
|
|
460
|
+
const rows = parsed?.headers;
|
|
461
|
+
if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
|
|
462
|
+
return rows.map((r) => ({ header: r.header, hash: r.hash }));
|
|
463
|
+
}
|
|
464
|
+
if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
|
|
465
|
+
res.body?.cancel?.().catch(() => {
|
|
466
|
+
});
|
|
467
|
+
await spvSleep(spvRetryDelayMs(attempt, Number(res.headers.get("retry-after")) || 0));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
throw new Error(`/api/headers ${res.status}`);
|
|
425
471
|
}
|
|
426
|
-
return this._spvLight;
|
|
427
472
|
}
|
|
428
473
|
/**
|
|
429
474
|
* F13 — corroborate an offer ON-CHAIN before building a `fillOffer` payment's `outputs`. Merkle-proves the
|
|
@@ -436,10 +481,14 @@ var Cairn = class {
|
|
|
436
481
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
437
482
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
438
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.
|
|
439
488
|
*/
|
|
440
|
-
async verifyOfferForFill(offerId, servedOffer) {
|
|
441
|
-
const light =
|
|
442
|
-
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 });
|
|
443
492
|
}
|
|
444
493
|
/**
|
|
445
494
|
* Detect + connect the Cairn Wallet (browser only). Wires the wallet into
|
|
@@ -487,9 +536,14 @@ export {
|
|
|
487
536
|
discoverProviders,
|
|
488
537
|
errorCode,
|
|
489
538
|
feeBpsAt,
|
|
539
|
+
fillEndorsement,
|
|
540
|
+
fillIsSafe,
|
|
541
|
+
fillOutputPlan,
|
|
490
542
|
getWallet,
|
|
491
543
|
isInstalled,
|
|
492
544
|
mapProviderError,
|
|
493
545
|
mapSubmitResultError,
|
|
494
|
-
preverifyOffer
|
|
546
|
+
preverifyOffer,
|
|
547
|
+
previewFill,
|
|
548
|
+
requiredFillOutputs
|
|
495
549
|
};
|