@ninsei-labs/sdk 0.48.0 → 0.48.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/CHANGELOG.md +13 -0
- package/index.d.ts +7 -0
- package/package.json +1 -1
- package/src/public/recovery.d.ts +10 -0
- package/src/recovery-key.mjs +64 -1
- package/src/swaps.mjs +53 -6
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 0.48.1 — 2026-10-21
|
|
2
|
+
|
|
3
|
+
A SWAP KEEPS ITS MONERO HEIGHT, AND A RECOVERED ORDER KNOWS WHERE TO SCAN.
|
|
4
|
+
|
|
5
|
+
- `swaps.start` measures the start height from the configured Monero nodes before it hands over recovery data. A
|
|
6
|
+
node on another network, or no answer at all, is `xmr-no-nodes { step: "restore-height" }`; a swap without a
|
|
7
|
+
height is not funded.
|
|
8
|
+
- The measured height is kept on the swap record and written into the recovery file. `openWatch` sends that
|
|
9
|
+
Monero height; the settlement-chain block is named `evmBirthBlock` and stays the bound for the EVM log search.
|
|
10
|
+
- `findSwaps` derives a `startHeight` for every order it finds: the timestamp of the order's block, then the last
|
|
11
|
+
Monero header at or before it, minus 60 blocks. The readers are seams (`evmBlockTime`, `moneroTip`,
|
|
12
|
+
`moneroBlockTimestamp`); without them the order comes back with `startHeight: null` and a named reason.
|
|
13
|
+
|
|
1
14
|
## 0.48.0 — 2026-10-21
|
|
2
15
|
|
|
3
16
|
A SWAP CAN START FROM THE ONE RECOVERY KEY.
|
package/index.d.ts
CHANGED
|
@@ -1115,6 +1115,13 @@ export interface StartRequest {
|
|
|
1115
1115
|
recoveryKey?: string | { readonly mnemonic: string };
|
|
1116
1116
|
/** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
|
|
1117
1117
|
recoveryIndex?: number;
|
|
1118
|
+
/** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
|
|
1119
|
+
* the start if none answers on the swap's network. */
|
|
1120
|
+
restoreHeight?: number;
|
|
1121
|
+
/** Monero nodes for that one measurement; absent - the configured nodes for the swap's network. */
|
|
1122
|
+
xmrNodes?: readonly (string | { readonly url: string })[];
|
|
1123
|
+
/** The fetch used for the height measurement (tests); absent - the global fetch. */
|
|
1124
|
+
fetchImpl?: typeof fetch;
|
|
1118
1125
|
/**
|
|
1119
1126
|
* Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
|
|
1120
1127
|
* `false` - the swap is not started. A key already in the store is not shown again.
|
package/package.json
CHANGED
package/src/public/recovery.d.ts
CHANGED
|
@@ -327,6 +327,10 @@ export interface FoundSwap {
|
|
|
327
327
|
readonly logIndex: number | null;
|
|
328
328
|
readonly index: number;
|
|
329
329
|
readonly side: "locker" | "claimer";
|
|
330
|
+
/** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
|
|
331
|
+
* not pass the readers; `startHeightReason` then names why. */
|
|
332
|
+
readonly startHeight: number | null;
|
|
333
|
+
readonly startHeightReason: "no-block-number" | "no-height-readers" | "no-block-time" | "monero-header" | null;
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
/** A storage adapter: the same shape the SDK store takes. */
|
|
@@ -375,4 +379,10 @@ export declare function findSwaps(request: {
|
|
|
375
379
|
toBlock?: number | string;
|
|
376
380
|
gap?: number;
|
|
377
381
|
maxIndex?: number;
|
|
382
|
+
/** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
|
|
383
|
+
evmBlockTime?: (blockNumber: number) => Promise<number>;
|
|
384
|
+
/** The Monero tip at the time of the search: () -> height. */
|
|
385
|
+
moneroTip?: () => Promise<number>;
|
|
386
|
+
/** A Monero block header timestamp, in seconds: (height) -> seconds. */
|
|
387
|
+
moneroBlockTimestamp?: (height: number) => Promise<number>;
|
|
378
388
|
}): Promise<{ swaps: FoundSwap[]; scanned: number }>;
|
package/src/recovery-key.mjs
CHANGED
|
@@ -396,6 +396,61 @@ async function readLogs(evmLogs, request) {
|
|
|
396
396
|
return logs;
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
// THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
|
|
400
|
+
// derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
|
|
401
|
+
// block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
|
|
402
|
+
//
|
|
403
|
+
// The two readers are seams, like evmLogs: the SDK owns the rule, the caller owns the transport. Without them the
|
|
404
|
+
// order is still returned, with startHeight null and a named reason, instead of a guessed height.
|
|
405
|
+
const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
|
|
406
|
+
|
|
407
|
+
function secondsOf(value) {
|
|
408
|
+
const n = Number(value);
|
|
409
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
410
|
+
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
|
|
414
|
+
const top = Number(await tip());
|
|
415
|
+
if (!Number.isSafeInteger(top) || top < 0) throw new Error("monero-tip");
|
|
416
|
+
const at = async (height) => secondsOf(await headerTimestamp(height));
|
|
417
|
+
const topTime = await at(top);
|
|
418
|
+
if (topTime !== null && topTime <= timestamp) return top;
|
|
419
|
+
let low = 0;
|
|
420
|
+
let high = top;
|
|
421
|
+
const zeroTime = await at(0);
|
|
422
|
+
if (zeroTime !== null && zeroTime > timestamp) return 0;
|
|
423
|
+
while (low < high) {
|
|
424
|
+
const mid = Math.ceil((low + high) / 2);
|
|
425
|
+
const ts = await at(mid);
|
|
426
|
+
if (ts === null) throw new Error("monero-header");
|
|
427
|
+
if (ts <= timestamp) low = mid;
|
|
428
|
+
else high = mid - 1;
|
|
429
|
+
}
|
|
430
|
+
return low;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
|
|
434
|
+
if (order.blockNumber === null || order.blockNumber === undefined) {
|
|
435
|
+
return { startHeight: null, startHeightReason: "no-block-number" };
|
|
436
|
+
}
|
|
437
|
+
if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
|
|
438
|
+
return { startHeight: null, startHeightReason: "no-height-readers" };
|
|
439
|
+
}
|
|
440
|
+
let timestamp = cache.get(order.blockNumber);
|
|
441
|
+
if (timestamp === undefined) {
|
|
442
|
+
timestamp = secondsOf(await evmBlockTime(order.blockNumber));
|
|
443
|
+
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
|
|
444
|
+
cache.set(order.blockNumber, timestamp);
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
|
|
448
|
+
return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
|
|
449
|
+
} catch {
|
|
450
|
+
return { startHeight: null, startHeightReason: "monero-header" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
399
454
|
/**
|
|
400
455
|
* Find orders made with this key.
|
|
401
456
|
*
|
|
@@ -403,7 +458,9 @@ async function readLogs(evmLogs, request) {
|
|
|
403
458
|
* search asks for it as the locker and as the claimer (two queries; the RPC topic filter cannot say OR across two
|
|
404
459
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
405
460
|
*/
|
|
406
|
-
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
461
|
+
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
462
|
+
fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
|
|
463
|
+
evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null } = {}) {
|
|
407
464
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
408
465
|
const chain = numberField(chainId, "chainId");
|
|
409
466
|
const max = numberField(maxIndex, "maxIndex") || 1000;
|
|
@@ -446,5 +503,11 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
446
503
|
found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
|
|
447
504
|
}
|
|
448
505
|
}
|
|
506
|
+
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
507
|
+
// page: a widget and the recovery page must not derive two different answers from one chain.
|
|
508
|
+
const cache = new Map();
|
|
509
|
+
for (const order of found) {
|
|
510
|
+
Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }));
|
|
511
|
+
}
|
|
449
512
|
return { swaps: found, scanned };
|
|
450
513
|
}
|
package/src/swaps.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { vmOfChain, legFor } from "./legs/index.mjs";
|
|
|
19
19
|
// THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
|
|
20
20
|
// with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
|
|
21
21
|
import { primitives } from "./primitives.mjs";
|
|
22
|
+
import { probeMoneroNode } from "./probes.mjs";
|
|
22
23
|
// MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
|
|
23
24
|
// the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
|
|
24
25
|
// the XMR" and "we allowed the settlement to be taken" would diverge.
|
|
@@ -306,6 +307,24 @@ const recordInputOf = (request) => {
|
|
|
306
307
|
// createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
|
|
307
308
|
// the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
|
|
308
309
|
// confirmation) is written and checked already now, rather than appearing together with the browser part.
|
|
310
|
+
/**
|
|
311
|
+
* The Monero height a swap starts at, measured from the configured nodes. Exported for the guard, not as a
|
|
312
|
+
* package entry: the start is the only production caller. A node that answers on another network is not a height
|
|
313
|
+
* for this deal, and a missing answer is a named refusal, never a scan from zero.
|
|
314
|
+
*/
|
|
315
|
+
export async function startHeightFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
|
|
316
|
+
const urls = (Array.isArray(nodes) ? nodes : [])
|
|
317
|
+
.map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
|
|
318
|
+
const answers = await Promise.all(urls.map(async (url) => {
|
|
319
|
+
try { return await probeMoneroNode({ url, fetchImpl }); }
|
|
320
|
+
catch { return { ok: false, why: "unreachable" }; }
|
|
321
|
+
}));
|
|
322
|
+
const good = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
|
|
323
|
+
if (good) return good.height;
|
|
324
|
+
const why = answers.find((answer) => answer.ok === true) ? "network" : (answers[0] && answers[0].why) || "no-node";
|
|
325
|
+
throw new SdkError("xmr-no-nodes", { step: "restore-height", network: network || null, why });
|
|
326
|
+
}
|
|
327
|
+
|
|
309
328
|
export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
|
|
310
329
|
// THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
|
|
311
330
|
// and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
|
|
@@ -342,6 +361,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
342
361
|
const value = config && config.limits ? config.limits[name] : null;
|
|
343
362
|
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
|
|
344
363
|
};
|
|
364
|
+
|
|
365
|
+
// THE MONERO HEIGHT A SWAP STARTS AT. It is measured before the recovery data is handed over: a file without
|
|
366
|
+
// it scans Monero from block zero, and a watch opened at an EVM block sees nothing. The node must name the same
|
|
367
|
+
// network as the swap; a different network is not a height for this deal.
|
|
368
|
+
async function measuredStartHeight(request, moneroNetwork) {
|
|
369
|
+
const explicit = Number(request.restoreHeight);
|
|
370
|
+
if (Number.isSafeInteger(explicit) && explicit > 0) return explicit;
|
|
371
|
+
const listed = Array.isArray(request.xmrNodes) ? request.xmrNodes
|
|
372
|
+
: (config && typeof config.nodesFor === "function" ? config.nodesFor(moneroNetwork) : []);
|
|
373
|
+
return startHeightFromNodes({ nodes: listed, network: moneroNetwork,
|
|
374
|
+
fetchImpl: request.fetchImpl || (typeof fetch === "function" ? fetch : null) });
|
|
375
|
+
}
|
|
345
376
|
const records = () => {
|
|
346
377
|
const state = engine.store.loadState();
|
|
347
378
|
const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
|
|
@@ -454,11 +485,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
454
485
|
if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
|
|
455
486
|
const network = swap.moneroNetwork || xmrNetwork || null;
|
|
456
487
|
if (!network) throw new SdkError("bad-input", { field: "moneroNetwork" });
|
|
488
|
+
const restoreHeight = Number(swap.restoreHeight) > 0 ? Number(swap.restoreHeight)
|
|
489
|
+
: (Number(e.moneroRestoreHeight) > 0 ? Number(e.moneroRestoreHeight) : null);
|
|
490
|
+
if (!(restoreHeight > 0)) throw new SdkError("bad-input", { field: "restoreHeight", step: "watch" });
|
|
457
491
|
const wanted = {
|
|
458
492
|
swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
|
|
459
|
-
// THE RESTORE HEIGHT -
|
|
460
|
-
//
|
|
461
|
-
|
|
493
|
+
// THE RESTORE HEIGHT - THE MONERO HEIGHT, NEVER THE EVM BLOCK. It is measured before the file is handed
|
|
494
|
+
// over and kept on the record; `birthHeight`/`evmBirthBlock` is a settlement-chain block for log search and
|
|
495
|
+
// is far above any Monero tip, so using it here opened the watch nowhere near the money.
|
|
496
|
+
restoreHeight,
|
|
462
497
|
chain: swap.network || swap.chain || null,
|
|
463
498
|
};
|
|
464
499
|
const body = {};
|
|
@@ -819,6 +854,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
819
854
|
builtSide = step;
|
|
820
855
|
}
|
|
821
856
|
|
|
857
|
+
// THE HEIGHT IS MEASURED BEFORE THE FILE OR THE WATCH: the node is asked once, while the swap is still in
|
|
858
|
+
// front of us. A swap without it is not funded - a recovery file that scans from zero is a file that looks
|
|
859
|
+
// usable and is not.
|
|
860
|
+
const measuredHeight = await measuredStartHeight(request, record.swap.moneroNetwork || xmrNetwork || null);
|
|
861
|
+
record.swap.restoreHeight = measuredHeight;
|
|
862
|
+
record.restoreHeight = measuredHeight;
|
|
863
|
+
record.swap.escrow = { ...(record.swap.escrow || {}), moneroRestoreHeight: measuredHeight };
|
|
864
|
+
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-restore-height" }); }
|
|
865
|
+
|
|
822
866
|
// 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
|
|
823
867
|
// no file per swap: the same twelve words cover every swap, and a person who already saved them is not
|
|
824
868
|
// asked again. A new key is shown before the money moves - no saved key, no funding.
|
|
@@ -911,8 +955,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
911
955
|
if (escrowAddress) {
|
|
912
956
|
const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
|
|
913
957
|
record.swap.escrow = { ...escrowRecord, address: escrowAddress,
|
|
914
|
-
// THE
|
|
915
|
-
|
|
958
|
+
// THE SETTLEMENT-CHAIN HEIGHT, NAMED AS SUCH. It is for the OrderCreated/Declined log search; the XMR
|
|
959
|
+
// watch and the recovery file take the Monero height measured above. The old name `birthHeight` stays
|
|
960
|
+
// readable on records written before this split.
|
|
961
|
+
evmBirthBlock: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.evmBirthBlock ?? escrowRecord.birthHeight ?? null) };
|
|
916
962
|
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
|
|
917
963
|
// THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
|
|
918
964
|
// reservation, so a retry uses the same number and the later search has no gap.
|
|
@@ -1009,7 +1055,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
1009
1055
|
const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
|
|
1010
1056
|
// FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
|
|
1011
1057
|
// walking the whole chain every tick is a cost with no answer behind it.
|
|
1012
|
-
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1058
|
+
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1059
|
+
? (record.escrow.evmBirthBlock ?? record.escrow.birthHeight) : null);
|
|
1013
1060
|
const chainDecline = { checked: false, error: null, logs: undefined };
|
|
1014
1061
|
const readDeclineLogs = async () => {
|
|
1015
1062
|
chainDecline.checked = false; chainDecline.error = null; chainDecline.logs = undefined;
|
package/src/version.mjs
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
// tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
|
|
7
7
|
// The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
|
|
8
8
|
|
|
9
|
-
export const SDK_VERSION = "0.48.
|
|
9
|
+
export const SDK_VERSION = "0.48.1";
|