@inversealtruism/cairn-sdk 0.8.0 → 0.8.2
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 +3 -3
- package/dist/chain.cjs +83 -3
- package/dist/chain.d.cts +9 -4
- package/dist/chain.d.ts +9 -4
- package/dist/chain.js +6 -1
- package/dist/{chunk-DTPADO7N.js → chunk-2RR4ZJPQ.js} +1 -1
- package/dist/{chunk-UAPWZZQK.js → chunk-37OHCH7R.js} +20 -8
- package/dist/{chunk-JXBO4LV6.js → chunk-AHPXOYCC.js} +1 -1
- package/dist/{chunk-WWO3FXHG.js → chunk-EVEBQSDK.js} +19 -14
- package/dist/{chunk-C452UODV.js → chunk-IPBMMJ3Z.js} +26 -4
- package/dist/{chunk-CI7YODPP.js → chunk-NRCTL7OI.js} +43 -3
- package/dist/connect.cjs +34 -20
- package/dist/connect.js +2 -2
- package/dist/content.cjs +1 -1
- package/dist/content.js +2 -2
- package/dist/controller.cjs +34 -20
- package/dist/controller.js +3 -3
- package/dist/index.cjs +268 -99
- package/dist/index.d.cts +77 -32
- package/dist/index.d.ts +77 -32
- package/dist/index.js +190 -82
- package/dist/indexer.cjs +98 -3
- package/dist/indexer.d.cts +5 -1
- package/dist/indexer.d.ts +5 -1
- package/dist/indexer.js +2 -1
- package/dist/react.cjs +34 -20
- package/dist/react.js +3 -3
- package/package.json +10 -7
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Chain,
|
|
3
3
|
LightClient
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-IPBMMJ3Z.js";
|
|
5
5
|
import {
|
|
6
6
|
ContentClient
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-2RR4ZJPQ.js";
|
|
8
8
|
import {
|
|
9
9
|
IndexerClient
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-NRCTL7OI.js";
|
|
11
11
|
import {
|
|
12
12
|
CairnController
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-AHPXOYCC.js";
|
|
14
14
|
import {
|
|
15
15
|
WalletConnection,
|
|
16
16
|
connect,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
discoverProviders,
|
|
19
19
|
getWallet,
|
|
20
20
|
isInstalled
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-EVEBQSDK.js";
|
|
22
22
|
import {
|
|
23
23
|
CairnError,
|
|
24
24
|
ContentVerificationError,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
errorCode,
|
|
33
33
|
mapProviderError,
|
|
34
34
|
mapSubmitResultError
|
|
35
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-37OHCH7R.js";
|
|
36
36
|
|
|
37
37
|
// src/http.ts
|
|
38
38
|
var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
@@ -358,20 +358,23 @@ var NamesClient = class {
|
|
|
358
358
|
// src/fillverify.ts
|
|
359
359
|
import { rpcTxToTx } from "@inversealtruism/csd-client";
|
|
360
360
|
import { txid, payloadHash } from "@inversealtruism/csd-codec";
|
|
361
|
+
import { authorOf } from "@inversealtruism/csd-crypto";
|
|
361
362
|
import {
|
|
362
|
-
DOMAIN,
|
|
363
363
|
parseRecord,
|
|
364
364
|
feeBpsAt,
|
|
365
365
|
bindOfferTerms,
|
|
366
366
|
provenOfferTerms,
|
|
367
|
+
provenOfferRefusal,
|
|
367
368
|
fillOutputPlan,
|
|
368
369
|
isTokenWant,
|
|
369
370
|
fillEndorsement,
|
|
370
371
|
fillIsSafe,
|
|
371
372
|
requiredFillOutputs,
|
|
372
373
|
previewFill,
|
|
373
|
-
|
|
374
|
-
|
|
374
|
+
offerExpiryHeightOf,
|
|
375
|
+
offerFillTooLate,
|
|
376
|
+
FILL_TIP_MARGIN,
|
|
377
|
+
fillTargetId as fillTargetOf
|
|
375
378
|
} from "@inversealtruism/cairnx-core";
|
|
376
379
|
var ADDR = /^0x[0-9a-f]{40}$/;
|
|
377
380
|
var BELOW_BASE = /^tx below the synced base \((\d+)\)/;
|
|
@@ -412,13 +415,116 @@ async function provenAuthor(client, provenTx) {
|
|
|
412
415
|
return null;
|
|
413
416
|
}
|
|
414
417
|
if (String(txid(src)).toLowerCase() !== String(in0.prevTxid).toLowerCase()) return null;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
+
return authorOf(provenTx, src.outputs?.[Number(in0.vout)]?.scriptPubkey);
|
|
419
|
+
}
|
|
420
|
+
var has = (v) => v !== void 0 && v !== null;
|
|
421
|
+
function fillInputs(o) {
|
|
422
|
+
const base = { pay: o.pay, plannedOutputs: o.plannedOutputs };
|
|
423
|
+
if (!has(o.me) && !has(o.tip) && !has(o.fillTargetId)) return base;
|
|
424
|
+
const me = typeof o.me === "string" ? o.me.toLowerCase() : "";
|
|
425
|
+
const target = has(o.fillTargetId) ? String(o.fillTargetId).toLowerCase() : void 0;
|
|
426
|
+
if (!ADDR.test(me) || !Number.isSafeInteger(o.tip) || o.tip < 0 || target !== void 0 && !HASH.test(target) || !has(o.pay))
|
|
427
|
+
return "invalid fill inputs: pass me (your 0x address), tip (the chain tip height) and pay together; fillTargetId, when given, is a 0x txid";
|
|
428
|
+
return { ...base, me, tip: o.tip, fillTargetId: target };
|
|
429
|
+
}
|
|
430
|
+
function judgeFill(rec, proven, served, f) {
|
|
431
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
432
|
+
const status = served?.status;
|
|
433
|
+
const terms = proven.terms;
|
|
434
|
+
const state = {
|
|
435
|
+
id: proven.id,
|
|
436
|
+
seller: proven.seller,
|
|
437
|
+
give: rec.give,
|
|
438
|
+
want: { ...rec.want, payto: proven.payto },
|
|
439
|
+
taker: rec.taker,
|
|
440
|
+
bid: rec.bid,
|
|
441
|
+
min: rec.min,
|
|
442
|
+
status,
|
|
443
|
+
expiresEpoch: proven.expiresEpoch,
|
|
444
|
+
height: terms.height,
|
|
445
|
+
feeBps: terms.feeBps,
|
|
446
|
+
paid: str(served?.paid),
|
|
447
|
+
delivered: str(served?.delivered),
|
|
448
|
+
claimedBy: str(served?.claimedBy),
|
|
449
|
+
claimUntilHeight: typeof served?.claimUntilHeight === "number" ? served.claimUntilHeight : void 0,
|
|
450
|
+
claimTxid: str(served?.claimTxid)
|
|
451
|
+
};
|
|
452
|
+
const j = { planBound: false };
|
|
453
|
+
const err = (e) => e?.message ?? String(e);
|
|
454
|
+
const token = isTokenWant(rec.want);
|
|
455
|
+
const progress = state.paid !== void 0 && state.delivered !== void 0;
|
|
456
|
+
if (typeof status === "string" && status !== "open")
|
|
457
|
+
j.refusal = `the resolver serves this offer as ${status}, not open - refusing (CairnX does not apply a fill of it)`;
|
|
458
|
+
if (token && has(f.plannedOutputs)) {
|
|
459
|
+
const legs = bindPlannedSums(f.plannedOutputs, []);
|
|
460
|
+
if (legs) j.refusal ??= `the offer is token-priced, so a fill moves no CSD: ${legs}`;
|
|
461
|
+
}
|
|
462
|
+
if (!has(f.pay)) j.planReason = "no `pay` was given, so no output plan was sized";
|
|
463
|
+
else if (j.refusal) j.planReason = "the fill is refused, so no output plan was sized";
|
|
464
|
+
else if (token) j.planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
|
|
465
|
+
else if (rec.min !== void 0) {
|
|
466
|
+
j.planReason = "the offer is partially fillable: its running paid/delivered is not provable here (the wallet's fill-SPV sizes it)";
|
|
467
|
+
if (typeof status === "string" && progress) {
|
|
468
|
+
try {
|
|
469
|
+
const p = previewFill(state, f.pay);
|
|
470
|
+
if (!p.deliverable) j.refusal = `this pay would not deliver against the offer's served progress (${p.reason}) - refusing (the CSD would be lost)`;
|
|
471
|
+
} catch (e) {
|
|
472
|
+
j.refusal = `couldn't size the fill against the offer (${err(e)}) - refusing`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
} else if (typeof status !== "string") j.planReason = "no served offer status: liveness is not provable here, so no output plan was sized (pass the resolver's offer object)";
|
|
476
|
+
else {
|
|
477
|
+
try {
|
|
478
|
+
const plan = fillOutputPlan(state, f.pay);
|
|
479
|
+
if (plan.kind === "csd-outputs") {
|
|
480
|
+
j.outputPlan = plan.outputs;
|
|
481
|
+
if (has(f.plannedOutputs)) {
|
|
482
|
+
j.refusal = bindPlannedSums(f.plannedOutputs, plan.outputs);
|
|
483
|
+
j.planBound = true;
|
|
484
|
+
}
|
|
485
|
+
} else if (plan.kind === "undeliverable") {
|
|
486
|
+
j.refusal = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
|
|
487
|
+
} else {
|
|
488
|
+
j.planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
|
|
489
|
+
}
|
|
490
|
+
} catch (e) {
|
|
491
|
+
j.refusal = `couldn't size the fill against the proven offer (${err(e)}) - refusing rather than sizing from served fields`;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (f.me !== void 0 && f.tip !== void 0 && has(f.pay)) {
|
|
495
|
+
const unplan = () => {
|
|
496
|
+
delete j.outputPlan;
|
|
497
|
+
j.planBound = false;
|
|
498
|
+
j.planReason = "the fill is refused, so no output plan was sized";
|
|
499
|
+
};
|
|
500
|
+
if (offerFillTooLate(proven.expiresAt, f.tip)) {
|
|
501
|
+
j.refusal ??= `the offer's last live block ${proven.expiresAt - 1} leaves less than the ${FILL_TIP_MARGIN}-block cushion above tip ${f.tip}, so the fill is too close to the offer's expiry - do not sign it`;
|
|
502
|
+
unplan();
|
|
503
|
+
}
|
|
504
|
+
if (typeof status !== "string") j.refusal ??= "the fill endorsement needs the resolver's offer object (its status, claim and progress) - pass servedOffer";
|
|
505
|
+
else if (rec.min !== void 0 && !progress) j.refusal ??= "the served offer is missing paid or delivered (a partial offer's progress) - pass the resolver's full offer object";
|
|
506
|
+
else {
|
|
507
|
+
try {
|
|
508
|
+
const e = fillEndorsement(state, f.me, f.pay, f.tip, f.fillTargetId !== void 0 ? { fillTargetId: f.fillTargetId } : void 0);
|
|
509
|
+
j.endorsement = { verdict: e.verdict, reason: e.reason };
|
|
510
|
+
j.fillTarget = fillTargetOf(state, f.tip);
|
|
511
|
+
if (e.verdict === "refused") {
|
|
512
|
+
j.refusal ??= `CairnX would refuse this fill (${e.reason}) - do not sign it`;
|
|
513
|
+
unplan();
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
j.refusal ??= `couldn't judge the fill against the offer (${err(e)}) - refusing`;
|
|
517
|
+
unplan();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return j;
|
|
418
522
|
}
|
|
419
523
|
async function preverifyOffer(opts) {
|
|
420
524
|
const id = String(opts.offerId).toLowerCase();
|
|
421
525
|
if (!HASH.test(id)) return { ok: false, trust: "unverified", reason: "invalid offer id" };
|
|
526
|
+
const fill = fillInputs(opts);
|
|
527
|
+
if (typeof fill === "string") return { ok: false, trust: "unverified", reason: fill };
|
|
422
528
|
let incl;
|
|
423
529
|
try {
|
|
424
530
|
incl = await opts.light.verifyTxInclusion(id);
|
|
@@ -442,8 +548,6 @@ async function preverifyOffer(opts) {
|
|
|
442
548
|
if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
|
|
443
549
|
const app = incl.tx.app;
|
|
444
550
|
if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
|
|
445
|
-
if (String(app.domain) !== DOMAIN)
|
|
446
|
-
return { ok: false, trust: "unverified", reason: `the offer tx is not a ${DOMAIN} Propose (wrong settlement domain)` };
|
|
447
551
|
let rec;
|
|
448
552
|
try {
|
|
449
553
|
rec = parseRecord(app.uri, incl.appPayloadHash);
|
|
@@ -456,60 +560,23 @@ async function preverifyOffer(opts) {
|
|
|
456
560
|
const seller = await provenAuthor(opts.client, provenTx);
|
|
457
561
|
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" };
|
|
458
562
|
const offerRec = rec;
|
|
563
|
+
const ee = app.expires_epoch;
|
|
564
|
+
const refusal = provenOfferRefusal(offerRec, { domain: String(app.domain), height: blockHeight, expiresEpoch: typeof ee === "bigint" ? ee : Number(ee), seller });
|
|
565
|
+
if (refusal) return { ok: false, trust: "verified", blockHeight, reason: `CairnX rejects this offer (${refusal}) - a fill would move CSD for nothing` };
|
|
459
566
|
const w = rec.want ?? {};
|
|
460
567
|
const payto = w.payto && ADDR.test(String(w.payto).toLowerCase()) ? String(w.payto).toLowerCase() : seller;
|
|
461
568
|
const terms = provenOfferTerms(offerRec, blockHeight);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
if (!Number.isSafeInteger(provenExpiresEpoch) || epochOf(blockHeight) > provenExpiresEpoch)
|
|
466
|
-
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" };
|
|
467
|
-
let outputPlan;
|
|
468
|
-
let sumsMismatch;
|
|
469
|
-
let planReason;
|
|
470
|
-
let planBound = false;
|
|
471
|
-
const has = (v) => v !== void 0 && v !== null;
|
|
472
|
-
const servedStatus = opts.servedOffer?.status;
|
|
473
|
-
if (!has(opts.pay)) planReason = "no `pay` was given, so no output plan was sized";
|
|
474
|
-
else if (isTokenWant(offerRec.want)) planReason = "the offer is token-priced: the payment is a token transfer, not CSD outputs";
|
|
475
|
-
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)";
|
|
476
|
-
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)";
|
|
477
|
-
else {
|
|
478
|
-
const provenState = {
|
|
479
|
-
id,
|
|
480
|
-
seller,
|
|
481
|
-
give: offerRec.give,
|
|
482
|
-
want: { ...offerRec.want, payto },
|
|
483
|
-
taker: offerRec.taker,
|
|
484
|
-
bid: offerRec.bid,
|
|
485
|
-
status: servedStatus,
|
|
486
|
-
expiresEpoch: 0,
|
|
487
|
-
height: blockHeight,
|
|
488
|
-
feeBps: terms.feeBps
|
|
489
|
-
};
|
|
490
|
-
try {
|
|
491
|
-
const plan = fillOutputPlan(provenState, opts.pay);
|
|
492
|
-
if (plan.kind === "csd-outputs") {
|
|
493
|
-
outputPlan = plan.outputs;
|
|
494
|
-
if (has(opts.plannedOutputs)) {
|
|
495
|
-
sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
|
|
496
|
-
planBound = true;
|
|
497
|
-
}
|
|
498
|
-
} else if (plan.kind === "undeliverable") {
|
|
499
|
-
sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
|
|
500
|
-
} else {
|
|
501
|
-
planReason = `the proven offer settles without CSD outputs (${plan.kind})`;
|
|
502
|
-
}
|
|
503
|
-
} catch (e) {
|
|
504
|
-
sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
569
|
+
const expiresEpoch = Number(ee);
|
|
570
|
+
const expiresAt = offerExpiryHeightOf(expiresEpoch, blockHeight);
|
|
571
|
+
const j = judgeFill(offerRec, { id, seller, payto, terms, expiresEpoch, expiresAt }, opts.servedOffer, fill);
|
|
507
572
|
const planFields = {
|
|
508
|
-
...outputPlan ? { outputPlan } : {},
|
|
509
|
-
...has(opts.
|
|
510
|
-
|
|
573
|
+
...j.outputPlan ? { outputPlan: j.outputPlan } : {},
|
|
574
|
+
...has(opts.pay) ? { sized: j.outputPlan !== void 0 } : {},
|
|
575
|
+
...has(opts.plannedOutputs) ? { planBound: j.planBound } : {},
|
|
576
|
+
...!j.outputPlan && j.planReason && (has(opts.pay) || has(opts.plannedOutputs)) ? { planReason: j.planReason } : {},
|
|
577
|
+
...j.endorsement ? { endorsement: j.endorsement, fillTarget: j.fillTarget } : {}
|
|
511
578
|
};
|
|
512
|
-
const base = { payto, seller, terms, blockHeight, ...planFields };
|
|
579
|
+
const base = { payto, seller, terms, blockHeight, expiresEpoch, expiresAt, ...planFields };
|
|
513
580
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
514
581
|
const so = opts.servedOffer;
|
|
515
582
|
if (so?.want?.payto === void 0 || so?.want?.payto === null)
|
|
@@ -520,12 +587,10 @@ async function preverifyOffer(opts) {
|
|
|
520
587
|
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 };
|
|
521
588
|
if (String(so.seller).toLowerCase() !== seller)
|
|
522
589
|
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 };
|
|
523
|
-
if (isTokenWant(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
|
|
524
|
-
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 };
|
|
525
590
|
if (bindOfferTerms(opts.servedOffer, terms, { give: true, wantType: true }))
|
|
526
|
-
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 };
|
|
591
|
+
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's type or token price - a lying resolver could mis-size, re-price or re-route the fill, redirecting or burning your payment)", ...base };
|
|
527
592
|
}
|
|
528
|
-
if (
|
|
593
|
+
if (j.refusal) return { ok: false, trust: "verified", reason: j.refusal, ...base };
|
|
529
594
|
return { ok: true, trust: "verified", ...base };
|
|
530
595
|
}
|
|
531
596
|
|
|
@@ -546,6 +611,20 @@ var spvBackoffMs = (attempt) => Math.min(4e3, 700 * (attempt + 1));
|
|
|
546
611
|
var SPV_RETRY_WAIT_BUDGET_MS = 16e3;
|
|
547
612
|
var SPV_RECHECK_DEPTH = 12;
|
|
548
613
|
var spvTipOf = (lc) => lc.baseHeight + lc.chain.length - 1;
|
|
614
|
+
var SPV_BEYOND_TIP = /^tx at \d+ is \d+ blocks beyond tip; sync\(\d+\) first$/;
|
|
615
|
+
var SpvLightClient = class extends LightClient {
|
|
616
|
+
syncInFlight = null;
|
|
617
|
+
async sync(to, from) {
|
|
618
|
+
while (this.syncInFlight) await this.syncInFlight;
|
|
619
|
+
const run = super.sync(to, from);
|
|
620
|
+
this.syncInFlight = run;
|
|
621
|
+
try {
|
|
622
|
+
return await run;
|
|
623
|
+
} finally {
|
|
624
|
+
if (this.syncInFlight === run) this.syncInFlight = null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
549
628
|
var Cairn = class {
|
|
550
629
|
config;
|
|
551
630
|
chain;
|
|
@@ -561,6 +640,9 @@ var Cairn = class {
|
|
|
561
640
|
// syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
|
|
562
641
|
// fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
|
|
563
642
|
_spvSeedInflight = null;
|
|
643
|
+
// The in-flight reseed of withSpvReseed, shared by callers whose structural failures overlap. Cleared when it
|
|
644
|
+
// settles; each caller still commits the fresh client only after its own retry succeeds.
|
|
645
|
+
_spvReseedInflight = null;
|
|
564
646
|
_spvCp;
|
|
565
647
|
// W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
|
|
566
648
|
_cairnBase;
|
|
@@ -616,14 +698,18 @@ var Cairn = class {
|
|
|
616
698
|
// (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
|
|
617
699
|
// on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
|
|
618
700
|
// failure propagates fail-closed; there is no loop. recheckCachedHeader feeds this path: a near-tip
|
|
619
|
-
// cached header that the served chain no longer carries is a structural failure.
|
|
701
|
+
// cached header that the served chain no longer carries is a structural failure. Callers whose failures overlap
|
|
702
|
+
// (waiters on one shared sync) share one reseed.
|
|
620
703
|
async withSpvReseed(op) {
|
|
621
704
|
const lc = await this.seededSpvLight();
|
|
622
705
|
try {
|
|
623
706
|
return await op(lc);
|
|
624
707
|
} catch (e) {
|
|
625
708
|
if (spvIsTransient(e)) throw e;
|
|
626
|
-
|
|
709
|
+
this._spvReseedInflight ??= this.seedFreshSpvLight().finally(() => {
|
|
710
|
+
this._spvReseedInflight = null;
|
|
711
|
+
});
|
|
712
|
+
const fresh = await this._spvReseedInflight;
|
|
627
713
|
const out = await op(fresh);
|
|
628
714
|
this._spvLight = fresh;
|
|
629
715
|
return out;
|
|
@@ -643,7 +729,7 @@ var Cairn = class {
|
|
|
643
729
|
// the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
|
|
644
730
|
// dirty the shared field while an attempt is in flight).
|
|
645
731
|
async seedFreshSpvLight() {
|
|
646
|
-
const lc = new
|
|
732
|
+
const lc = new SpvLightClient({
|
|
647
733
|
client: this.chain.client,
|
|
648
734
|
checkpoints: { [this._spvCp.height]: this._spvCp.hash },
|
|
649
735
|
headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
|
|
@@ -685,11 +771,7 @@ var Cairn = class {
|
|
|
685
771
|
* data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
|
|
686
772
|
* `sync()` fails closed. A non-dense range is rejected terminally (never retried).
|
|
687
773
|
*
|
|
688
|
-
*
|
|
689
|
-
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
690
|
-
* cairn base) reaches it now; a CROSS-ORIGIN third-party dApp cannot until B7d adds the CORS mount and
|
|
691
|
-
* restarts the cairn service. This code is correct as written; it becomes reachable cross-origin only
|
|
692
|
-
* once B7d ships. This is a recorded dependency, not a blocker for authoring/testing.
|
|
774
|
+
* Cross-origin reach depends on the server's CORS mount on `/api/headers` (the cairn server mounts one).
|
|
693
775
|
*/
|
|
694
776
|
async fetchHeadersBatch(from, count) {
|
|
695
777
|
const g = this.config.fetch ?? globalThis.fetch;
|
|
@@ -737,7 +819,8 @@ var Cairn = class {
|
|
|
737
819
|
* commitment, derives the payment recipient + seller from the offer's on-chain AUTHOR (the funding input's
|
|
738
820
|
* prevout owner, txid-committed, NOT the malleable scriptSig), and, if you pass the resolver-SERVED `offer`
|
|
739
821
|
* object, binds its payto/seller + fee/rebate/partial terms to the proven ones. Returns a TRUST-LABELED result
|
|
740
|
-
* (fail-closed on a positive mismatch
|
|
822
|
+
* (fail-closed on a positive mismatch; `transient` on a lagging chain view; final, naming the anchor, for an
|
|
823
|
+
* offer mined below the SPV checkpoint).
|
|
741
824
|
*
|
|
742
825
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
743
826
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
@@ -745,19 +828,44 @@ var Cairn = class {
|
|
|
745
828
|
*
|
|
746
829
|
* Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
|
|
747
830
|
* non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
|
|
748
|
-
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
831
|
+
* EXACTLY the outputPlan, never resolver-served outputs. Pass opts.me (your address) and opts.tip (the chain tip
|
|
832
|
+
* height) with pay to have cairnx-core's fillEndorsement and offerFillTooLate judge the fill for you at that tip; without them `ok`
|
|
833
|
+
* says the offer record is proven, not that you can fill it now.
|
|
749
834
|
*/
|
|
750
835
|
async verifyOfferForFill(offerId, servedOffer, opts) {
|
|
751
836
|
const light = {
|
|
752
837
|
verifyTxInclusion: (txidHex) => this.withSpvReseed(async (lc) => {
|
|
753
838
|
const haveTo = spvTipOf(lc);
|
|
754
|
-
|
|
755
|
-
if (r.trustLevel === "
|
|
756
|
-
|
|
757
|
-
|
|
839
|
+
let r = await lc.verifyTxInclusion(txidHex);
|
|
840
|
+
if (r.trustLevel === "rpc-trusted" && SPV_BEYOND_TIP.test(r.reason ?? "")) {
|
|
841
|
+
let tipHeight;
|
|
842
|
+
try {
|
|
843
|
+
tipHeight = Number((await this.chain.tip()).height);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
return { ...r, reason: `${r.reason} (the node tip read failed: ${e?.message ?? e})` };
|
|
846
|
+
}
|
|
847
|
+
if (Number.isSafeInteger(tipHeight) && tipHeight > spvTipOf(lc)) await lc.sync(tipHeight);
|
|
848
|
+
r = await lc.verifyTxInclusion(txidHex);
|
|
849
|
+
}
|
|
850
|
+
if (r.trustLevel !== "verified-inclusion") return r;
|
|
851
|
+
const height = Number(r.blockHeight);
|
|
852
|
+
if (!Number.isSafeInteger(height))
|
|
853
|
+
return { trustLevel: "rpc-trusted", included: false, reason: `the node's containing height for this tx is not a block height (${String(r.blockHeight)})` };
|
|
854
|
+
if (height <= haveTo) await this.recheckCachedHeader(lc, height, haveTo);
|
|
855
|
+
return { ...r, blockHeight: height };
|
|
758
856
|
})
|
|
759
857
|
};
|
|
760
|
-
return preverifyOffer({
|
|
858
|
+
return preverifyOffer({
|
|
859
|
+
light,
|
|
860
|
+
client: this.chain.client,
|
|
861
|
+
offerId,
|
|
862
|
+
servedOffer,
|
|
863
|
+
pay: opts?.pay,
|
|
864
|
+
plannedOutputs: opts?.plannedOutputs,
|
|
865
|
+
me: opts?.me,
|
|
866
|
+
tip: opts?.tip,
|
|
867
|
+
fillTargetId: opts?.fillTargetId
|
|
868
|
+
});
|
|
761
869
|
}
|
|
762
870
|
/**
|
|
763
871
|
* Detect + connect the Cairn Wallet (browser only) and attach it as `cairn.wallet`.
|
package/dist/indexer.cjs
CHANGED
|
@@ -24,8 +24,81 @@ __export(indexer_exports, {
|
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(indexer_exports);
|
|
26
26
|
var import_csd_codec = require("@inversealtruism/csd-codec");
|
|
27
|
+
|
|
28
|
+
// src/errors.ts
|
|
29
|
+
var SDK_VERSION = "0.8.2";
|
|
30
|
+
var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
|
|
31
|
+
var CairnError = class extends Error {
|
|
32
|
+
/** Stable machine code for programmatic branching. */
|
|
33
|
+
code;
|
|
34
|
+
/** The human message without the appended docs/version footer. */
|
|
35
|
+
shortMessage;
|
|
36
|
+
/** Relative docs path (under the SDK docs base), if any. */
|
|
37
|
+
docsPath;
|
|
38
|
+
/** The cairn-sdk version that produced this error. */
|
|
39
|
+
version = SDK_VERSION;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
|
|
42
|
+
* declines (the default); `true` only for provably transient infra faults where nothing was signed.
|
|
43
|
+
* A `SubmitInFlightError` is `false` too: reconcile its `txid`, never blind-retry.
|
|
44
|
+
*/
|
|
45
|
+
retryable;
|
|
46
|
+
constructor(shortMessage, opts = {}) {
|
|
47
|
+
const docs = opts.docsPath;
|
|
48
|
+
super(
|
|
49
|
+
[shortMessage, docs ? `Docs: ${DOCS_BASE}${docs}` : "", `Version: cairn-sdk@${SDK_VERSION}`].filter(Boolean).join("\n"),
|
|
50
|
+
{ cause: opts.cause }
|
|
51
|
+
);
|
|
52
|
+
this.name = new.target.name;
|
|
53
|
+
this.code = opts.code ?? "UNKNOWN";
|
|
54
|
+
this.shortMessage = shortMessage;
|
|
55
|
+
this.docsPath = docs;
|
|
56
|
+
this.retryable = opts.retryable ?? false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Walk the `cause` chain. With a predicate, returns the FIRST matching error (or null). Without one,
|
|
60
|
+
* returns the DEEPEST cause (the root). Mirrors viem's `BaseError.walk()`.
|
|
61
|
+
*/
|
|
62
|
+
walk(fn) {
|
|
63
|
+
let current = this;
|
|
64
|
+
let last = this;
|
|
65
|
+
while (current != null) {
|
|
66
|
+
if (fn && fn(current)) return current;
|
|
67
|
+
last = current;
|
|
68
|
+
current = current?.cause;
|
|
69
|
+
}
|
|
70
|
+
return fn ? null : last;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var HttpError = class extends CairnError {
|
|
74
|
+
status;
|
|
75
|
+
url;
|
|
76
|
+
body;
|
|
77
|
+
constructor(status, url, body) {
|
|
78
|
+
super(`HTTP ${status} for ${url}${body ? `: ${String(body).slice(0, 200)}` : ""}`, { code: "HTTP_ERROR" });
|
|
79
|
+
this.status = status;
|
|
80
|
+
this.url = url;
|
|
81
|
+
this.body = body;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/indexer.ts
|
|
27
86
|
var HEX64 = /^0x[0-9a-fA-F]{64}$/;
|
|
28
87
|
var MAX_SSE_BUF = 1024 * 1024;
|
|
88
|
+
var UTXO_PAGE = 1e3;
|
|
89
|
+
var UTXO_MAX_PAGES = 100;
|
|
90
|
+
var UTXO_CURSOR = /^\d{1,16}:0x[0-9a-fA-F]{64}:\d{1,10}$/;
|
|
91
|
+
function utxoCursor(row) {
|
|
92
|
+
const r = row;
|
|
93
|
+
const c = `${String(r?.status?.block_height)}:${String(r?.txid)}:${String(r?.vout)}`;
|
|
94
|
+
return UTXO_CURSOR.test(c) ? c : null;
|
|
95
|
+
}
|
|
96
|
+
function cursorAfter(a, b) {
|
|
97
|
+
const [ha, ta, va] = a.split(":"), [hb, tb, vb] = b.split(":");
|
|
98
|
+
if (Number(ha) !== Number(hb)) return Number(ha) > Number(hb);
|
|
99
|
+
if (ta.toLowerCase() !== tb.toLowerCase()) return ta.toLowerCase() > tb.toLowerCase();
|
|
100
|
+
return Number(va) > Number(vb);
|
|
101
|
+
}
|
|
29
102
|
function isValidMerkleProof(p) {
|
|
30
103
|
if (!p || typeof p !== "object") return false;
|
|
31
104
|
const m = p;
|
|
@@ -75,8 +148,29 @@ var IndexerClient = class {
|
|
|
75
148
|
addressTxs(a) {
|
|
76
149
|
return this.http.getJson(`/address/${encodeURIComponent(a)}/txs`);
|
|
77
150
|
}
|
|
78
|
-
|
|
79
|
-
|
|
151
|
+
/** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
|
|
152
|
+
* `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
|
|
153
|
+
* outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
|
|
154
|
+
async addressUtxo(a) {
|
|
155
|
+
const path = `/address/${encodeURIComponent(a)}/utxo`;
|
|
156
|
+
const first = await this.http.getJson(path);
|
|
157
|
+
if (!Array.isArray(first) || first.length !== UTXO_PAGE) return first;
|
|
158
|
+
const out = [...first];
|
|
159
|
+
let page = first;
|
|
160
|
+
let prev = null;
|
|
161
|
+
for (let pages = 1; page.length === UTXO_PAGE; pages++) {
|
|
162
|
+
if (pages === UTXO_MAX_PAGES)
|
|
163
|
+
throw new HttpError(200, this.http.url(path), `too many unspent outputs: addressUtxo reads at most ${UTXO_MAX_PAGES} pages of ${UTXO_PAGE} rows, and page ${UTXO_MAX_PAGES} is full`);
|
|
164
|
+
const after = utxoCursor(page[UTXO_PAGE - 1]);
|
|
165
|
+
if (!after) throw new HttpError(200, this.http.url(path), "malformed response: a utxo row has no block_height, txid and vout to page from");
|
|
166
|
+
if (prev !== null && !cursorAfter(after, prev)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: the utxo pages do not advance");
|
|
167
|
+
page = await this.http.getJson(path, { after });
|
|
168
|
+
if (!Array.isArray(page)) throw new HttpError(200, this.http.url(path, { after }), "malformed response: a utxo page is not an array");
|
|
169
|
+
if (prev === null && page.length > 0 && utxoCursor(page[0]) === utxoCursor(first[0])) return first;
|
|
170
|
+
prev = after;
|
|
171
|
+
out.push(...page);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
80
174
|
}
|
|
81
175
|
reputation(a) {
|
|
82
176
|
return this.http.getJson(`/address/${encodeURIComponent(a)}/reputation`);
|
|
@@ -95,7 +189,8 @@ var IndexerClient = class {
|
|
|
95
189
|
}
|
|
96
190
|
// ---- trust-minimized inclusion ----------------------------------------
|
|
97
191
|
/**
|
|
98
|
-
* Verify
|
|
192
|
+
* Verify that `txid` is a node of a block's merkle tree (an inner node or the root folds too, so pass an id you
|
|
193
|
+
* hashed from a transaction body you hold) by folding its merkle branch to the root
|
|
99
194
|
* with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
|
|
100
195
|
* proof's root is cross-checked against the header merkle at that height →
|
|
101
196
|
* trustLevel "verified-inclusion" (only as trustworthy as that header source —
|
package/dist/indexer.d.cts
CHANGED
|
@@ -62,6 +62,9 @@ declare class IndexerClient {
|
|
|
62
62
|
txMerkleProof(id: string): Promise<MerkleProof>;
|
|
63
63
|
address(a: string): Promise<unknown>;
|
|
64
64
|
addressTxs(a: string): Promise<unknown[]>;
|
|
65
|
+
/** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
|
|
66
|
+
* `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
|
|
67
|
+
* outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
|
|
65
68
|
addressUtxo(a: string): Promise<unknown[]>;
|
|
66
69
|
reputation(a: string): Promise<unknown>;
|
|
67
70
|
domains(): Promise<unknown[]>;
|
|
@@ -69,7 +72,8 @@ declare class IndexerClient {
|
|
|
69
72
|
proposal(id: string): Promise<unknown>;
|
|
70
73
|
attestations(id: string): Promise<unknown[]>;
|
|
71
74
|
/**
|
|
72
|
-
* Verify
|
|
75
|
+
* Verify that `txid` is a node of a block's merkle tree (an inner node or the root folds too, so pass an id you
|
|
76
|
+
* hashed from a transaction body you hold) by folding its merkle branch to the root
|
|
73
77
|
* with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
|
|
74
78
|
* proof's root is cross-checked against the header merkle at that height →
|
|
75
79
|
* trustLevel "verified-inclusion" (only as trustworthy as that header source —
|
package/dist/indexer.d.ts
CHANGED
|
@@ -62,6 +62,9 @@ declare class IndexerClient {
|
|
|
62
62
|
txMerkleProof(id: string): Promise<MerkleProof>;
|
|
63
63
|
address(a: string): Promise<unknown>;
|
|
64
64
|
addressTxs(a: string): Promise<unknown[]>;
|
|
65
|
+
/** Every unspent output of an address. The indexer answers in pages of at most 1,000 rows; this follows its
|
|
66
|
+
* `?after=` cursor until a short page, reading at most 100 pages: when the 100th is full (100,000 or more
|
|
67
|
+
* outputs) it throws HttpError instead. An indexer that predates paging answers the whole set at once. */
|
|
65
68
|
addressUtxo(a: string): Promise<unknown[]>;
|
|
66
69
|
reputation(a: string): Promise<unknown>;
|
|
67
70
|
domains(): Promise<unknown[]>;
|
|
@@ -69,7 +72,8 @@ declare class IndexerClient {
|
|
|
69
72
|
proposal(id: string): Promise<unknown>;
|
|
70
73
|
attestations(id: string): Promise<unknown[]>;
|
|
71
74
|
/**
|
|
72
|
-
* Verify
|
|
75
|
+
* Verify that `txid` is a node of a block's merkle tree (an inner node or the root folds too, so pass an id you
|
|
76
|
+
* hashed from a transaction body you hold) by folding its merkle branch to the root
|
|
73
77
|
* with csd-codec.verifyMerkleProof. If `headerMerkleAt` was provided, the
|
|
74
78
|
* proof's root is cross-checked against the header merkle at that height →
|
|
75
79
|
* trustLevel "verified-inclusion" (only as trustworthy as that header source —
|