@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -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 }>;
@@ -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, fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000 } = {}) {
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 - FROM THE SWAP RECORD (the order puts it there), not invented: a scan from zero would take
460
- // tens of hours, and the arrivals would not be visible in a reasonable time.
461
- restoreHeight: Number(e.birthHeight) > 0 ? Number(e.birthHeight) : null,
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 FUNDING HEIGHT - FROM THE RECEIPT: the XMR watch and the recovery file take it from here.
915
- birthHeight: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.birthHeight ?? null) };
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 ? record.escrow.birthHeight : null);
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.0";
9
+ export const SDK_VERSION = "0.48.1";