@inversealtruism/cairn-sdk 0.4.6 → 0.6.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-ZFVE6MU7.js → chunk-2A4AJBO5.js} +1 -1
- package/dist/{chunk-2HP3ZXBO.js → chunk-67QBDZPW.js} +36 -5
- package/dist/{chunk-FWTPXLGV.js → chunk-BMH2MACB.js} +2 -2
- package/dist/{chunk-J5J4JEJ6.js → chunk-K5MGEYEU.js} +13 -3
- package/dist/{chunk-OB33SSGA.js → chunk-MLNW2RQB.js} +6 -3
- package/dist/{chunk-E4YQRWZZ.js → chunk-QEYBWBXC.js} +19 -8
- package/dist/connect.cjs +2 -2
- 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 +7 -4
- package/dist/controller.js +3 -3
- package/dist/index.cjs +214 -227
- package/dist/index.d.cts +54 -23
- package/dist/index.d.ts +54 -23
- 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 +12 -5
- package/dist/react.js +8 -4
- package/dist/registry.cjs +34 -5
- package/dist/registry.js +2 -2
- package/package.json +11 -17
- 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-E2MA75S3.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.6.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. */
|
|
@@ -205,6 +204,26 @@ function errorCode(err) {
|
|
|
205
204
|
// src/http.ts
|
|
206
205
|
var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
207
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;
|
|
208
227
|
async function readCapped(res, maxBytes) {
|
|
209
228
|
const cl = Number(res.headers.get("content-length"));
|
|
210
229
|
if (Number.isFinite(cl) && cl > maxBytes) throw new HttpError(res.status, res.url, `response too large (content-length ${cl} > ${maxBytes})`);
|
|
@@ -253,9 +272,10 @@ var Http = class {
|
|
|
253
272
|
if (!f) throw new Error("No fetch available \u2014 pass `fetch` in the SDK options.");
|
|
254
273
|
this._fetch = opts.fetch ? opts.fetch : f.bind(globalThis);
|
|
255
274
|
this.timeoutMs = opts.timeoutMs ?? 15e3;
|
|
256
|
-
this.maxBytes = opts.maxBytes
|
|
275
|
+
this.maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes >= 1 ? Math.floor(opts.maxBytes) : DEFAULT_MAX_BYTES;
|
|
257
276
|
this.headers = opts.headers ?? {};
|
|
258
|
-
|
|
277
|
+
const retries = Number(opts.retries ?? 0);
|
|
278
|
+
this.retries = Number.isNaN(retries) ? 0 : Math.min(10, Math.max(0, Math.floor(retries)));
|
|
259
279
|
}
|
|
260
280
|
/** Build an absolute URL from a path + optional query params. */
|
|
261
281
|
url(path, query) {
|
|
@@ -304,11 +324,16 @@ var Http = class {
|
|
|
304
324
|
}
|
|
305
325
|
async getJson(path, query) {
|
|
306
326
|
const url = this.url(path, query);
|
|
327
|
+
const budget = new RetryWaitBudget(HTTP_RETRY_WAIT_BUDGET_MS);
|
|
307
328
|
for (let attempt = 0; ; attempt++) {
|
|
329
|
+
let serverWaitMs = 0;
|
|
330
|
+
let last;
|
|
308
331
|
try {
|
|
309
332
|
const { res, body } = await this.raw("GET", path, { query });
|
|
310
333
|
if (!res.ok) {
|
|
311
|
-
|
|
334
|
+
last = new HttpError(res.status, url, errText(body));
|
|
335
|
+
if (!(res.status >= 500 && attempt < this.retries)) throw last;
|
|
336
|
+
serverWaitMs = retryAfterMs(res);
|
|
312
337
|
} else {
|
|
313
338
|
return this.parseJson(res, new TextDecoder().decode(body), url);
|
|
314
339
|
}
|
|
@@ -316,7 +341,10 @@ var Http = class {
|
|
|
316
341
|
if (e instanceof HttpError && e.status < 500) throw e;
|
|
317
342
|
if (attempt >= this.retries) throw e;
|
|
318
343
|
}
|
|
319
|
-
|
|
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));
|
|
320
348
|
}
|
|
321
349
|
}
|
|
322
350
|
async postJson(path, body, headers) {
|
|
@@ -403,164 +431,8 @@ var Chain = class {
|
|
|
403
431
|
}
|
|
404
432
|
};
|
|
405
433
|
|
|
406
|
-
// src/board.ts
|
|
407
|
-
var import_csd_codec2 = require("@inversealtruism/csd-codec");
|
|
408
|
-
var import_csd_codec3 = require("@inversealtruism/csd-codec");
|
|
409
|
-
var BoardClient = class {
|
|
410
|
-
http;
|
|
411
|
-
wallet;
|
|
412
|
-
constructor(http, wallet) {
|
|
413
|
-
this.http = http;
|
|
414
|
-
this.wallet = wallet;
|
|
415
|
-
}
|
|
416
|
-
requireWallet() {
|
|
417
|
-
if (!this.wallet) {
|
|
418
|
-
throw new CairnError("This action needs a connected wallet. Construct Cairn with a wallet, or call cairn.connect() first.");
|
|
419
|
-
}
|
|
420
|
-
return this.wallet;
|
|
421
|
-
}
|
|
422
|
-
// ---- reads (public) ----------------------------------------------------
|
|
423
|
-
/** Ranked board for a domain + time window. */
|
|
424
|
-
board(opts = {}) {
|
|
425
|
-
return this.http.getJson("/api/board", { domain: opts.domain ?? "all", window: opts.window ?? "all" });
|
|
426
|
-
}
|
|
427
|
-
/** Alias for `board()` — the top items. */
|
|
428
|
-
top(opts = {}) {
|
|
429
|
-
return this.board(opts);
|
|
430
|
-
}
|
|
431
|
-
/** A single item with its supports + pledges + integrity flag. */
|
|
432
|
-
item(id) {
|
|
433
|
-
return this.http.getJson(`/api/item/${encodeURIComponent(id)}`);
|
|
434
|
-
}
|
|
435
|
-
/** Known + discovered domains. */
|
|
436
|
-
domains() {
|
|
437
|
-
return this.http.getJson("/api/domains");
|
|
438
|
-
}
|
|
439
|
-
/** All quests (work-graph items). */
|
|
440
|
-
quests() {
|
|
441
|
-
return this.http.getJson("/api/quests");
|
|
442
|
-
}
|
|
443
|
-
/** A single quest with claims/submissions/payouts. */
|
|
444
|
-
quest(id) {
|
|
445
|
-
return this.http.getJson(`/api/quest/${encodeURIComponent(id)}`);
|
|
446
|
-
}
|
|
447
|
-
/** An address's on-chain-derived profile + identity + reputation. */
|
|
448
|
-
profile(addr, opts = {}) {
|
|
449
|
-
return this.http.getJson(`/api/profile/${encodeURIComponent(addr)}`, opts.live ? { live: 1 } : void 0);
|
|
450
|
-
}
|
|
451
|
-
/** Reputation leaderboard (top 50). */
|
|
452
|
-
leaderboard() {
|
|
453
|
-
return this.http.getJson("/api/leaderboard");
|
|
454
|
-
}
|
|
455
|
-
/** Network dashboard snapshot. */
|
|
456
|
-
network() {
|
|
457
|
-
return this.http.getJson("/api/network");
|
|
458
|
-
}
|
|
459
|
-
/** Network time-series for charts. */
|
|
460
|
-
networkSeries(range = "24h") {
|
|
461
|
-
return this.http.getJson("/api/network/series", { range });
|
|
462
|
-
}
|
|
463
|
-
/** Recent blocks with miner/reward. */
|
|
464
|
-
networkBlocks(limit = 15) {
|
|
465
|
-
return this.http.getJson("/api/network/blocks", { limit });
|
|
466
|
-
}
|
|
467
|
-
/** Per-miner hashrate estimate. */
|
|
468
|
-
miner(addr) {
|
|
469
|
-
return this.http.getJson(`/api/miner/${encodeURIComponent(addr)}`);
|
|
470
|
-
}
|
|
471
|
-
/** Recent activity feed. */
|
|
472
|
-
activity() {
|
|
473
|
-
return this.http.getJson("/api/activity");
|
|
474
|
-
}
|
|
475
|
-
/** The gamified cairn wall. */
|
|
476
|
-
wall() {
|
|
477
|
-
return this.http.getJson("/api/wall");
|
|
478
|
-
}
|
|
479
|
-
/** Server health + chain reachability. */
|
|
480
|
-
health() {
|
|
481
|
-
return this.http.getJson("/api/health");
|
|
482
|
-
}
|
|
483
|
-
// ---- writes (wallet-signed) -------------------------------------------
|
|
484
|
-
/**
|
|
485
|
-
* Publish a board item: compute the canonical payload hash, have the WALLET
|
|
486
|
-
* build/sign/submit the Propose (clear-signed approval), then register the
|
|
487
|
-
* off-chain content. The payload hash is byte-identical to what the cairn
|
|
488
|
-
* server verifies (csd-codec.payloadHash over { v:1, domain, title, body, links }).
|
|
489
|
-
*/
|
|
490
|
-
async propose(input) {
|
|
491
|
-
const wallet = this.requireWallet();
|
|
492
|
-
const content = {
|
|
493
|
-
v: 1,
|
|
494
|
-
domain: input.domain,
|
|
495
|
-
title: input.title,
|
|
496
|
-
body: input.body ?? "",
|
|
497
|
-
links: input.links ?? []
|
|
498
|
-
};
|
|
499
|
-
const ph = (0, import_csd_codec2.payloadHash)(content);
|
|
500
|
-
const uri = `cairn:v1:${ph.slice(2, 14)}`;
|
|
501
|
-
const lifetime = input.lifetimeEpochs ?? 720;
|
|
502
|
-
const height = await this.tipHeight();
|
|
503
|
-
const expiresEpoch = Math.floor(height / import_csd_codec3.EPOCH_LEN) + lifetime;
|
|
504
|
-
const fee = input.fee ?? import_csd_codec3.MIN_FEE_PROPOSE;
|
|
505
|
-
const r = await wallet.propose({ domain: content.domain, payloadHash: ph, uri, expiresEpoch, fee });
|
|
506
|
-
const txid3 = String(r.txid ?? "");
|
|
507
|
-
if (!txid3) throw new CairnError("Propose submitted but no txid was returned by the wallet.");
|
|
508
|
-
const reg = await this.tryRegister(content, txid3);
|
|
509
|
-
return { txid: txid3, payloadHash: ph, content, registered: reg.ok, registerError: reg.error };
|
|
510
|
-
}
|
|
511
|
-
/**
|
|
512
|
-
* Register off-chain content for an already-submitted proposal txid. Idempotent.
|
|
513
|
-
* Returns { ok } — the cairn server verifies sha256(canonical) == on-chain payload_hash.
|
|
514
|
-
*/
|
|
515
|
-
async registerContent(content, txid3) {
|
|
516
|
-
try {
|
|
517
|
-
const res = await this.http.postJson("/api/content", {
|
|
518
|
-
domain: content.domain,
|
|
519
|
-
title: content.title,
|
|
520
|
-
body: content.body,
|
|
521
|
-
links: content.links,
|
|
522
|
-
txid: txid3
|
|
523
|
-
});
|
|
524
|
-
return { ok: !!res.ok, error: res.ok ? void 0 : res.error };
|
|
525
|
-
} catch (e) {
|
|
526
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
/** Support/attest a proposal (wallet-signed; always clear-signed). */
|
|
530
|
-
async support(proposalId, opts = {}) {
|
|
531
|
-
const wallet = this.requireWallet();
|
|
532
|
-
return wallet.attest({
|
|
533
|
-
proposalId,
|
|
534
|
-
score: opts.score ?? 80,
|
|
535
|
-
confidence: opts.confidence ?? 70,
|
|
536
|
-
fee: opts.fee ?? import_csd_codec3.MIN_FEE_ATTEST
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
// ---- internal ----------------------------------------------------------
|
|
540
|
-
async tipHeight() {
|
|
541
|
-
const tip = await this.http.getJson("/api/rpc/tip");
|
|
542
|
-
const h = Number(tip?.height);
|
|
543
|
-
if (!Number.isFinite(h)) throw new CairnError("Could not read chain tip height for expiry computation.");
|
|
544
|
-
return h;
|
|
545
|
-
}
|
|
546
|
-
async tryRegister(content, txid3) {
|
|
547
|
-
const delays = [0, 1500, 3e3, 5e3];
|
|
548
|
-
let last = { ok: false, error: "not attempted" };
|
|
549
|
-
for (const d of delays) {
|
|
550
|
-
if (d) await sleep(d);
|
|
551
|
-
last = await this.registerContent(content, txid3);
|
|
552
|
-
if (last.ok) return last;
|
|
553
|
-
if (!/not found yet|not found/i.test(last.error ?? "")) return last;
|
|
554
|
-
}
|
|
555
|
-
return last;
|
|
556
|
-
}
|
|
557
|
-
};
|
|
558
|
-
function sleep(ms) {
|
|
559
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
560
|
-
}
|
|
561
|
-
|
|
562
434
|
// src/indexer.ts
|
|
563
|
-
var
|
|
435
|
+
var import_csd_codec2 = require("@inversealtruism/csd-codec");
|
|
564
436
|
var HEX64 = /^0x[0-9a-fA-F]{64}$/;
|
|
565
437
|
var MAX_SSE_BUF = 1024 * 1024;
|
|
566
438
|
function isValidMerkleProof(p) {
|
|
@@ -667,24 +539,34 @@ var IndexerClient = class {
|
|
|
667
539
|
}
|
|
668
540
|
let folds = false;
|
|
669
541
|
try {
|
|
670
|
-
folds = (0,
|
|
542
|
+
folds = (0, import_csd_codec2.verifyMerkleProof)(txid3, proof.pos, proof.merkle, proof.merkle_root);
|
|
671
543
|
} catch {
|
|
672
544
|
return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "malformed merkle proof" };
|
|
673
545
|
}
|
|
674
546
|
if (!folds) {
|
|
675
547
|
return { included: false, trustLevel: "not-found", blockHeight: proof.block_height, reason: "merkle branch does not fold to the claimed root" };
|
|
676
548
|
}
|
|
549
|
+
let unverified;
|
|
677
550
|
if (this.opts.headerMerkleAt) {
|
|
551
|
+
let onchain;
|
|
678
552
|
try {
|
|
679
|
-
|
|
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) {
|
|
680
559
|
if (norm(onchain) !== norm(proof.merkle_root)) {
|
|
681
560
|
return { included: false, trustLevel: "not-found", equivocation: true, blockHeight: proof.block_height, reason: "proof root != on-chain header merkle (indexer disagrees with chain)" };
|
|
682
561
|
}
|
|
683
562
|
return { included: true, trustLevel: "verified-inclusion", blockHeight: proof.block_height, pos: proof.pos, merkleRoot: proof.merkle_root };
|
|
684
|
-
} catch {
|
|
685
563
|
}
|
|
564
|
+
unverified ??= `header source returned no merkle root for ${proof.block_height}`;
|
|
565
|
+
} else {
|
|
566
|
+
unverified = "no header source configured (headerMerkleAt)";
|
|
686
567
|
}
|
|
687
|
-
|
|
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}` };
|
|
688
570
|
}
|
|
689
571
|
// ---- live streams ------------------------------------------------------
|
|
690
572
|
/** Stream all events (blocks, proposals, attestations, reorgs) via SSE. */
|
|
@@ -824,7 +706,7 @@ function norm(h) {
|
|
|
824
706
|
}
|
|
825
707
|
|
|
826
708
|
// src/content.ts
|
|
827
|
-
var
|
|
709
|
+
var import_csd_codec3 = require("@inversealtruism/csd-codec");
|
|
828
710
|
var ContentClient = class {
|
|
829
711
|
sources;
|
|
830
712
|
constructor(opts) {
|
|
@@ -838,29 +720,32 @@ var ContentClient = class {
|
|
|
838
720
|
* This is byte-identical to what the cairn server and wallet compute.
|
|
839
721
|
*/
|
|
840
722
|
prepare(obj) {
|
|
841
|
-
const canonical = (0,
|
|
723
|
+
const canonical = (0, import_csd_codec3.canonicalJson)(obj);
|
|
842
724
|
return {
|
|
843
|
-
payloadHash: (0,
|
|
725
|
+
payloadHash: (0, import_csd_codec3.payloadHash)(obj),
|
|
844
726
|
canonical,
|
|
845
727
|
bytes: new TextEncoder().encode(canonical)
|
|
846
728
|
};
|
|
847
729
|
}
|
|
848
|
-
/** Alias for `prepare()
|
|
730
|
+
/** Alias for `prepare()`. It does not publish anything; anchor the hash with a Propose. */
|
|
849
731
|
put(obj) {
|
|
850
732
|
return this.prepare(obj);
|
|
851
733
|
}
|
|
852
734
|
/** Just the payload hash for an object. */
|
|
853
735
|
hash(obj) {
|
|
854
|
-
return (0,
|
|
736
|
+
return (0, import_csd_codec3.payloadHash)(obj);
|
|
855
737
|
}
|
|
856
738
|
/**
|
|
857
739
|
* Fetch content by hash and VERIFY it self-certifies (sha256(bytes) == hash).
|
|
858
|
-
* Tries each configured source in order
|
|
859
|
-
*
|
|
860
|
-
*
|
|
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).
|
|
861
745
|
*/
|
|
862
746
|
async getBytes(hash) {
|
|
863
747
|
const h = normalizeHash(hash);
|
|
748
|
+
let mismatched = false;
|
|
864
749
|
for (const src of this.sources) {
|
|
865
750
|
let got = null;
|
|
866
751
|
try {
|
|
@@ -869,22 +754,29 @@ var ContentClient = class {
|
|
|
869
754
|
continue;
|
|
870
755
|
}
|
|
871
756
|
if (!got) continue;
|
|
872
|
-
if (!(0,
|
|
873
|
-
|
|
757
|
+
if (!(0, import_csd_codec3.verifyContentBytes)(got.bytes, h)) {
|
|
758
|
+
mismatched = true;
|
|
759
|
+
continue;
|
|
874
760
|
}
|
|
875
761
|
return got.bytes;
|
|
876
762
|
}
|
|
763
|
+
if (mismatched) throw new ContentVerificationError(h);
|
|
877
764
|
return null;
|
|
878
765
|
}
|
|
879
|
-
/** 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. */
|
|
880
768
|
async get(hash) {
|
|
881
769
|
const bytes = await this.getBytes(hash);
|
|
882
770
|
if (!bytes) return null;
|
|
883
|
-
|
|
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
|
+
}
|
|
884
776
|
}
|
|
885
777
|
/** Verify already-fetched bytes against a hash (no network). */
|
|
886
778
|
verify(bytes, hash) {
|
|
887
|
-
return (0,
|
|
779
|
+
return (0, import_csd_codec3.verifyContentBytes)(bytes, normalizeHash(hash));
|
|
888
780
|
}
|
|
889
781
|
};
|
|
890
782
|
function normalizeHash(hash) {
|
|
@@ -948,11 +840,46 @@ var RegistryClient = class {
|
|
|
948
840
|
// src/names.ts
|
|
949
841
|
var import_cairnx_core = require("@inversealtruism/cairnx-core");
|
|
950
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
|
+
}
|
|
951
870
|
var NamesClient = class {
|
|
952
871
|
constructor(http) {
|
|
953
872
|
this.http = http;
|
|
954
873
|
}
|
|
955
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
|
+
}
|
|
956
883
|
/** Resolve a .csd name for DISPLAY (server-trusted; see the module trust note). null = unknown/invalid name. */
|
|
957
884
|
async resolve(name) {
|
|
958
885
|
if (!nameOk(name)) return null;
|
|
@@ -973,15 +900,21 @@ var NamesClient = class {
|
|
|
973
900
|
throw e;
|
|
974
901
|
}
|
|
975
902
|
}
|
|
976
|
-
/** 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. */
|
|
977
905
|
async nameHistory(name) {
|
|
978
906
|
if (!nameOk(name)) return null;
|
|
907
|
+
const path = `/trade/api/cairnx/name-history/${encodeURIComponent(name)}`;
|
|
908
|
+
let h;
|
|
979
909
|
try {
|
|
980
|
-
|
|
910
|
+
h = await this.http.getJson(path);
|
|
981
911
|
} catch (e) {
|
|
982
912
|
if (e?.status === 404) return null;
|
|
983
913
|
throw e;
|
|
984
914
|
}
|
|
915
|
+
const p = nameHistoryProblem(h, name);
|
|
916
|
+
if (p) throw this.malformed(path, p);
|
|
917
|
+
return h;
|
|
985
918
|
}
|
|
986
919
|
/** Reverse lookup: an address's chosen primary .csd name (null name = none set). */
|
|
987
920
|
async primary(addr) {
|
|
@@ -1005,29 +938,40 @@ var NamesClient = class {
|
|
|
1005
938
|
}
|
|
1006
939
|
/** All registered names (display list; short-edge-cached server-side). */
|
|
1007
940
|
names() {
|
|
1008
|
-
return this.
|
|
1009
|
-
}
|
|
1010
|
-
/** All deployed tokens. */
|
|
1011
|
-
tokens() {
|
|
1012
|
-
|
|
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;
|
|
1013
952
|
}
|
|
1014
|
-
/** 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. */
|
|
1015
954
|
async token(ticker) {
|
|
1016
|
-
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;
|
|
1017
958
|
try {
|
|
1018
|
-
|
|
959
|
+
t = await this.http.getJson(path);
|
|
1019
960
|
} catch (e) {
|
|
1020
961
|
if (e?.status === 404) return null;
|
|
1021
962
|
throw e;
|
|
1022
963
|
}
|
|
964
|
+
const p = tokenProblem(t);
|
|
965
|
+
if (p) throw this.malformed(path, p);
|
|
966
|
+
return t;
|
|
1023
967
|
}
|
|
1024
968
|
/** Open offers (the market's display list). */
|
|
1025
969
|
offers() {
|
|
1026
|
-
return this.
|
|
970
|
+
return this.list(`/trade/api/cairnx/offers`);
|
|
1027
971
|
}
|
|
1028
972
|
/** Open bids. */
|
|
1029
973
|
bids() {
|
|
1030
|
-
return this.
|
|
974
|
+
return this.list(`/trade/api/cairnx/bids`);
|
|
1031
975
|
}
|
|
1032
976
|
/** The full canonical CairnX state (large; prefer the scoped reads above). */
|
|
1033
977
|
state() {
|
|
@@ -1260,7 +1204,7 @@ var WalletConnection = class {
|
|
|
1260
1204
|
send(params) {
|
|
1261
1205
|
return this.call(() => this.provider.send(params), "send").then(unwrapWrite);
|
|
1262
1206
|
}
|
|
1263
|
-
/** Low-level Propose (always prompts).
|
|
1207
|
+
/** Low-level Propose (always prompts). The caller computes the payload hash, uri and expiry epoch. */
|
|
1264
1208
|
propose(params) {
|
|
1265
1209
|
return this.call(() => this.provider.propose(params), "propose").then(unwrapWrite);
|
|
1266
1210
|
}
|
|
@@ -1291,9 +1235,10 @@ async function connect(opts = {}) {
|
|
|
1291
1235
|
|
|
1292
1236
|
// src/fillverify.ts
|
|
1293
1237
|
var import_csd_client3 = require("@inversealtruism/csd-client");
|
|
1294
|
-
var
|
|
1238
|
+
var import_csd_codec4 = require("@inversealtruism/csd-codec");
|
|
1295
1239
|
var import_cairnx_core2 = require("@inversealtruism/cairnx-core");
|
|
1296
1240
|
var ADDR = /^0x[0-9a-f]{40}$/;
|
|
1241
|
+
var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
|
|
1297
1242
|
var HASH = /^0x[0-9a-f]{64}$/;
|
|
1298
1243
|
var COINBASE = "0x" + "00".repeat(32);
|
|
1299
1244
|
function bindPlannedSums(planned, proven) {
|
|
@@ -1330,7 +1275,7 @@ async function provenAuthor(client, provenTx) {
|
|
|
1330
1275
|
} catch {
|
|
1331
1276
|
return null;
|
|
1332
1277
|
}
|
|
1333
|
-
if (String((0,
|
|
1278
|
+
if (String((0, import_csd_codec4.txid)(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
|
|
1334
1279
|
const out = src.outputs?.[Number(in0.vout)];
|
|
1335
1280
|
const spk = out?.scriptPubkey ? String(out.scriptPubkey).toLowerCase() : null;
|
|
1336
1281
|
return spk && ADDR.test(spk) ? spk : null;
|
|
@@ -1344,13 +1289,17 @@ async function preverifyOffer(opts) {
|
|
|
1344
1289
|
} catch (e) {
|
|
1345
1290
|
return { ok: false, trust: "unverified", transient: true, reason: `couldn't reach the chain to verify the offer (${e?.message ?? e})` };
|
|
1346
1291
|
}
|
|
1347
|
-
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` };
|
|
1348
1296
|
return { ok: false, trust: "unverified", transient: true, blockHeight: incl.blockHeight, reason: incl.reason ?? `offer not merkle-proven on-chain (${incl.trustLevel})` };
|
|
1297
|
+
}
|
|
1349
1298
|
const blockHeight = Number(incl.blockHeight);
|
|
1350
1299
|
let provenTx, provenId;
|
|
1351
1300
|
try {
|
|
1352
1301
|
provenTx = (0, import_csd_client3.rpcTxToTx)(incl.tx);
|
|
1353
|
-
provenId = (0,
|
|
1302
|
+
provenId = (0, import_csd_codec4.txid)(provenTx);
|
|
1354
1303
|
} catch {
|
|
1355
1304
|
return { ok: false, trust: "unverified", reason: "the on-chain offer tx couldn't be re-derived" };
|
|
1356
1305
|
}
|
|
@@ -1366,7 +1315,7 @@ async function preverifyOffer(opts) {
|
|
|
1366
1315
|
rec = null;
|
|
1367
1316
|
}
|
|
1368
1317
|
if (!rec || rec.t !== "offer") return { ok: false, trust: "unverified", reason: "the offer record doesn't bind to its on-chain commitment" };
|
|
1369
|
-
if (String((0,
|
|
1318
|
+
if (String((0, import_csd_codec4.payloadHash)(rec)).toLowerCase() !== String(incl.appPayloadHash).toLowerCase())
|
|
1370
1319
|
return { ok: false, trust: "unverified", reason: "the offer record doesn't match the on-chain (merkle-proven) commitment" };
|
|
1371
1320
|
const seller = await provenAuthor(opts.client, provenTx);
|
|
1372
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" };
|
|
@@ -1381,8 +1330,15 @@ async function preverifyOffer(opts) {
|
|
|
1381
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" };
|
|
1382
1331
|
let outputPlan;
|
|
1383
1332
|
let sumsMismatch;
|
|
1384
|
-
|
|
1385
|
-
|
|
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 {
|
|
1386
1342
|
const provenState = {
|
|
1387
1343
|
id,
|
|
1388
1344
|
seller,
|
|
@@ -1390,7 +1346,7 @@ async function preverifyOffer(opts) {
|
|
|
1390
1346
|
want: { ...offerRec.want, payto },
|
|
1391
1347
|
taker: offerRec.taker,
|
|
1392
1348
|
bid: offerRec.bid,
|
|
1393
|
-
status:
|
|
1349
|
+
status: servedStatus,
|
|
1394
1350
|
expiresEpoch: 0,
|
|
1395
1351
|
height: blockHeight,
|
|
1396
1352
|
feeBps: terms.feeBps
|
|
@@ -1399,16 +1355,25 @@ async function preverifyOffer(opts) {
|
|
|
1399
1355
|
const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
|
|
1400
1356
|
if (plan.kind === "csd-outputs") {
|
|
1401
1357
|
outputPlan = plan.outputs;
|
|
1402
|
-
if (opts.plannedOutputs
|
|
1358
|
+
if (has(opts.plannedOutputs)) {
|
|
1403
1359
|
sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
|
|
1360
|
+
planBound = true;
|
|
1361
|
+
}
|
|
1404
1362
|
} else if (plan.kind === "undeliverable") {
|
|
1405
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})`;
|
|
1406
1366
|
}
|
|
1407
1367
|
} catch (e) {
|
|
1408
1368
|
sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
|
|
1409
1369
|
}
|
|
1410
1370
|
}
|
|
1411
|
-
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 };
|
|
1412
1377
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
1413
1378
|
const so = opts.servedOffer;
|
|
1414
1379
|
if (so?.want?.payto === void 0 || so?.want?.payto === null)
|
|
@@ -1433,8 +1398,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
|
|
|
1433
1398
|
var CairnController = class {
|
|
1434
1399
|
state = DISCONNECTED;
|
|
1435
1400
|
conn = null;
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
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.
|
|
1438
1404
|
epoch = 0;
|
|
1439
1405
|
listeners = /* @__PURE__ */ new Set();
|
|
1440
1406
|
getWalletFn;
|
|
@@ -1465,6 +1431,7 @@ var CairnController = class {
|
|
|
1465
1431
|
}
|
|
1466
1432
|
onAccounts = (accounts) => {
|
|
1467
1433
|
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
1434
|
+
this.epoch++;
|
|
1468
1435
|
this.setState({ status: "disconnected", account: null });
|
|
1469
1436
|
return;
|
|
1470
1437
|
}
|
|
@@ -1476,6 +1443,7 @@ var CairnController = class {
|
|
|
1476
1443
|
}
|
|
1477
1444
|
};
|
|
1478
1445
|
onDisconnect = () => {
|
|
1446
|
+
this.epoch++;
|
|
1479
1447
|
this.setState({ status: "disconnected", account: null });
|
|
1480
1448
|
};
|
|
1481
1449
|
// Detach the provider listeners (ghost-reconnect fix). Idempotent + best-effort.
|
|
@@ -1530,7 +1498,7 @@ var CairnController = class {
|
|
|
1530
1498
|
};
|
|
1531
1499
|
|
|
1532
1500
|
// src/index.ts
|
|
1533
|
-
var DEFAULT_SPV_CHECKPOINT = { height:
|
|
1501
|
+
var DEFAULT_SPV_CHECKPOINT = { height: 115403, hash: "0x00000000000052e29166597d598827b137af1dea7542123b848637fee0b67347" };
|
|
1534
1502
|
var DEFAULT_CAIRN = "https://cairn-substrate.com";
|
|
1535
1503
|
var SPV_HTTP_TIMEOUT_MS = 15e3;
|
|
1536
1504
|
var SPV_HTTP_MAX_RETRIES = 4;
|
|
@@ -1542,7 +1510,10 @@ var spvIsTransient = (e) => {
|
|
|
1542
1510
|
return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
|
|
1543
1511
|
};
|
|
1544
1512
|
var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1545
|
-
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;
|
|
1546
1517
|
var Cairn = class {
|
|
1547
1518
|
config;
|
|
1548
1519
|
chain;
|
|
@@ -1551,7 +1522,6 @@ var Cairn = class {
|
|
|
1551
1522
|
registry;
|
|
1552
1523
|
/** .csd names + CairnX market READS (server-trusted display reads; see names.ts trust note). */
|
|
1553
1524
|
names;
|
|
1554
|
-
board;
|
|
1555
1525
|
wallet;
|
|
1556
1526
|
cairnHttp;
|
|
1557
1527
|
// Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
|
|
@@ -1577,7 +1547,6 @@ var Cairn = class {
|
|
|
1577
1547
|
this._spvCp = config.spvCheckpoint ?? DEFAULT_SPV_CHECKPOINT;
|
|
1578
1548
|
this.chain = new Chain({ rpcUrl: rpc, fetch, timeoutMs });
|
|
1579
1549
|
this.wallet = config.wallet;
|
|
1580
|
-
this.board = new BoardClient(this.cairnHttp, this.wallet);
|
|
1581
1550
|
this.index = new IndexerClient(indexerHttp, {
|
|
1582
1551
|
fetch,
|
|
1583
1552
|
WebSocketImpl: config.WebSocketImpl,
|
|
@@ -1595,16 +1564,17 @@ var Cairn = class {
|
|
|
1595
1564
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
1596
1565
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
1597
1566
|
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
1598
|
-
* withSpvReseed.
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
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.
|
|
1601
1570
|
*/
|
|
1602
1571
|
async verifiedHeaderMerkleAt(height) {
|
|
1603
1572
|
if (!Number.isInteger(height) || height < this._spvCp.height)
|
|
1604
1573
|
throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
|
|
1605
1574
|
return this.withSpvReseed(async (lc) => {
|
|
1606
|
-
const haveTo = lc
|
|
1575
|
+
const haveTo = spvTipOf(lc);
|
|
1607
1576
|
if (haveTo < height) await lc.sync(height);
|
|
1577
|
+
else await this.recheckCachedHeader(lc, height, haveTo);
|
|
1608
1578
|
const vh = lc.chain[height - lc.baseHeight];
|
|
1609
1579
|
if (!vh) throw new Error(`light client has no verified header at ${height}`);
|
|
1610
1580
|
return String(vh.header.merkle);
|
|
@@ -1616,11 +1586,8 @@ var Cairn = class {
|
|
|
1616
1586
|
// this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
|
|
1617
1587
|
// (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
|
|
1618
1588
|
// on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
|
|
1619
|
-
// failure propagates fail-closed; there is no loop.
|
|
1620
|
-
//
|
|
1621
|
-
// branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
|
|
1622
|
-
// for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
|
|
1623
|
-
// 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.
|
|
1624
1591
|
async withSpvReseed(op) {
|
|
1625
1592
|
const lc = await this.seededSpvLight();
|
|
1626
1593
|
try {
|
|
@@ -1633,6 +1600,16 @@ var Cairn = class {
|
|
|
1633
1600
|
return out;
|
|
1634
1601
|
}
|
|
1635
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
|
+
}
|
|
1636
1613
|
// Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
|
|
1637
1614
|
// the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
|
|
1638
1615
|
// dirty the shared field while an attempt is in flight).
|
|
@@ -1645,7 +1622,7 @@ var Cairn = class {
|
|
|
1645
1622
|
await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
|
|
1646
1623
|
try {
|
|
1647
1624
|
const tipHeight = Number((await this.chain.tip()).height);
|
|
1648
|
-
const have = lc
|
|
1625
|
+
const have = spvTipOf(lc);
|
|
1649
1626
|
if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
|
|
1650
1627
|
} catch {
|
|
1651
1628
|
}
|
|
@@ -1690,6 +1667,7 @@ var Cairn = class {
|
|
|
1690
1667
|
if (!g) throw new Error("No fetch available for SPV header sync - pass `fetch` in the SDK options.");
|
|
1691
1668
|
const doFetch = this.config.fetch ? this.config.fetch : g.bind(globalThis);
|
|
1692
1669
|
const url = `${this._cairnBase}/api/headers/${from}/${count}`;
|
|
1670
|
+
const budget = new RetryWaitBudget(SPV_RETRY_WAIT_BUDGET_MS);
|
|
1693
1671
|
for (let attempt = 0; ; attempt++) {
|
|
1694
1672
|
let res;
|
|
1695
1673
|
let parsed;
|
|
@@ -1701,8 +1679,9 @@ var Cairn = class {
|
|
|
1701
1679
|
parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
|
|
1702
1680
|
}
|
|
1703
1681
|
} catch (e) {
|
|
1704
|
-
|
|
1705
|
-
|
|
1682
|
+
const waitMs = attempt < SPV_HTTP_MAX_RETRIES ? budget.take(spvBackoffMs(attempt), 0) : null;
|
|
1683
|
+
if (waitMs === null) throw e;
|
|
1684
|
+
await spvSleep(waitMs);
|
|
1706
1685
|
continue;
|
|
1707
1686
|
} finally {
|
|
1708
1687
|
clearTimeout(timer);
|
|
@@ -1715,7 +1694,9 @@ var Cairn = class {
|
|
|
1715
1694
|
if ((res.status === 429 || res.status === 502 || res.status === 503) && attempt < SPV_HTTP_MAX_RETRIES) {
|
|
1716
1695
|
res.body?.cancel?.().catch(() => {
|
|
1717
1696
|
});
|
|
1718
|
-
|
|
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);
|
|
1719
1700
|
continue;
|
|
1720
1701
|
}
|
|
1721
1702
|
throw new Error(`/api/headers ${res.status}`);
|
|
@@ -1738,12 +1719,20 @@ var Cairn = class {
|
|
|
1738
1719
|
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
1739
1720
|
*/
|
|
1740
1721
|
async verifyOfferForFill(offerId, servedOffer, opts) {
|
|
1741
|
-
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
|
+
};
|
|
1742
1731
|
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
|
|
1743
1732
|
}
|
|
1744
1733
|
/**
|
|
1745
|
-
* Detect + connect the Cairn Wallet (browser only)
|
|
1746
|
-
*
|
|
1734
|
+
* Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
|
|
1735
|
+
* Resolves to the connected address.
|
|
1747
1736
|
*/
|
|
1748
1737
|
async connect(opts = {}) {
|
|
1749
1738
|
const { wallet, address } = await connect(opts);
|
|
@@ -1753,7 +1742,6 @@ var Cairn = class {
|
|
|
1753
1742
|
/** Attach an already-connected wallet (e.g. one you constructed yourself). */
|
|
1754
1743
|
attachWallet(wallet) {
|
|
1755
1744
|
this.wallet = wallet;
|
|
1756
|
-
this.board = new BoardClient(this.cairnHttp, wallet);
|
|
1757
1745
|
}
|
|
1758
1746
|
/** True if a wallet is attached. */
|
|
1759
1747
|
get connected() {
|
|
@@ -1762,7 +1750,6 @@ var Cairn = class {
|
|
|
1762
1750
|
};
|
|
1763
1751
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1764
1752
|
0 && (module.exports = {
|
|
1765
|
-
BoardClient,
|
|
1766
1753
|
Cairn,
|
|
1767
1754
|
CairnController,
|
|
1768
1755
|
CairnError,
|