@inversealtruism/cairn-sdk 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board.cjs +1 -1
- package/dist/board.js +2 -2
- package/dist/chunk-7UOTIAYK.js +224 -0
- package/dist/{chunk-2ZML7IKV.js → chunk-DUTVUIQF.js} +1 -1
- package/dist/{chunk-MREZG2HN.js → chunk-ERBT4DFD.js} +1 -1
- package/dist/{chunk-2L5QNDAE.js → chunk-OCEHXK6A.js} +1 -1
- package/dist/{chunk-RKR3JMIA.js → chunk-PIPN6FSA.js} +22 -3
- package/dist/{chunk-GTTLMWGY.js → chunk-QATE6AIP.js} +25 -13
- package/dist/connect.cjs +22 -3
- package/dist/connect.d.cts +4 -1
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +2 -2
- package/dist/content.cjs +1 -1
- package/dist/content.js +2 -2
- package/dist/controller.cjs +46 -15
- package/dist/controller.d.cts +4 -1
- package/dist/controller.d.ts +4 -1
- package/dist/controller.js +3 -3
- package/dist/index.cjs +182 -62
- package/dist/index.d.cts +30 -9
- package/dist/index.d.ts +30 -9
- package/dist/index.js +125 -186
- package/dist/react.cjs +46 -15
- package/dist/react.js +3 -3
- package/dist/registry.cjs +219 -7
- package/dist/registry.d.cts +16 -11
- package/dist/registry.d.ts +16 -11
- package/dist/registry.js +2 -1
- package/package.json +6 -6
- package/dist/chunk-BGJZF7S2.js +0 -69
package/dist/controller.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ interface CairnControllerOptions extends DetectOptions {
|
|
|
12
12
|
declare class CairnController {
|
|
13
13
|
private state;
|
|
14
14
|
private conn;
|
|
15
|
+
private epoch;
|
|
15
16
|
private readonly listeners;
|
|
16
17
|
private readonly getWalletFn;
|
|
17
18
|
private readonly detectOpts;
|
|
@@ -28,7 +29,9 @@ declare class CairnController {
|
|
|
28
29
|
private detach;
|
|
29
30
|
/** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
|
|
30
31
|
connect: () => Promise<string>;
|
|
31
|
-
/** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
|
|
32
|
+
/** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
|
|
33
|
+
* Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
|
|
34
|
+
* provider can never hold a torn-down session in "connected". */
|
|
32
35
|
disconnect: () => Promise<void>;
|
|
33
36
|
/** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
|
|
34
37
|
signInWithCsd: (params: SiwcParams) => Promise<SiwcResult>;
|
package/dist/controller.js
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -60,7 +60,7 @@ __export(index_exports, {
|
|
|
60
60
|
module.exports = __toCommonJS(index_exports);
|
|
61
61
|
|
|
62
62
|
// src/errors.ts
|
|
63
|
-
var SDK_VERSION = "0.4.
|
|
63
|
+
var SDK_VERSION = "0.4.1";
|
|
64
64
|
var DOCS_BASE = "https://cairn-substrate.com/docs/sdk";
|
|
65
65
|
var CairnError = class extends Error {
|
|
66
66
|
/** Stable machine code for programmatic branching. */
|
|
@@ -894,17 +894,20 @@ function normalizeHash(hash) {
|
|
|
894
894
|
var import_csd_registry = require("@inversealtruism/csd-registry");
|
|
895
895
|
var import_csd_registry2 = require("@inversealtruism/csd-registry");
|
|
896
896
|
var RegistryClient = class {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
897
|
+
http;
|
|
898
|
+
/** Accepts the SDK's hardened Http directly (the Cairn facade path) or the legacy
|
|
899
|
+
* { baseUrl, fetch } options (kept for direct constructors; they now get the same
|
|
900
|
+
* hardened transport, built internally). */
|
|
901
|
+
constructor(src) {
|
|
902
|
+
this.http = src instanceof Http ? src : new Http({ baseUrl: src.baseUrl, fetch: src.fetch });
|
|
900
903
|
}
|
|
901
904
|
/** Discover ranked content/pin gateways (GET /registry/gateways). */
|
|
902
905
|
gateways() {
|
|
903
|
-
return
|
|
906
|
+
return this.http.getJson("/registry/gateways");
|
|
904
907
|
}
|
|
905
908
|
/** Discover ranked libp2p peers (GET /registry/peers). */
|
|
906
909
|
peers() {
|
|
907
|
-
return
|
|
910
|
+
return this.http.getJson("/registry/peers");
|
|
908
911
|
}
|
|
909
912
|
/**
|
|
910
913
|
* Resolve a handle → identity (GET /identity/:handle). Returns null if unresolved.
|
|
@@ -916,12 +919,22 @@ var RegistryClient = class {
|
|
|
916
919
|
* source (the wallet's namespv union cure). Treat `verified` here as a hint, not a guarantee.
|
|
917
920
|
*/
|
|
918
921
|
resolveName(handle) {
|
|
919
|
-
return
|
|
922
|
+
return this.getOr404Null(`/identity/${encodeURIComponent(handle)}`);
|
|
920
923
|
}
|
|
921
924
|
/** Reverse-resolve an address → identity (GET /address/:addr/identity).
|
|
922
925
|
* ⚠ Same indexer-trust caveat as resolveName — `verified` is the indexer's claim, not a client proof. */
|
|
923
926
|
reverseName(address) {
|
|
924
|
-
return
|
|
927
|
+
return this.getOr404Null(`/address/${encodeURIComponent(address)}/identity`);
|
|
928
|
+
}
|
|
929
|
+
// MF-12: 404 → null (the preserved csd-registry contract for both name resolvers); every other
|
|
930
|
+
// failure rethrows the typed HttpError/CairnError the hardened Http raises.
|
|
931
|
+
async getOr404Null(path) {
|
|
932
|
+
try {
|
|
933
|
+
return await this.http.getJson(path);
|
|
934
|
+
} catch (e) {
|
|
935
|
+
if (e instanceof HttpError && e.status === 404) return null;
|
|
936
|
+
return Promise.reject(e);
|
|
937
|
+
}
|
|
925
938
|
}
|
|
926
939
|
/** The deterministic, client-side resolvers (recompute from raw ChainRecord[]). */
|
|
927
940
|
get fromRecords() {
|
|
@@ -1112,12 +1125,15 @@ var WalletConnection = class {
|
|
|
1112
1125
|
// genuine wallet resolves quickly once the user acts; this only fires on a truly hung/absent response.
|
|
1113
1126
|
call(op, label) {
|
|
1114
1127
|
const ms = this._timeoutMs;
|
|
1115
|
-
|
|
1128
|
+
const invoke = () => new Promise((resolve) => {
|
|
1129
|
+
resolve(op());
|
|
1130
|
+
});
|
|
1131
|
+
if (!Number.isFinite(ms) || ms <= 0) return invoke();
|
|
1116
1132
|
let timer;
|
|
1117
1133
|
const timeout = new Promise((_, reject) => {
|
|
1118
1134
|
timer = setTimeout(() => reject(new CairnError(`the wallet did not respond to ${label} within ${ms}ms`, { retryable: false })), ms);
|
|
1119
1135
|
});
|
|
1120
|
-
return Promise.race([
|
|
1136
|
+
return Promise.race([invoke(), timeout]).finally(() => clearTimeout(timer));
|
|
1121
1137
|
}
|
|
1122
1138
|
/** The wallet's version string (e.g. "0.2.x"). */
|
|
1123
1139
|
get version() {
|
|
@@ -1190,8 +1206,24 @@ var WalletConnection = class {
|
|
|
1190
1206
|
if (typeof this.provider.fillOffer !== "function") {
|
|
1191
1207
|
return Promise.reject(new UnsupportedMethodError("this wallet predates fillOffer() \u2014 ask the user to update the Cairn Wallet"));
|
|
1192
1208
|
}
|
|
1209
|
+
this.adviseFillSpvCapability();
|
|
1193
1210
|
return this.call(() => this.provider.fillOffer(params), "fillOffer").then(unwrapWrite);
|
|
1194
1211
|
}
|
|
1212
|
+
// P75-5 MF-10: ONE capability advisory per connection. The wallet's on-device fill-SPV (0.2.60+) is the
|
|
1213
|
+
// payment-grade fill boundary; when the provider SELF-REPORTS an older version, warn the integrator once
|
|
1214
|
+
// and STILL RELAY. WARN, NEVER BLOCK: a hard version gate would decline honest fills (version strings are
|
|
1215
|
+
// spoofable and unparseable forks exist), and the wallet itself is the enforcement point. Reads only the
|
|
1216
|
+
// sync `provider.version`; never an awaited getCapabilities() round-trip on the fill hot path. An
|
|
1217
|
+
// unparseable version never warns (no false alarms).
|
|
1218
|
+
_fillSpvAdvised = false;
|
|
1219
|
+
adviseFillSpvCapability() {
|
|
1220
|
+
if (this._fillSpvAdvised) return;
|
|
1221
|
+
this._fillSpvAdvised = true;
|
|
1222
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(this.provider?.version ?? ""));
|
|
1223
|
+
if (!m) return;
|
|
1224
|
+
const below = Number(m[1]) === 0 && (Number(m[2]) < 2 || Number(m[2]) === 2 && Number(m[3]) < 60);
|
|
1225
|
+
if (below) console.warn(`[cairn-sdk] this Cairn Wallet (v${m[0]}) predates the on-device fill-SPV boundary (cairn-wallet 0.2.60+). The fill proceeds; suggest a wallet update for payment-grade offer verification.`);
|
|
1226
|
+
}
|
|
1195
1227
|
/** This origin's current permission grant (EIP-2255-style). Silent; [] if none / unsupported. */
|
|
1196
1228
|
async getPermissions() {
|
|
1197
1229
|
if (typeof this.provider.getPermissions !== "function") return [];
|
|
@@ -1322,6 +1354,8 @@ async function preverifyOffer(opts) {
|
|
|
1322
1354
|
if (String(provenId).toLowerCase() !== id) return { ok: false, trust: "unverified", reason: "the merkle-proven tx doesn't match the offer id" };
|
|
1323
1355
|
const app = incl.tx.app;
|
|
1324
1356
|
if (!app || app.type !== "Propose") return { ok: false, trust: "unverified", reason: "the offer tx is not a Propose" };
|
|
1357
|
+
if (String(app.domain) !== import_cairnx_core2.DOMAIN)
|
|
1358
|
+
return { ok: false, trust: "unverified", reason: `the offer tx is not a ${import_cairnx_core2.DOMAIN} Propose (wrong settlement domain)` };
|
|
1325
1359
|
let rec;
|
|
1326
1360
|
try {
|
|
1327
1361
|
rec = (0, import_cairnx_core2.parseRecord)(app.uri, incl.appPayloadHash);
|
|
@@ -1340,37 +1374,47 @@ async function preverifyOffer(opts) {
|
|
|
1340
1374
|
let outputPlan;
|
|
1341
1375
|
let sumsMismatch;
|
|
1342
1376
|
if (opts.pay !== void 0 && opts.pay !== null && !(0, import_cairnx_core2.isTokenWant)(offerRec.want) && offerRec.min === void 0) {
|
|
1377
|
+
const servedStatus = opts.servedOffer?.status;
|
|
1343
1378
|
const provenState = {
|
|
1344
1379
|
id,
|
|
1345
1380
|
seller,
|
|
1346
1381
|
give: offerRec.give,
|
|
1347
|
-
want: offerRec.want,
|
|
1382
|
+
want: { ...offerRec.want, payto },
|
|
1348
1383
|
taker: offerRec.taker,
|
|
1349
1384
|
bid: offerRec.bid,
|
|
1350
|
-
status: "open",
|
|
1385
|
+
status: typeof servedStatus === "string" ? servedStatus : "open",
|
|
1351
1386
|
expiresEpoch: 0,
|
|
1352
1387
|
height: blockHeight,
|
|
1353
1388
|
feeBps: terms.feeBps
|
|
1354
1389
|
};
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1390
|
+
try {
|
|
1391
|
+
const plan = (0, import_cairnx_core2.fillOutputPlan)(provenState, opts.pay);
|
|
1392
|
+
if (plan.kind === "csd-outputs") {
|
|
1393
|
+
outputPlan = plan.outputs;
|
|
1394
|
+
if (opts.plannedOutputs !== void 0 && opts.plannedOutputs !== null)
|
|
1395
|
+
sumsMismatch = bindPlannedSums(opts.plannedOutputs, plan.outputs);
|
|
1396
|
+
} else if (plan.kind === "undeliverable") {
|
|
1397
|
+
sumsMismatch = `this pay would not deliver against the proven offer (${plan.reason}) - refusing (the CSD would be lost)`;
|
|
1398
|
+
}
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
sumsMismatch = `couldn't size the fill against the proven offer (${e?.message ?? e}) - refusing rather than sizing from served fields`;
|
|
1362
1401
|
}
|
|
1363
1402
|
}
|
|
1364
1403
|
const base = { payto, seller, terms, blockHeight, ...outputPlan ? { outputPlan } : {} };
|
|
1365
1404
|
if (opts.servedOffer !== void 0 && opts.servedOffer !== null) {
|
|
1366
1405
|
const so = opts.servedOffer;
|
|
1367
|
-
|
|
1368
|
-
|
|
1406
|
+
if (so?.want?.payto === void 0 || so?.want?.payto === null)
|
|
1407
|
+
return { ok: false, trust: "verified", reason: "the served offer is missing want.payto (the payment recipient) - pass the resolver's full offer object, or omit servedOffer", ...base };
|
|
1408
|
+
if (String(so.want.payto).toLowerCase() !== payto)
|
|
1369
1409
|
return { ok: false, trust: "verified", reason: "the served payment recipient doesn't match the offer's on-chain author/record", ...base };
|
|
1370
|
-
if (so?.seller
|
|
1410
|
+
if (so?.seller === void 0 || so?.seller === null)
|
|
1411
|
+
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 };
|
|
1412
|
+
if (String(so.seller).toLowerCase() !== seller)
|
|
1371
1413
|
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 };
|
|
1372
1414
|
if ((0, import_cairnx_core2.bindOfferTerms)(opts.servedOffer, terms, { give: true, wantType: true }))
|
|
1373
1415
|
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 };
|
|
1416
|
+
if ((0, import_cairnx_core2.isTokenWant)(offerRec.want) && (String(so?.want?.ticker) !== String(offerRec.want.ticker) || String(so?.want?.amount) !== String(offerRec.want.amount)))
|
|
1417
|
+
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 };
|
|
1374
1418
|
}
|
|
1375
1419
|
if (sumsMismatch) return { ok: false, trust: "verified", reason: sumsMismatch, ...base };
|
|
1376
1420
|
return { ok: true, trust: "verified", ...base };
|
|
@@ -1381,6 +1425,9 @@ var DISCONNECTED = Object.freeze({ status: "disconnected", account: null, error:
|
|
|
1381
1425
|
var CairnController = class {
|
|
1382
1426
|
state = DISCONNECTED;
|
|
1383
1427
|
conn = null;
|
|
1428
|
+
// MF-15: connect/disconnect generation token. disconnect() bumps it; a connect() whose await straddled
|
|
1429
|
+
// the bump must never commit "connected" over the torn-down session (a zombie null-connection session).
|
|
1430
|
+
epoch = 0;
|
|
1384
1431
|
listeners = /* @__PURE__ */ new Set();
|
|
1385
1432
|
getWalletFn;
|
|
1386
1433
|
detectOpts;
|
|
@@ -1433,34 +1480,43 @@ var CairnController = class {
|
|
|
1433
1480
|
}
|
|
1434
1481
|
/** Detect + connect (prompts the user the first time per origin). Resolves the connected address. */
|
|
1435
1482
|
connect = async () => {
|
|
1483
|
+
const epoch = this.epoch;
|
|
1436
1484
|
this.setState({ status: "connecting", error: null });
|
|
1437
1485
|
try {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
this.
|
|
1441
|
-
this.
|
|
1486
|
+
let conn = this.conn;
|
|
1487
|
+
if (!conn) {
|
|
1488
|
+
conn = await this.getWalletFn(this.detectOpts);
|
|
1489
|
+
if (epoch !== this.epoch) throw new Error("connection was torn down while connecting - call connect() again");
|
|
1490
|
+
conn.on("accountsChanged", this.onAccounts);
|
|
1491
|
+
conn.on("disconnect", this.onDisconnect);
|
|
1492
|
+
this.conn = conn;
|
|
1442
1493
|
}
|
|
1443
|
-
const addr = await
|
|
1494
|
+
const addr = await conn.connect();
|
|
1495
|
+
if (epoch !== this.epoch) throw new Error("connection was torn down while awaiting approval - call connect() again");
|
|
1444
1496
|
this.setState({ status: "connected", account: addr, error: null });
|
|
1445
1497
|
return addr;
|
|
1446
1498
|
} catch (e) {
|
|
1447
|
-
this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
|
|
1499
|
+
if (epoch === this.epoch) this.setState({ status: "disconnected", account: null, error: e?.message ?? String(e) });
|
|
1448
1500
|
throw e;
|
|
1449
1501
|
}
|
|
1450
1502
|
};
|
|
1451
|
-
/** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
|
|
1503
|
+
/** Forget the connection locally AND revoke this origin's wallet-side permission (best-effort).
|
|
1504
|
+
* Local teardown is IMMEDIATE and unconditional; the wallet-side revoke happens after, so a hung
|
|
1505
|
+
* provider can never hold a torn-down session in "connected". */
|
|
1452
1506
|
disconnect = async () => {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
} catch {
|
|
1456
|
-
}
|
|
1507
|
+
this.epoch++;
|
|
1508
|
+
const conn = this.conn;
|
|
1457
1509
|
this.detach();
|
|
1458
1510
|
this.conn = null;
|
|
1459
1511
|
this.setState({ status: "disconnected", account: null, error: null });
|
|
1512
|
+
try {
|
|
1513
|
+
await conn?.revokePermissions();
|
|
1514
|
+
} catch {
|
|
1515
|
+
}
|
|
1460
1516
|
};
|
|
1461
1517
|
/** Audience-bound sign-in (must connect() first). Returns the signed artifact to verify server-side. */
|
|
1462
1518
|
signInWithCsd = (params) => {
|
|
1463
|
-
if (!this.conn) return Promise.reject(new Error("call connect() before signInWithCsd()"));
|
|
1519
|
+
if (!this.conn || this.state.status !== "connected") return Promise.reject(new Error("call connect() before signInWithCsd()"));
|
|
1464
1520
|
return this.conn.signInWithCsd(params);
|
|
1465
1521
|
};
|
|
1466
1522
|
};
|
|
@@ -1470,6 +1526,13 @@ var DEFAULT_SPV_CHECKPOINT = { height: 38142, hash: "0x00000000000140f023cc0ee14
|
|
|
1470
1526
|
var DEFAULT_CAIRN = "https://cairn-substrate.com";
|
|
1471
1527
|
var SPV_HTTP_TIMEOUT_MS = 15e3;
|
|
1472
1528
|
var SPV_HTTP_MAX_RETRIES = 4;
|
|
1529
|
+
var SPV_MAX_BATCH_BYTES = 2 * 1024 * 1024;
|
|
1530
|
+
var SPV_TRANSIENT_RE = /\b(429|50[0-9]|timeout|timed out|abort|aborted|headers|non-dense|failed to fetch|networkerror|load failed)\b/i;
|
|
1531
|
+
var SPV_TRANSIENT_JSON_RE = /unexpected (token|end)|json/i;
|
|
1532
|
+
var spvIsTransient = (e) => {
|
|
1533
|
+
const msg = String(e?.message || e);
|
|
1534
|
+
return SPV_TRANSIENT_RE.test(msg) || SPV_TRANSIENT_JSON_RE.test(msg);
|
|
1535
|
+
};
|
|
1473
1536
|
var spvSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1474
1537
|
var spvRetryDelayMs = (attempt, retryAfterSecs) => Math.min(4e3, Math.max(700 * (attempt + 1), retryAfterSecs * 1e3));
|
|
1475
1538
|
var Cairn = class {
|
|
@@ -1485,7 +1548,10 @@ var Cairn = class {
|
|
|
1485
1548
|
cairnHttp;
|
|
1486
1549
|
// Lazily-seeded SPV light client + its checkpoint (audit M3 — PoW-verified header source).
|
|
1487
1550
|
_spvLight = null;
|
|
1488
|
-
|
|
1551
|
+
// MF-14: the in-flight seed promise. Concurrent first callers share ONE seed instead of racing two
|
|
1552
|
+
// syncFromCheckpoint calls onto the same client (the loser would hit "seedTrusted must be called on a
|
|
1553
|
+
// fresh client"). Cleared when the seed settles; a failure leaves no cached state, so the next call retries.
|
|
1554
|
+
_spvSeedInflight = null;
|
|
1489
1555
|
_spvCp;
|
|
1490
1556
|
// W12: the cairn base the SPV header-batch provider fetches /api/headers from (same base the rest of the SDK uses).
|
|
1491
1557
|
_cairnBase;
|
|
@@ -1510,7 +1576,7 @@ var Cairn = class {
|
|
|
1510
1576
|
headerMerkleAt: (h) => this.verifiedHeaderMerkleAt(h)
|
|
1511
1577
|
});
|
|
1512
1578
|
this.content = new ContentClient({ swarm: swarmHttp, indexer: indexerHttp, cairn: this.cairnHttp });
|
|
1513
|
-
this.registry = new RegistryClient(
|
|
1579
|
+
this.registry = new RegistryClient(indexerHttp);
|
|
1514
1580
|
this.names = new NamesClient(this.cairnHttp);
|
|
1515
1581
|
}
|
|
1516
1582
|
/**
|
|
@@ -1520,44 +1586,90 @@ var Cairn = class {
|
|
|
1520
1586
|
* checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
|
|
1521
1587
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
1522
1588
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
1589
|
+
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
1590
|
+
* withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
|
|
1591
|
+
* NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
|
|
1592
|
+
* read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
|
|
1523
1593
|
*/
|
|
1524
1594
|
async verifiedHeaderMerkleAt(height) {
|
|
1525
1595
|
if (!Number.isInteger(height) || height < this._spvCp.height)
|
|
1526
1596
|
throw new Error(`height ${height} is below the SPV checkpoint ${this._spvCp.height} (cannot PoW-verify backward)`);
|
|
1597
|
+
return this.withSpvReseed(async (lc) => {
|
|
1598
|
+
const haveTo = lc.baseHeight + lc.chain.length - 1;
|
|
1599
|
+
if (haveTo < height) await lc.sync(height);
|
|
1600
|
+
const vh = lc.chain[height - lc.baseHeight];
|
|
1601
|
+
if (!vh) throw new Error(`light client has no verified header at ${height}`);
|
|
1602
|
+
return String(vh.header.merkle);
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
// MF-03: run an SPV read; on a STRUCTURAL failure (a reorg orphaned our cached tip: broken
|
|
1606
|
+
// prev-link / bad PoW / hash mismatch) reseed ONCE from the pinned checkpoint into a LOCAL fresh
|
|
1607
|
+
// client, retry the read on it, and commit the fresh client ONLY when both succeed (never null
|
|
1608
|
+
// this._spvLight first - the wallet G8 lesson, same shape as the MF-14 seed). A TRANSIENT failure
|
|
1609
|
+
// (rate limit / gateway / timeout / non-dense / CF 200-HTML) keeps the cache and rethrows: wiping
|
|
1610
|
+
// on those turns one /api/headers 429 into a reseed storm (DOS-HDR-3). A second structural
|
|
1611
|
+
// failure propagates fail-closed; there is no loop.
|
|
1612
|
+
// RESIDUAL (recorded, not closed): heights at or below the cached tip answer from the cached
|
|
1613
|
+
// branch with NO network touch, so after a reorg "verified-inclusion" can still be over-claimed
|
|
1614
|
+
// for a tx in an orphaned block until some read past the tip trips this reseed. This fix closes
|
|
1615
|
+
// the permanent-brick leg only.
|
|
1616
|
+
async withSpvReseed(op) {
|
|
1527
1617
|
const lc = await this.seededSpvLight();
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1618
|
+
try {
|
|
1619
|
+
return await op(lc);
|
|
1620
|
+
} catch (e) {
|
|
1621
|
+
if (spvIsTransient(e)) throw e;
|
|
1622
|
+
const fresh = await this.seedFreshSpvLight();
|
|
1623
|
+
const out = await op(fresh);
|
|
1624
|
+
this._spvLight = fresh;
|
|
1625
|
+
return out;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
// Build + seed + tip-advance a FRESH light client into a LOCAL. Never touches this._spvLight:
|
|
1629
|
+
// the caller commits the result only on success (MF-14; the wallet G8 posture - never null or
|
|
1630
|
+
// dirty the shared field while an attempt is in flight).
|
|
1631
|
+
async seedFreshSpvLight() {
|
|
1632
|
+
const lc = new import_csd_light.LightClient({
|
|
1538
1633
|
client: this.chain.client,
|
|
1539
1634
|
checkpoints: { [this._spvCp.height]: this._spvCp.hash },
|
|
1540
1635
|
headersBatchProvider: (from, count) => this.fetchHeadersBatch(from, count)
|
|
1541
1636
|
});
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
} catch {
|
|
1549
|
-
}
|
|
1550
|
-
this._spvSeeded = true;
|
|
1637
|
+
await lc.syncFromCheckpoint(this._spvCp.height, this._spvCp.hash);
|
|
1638
|
+
try {
|
|
1639
|
+
const tipHeight = Number((await this.chain.tip()).height);
|
|
1640
|
+
const have = lc.baseHeight + lc.chain.length - 1;
|
|
1641
|
+
if (Number.isFinite(tipHeight) && tipHeight > have) await lc.sync(tipHeight);
|
|
1642
|
+
} catch {
|
|
1551
1643
|
}
|
|
1552
|
-
return
|
|
1644
|
+
return lc;
|
|
1645
|
+
}
|
|
1646
|
+
/** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
|
|
1647
|
+
* (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
|
|
1648
|
+
* the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
|
|
1649
|
+
* the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
|
|
1650
|
+
* fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
|
|
1651
|
+
* structural (reorg) break is detected, see withSpvReseed. */
|
|
1652
|
+
seededSpvLight() {
|
|
1653
|
+
if (this._spvLight) return Promise.resolve(this._spvLight);
|
|
1654
|
+
if (!this._spvSeedInflight) {
|
|
1655
|
+
this._spvSeedInflight = this.seedFreshSpvLight().then((lc) => {
|
|
1656
|
+
this._spvLight = lc;
|
|
1657
|
+
return lc;
|
|
1658
|
+
}).finally(() => {
|
|
1659
|
+
this._spvSeedInflight = null;
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return this._spvSeedInflight;
|
|
1553
1663
|
}
|
|
1554
1664
|
/**
|
|
1555
1665
|
* W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
|
|
1556
1666
|
* `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
|
|
1557
1667
|
* prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
|
|
1558
1668
|
* Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
|
|
1559
|
-
* server's 10s deadline)
|
|
1560
|
-
*
|
|
1669
|
+
* server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
|
|
1670
|
+
* SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
|
|
1671
|
+
* data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
|
|
1672
|
+
* `sync()` fails closed. A non-dense range is rejected terminally (never retried).
|
|
1561
1673
|
*
|
|
1562
1674
|
* B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
|
|
1563
1675
|
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
@@ -1572,10 +1684,14 @@ var Cairn = class {
|
|
|
1572
1684
|
const url = `${this._cairnBase}/api/headers/${from}/${count}`;
|
|
1573
1685
|
for (let attempt = 0; ; attempt++) {
|
|
1574
1686
|
let res;
|
|
1687
|
+
let parsed;
|
|
1575
1688
|
const ctrl = new AbortController();
|
|
1576
1689
|
const timer = setTimeout(() => ctrl.abort(), SPV_HTTP_TIMEOUT_MS);
|
|
1577
1690
|
try {
|
|
1578
1691
|
res = await doFetch(url, { signal: ctrl.signal });
|
|
1692
|
+
if (res.ok) {
|
|
1693
|
+
parsed = JSON.parse(new TextDecoder().decode(await readCapped(res, SPV_MAX_BATCH_BYTES)));
|
|
1694
|
+
}
|
|
1579
1695
|
} catch (e) {
|
|
1580
1696
|
if (attempt >= SPV_HTTP_MAX_RETRIES) throw e;
|
|
1581
1697
|
await spvSleep(spvRetryDelayMs(attempt, 0));
|
|
@@ -1584,7 +1700,7 @@ var Cairn = class {
|
|
|
1584
1700
|
clearTimeout(timer);
|
|
1585
1701
|
}
|
|
1586
1702
|
if (res.ok) {
|
|
1587
|
-
const rows =
|
|
1703
|
+
const rows = parsed?.headers;
|
|
1588
1704
|
if (!Array.isArray(rows) || rows.length !== count) throw new Error(`/api/headers: non-dense range (${from}/${count})`);
|
|
1589
1705
|
return rows.map((r) => ({ header: r.header, hash: r.hash }));
|
|
1590
1706
|
}
|
|
@@ -1608,10 +1724,14 @@ var Cairn = class {
|
|
|
1608
1724
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
1609
1725
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
1610
1726
|
* before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
|
|
1727
|
+
*
|
|
1728
|
+
* Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
|
|
1729
|
+
* non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
|
|
1730
|
+
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
1611
1731
|
*/
|
|
1612
|
-
async verifyOfferForFill(offerId, servedOffer) {
|
|
1613
|
-
const light =
|
|
1614
|
-
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer });
|
|
1732
|
+
async verifyOfferForFill(offerId, servedOffer, opts) {
|
|
1733
|
+
const light = { verifyTxInclusion: (txidHex) => this.withSpvReseed((lc) => lc.verifyTxInclusion(txidHex)) };
|
|
1734
|
+
return preverifyOffer({ light, client: this.chain.client, offerId, servedOffer, pay: opts?.pay, plannedOutputs: opts?.plannedOutputs });
|
|
1615
1735
|
}
|
|
1616
1736
|
/**
|
|
1617
1737
|
* Detect + connect the Cairn Wallet (browser only). Wires the wallet into
|
package/dist/index.d.cts
CHANGED
|
@@ -134,7 +134,9 @@ interface OfferFillCheck {
|
|
|
134
134
|
* Pre-verify an offer on-chain before building a `fillOffer` payment. `light` is a PoW-verifying light client
|
|
135
135
|
* (its `verifyTxInclusion` surfaces the merkle-proven tx + committed appPayloadHash); `client` reads source txs
|
|
136
136
|
* for the prevout-owner author bind. Pass `servedOffer` (the resolver's offer object) to also bind its
|
|
137
|
-
* payto/seller/terms to the proven values.
|
|
137
|
+
* payto/seller/terms to the proven values. The FULL object is required: a served offer missing want.payto
|
|
138
|
+
* or seller is refused (P75-5). A token-priced offer's served want.ticker/want.amount are bound verbatim to
|
|
139
|
+
* the record. See the module header for the trust posture.
|
|
138
140
|
*/
|
|
139
141
|
declare function preverifyOffer(opts: {
|
|
140
142
|
light: {
|
|
@@ -158,7 +160,7 @@ declare function preverifyOffer(opts: {
|
|
|
158
160
|
|
|
159
161
|
/** Stable machine-readable error codes (branch on these, not the message text). */
|
|
160
162
|
type CairnErrorCode = "NOT_INSTALLED" | "USER_REJECTED" | "WALLET_LOCKED" | "UNSUPPORTED_METHOD" | "HTTP_ERROR" | "CONTENT_VERIFICATION" | "ACCOUNT_CHANGED" | "FIRST_PARTY_ONLY" | "RATE_LIMITED" | "APPROVAL_CLOSED" | "FORBIDDEN" | "INTERNAL" | "GHOST_INPUTS_SKIPPED" | "VERIFY_UNAVAILABLE" | "VERIFY_TAMPER" | "INSUFFICIENT" | "TOO_MANY_INPUTS" | "FEE_TOO_LOW" | "FEE_CAP" | "BAD_FEE" | "ZERO_ADDR_REFUSED" | "BAD_OUTPUTS" | "NO_OUTPUTS" | "BAD_REQUEST" | "OFFER_UNKNOWN" | "FILL_UNSAFE" | "FILL_WRONG_TARGET" | "SOURCE_DIVERGENCE" | "SUBMIT_REJECTED" | "SUBMIT_MAYBE_INFLIGHT" | "SUBMIT_DUPLICATE" | "NOTHING_TO_CONSOLIDATE" | "UNKNOWN";
|
|
161
|
-
declare const SDK_VERSION = "0.4.
|
|
163
|
+
declare const SDK_VERSION = "0.4.1";
|
|
162
164
|
interface CairnErrorOptions {
|
|
163
165
|
code?: CairnErrorCode;
|
|
164
166
|
docsPath?: string;
|
|
@@ -178,7 +180,7 @@ declare class CairnError extends Error {
|
|
|
178
180
|
/** Relative docs path (under the SDK docs base), if any. */
|
|
179
181
|
readonly docsPath?: string;
|
|
180
182
|
/** The cairn-sdk version that produced this error. */
|
|
181
|
-
readonly version = "0.4.
|
|
183
|
+
readonly version = "0.4.1";
|
|
182
184
|
/**
|
|
183
185
|
* Whether a dApp may safely AUTO-retry this failure. `false` for fund-safety refusals and user-intent
|
|
184
186
|
* declines (the default); `true` only for provably transient infra faults where nothing was signed.
|
|
@@ -317,7 +319,7 @@ declare class Cairn {
|
|
|
317
319
|
wallet?: WalletConnection;
|
|
318
320
|
private readonly cairnHttp;
|
|
319
321
|
private _spvLight;
|
|
320
|
-
private
|
|
322
|
+
private _spvSeedInflight;
|
|
321
323
|
private readonly _spvCp;
|
|
322
324
|
private readonly _cairnBase;
|
|
323
325
|
constructor(config?: CairnConfig);
|
|
@@ -328,18 +330,30 @@ declare class Cairn {
|
|
|
328
330
|
* checkpoint (can't verify backward) or if verification fails — the IndexerClient then degrades
|
|
329
331
|
* to the honest "proof-consistent" instead of over-claiming "verified-inclusion". The verified
|
|
330
332
|
* header chain is cached across calls; the first call near tip syncs the post-checkpoint span.
|
|
333
|
+
* MF-03: a STRUCTURAL forward-sync break (a reorg orphaned the cached tip) reseeds ONCE via
|
|
334
|
+
* withSpvReseed. RESIDUAL: a height at or below the cached tip answers from the cached branch with
|
|
335
|
+
* NO network touch, so a tx in a reorg-orphaned block can still read "verified-inclusion" until a
|
|
336
|
+
* read PAST the tip trips the reseed; this closes the permanent post-reorg brick, not reorg-blindness.
|
|
331
337
|
*/
|
|
332
338
|
private verifiedHeaderMerkleAt;
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
private withSpvReseed;
|
|
340
|
+
private seedFreshSpvLight;
|
|
341
|
+
/** The PoW-verifying SPV light client, seeded from the pinned checkpoint and cached across calls
|
|
342
|
+
* (shared by the M3 header-merkle bind and the F13 offer pre-verify). The client is committed to
|
|
343
|
+
* the field ONLY after a fully successful seed (MF-14): a failed seed leaves NO cached state, so
|
|
344
|
+
* the next call retries on a fresh client instead of wedging on "seedTrusted must be called on a
|
|
345
|
+
* fresh client". Concurrent first callers share ONE in-flight seed. Re-seeded only when a
|
|
346
|
+
* structural (reorg) break is detected, see withSpvReseed. */
|
|
335
347
|
private seededSpvLight;
|
|
336
348
|
/**
|
|
337
349
|
* W12 batch header transport for the checkpoint..tip forward sync, backed by the cairn server's
|
|
338
350
|
* `/api/headers/:from/:count`. Carries ZERO trust: the LightClient re-verifies every header's PoW /
|
|
339
351
|
* prev-link / LWMA, so a forged or stale row fails closed and retrying transport proves nothing new.
|
|
340
352
|
* Retry budget: a 429 (per-IP header budget) or 502/503 (a height past the lagging indexer, or the
|
|
341
|
-
* server's 10s deadline)
|
|
342
|
-
*
|
|
353
|
+
* server's 10s deadline), a transport/timeout error, or a body-read fault (an oversize stream past the
|
|
354
|
+
* SPV_MAX_BATCH_BYTES cap, or a non-JSON 200 body such as a CF interstitial) is a transient
|
|
355
|
+
* data-availability hiccup; back off (honouring Retry-After) a bounded number of times, then THROW so
|
|
356
|
+
* `sync()` fails closed. A non-dense range is rejected terminally (never retried).
|
|
343
357
|
*
|
|
344
358
|
* B7d / F6 DEPENDENCY: `/api/headers` serves no Access-Control-Allow-Origin today (`corsPublic` is
|
|
345
359
|
* mounted on `/explorer/api`, `/trade/api` and `/api/rpc`, not here). A SAME-ORIGIN dApp (hosted on the
|
|
@@ -359,8 +373,15 @@ declare class Cairn {
|
|
|
359
373
|
* This is best-effort corroboration, NOT the payment-grade boundary: the Cairn Wallet's OWN on-device
|
|
360
374
|
* fill-SPV (0.2.60+, fails-closed before signing) is that. A dApp settling real value should still clear this
|
|
361
375
|
* before building `outputs`, so it never hands the wallet a payment a lying resolver would redirect.
|
|
376
|
+
*
|
|
377
|
+
* Pass opts.pay (the CSD base units you intend to pay) to receive the PROVEN outputPlan for a whole fill of a
|
|
378
|
+
* non-partial offer, and opts.plannedOutputs to bind your planned per-address sums to it before signing; build
|
|
379
|
+
* EXACTLY the outputPlan, never resolver-served outputs.
|
|
362
380
|
*/
|
|
363
|
-
verifyOfferForFill(offerId: string, servedOffer?: unknown
|
|
381
|
+
verifyOfferForFill(offerId: string, servedOffer?: unknown, opts?: {
|
|
382
|
+
pay?: bigint | string | number;
|
|
383
|
+
plannedOutputs?: Record<string, bigint | string | number>;
|
|
384
|
+
}): Promise<OfferFillCheck>;
|
|
364
385
|
/**
|
|
365
386
|
* Detect + connect the Cairn Wallet (browser only). Wires the wallet into
|
|
366
387
|
* `cairn.board` for signed writes. Resolves to the connected address.
|