@inversealtruism/cairn-sdk 0.4.5 → 0.5.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/README.md +13 -21
- package/dist/{chunk-SWCUE26T.js → chunk-5RGFC2JR.js} +2 -2
- package/dist/{chunk-WZFWAEXL.js → chunk-BL6VFALH.js} +36 -5
- package/dist/{chunk-FX5LLMNP.js → chunk-CZAQNZZG.js} +19 -8
- package/dist/{chunk-J5J4JEJ6.js → chunk-K5MGEYEU.js} +13 -3
- package/dist/{chunk-XW3IFE6K.js → chunk-OE5BEEQY.js} +6 -3
- package/dist/{chunk-LOAOBV36.js → chunk-OSPAN7GX.js} +7 -3
- package/dist/connect.cjs +8 -4
- package/dist/connect.d.cts +2 -2
- package/dist/connect.d.ts +2 -2
- package/dist/connect.js +2 -2
- package/dist/content.cjs +18 -8
- package/dist/content.d.cts +8 -5
- package/dist/content.d.ts +8 -5
- package/dist/content.js +2 -2
- package/dist/controller.cjs +13 -6
- package/dist/controller.js +3 -3
- package/dist/index.cjs +220 -229
- package/dist/index.d.cts +52 -22
- package/dist/index.d.ts +52 -22
- package/dist/index.js +144 -53
- package/dist/indexer.cjs +13 -3
- package/dist/indexer.d.cts +3 -0
- package/dist/indexer.d.ts +3 -0
- package/dist/indexer.js +1 -1
- package/dist/react.cjs +18 -7
- package/dist/react.js +8 -4
- package/dist/registry.cjs +34 -5
- package/dist/registry.js +2 -2
- package/package.json +3 -9
- package/dist/board.cjs +0 -231
- package/dist/board.d.cts +0 -151
- package/dist/board.d.ts +0 -151
- package/dist/board.js +0 -7
- package/dist/chunk-IGJ46AYP.js +0 -163
package/dist/index.cjs
CHANGED
|
@@ -20,7 +20,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
-
BoardClient: () => BoardClient,
|
|
24
23
|
Cairn: () => Cairn,
|
|
25
24
|
CairnController: () => CairnController,
|
|
26
25
|
CairnError: () => CairnError,
|
|
@@ -60,7 +59,7 @@ __export(index_exports, {
|
|
|
60
59
|
module.exports = __toCommonJS(index_exports);
|
|
61
60
|
|
|
62
61
|
// src/errors.ts
|
|
63
|
-
var SDK_VERSION = "0.
|
|
62
|
+
var SDK_VERSION = "0.5.0";
|
|
64
63
|
var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
|
|
65
64
|
var CairnError = class extends Error {
|
|
66
65
|
/** Stable machine code for programmatic branching. */
|
|
@@ -153,7 +152,7 @@ var SubmitInFlightError = class extends CairnError {
|
|
|
153
152
|
}
|
|
154
153
|
};
|
|
155
154
|
var NATIVE_CODES = /* @__PURE__ */ new Set(["FIRST_PARTY_ONLY", "RATE_LIMITED", "FORBIDDEN", "INTERNAL"]);
|
|
156
|
-
var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN"]);
|
|
155
|
+
var NESTED_TRANSIENT = /* @__PURE__ */ new Set(["VERIFY_UNAVAILABLE", "OFFER_UNKNOWN", "WALLET_UNAVAILABLE", "REVIEW_REQUIRED"]);
|
|
157
156
|
var NESTED_TERMINAL = /* @__PURE__ */ new Set([
|
|
158
157
|
"FILL_UNSAFE",
|
|
159
158
|
"FILL_WRONG_TARGET",
|
|
@@ -171,7 +170,11 @@ var NESTED_TERMINAL = /* @__PURE__ */ new Set([
|
|
|
171
170
|
"FEE_TOO_LOW",
|
|
172
171
|
"ACCOUNT_CHANGED",
|
|
173
172
|
"GHOST_INPUTS_SKIPPED",
|
|
174
|
-
"NOTHING_TO_CONSOLIDATE"
|
|
173
|
+
"NOTHING_TO_CONSOLIDATE",
|
|
174
|
+
"OFFER_SOURCE_CONFLICT",
|
|
175
|
+
"FILL_LEGACY_SUNSET",
|
|
176
|
+
"VERIFY_HORIZON",
|
|
177
|
+
"VERIFY_UNREPRESENTABLE"
|
|
175
178
|
]);
|
|
176
179
|
function mapProviderError(error, code) {
|
|
177
180
|
if (code === "USER_REJECTED" || code === "APPROVAL_CLOSED") return new UserRejectedError(error);
|
|
@@ -201,6 +204,26 @@ function errorCode(err) {
|
|
|
201
204
|
// src/http.ts
|
|
202
205
|
var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
203
206
|
var ERR_TEXT_CAP = 8 * 1024;
|
|
207
|
+
var MAX_RETRY_AFTER_MS = 6e4;
|
|
208
|
+
function retryAfterMs(res) {
|
|
209
|
+
const secs = Number(res.headers.get("retry-after"));
|
|
210
|
+
return Number.isFinite(secs) && secs > 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : 0;
|
|
211
|
+
}
|
|
212
|
+
var RetryWaitBudget = class {
|
|
213
|
+
left;
|
|
214
|
+
constructor(totalMs) {
|
|
215
|
+
this.left = totalMs;
|
|
216
|
+
}
|
|
217
|
+
/** The ms to sleep before the next attempt (`ownMs` is the caller's backoff, clipped to what is left), or
|
|
218
|
+
* null when the server's hint exceeds the remaining budget. */
|
|
219
|
+
take(ownMs, serverMs) {
|
|
220
|
+
if (serverMs > this.left) return null;
|
|
221
|
+
const ms = Math.min(Math.max(ownMs, serverMs), this.left);
|
|
222
|
+
this.left -= ms;
|
|
223
|
+
return ms;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
var HTTP_RETRY_WAIT_BUDGET_MS = 3e4;
|
|
204
227
|
async function readCapped(res, maxBytes) {
|
|
205
228
|
const cl = Number(res.headers.get("content-length"));
|
|
206
229
|
if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
|
|
@@ -249,9 +272,10 @@ var Http = class {
|
|
|
249
272
|
if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
|
|
250
273
|
this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
|
|
251
274
|
this.timeoutMs = opts.timeoutMs ?? 15e3;
|
|
252
|
-
this.maxBytes = opts.maxBytes
|
|
275
|
+
this.maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 1 ? Math.floor(opts.maxBytes) : DEFAULT_MAX_BYTES;
|
|
253
276
|
this.headers = opts.headers ?? {};
|
|
254
|
-
|
|
277
|
+
const retries = Number(opts.retries ?? 0);
|
|
278
|
+
this.retries = Number.isNaN(retries) ? 0 : Math.min(10, Math.max(0, Math.floor(retries)));
|
|
255
279
|
}
|
|
256
280
|
/** Build an absolute URL from a path + optional query params. */
|
|
257
281
|
url(path, query) {
|
|
@@ -300,11 +324,16 @@ var Http = class {
|
|
|
300
324
|
}
|
|
301
325
|
async getJson(path, query) {
|
|
302
326
|
const url = this.url(path, query);
|
|
327
|
+
const budget = new RetryWaitBudget(HTTP_RETRY_WAIT_BUDGET_MS);
|
|
303
328
|
for (let attempt = 0; ; attempt++) {
|
|
329
|
+
let serverWaitMs = 0;
|
|
330
|
+
let last;
|
|
304
331
|
try {
|
|
305
332
|
const { res, body } = await this.raw("GET", path, { query });
|
|
306
333
|
if (!res.ok) {
|
|
307
|
-
|
|
334
|
+
last = new HttpError(res.status, url, errText(body));
|
|
335
|
+
if (!(res.status >= 500 && attempt < this.retries)) throw last;
|
|
336
|
+
serverWaitMs = retryAfterMs(res);
|
|
308
337
|
} else {
|
|
309
338
|
return this.parseJson(res, new TextDecoder().decode(body), url);
|
|
310
339
|
}
|
|
@@ -312,7 +341,10 @@ var Http = class {
|
|
|
312
341
|
if (e instanceof HttpError && e.status < 500) throw e;
|
|
313
342
|
if (attempt >= this.retries) throw e;
|
|
314
343
|
}
|
|
315
|
-
|
|
344
|
+
const jitterMs = Math.floor(Math.random() * Math.min(4e3, 250 * 2 ** attempt));
|
|
345
|
+
const waitMs = budget.take(jitterMs, serverWaitMs);
|
|
346
|
+
if (waitMs === null) throw last ?? new HttpError(0, url, "retry wait budget exhausted");
|
|
347
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
316
348
|
}
|
|
317
349
|
}
|
|
318
350
|
async postJson(path, body, headers) {
|
|
@@ -399,164 +431,8 @@ var Chain = class {
|
|
|
399
431
|
}
|
|
400
432
|
};
|
|
401
433
|
|
|
402
|
-
// src/board.ts
|
|
403
|
-
var import_csd_codec2 = require("@inversealtruism/csd-codec");
|
|
404
|
-
var import_csd_codec3 = require("@inversealtruism/csd-codec");
|
|
405
|
-
var BoardClient = class {
|
|
406
|
-
http;
|
|
407
|
-
wallet;
|
|
408
|
-
constructor(http, wallet) {
|
|
409
|
-
this.http = http;
|
|
410
|
-
this.wallet = wallet;
|
|
411
|
-
}
|
|
412
|
-
requireWallet() {
|
|
413
|
-
if (!this.wallet) {
|
|
414
|
-
throw new CairnError("This action needs a connected wallet. Construct Cairn with a wallet, or call cairn.connect() first.");
|
|
415
|
-
}
|
|
416
|
-
return this.wallet;
|
|
417
|
-
}
|
|
418
|
-
// ---- reads (public) ----------------------------------------------------
|
|
419
|
-
/** Ranked board for a domain + time window. */
|
|
420
|
-
board(opts = {}) {
|
|
421
|
-
return this.http.getJson("/api/board", { domain: opts.domain ?? "all", window: opts.window ?? "all" });
|
|
422
|
-
}
|
|
423
|
-
/** Alias for `board()` — the top items. */
|
|
424
|
-
top(opts = {}) {
|
|
425
|
-
return this.board(opts);
|
|
426
|
-
}
|
|
427
|
-
/** A single item with its supports + pledges + integrity flag. */
|
|
428
|
-
item(id) {
|
|
429
|
-
return this.http.getJson(`/api/item/${encodeURIComponent(id)}`);
|
|
430
|
-
}
|
|
431
|
-
/** Known + discovered domains. */
|
|
432
|
-
domains() {
|
|
433
|
-
return this.http.getJson("/api/domains");
|
|
434
|
-
}
|
|
435
|
-
/** All quests (work-graph items). */
|
|
436
|
-
quests() {
|
|
437
|
-
return this.http.getJson("/api/quests");
|
|
438
|
-
}
|
|
439
|
-
/** A single quest with claims/submissions/payouts. */
|
|
440
|
-
quest(id) {
|
|
441
|
-
return this.http.getJson(`/api/quest/${encodeURIComponent(id)}`);
|
|
442
|
-
}
|
|
443
|
-
/** An address's on-chain-derived profile + identity + reputation. */
|
|
444
|
-
profile(addr, opts = {}) {
|
|
445
|
-
return this.http.getJson(`/api/profile/${encodeURIComponent(addr)}`, opts.live ? { live: 1 } : void 0);
|
|
446
|
-
}
|
|
447
|
-
/** Reputation leaderboard (top 50). */
|
|
448
|
-
leaderboard() {
|
|
449
|
-
return this.http.getJson("/api/leaderboard");
|
|
450
|
-
}
|
|
451
|
-
/** Network dashboard snapshot. */
|
|
452
|
-
network() {
|
|
453
|
-
return this.http.getJson("/api/network");
|
|
454
|
-
}
|
|
455
|
-
/** Network time-series for charts. */
|
|
456
|
-
networkSeries(range = "24h") {
|
|
457
|
-
return this.http.getJson("/api/network/series", { range });
|
|
458
|
-
}
|
|
459
|
-
/** Recent blocks with miner/reward. */
|
|
460
|
-
networkBlocks(limit = 15) {
|
|
461
|
-
return this.http.getJson("/api/network/blocks", { limit });
|
|
462
|
-
}
|
|
463
|
-
/** Per-miner hashrate estimate. */
|
|
464
|
-
miner(addr) {
|
|
465
|
-
return this.http.getJson(`/api/miner/${encodeURIComponent(addr)}`);
|
|
466
|
-
}
|
|
467
|
-
/** Recent activity feed. */
|
|
468
|
-
activity() {
|
|
469
|
-
return this.http.getJson("/api/activity");
|
|
470
|
-
}
|
|
471
|
-
/** The gamified cairn wall. */
|
|
472
|
-
wall() {
|
|
473
|
-
return this.http.getJson("/api/wall");
|
|
474
|
-
}
|
|
475
|
-
/** Server health + chain reachability. */
|
|
476
|
-
health() {
|
|
477
|
-
return this.http.getJson("/api/health");
|
|
478
|
-
}
|
|
479
|
-
// ---- writes (wallet-signed) -------------------------------------------
|
|
480
|
-
/**
|
|
481
|
-
* Publish a board item: compute the canonical payload hash, have the WALLET
|
|
482
|
-
* build/sign/submit the Propose (clear-signed approval), then register the
|
|
483
|
-
* off-chain content. The payload hash is byte-identical to what the cairn
|
|
484
|
-
* server verifies (csd-codec.payloadHash over { v:1, domain, title, body, links }).
|
|
485
|
-
*/
|
|
486
|
-
async propose(input) {
|
|
487
|
-
const wallet = this.requireWallet();
|
|
488
|
-
const content = {
|
|
489
|
-
v: 1,
|
|
490
|
-
domain: input.domain,
|
|
491
|
-
title: input.title,
|
|
492
|
-
body: input.body ?? "",
|
|
493
|
-
links: input.links ?? []
|
|
494
|
-
};
|
|
495
|
-
const ph = (0, import_csd_codec2.payloadHash)(content);
|
|
496
|
-
const uri = `cairn:v1:${ph.slice(2, 14)}`;
|
|
497
|
-
const lifetime = input.lifetimeEpochs ?? 720;
|
|
498
|
-
const height = await this.tipHeight();
|
|
499
|
-
const expiresEpoch = Math.floor(height / import_csd_codec3.EPOCH_LEN) + lifetime;
|
|
500
|
-
const fee = input.fee ?? import_csd_codec3.MIN_FEE_PROPOSE;
|
|
501
|
-
const r = await wallet.propose({ domain: content.domain, payloadHash: ph, uri, expiresEpoch, fee });
|
|
502
|
-
const txid3 = String(r.txid ?? "");
|
|
503
|
-
if (!txid3) throw new CairnError("Propose submitted but no txid was returned by the wallet.");
|
|
504
|
-
const reg = await this.tryRegister(content, txid3);
|
|
505
|
-
return { txid: txid3, payloadHash: ph, content, registered: reg.ok, registerError: reg.error };
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Register off-chain content for an already-submitted proposal txid. Idempotent.
|
|
509
|
-
* Returns { ok } — the cairn server verifies sha256(canonical) == on-chain payload_hash.
|
|
510
|
-
*/
|
|
511
|
-
async registerContent(content, txid3) {
|
|
512
|
-
try {
|
|
513
|
-
const res = await this.http.postJson("/api/content", {
|
|
514
|
-
domain: content.domain,
|
|
515
|
-
title: content.title,
|
|
516
|
-
body: content.body,
|
|
517
|
-
links: content.links,
|
|
518
|
-
txid: txid3
|
|
519
|
-
});
|
|
520
|
-
return { ok: !!res.ok, error: res.ok ? void 0 : res.error };
|
|
521
|
-
} catch (e) {
|
|
522
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
/** Support/attest a proposal (wallet-signed; always clear-signed). */
|
|
526
|
-
async support(proposalId, opts = {}) {
|
|
527
|
-
const wallet = this.requireWallet();
|
|
528
|
-
return wallet.attest({
|
|
529
|
-
proposalId,
|
|
530
|
-
score: opts.score ?? 80,
|
|
531
|
-
confidence: opts.confidence ?? 70,
|
|
532
|
-
fee: opts.fee ?? import_csd_codec3.MIN_FEE_ATTEST
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
// ---- internal ----------------------------------------------------------
|
|
536
|
-
async tipHeight() {
|
|
537
|
-
const tip = await this.http.getJson("/api/rpc/tip");
|
|
538
|
-
const h = Number(tip?.height);
|
|
539
|
-
if (!Number.isFinite(h)) throw new CairnError("Could not read chain tip height for expiry computation.");
|
|
540
|
-
return h;
|
|
541
|
-
}
|
|
542
|
-
async tryRegister(content, txid3) {
|
|
543
|
-
const delays = [0, 1500, 3e3, 5e3];
|
|
544
|
-
let last = { ok: false, error: "not attempted" };
|
|
545
|
-
for (const d of delays) {
|
|
546
|
-
if (d) await sleep(d);
|
|
547
|
-
last = await this.registerContent(content, txid3);
|
|
548
|
-
if (last.ok) return last;
|
|
549
|
-
if (!/not found yet|not found/i.test(last.error ?? "")) return last;
|
|
550
|
-
}
|
|
551
|
-
return last;
|
|
552
|
-
}
|
|
553
|
-
};
|
|
554
|
-
function sleep(ms) {
|
|
555
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
556
|
-
}
|
|
557
|
-
|
|
558
434
|
// src/indexer.ts
|
|
559
|
-
var
|
|
435
|
+
var import_csd_codec2 = require("@inversealtruism/csd-codec");
|
|
560
436
|
var HEX64 = /^0x[0-9a-fA-F]{64}$/;
|
|
561
437
|
var MAX_SSE_BUF = 1024 * 1024;
|
|
562
438
|
function isValidMerkleProof(p) {
|
|
@@ -663,24 +539,34 @@ var IndexerClient = class {
|
|
|
663
539
|
}
|
|
664
540
|
let folds = false;
|
|
665
541
|
try {
|
|
666
|
-
folds = (0,
|
|
542
|
+
folds = (0, import_csd_codec2.verifyMerkleProof)(txid3, proof.pos, proof.merkle, proof.merkle_root);
|
|
667
543
|
} catch {
|
|
668
544
|
return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "malformed merkle proof" };
|
|
669
545
|
}
|
|
670
546
|
if (!folds) {
|
|
671
547
|
return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
|
|
672
548
|
}
|
|
549
|
+
let unverified;
|
|
673
550
|
if (this.opts.headerMerkleAt) {
|
|
551
|
+
let onchain;
|
|
674
552
|
try {
|
|
675
|
-
|
|
553
|
+
onchain = await this.opts.headerMerkleAt(proof.block_height);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
onchain = "";
|
|
556
|
+
unverified = `header at ${proof.block_height} not verified (${e?.message ?? String(e)})`;
|
|
557
|
+
}
|
|
558
|
+
if (onchain) {
|
|
676
559
|
if (norm(onchain) !== norm(proof.merkle_root)) {
|
|
677
560
|
return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
|
|
678
561
|
}
|
|
679
562
|
return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
|
|
680
|
-
} catch {
|
|
681
563
|
}
|
|
564
|
+
unverified ??= `header source returned no merkle root for ${proof.block_height}`;
|
|
565
|
+
} else {
|
|
566
|
+
unverified = "no header source configured (headerMerkleAt)";
|
|
682
567
|
}
|
|
683
|
-
|
|
568
|
+
const trivial = proof.merkle.length === 0 && norm(proof.merkle_root) === norm(txid3) ? "; empty branch (root equals the txid)" : "";
|
|
569
|
+
return { included: false, trustLevel: "proof-consistent", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root, reason: `indexer proof only, ${unverified}${trivial}` };
|
|
684
570
|
}
|
|
685
571
|
// ---- live streams ------------------------------------------------------
|
|
686
572
|
/** Stream all events (blocks, proposals, attestations, reorgs) via SSE. */
|
|
@@ -820,7 +706,7 @@ function norm(h) {
|
|
|
820
706
|
}
|
|
821
707
|
|
|
822
708
|
// src/content.ts
|
|
823
|
-
var
|
|
709
|
+
var import_csd_codec3 = require("@inversealtruism/csd-codec");
|
|
824
710
|
var ContentClient = class {
|
|
825
711
|
sources;
|
|
826
712
|
constructor(opts) {
|
|
@@ -834,29 +720,32 @@ var ContentClient = class {
|
|
|
834
720
|
* This is byte-identical to what the cairn server and wallet compute.
|
|
835
721
|
*/
|
|
836
722
|
prepare(obj) {
|
|
837
|
-
const canonical = (0,
|
|
723
|
+
const canonical = (0, import_csd_codec3.canonicalJson)(obj);
|
|
838
724
|
return {
|
|
839
|
-
payloadHash: (0,
|
|
725
|
+
payloadHash: (0, import_csd_codec3.payloadHash)(obj),
|
|
840
726
|
canonical,
|
|
841
727
|
bytes: new TextEncoder().encode(canonical)
|
|
842
728
|
};
|
|
843
729
|
}
|
|
844
|
-
/** Alias for `prepare()
|
|
730
|
+
/** Alias for `prepare()`. It does not publish anything; anchor the hash with a Propose. */
|
|
845
731
|
put(obj) {
|
|
846
732
|
return this.prepare(obj);
|
|
847
733
|
}
|
|
848
734
|
/** Just the payload hash for an object. */
|
|
849
735
|
hash(obj) {
|
|
850
|
-
return (0,
|
|
736
|
+
return (0, import_csd_codec3.payloadHash)(obj);
|
|
851
737
|
}
|
|
852
738
|
/**
|
|
853
739
|
* Fetch content by hash and VERIFY it self-certifies (sha256(bytes) == hash).
|
|
854
|
-
* Tries each configured source in order
|
|
855
|
-
*
|
|
856
|
-
*
|
|
740
|
+
* Tries each configured source in order; a source that is unreachable, lacks
|
|
741
|
+
* the content or serves bytes that don't match the hash is skipped. Returns the
|
|
742
|
+
* first verified bytes, or null if no source holds it. Throws
|
|
743
|
+
* ContentVerificationError only when no source served verified bytes and at
|
|
744
|
+
* least one served mismatching bytes (tampering / wrong object).
|
|
857
745
|
*/
|
|
858
746
|
async getBytes(hash) {
|
|
859
747
|
const h = normalizeHash(hash);
|
|
748
|
+
let mismatched = false;
|
|
860
749
|
for (const src of this.sources) {
|
|
861
750
|
let got = null;
|
|
862
751
|
try {
|
|
@@ -865,22 +754,29 @@ var ContentClient = class {
|
|
|
865
754
|
continue;
|
|
866
755
|
}
|
|
867
756
|
if (!got) continue;
|
|
868
|
-
if (!(0,
|
|
869
|
-
|
|
757
|
+
if (!(0, import_csd_codec3.verifyContentBytes)(got.bytes, h)) {
|
|
758
|
+
mismatched = true;
|
|
759
|
+
continue;
|
|
870
760
|
}
|
|
871
761
|
return got.bytes;
|
|
872
762
|
}
|
|
763
|
+
if (mismatched) throw new ContentVerificationError(h);
|
|
873
764
|
return null;
|
|
874
765
|
}
|
|
875
|
-
/** Fetch + verify + parse as UTF-8 JSON. Returns null if not found.
|
|
766
|
+
/** Fetch + verify + parse as UTF-8 JSON. Returns null if not found. Throws a CairnError with code
|
|
767
|
+
* CONTENT_NOT_JSON when the verified bytes are not JSON. */
|
|
876
768
|
async get(hash) {
|
|
877
769
|
const bytes = await this.getBytes(hash);
|
|
878
770
|
if (!bytes) return null;
|
|
879
|
-
|
|
771
|
+
try {
|
|
772
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
773
|
+
} catch (e) {
|
|
774
|
+
throw new CairnError(`Content ${normalizeHash(hash)} verified against its hash but is not JSON; use getBytes() for raw content.`, { code: "CONTENT_NOT_JSON", cause: e });
|
|
775
|
+
}
|
|
880
776
|
}
|
|
881
777
|
/** Verify already-fetched bytes against a hash (no network). */
|
|
882
778
|
verify(bytes, hash) {
|
|
883
|
-
return (0,
|
|
779
|
+
return (0, import_csd_codec3.verifyContentBytes)(bytes, normalizeHash(hash));
|
|
884
780
|
}
|
|
885
781
|
};
|
|
886
782
|
function normalizeHash(hash) {
|
|
@@ -944,11 +840,46 @@ var RegistryClient = class {
|
|
|
944
840
|
// src/names.ts
|
|
945
841
|
var import_cairnx_core = require("@inversealtruism/cairnx-core");
|
|
946
842
|
var nameOk = (n) => typeof n === "string" && import_cairnx_core.NAME_RE.test(n) && !import_cairnx_core.RESERVED_NAMES.has(n);
|
|
843
|
+
var amountOk = (v) => typeof v === "string" && import_cairnx_core.AMOUNT_RE.test(v);
|
|
844
|
+
var heightOk = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
845
|
+
function nameHistoryProblem(h, name) {
|
|
846
|
+
if (!h || typeof h !== "object" || Array.isArray(h)) return "not an object";
|
|
847
|
+
const r = h;
|
|
848
|
+
if (r.name !== name) return `name is ${JSON.stringify(r.name)}, expected ${JSON.stringify(name)}`;
|
|
849
|
+
if (!heightOk(r.tipHeight)) return "bad tipHeight";
|
|
850
|
+
if (!r.resolve || typeof r.resolve !== "object") return "resolve is not an object";
|
|
851
|
+
if (typeof r.scopedReplaySufficient !== "boolean") return "scopedReplaySufficient is not a boolean";
|
|
852
|
+
if (!Array.isArray(r.events)) return "events is not an array";
|
|
853
|
+
for (const [i, e] of r.events.entries()) {
|
|
854
|
+
const ev = e;
|
|
855
|
+
if (!ev || typeof ev.txid !== "string" || !import_cairnx_core.HASH_RE.test(ev.txid) || !heightOk(ev.height) || !heightOk(ev.pos) || typeof ev.kind !== "string")
|
|
856
|
+
return `event ${i} is malformed`;
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
function tokenProblem(t) {
|
|
861
|
+
if (!t || typeof t !== "object") return "not an object";
|
|
862
|
+
const r = t;
|
|
863
|
+
if (typeof r.ticker !== "string" || !import_cairnx_core.TICKER_RE.test(r.ticker)) return "bad ticker";
|
|
864
|
+
if (!Number.isSafeInteger(r.decimals) || r.decimals < 0) return `bad decimals (${String(r.decimals)})`;
|
|
865
|
+
if (!amountOk(r.supply)) return "supply is not a base-unit integer string";
|
|
866
|
+
if (!amountOk(r.minted)) return "minted is not a base-unit integer string";
|
|
867
|
+
if (r.mintLimit !== void 0 && !amountOk(r.mintLimit)) return "mintLimit is not a base-unit integer string";
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
947
870
|
var NamesClient = class {
|
|
948
871
|
constructor(http) {
|
|
949
872
|
this.http = http;
|
|
950
873
|
}
|
|
951
874
|
http;
|
|
875
|
+
malformed(path, what) {
|
|
876
|
+
return new HttpError(200, this.http.url(path), `malformed response: ${what}`);
|
|
877
|
+
}
|
|
878
|
+
async list(path) {
|
|
879
|
+
const body = await this.http.getJson(path);
|
|
880
|
+
if (!Array.isArray(body)) throw this.malformed(path, "expected an array");
|
|
881
|
+
return body;
|
|
882
|
+
}
|
|
952
883
|
/** Resolve a .csd name for DISPLAY (server-trusted; see the module trust note). null = unknown/invalid name. */
|
|
953
884
|
async resolve(name) {
|
|
954
885
|
if (!nameOk(name)) return null;
|
|
@@ -969,15 +900,21 @@ var NamesClient = class {
|
|
|
969
900
|
throw e;
|
|
970
901
|
}
|
|
971
902
|
}
|
|
972
|
-
/** The event history of one name (registrations, transfers, sets, renewals)
|
|
903
|
+
/** The event history of one name (registrations, transfers, sets, renewals) with the server's current
|
|
904
|
+
* claim. null = invalid or unregistered name. Throws a typed HttpError on a malformed body. */
|
|
973
905
|
async nameHistory(name) {
|
|
974
906
|
if (!nameOk(name)) return null;
|
|
907
|
+
const path = `/trade/api/cairnx/name-history/${encodeURIComponent(name)}`;
|
|
908
|
+
let h;
|
|
975
909
|
try {
|
|
976
|
-
|
|
910
|
+
h = await this.http.getJson(path);
|
|
977
911
|
} catch (e) {
|
|
978
912
|
if (e?.status === 404) return null;
|
|
979
913
|
throw e;
|
|
980
914
|
}
|
|
915
|
+
const p = nameHistoryProblem(h, name);
|
|
916
|
+
if (p) throw this.malformed(path, p);
|
|
917
|
+
return h;
|
|
981
918
|
}
|
|
982
919
|
/** Reverse lookup: an address's chosen primary .csd name (null name = none set). */
|
|
983
920
|
async primary(addr) {
|
|
@@ -1001,29 +938,40 @@ var NamesClient = class {
|
|
|
1001
938
|
}
|
|
1002
939
|
/** All registered names (display list; short-edge-cached server-side). */
|
|
1003
940
|
names() {
|
|
1004
|
-
return this.
|
|
1005
|
-
}
|
|
1006
|
-
/** All deployed tokens. */
|
|
1007
|
-
tokens() {
|
|
1008
|
-
|
|
941
|
+
return this.list(`/trade/api/cairnx/names`);
|
|
942
|
+
}
|
|
943
|
+
/** All deployed tokens. Throws a typed HttpError if any row has a malformed ticker, decimals or amount. */
|
|
944
|
+
async tokens() {
|
|
945
|
+
const path = `/trade/api/cairnx/tokens`;
|
|
946
|
+
const rows = await this.list(path);
|
|
947
|
+
rows.forEach((t, i) => {
|
|
948
|
+
const p = tokenProblem(t);
|
|
949
|
+
if (p) throw this.malformed(path, `token row ${i}: ${p}`);
|
|
950
|
+
});
|
|
951
|
+
return rows;
|
|
1009
952
|
}
|
|
1010
|
-
/** One token by ticker. null = unknown/invalid ticker. */
|
|
953
|
+
/** One token by ticker. null = unknown/invalid ticker. Throws a typed HttpError on a malformed body. */
|
|
1011
954
|
async token(ticker) {
|
|
1012
|
-
if (typeof ticker !== "string" ||
|
|
955
|
+
if (typeof ticker !== "string" || !import_cairnx_core.TICKER_RE.test(ticker)) return null;
|
|
956
|
+
const path = `/trade/api/cairnx/token/${ticker}`;
|
|
957
|
+
let t;
|
|
1013
958
|
try {
|
|
1014
|
-
|
|
959
|
+
t = await this.http.getJson(path);
|
|
1015
960
|
} catch (e) {
|
|
1016
961
|
if (e?.status === 404) return null;
|
|
1017
962
|
throw e;
|
|
1018
963
|
}
|
|
964
|
+
const p = tokenProblem(t);
|
|
965
|
+
if (p) throw this.malformed(path, p);
|
|
966
|
+
return t;
|
|
1019
967
|
}
|
|
1020
968
|
/** Open offers (the market's display list). */
|
|
1021
969
|
offers() {
|
|
1022
|
-
return this.
|
|
970
|
+
return this.list(`/trade/api/cairnx/offers`);
|
|
1023
971
|
}
|
|
1024
972
|
/** Open bids. */
|
|
1025
973
|
bids() {
|
|
1026
|
-
return this.
|
|
974
|
+
return this.list(`/trade/api/cairnx/bids`);
|
|
1027
975
|
}
|
|
1028
976
|
/** The full canonical CairnX state (large; prefer the scoped reads above). */
|
|
1029
977
|
state() {
|
|
@@ -1256,7 +1204,7 @@ var WalletConnection = class {
|
|
|
1256
1204
|
send(params) {
|
|
1257
1205
|
return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
|
|
1258
1206
|
}
|
|
1259
|
-
/** Low-level Propose (always prompts).
|
|
1207
|
+
/** Low-level Propose (always prompts). The caller computes the payload hash, uri and expiry epoch. */
|
|
1260
1208
|
propose(params) {
|
|
1261
1209
|
return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
|
|
1262
1210
|
}
|
|
@@ -1287,9 +1235,10 @@ async function connect(opts = {}) {
|
|
|
1287
1235
|
|
|
1288
1236
|
// src/fillverify.ts
|
|
1289
1237
|
var import_csd_client3 = require("@inversealtruism/csd-client");
|
|
1290
|
-
var
|
|
1238
|
+
var import_csd_codec4 = require("@inversealtruism/csd-codec");
|
|
1291
1239
|
var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
|
|
1292
1240
|
var ADDR = /^0x[0-9a-f]{40}$/;
|
|
1241
|
+
var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
|
|
1293
1242
|
var HASH = /^0x[0-9a-f]{64}$/;
|
|
1294
1243
|
var COINBASE = "0x" + "00".repeat(32);
|
|
1295
1244
|
function bindPlannedSums(planned, proven) {
|
|
@@ -1326,7 +1275,7 @@ async function provenAuthor(client, provenTx) {
|
|
|
1326
1275
|
} catch {
|
|
1327
1276
|
return null;
|
|
1328
1277
|
}
|
|
1329
|
-
if (String((0,
|
|
1278
|
+
if (String((0, import_csd_codec4.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
|
|
1330
1279
|
const out = src.outputs?.[Number(in0.vout)];
|
|
1331
1280
|
const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
|
|
1332
1281
|
return spk && ADDR.test(spk) ? spk : null;
|
|
@@ -1340,13 +1289,17 @@ async function preverifyOffer(opts) {
|
|
|
1340
1289
|
} catch (e) {
|
|
1341
1290
|
return { ok: false, trust: "unverified", transient: true, reason: `couldn't reach the chain to verify the offer (${e?.message ?? e})` };
|
|
1342
1291
|
}
|
|
1343
|
-
if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash)
|
|
1292
|
+
if (!incl.included || incl.trustLevel !== "verified-inclusion" || !incl.tx || !incl.appPayloadHash) {
|
|
1293
|
+
const below = BELOW_BASE.exec(incl.reason ?? "");
|
|
1294
|
+
if (below)
|
|
1295
|
+
return { ok: false, trust: "unverified", blockHeight: incl.blockHeight, reason: `the offer was mined below this SDK's SPV checkpoint window (base ${below[1]}), so it cannot be merkle-proven here and retrying will not change that; pass an older anchor in config.spvCheckpoint to verify it` };
|
|
1344
1296
|
return { ok: false, trust: "unverified", transient: true, blockHeight: incl.blockHeight, reason: incl.reason ?? `offer not merkle-proven on-chain (${incl.trustLevel})` };
|
|
1297
|
+
}
|
|
1345
1298
|
const blockHeight = Number(incl.blockHeight);
|
|
1346
1299
|
let provenTx, provenId;
|
|
1347
1300
|
try {
|
|
1348
1301
|
provenTx = (0, import_csd_client3.rpcTxToTx)(incl.tx);
|
|
1349
|
-
provenId = (0,
|
|
1302
|
+
provenId = (0, import_csd_codec4.txid)(provenTx);
|
|
1350
1303
|
} catch {
|
|
1351
1304
|
return { ok: false, trust: "unverified", reason: "the on-chain offer tx couldn't be re-derived" };
|
|
1352
1305
|
}
|
|
@@ -1362,7 +1315,7 @@ async function preverifyOffer(opts) {
|
|
|
1362
1315
|
rec = null;
|
|
1363
1316
|
}
|
|
1364
1317
|
if (!rec || rec.t !== "offer") return { ok: false, trust: "unverified", reason: "the offer record doesn't bind to its on-chain commitment" };
|
|
1365
|
-
if (String((0,
|
|
1318
|
+
if (String((0, import_csd_codec4.payloadHash)(rec)).toLowerCase() !== String(incl.appPayloadHash).toLowerCase())
|
|
1366
1319
|
return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
|
|
1367
1320
|
const seller = await provenAuthor(opts.client, provenTx);
|
|
1368
1321
|
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" };
|
|
@@ -1377,8 +1330,15 @@ async function preverifyOffer(opts) {
|
|
|
1377
1330
|
return { ok: false, trust: "verified", blockHeight, reason: "the offer was already expired when it was mined - the chain rejects it, and a fill would move CSD for nothing" };
|
|
1378
1331
|
let outputPlan;
|
|
1379
1332
|
let sumsMismatch;
|
|
1380
|
-
|
|
1381
|
-
|
|
1333
|
+
let planReason;
|
|
1334
|
+
let planBound = false;
|
|
1335
|
+
const has = (v) => v !== void 0 && v !== null;
|
|
1336
|
+
const servedStatus = opts.servedOffer?.status;
|
|
1337
|
+
if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
|
|
1338
|
+
else if ((0, import_cairnx_core2.isTokenWant)(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
|
|
1339
|
+
else if (offerRec.min !== void 0) planReason = "the offer is partially fillable: its running paid/delivered is not provable here (the wallet's fill-SPV sizes it)";
|
|
1340
|
+
else if (typeof servedStatus !== "string") planReason = "no served offer status: liveness is not provable here, so no output plan was sized (pass the resolver's offer object)";
|
|
1341
|
+
else {
|
|
1382
1342
|
const provenState = {
|
|
1383
1343
|
id,
|
|
1384
1344
|
seller,
|
|
@@ -1386,7 +1346,7 @@ async function preverifyOffer(opts) {
|
|
|
1386
1346
|
want: { ...offerRec.want, payto },
|
|
1387
1347
|
taker: offerRec.taker,
|
|
1388
1348
|
bid: offerRec.bid,
|
|
1389
|
-
status:
|
|
1349
|
+
status: servedStatus,
|
|
1390
1350
|
expiresEpoch: 0,
|
|
1391
1351
|
height: blockHeight,
|
|
1392
1352
|
feeBps: terms.feeBps
|
|
@@ -1395,16 +1355,25 @@ async function preverifyOffer(opts) {
|
|
|
1395
1355
|
const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
|
|
1396
1356
|
if (plan.kind === "csd-outputs") {
|
|
1397
1357
|
outputPlan = plan.outputs;
|
|
1398
|
-
if (opts.plannedOutputs
|
|
1358
|
+
if (has(opts.plannedOutputs)) {
|
|
1399
1359
|
sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
|
|
1360
|
+
planBound = true;
|
|
1361
|
+
}
|
|
1400
1362
|
} else if (plan.kind === "undeliverable") {
|
|
1401
1363
|
sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
|
|
1364
|
+
} else {
|
|
1365
|
+
planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
|
|
1402
1366
|
}
|
|
1403
1367
|
} catch (e) {
|
|
1404
1368
|
sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
|
|
1405
1369
|
}
|
|
1406
1370
|
}
|
|
1407
|
-
const
|
|
1371
|
+
const planFields = {
|
|
1372
|
+
...outputPlan ? { outputPlan } : {},
|
|
1373
|
+
...has(opts.plannedOutputs) ? { planBound } : {},
|
|
1374
|
+
...!outputPlan && planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason } : {}
|
|
1375
|
+
};
|
|
1376
|
+
const base = { payto, seller, terms, blockHeight, ...planFields };
|
|
1408
1377
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
1409
1378
|
const so = opts.servedOffer;
|
|
1410
1379
|
if (so?.want?.payto === void 0 || so?.want?.payto === null)
|
|
@@ -1429,8 +1398,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
|
|
|
1429
1398
|
var CairnController = class {
|
|
1430
1399
|
state = DISCONNECTED;
|
|
1431
1400
|
conn = null;
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1401
|
+
// Connect/disconnect generation token. disconnect(), the wallet's `disconnect` event and an empty
|
|
1402
|
+
// accountsChanged (lock, account switch, revoke) bump it; a connect() whose await straddled the bump must
|
|
1403
|
+
// never commit "connected" over the torn-down session.
|
|
1434
1404
|
epoch = 0;
|
|
1435
1405
|
listeners = /* @__PURE__ */ new Set();
|
|
1436
1406
|
getWalletFn;
|
|
@@ -1461,6 +1431,7 @@ var CairnController = class {
|
|
|
1461
1431
|
}
|
|
1462
1432
|
onAccounts = (accounts) => {
|
|
1463
1433
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
1434
|
+
this.epoch++;
|
|
1464
1435
|
this.setState({ status: "disconnected", account: null });
|
|
1465
1436
|
return;
|
|
1466
1437
|
}
|
|
@@ -1472,6 +1443,7 @@ var CairnController = class {
|
|
|
1472
1443
|
}
|
|
1473
1444
|
};
|
|
1474
1445
|
onDisconnect = () => {
|
|
1446
|
+
this.epoch++;
|
|
1475
1447
|
this.setState({ status: "disconnected", account: null });
|
|
1476
1448
|
};
|
|
1477
1449
|
// Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
|
|
@@ -1526,7 +1498,7 @@ var CairnController = class {
|
|
|
1526
1498
|
};
|
|
1527
1499
|
|
|
1528
1500
|
// src/index.ts
|
|
1529
|
-
var DEFAULT_SPV_CHECKPOINT = { height:
|
|
1501
|
+
var DEFAULT_SPV_CHECKPOINT = { height: 115403, hash: "0x00000000000052e29166597d598827b137af1dea7542123b848637fee0b67347" };
|
|
1530
1502
|
var DEFAULT_CAIRN = "https://cairn-substrate.com";
|
|
1531
1503
|
var SPV_HTTP_TIMEOUT_MS = 15e3;
|
|
1532
1504
|
var SPV_HTTP_MAX_RETRIES = 4;
|
|
@@ -1538,7 +1510,10 @@ var spvIsTransient = (e) => {
|
|
|
1538
1510
|
return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
|
|
1539
1511
|
};
|
|
1540
1512
|
var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1541
|
-
var
|
|
1513
|
+
var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
|
|
1514
|
+
var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
|
|
1515
|
+
var SPV_RECHECK_DEPTH = 12;
|
|
1516
|
+
var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
|
|
1542
1517
|
var Cairn = class {
|
|
1543
1518
|
config;
|
|
1544
1519
|
chain;
|
|
@@ -1547,7 +1522,6 @@ var Cairn = class {
|
|
|
1547
1522
|
registry;
|
|
1548
1523
|
/** .csd names + CairnX market READS (server-trusted display reads; see names.ts trust note). */
|
|
1549
1524
|
names;
|
|
1550
|
-
board;
|
|
1551
1525
|
wallet;
|
|
1552
1526
|
cairnHttp;
|
|
1553
1527
|
// Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
|
|
@@ -1573,7 +1547,6 @@ var Cairn = class {
|
|
|
1573
1547
|
this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
|
|
1574
1548
|
this.chain = new Chain({ rpcUrl: rpc, fetch, timeoutMs });
|
|
1575
1549
|
this.wallet = config.wallet;
|
|
1576
|
-
this.board = new BoardClient(this.cairnHttp, this.wallet);
|
|
1577
1550
|
this.index = new IndexerClient(indexerHttp, {
|
|
1578
1551
|
fetch,
|
|
1579
1552
|
WebSocketImpl: config.WebSocketImpl,
|
|
@@ -1591,16 +1564,17 @@ var Cairn = class {
|
|
|
1591
1564
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
1592
1565
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
1593
1566
|
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
1594
|
-
* withSpvReseed.
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1567
|
+
* withSpvReseed. A height within SPV_RECHECK_DEPTH of the cached tip is re-checked against the served
|
|
1568
|
+
* chain first (recheckCachedHeader), so a block orphaned after it was cached stops backing
|
|
1569
|
+
* "verified-inclusion". Deeper heights answer from the cache with no network touch.
|
|
1597
1570
|
*/
|
|
1598
1571
|
async verifiedHeaderMerkleAt(height) {
|
|
1599
1572
|
if (!Number.isInteger(height) || height < this._spvCp.height)
|
|
1600
1573
|
throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
|
|
1601
1574
|
return this.withSpvReseed(async (lc) => {
|
|
1602
|
-
const haveTo = lc
|
|
1575
|
+
const haveTo = spvTipOf(lc);
|
|
1603
1576
|
if (haveTo < height) await lc.sync(height);
|
|
1577
|
+
else await this.recheckCachedHeader(lc, height, haveTo);
|
|
1604
1578
|
const vh = lc.chain[height - lc.baseHeight];
|
|
1605
1579
|
if (!vh) throw new Error(`light client has no verified header at ${height}`);
|
|
1606
1580
|
return String(vh.header.merkle);
|
|
@@ -1612,11 +1586,8 @@ var Cairn = class {
|
|
|
1612
1586
|
// this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
|
|
1613
1587
|
// (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
|
|
1614
1588
|
// on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
|
|
1615
|
-
// failure propagates fail-closed; there is no loop.
|
|
1616
|
-
//
|
|
1617
|
-
// branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
|
|
1618
|
-
// for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
|
|
1619
|
-
// the permanent-brick leg only.
|
|
1589
|
+
// failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
|
|
1590
|
+
// cached header that the served chain no longer carries is a structural failure.
|
|
1620
1591
|
async withSpvReseed(op) {
|
|
1621
1592
|
const lc = await this.seededSpvLight();
|
|
1622
1593
|
try {
|
|
@@ -1629,6 +1600,16 @@ var Cairn = class {
|
|
|
1629
1600
|
return out;
|
|
1630
1601
|
}
|
|
1631
1602
|
}
|
|
1603
|
+
// A near-tip cached header the served chain no longer carries is a reorg: throw a structural error so
|
|
1604
|
+
// withSpvReseed rebuilds. The message must not match SPV_TRANSIENT_RE, or it would be retried on the stale cache.
|
|
1605
|
+
async recheckCachedHeader(lc, height, haveTo) {
|
|
1606
|
+
if (height <= haveTo - SPV_RECHECK_DEPTH) return;
|
|
1607
|
+
const cached = lc.chain[height - lc.baseHeight];
|
|
1608
|
+
if (!cached) return;
|
|
1609
|
+
const [served] = await this.fetchHeadersBatch(height, 1);
|
|
1610
|
+
if (String(served?.hash ?? "").toLowerCase() !== String(cached.hash).toLowerCase())
|
|
1611
|
+
throw new Error(`SPV cached block at height ${height} is no longer on the served chain (reorg); reseeding`);
|
|
1612
|
+
}
|
|
1632
1613
|
// Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
|
|
1633
1614
|
// the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
|
|
1634
1615
|
// dirty the shared field while an attempt is in flight).
|
|
@@ -1641,7 +1622,7 @@ var Cairn = class {
|
|
|
1641
1622
|
await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
|
|
1642
1623
|
try {
|
|
1643
1624
|
const tipHeight = Number((await this.chain.tip()).height);
|
|
1644
|
-
const have = lc
|
|
1625
|
+
const have = spvTipOf(lc);
|
|
1645
1626
|
if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
|
|
1646
1627
|
} catch {
|
|
1647
1628
|
}
|
|
@@ -1686,6 +1667,7 @@ var Cairn = class {
|
|
|
1686
1667
|
if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
|
|
1687
1668
|
const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
|
|
1688
1669
|
const url = `${this._cairnBase}/api/headers/${from}/${count}`;
|
|
1670
|
+
const budget = new RetryWaitBudget(SPV_RETRY_WAIT_BUDGET_MS);
|
|
1689
1671
|
for (let attempt = 0; ; attempt++) {
|
|
1690
1672
|
let res;
|
|
1691
1673
|
let parsed;
|
|
@@ -1697,8 +1679,9 @@ var Cairn = class {
|
|
|
1697
1679
|
parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
|
|
1698
1680
|
}
|
|
1699
1681
|
} catch (e) {
|
|
1700
|
-
|
|
1701
|
-
|
|
1682
|
+
const waitMs = attempt < SPV_HTTP_MAX_RETRIES ? budget.take(spvBackoffMs(attempt), 0) : null;
|
|
1683
|
+
if (waitMs === null) throw e;
|
|
1684
|
+
await spvSleep(waitMs);
|
|
1702
1685
|
continue;
|
|
1703
1686
|
} finally {
|
|
1704
1687
|
clearTimeout(timer);
|
|
@@ -1711,7 +1694,9 @@ var Cairn = class {
|
|
|
1711
1694
|
if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
|
|
1712
1695
|
res.body?.cancel?.().catch(() => {
|
|
1713
1696
|
});
|
|
1714
|
-
|
|
1697
|
+
const waitMs = budget.take(spvBackoffMs(attempt), retryAfterMs(res));
|
|
1698
|
+
if (waitMs === null) throw new Error(`/api/headers ${res.status}: Retry-After exceeds the remaining retry budget`);
|
|
1699
|
+
await spvSleep(waitMs);
|
|
1715
1700
|
continue;
|
|
1716
1701
|
}
|
|
1717
1702
|
throw new Error(`/api/headers ${res.status}`);
|
|
@@ -1734,12 +1719,20 @@ var Cairn = class {
|
|
|
1734
1719
|
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
1735
1720
|
*/
|
|
1736
1721
|
async verifyOfferForFill(offerId, servedOffer, opts) {
|
|
1737
|
-
const light = {
|
|
1722
|
+
const light = {
|
|
1723
|
+
verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
|
|
1724
|
+
const haveTo = spvTipOf(lc);
|
|
1725
|
+
const r = await lc.verifyTxInclusion(txidHex);
|
|
1726
|
+
if (r.trustLevel === "verified-inclusion" && typeof r.blockHeight === "number" && r.blockHeight <= haveTo)
|
|
1727
|
+
await this.recheckCachedHeader(lc, r.blockHeight, haveTo);
|
|
1728
|
+
return r;
|
|
1729
|
+
})
|
|
1730
|
+
};
|
|
1738
1731
|
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
|
|
1739
1732
|
}
|
|
1740
1733
|
/**
|
|
1741
|
-
* Detect + connect the Cairn Wallet (browser only)
|
|
1742
|
-
*
|
|
1734
|
+
* Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
|
|
1735
|
+
* Resolves to the connected address.
|
|
1743
1736
|
*/
|
|
1744
1737
|
async connect(opts = {}) {
|
|
1745
1738
|
const { wallet, address } = await connect(opts);
|
|
@@ -1749,7 +1742,6 @@ var Cairn = class {
|
|
|
1749
1742
|
/** Attach an already-connected wallet (e.g. one you constructed yourself). */
|
|
1750
1743
|
attachWallet(wallet) {
|
|
1751
1744
|
this.wallet = wallet;
|
|
1752
|
-
this.board = new BoardClient(this.cairnHttp, wallet);
|
|
1753
1745
|
}
|
|
1754
1746
|
/** True if a wallet is attached. */
|
|
1755
1747
|
get connected() {
|
|
@@ -1758,7 +1750,6 @@ var Cairn = class {
|
|
|
1758
1750
|
};
|
|
1759
1751
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1760
1752
|
0 && (module.exports = {
|
|
1761
|
-
BoardClient,
|
|
1762
1753
|
Cairn,
|
|
1763
1754
|
CairnController,
|
|
1764
1755
|
CairnError,
|