@ninsei-labs/sdk 0.47.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,28 @@
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
+
14
+ ## 0.48.0 — 2026-10-21
15
+
16
+ A SWAP CAN START FROM THE ONE RECOVERY KEY.
17
+
18
+ - `swaps.start` accepts `recoveryKey` (a BIP39 mnemonic or a key object). It derives the spend and view halves
19
+ for the reserved swap number instead of generating a random pair, and writes no per-swap recovery file.
20
+ - A key created by the call is handed to `onRecoveryKey` once, before the funds are locked; a key already in the
21
+ store is not shown again. The number is reserved before the order and retired only after the escrow exists.
22
+ - The old path is unchanged: without `recoveryKey`, `onRecoveryFile` still assembles and confirms a per-swap file.
23
+ - `buildOwnSideFromHalves` builds the order side and its DLEQ proof from supplied halves; `createRecord` accepts
24
+ that side, so the recovery key remains the only taker secret.
25
+
1
26
  ## 0.47.0 — 2026-10-21
2
27
 
3
28
  ONE RECOVERY KEY, AND THE NODE'S VIEW HALF COMES FROM IT.
package/index.d.ts CHANGED
@@ -717,7 +717,7 @@ export interface SwapsApi {
717
717
  /**
718
718
  * Start a swap. The SDK holds the step order, not the screen:
719
719
  * 1) guards (preflight); 2) the swap record (createSwap); 3) the order - it GENERATES the key halves;
720
- * 4) the recovery file and the interface's confirmation that the file is saved; 5) locking the funds (signature);
720
+ * 4) the recovery file / one key and the interface's confirmation that it is saved; 5) locking the funds (signature);
721
721
  * 6) saving on the server; 7) opening the watch on the Monero address.
722
722
  * HALVES DO NOT EXIST BEFORE THE ORDER: the recovery file carries MY half of the spend key and the counterparty's
723
723
  * open points, and they are taken from the signed order (www/js/atomic/order-worker.js, createOrderBuilder; in the
@@ -1102,10 +1102,31 @@ export interface StartRequest {
1102
1102
  */
1103
1103
  readonly wallet?: WalletAdapter;
1104
1104
  /**
1105
- * Hand the recovery file to the interface and wait for the person's decision.
1105
+ * Hand the OLD per-swap recovery file to the interface and wait for the person's decision.
1106
1106
  * `true` - the person confirmed the file is saved; `false` - the swap is not started.
1107
+ * Not used when `recoveryKey` is present: that path has one key, saved once.
1107
1108
  */
1108
- onRecoveryFile(file: RecoveryFile): Promise<boolean>;
1109
+ onRecoveryFile?(file: RecoveryFile): Promise<boolean>;
1110
+ /**
1111
+ * THE ONE RECOVERY KEY (sdk#76). A BIP39 mnemonic, or a key object from `@ninsei-labs/sdk/recovery`.
1112
+ * When present, the swap's spend and view halves are derived from it instead of being generated, and no
1113
+ * per-swap recovery file is written. The key is kept through the SDK's storage adapter.
1114
+ */
1115
+ recoveryKey?: string | { readonly mnemonic: string };
1116
+ /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
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;
1125
+ /**
1126
+ * Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
1127
+ * `false` - the swap is not started. A key already in the store is not shown again.
1128
+ */
1129
+ onRecoveryKey?(file: RecoveryFile): Promise<boolean>;
1109
1130
  }
1110
1131
 
1111
1132
  export interface RecoveryFile {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.47.0",
3
+ "version": "0.48.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/order.mjs CHANGED
@@ -97,6 +97,29 @@ export function buildOwnSide(order, onBit) {
97
97
  return { context, side, spendPoint: norm(side.pub.ed), viewPoint: norm(side.viewPub), encPub: norm(side.enc.pub) };
98
98
  }
99
99
 
100
+ /**
101
+ * OUR SIDE FROM HALVES WE ALREADY HAVE - the one recovery key case. A random side would be a second secret,
102
+ * which is exactly what one key removes. The proof is still bound to this order and built by the same engine
103
+ * builder; only the source of the two numbers changes.
104
+ */
105
+ export function buildOwnSideFromHalves(order, { spendHalf, viewHalf } = {}) {
106
+ const context = contextOf(order);
107
+ const b = builder();
108
+ const scalar = (value, name) => {
109
+ const n = BigInt(value);
110
+ if (n <= 0n || n >= BigInt(b.halves.LIMIT)) throw new SdkError("bad-input", { field: name });
111
+ return n;
112
+ };
113
+ const half = scalar(spendHalf, "spendHalf");
114
+ const view = scalar(viewHalf, "viewHalf");
115
+ const pub = b.halves.publicHalves(half);
116
+ const viewPub = b.halves.publicHalves(view).ed;
117
+ const encPair = b.enc.recipientKeyPair();
118
+ const proof = b.dleq.prove(half, context);
119
+ const side = { half, pub, viewHalf: view, viewPub, enc: encPair, proof, standIn: false };
120
+ return { context, side, spendPoint: norm(pub.ed), viewPoint: norm(viewPub), encPub: norm(encPair.pub) };
121
+ }
122
+
100
123
  /**
101
124
  * The counterparty's side: accept and verify. The check is ONE for both sides and is done by the engine
102
125
  * (order.js, verifySide): the proof stands in the CONTEXT OF THIS order, the proven point matches the one that
@@ -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/record.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  import { SdkError } from "./errors.mjs";
15
15
  import { primitives } from "./primitives.mjs";
16
16
  import * as engine from "./engine.mjs";
17
- import { buildOwnSide, halfHex, norm } from "./order.mjs";
17
+ import { buildOwnSide, buildOwnSideFromHalves, halfHex, norm } from "./order.mjs";
18
18
 
19
19
  /**
20
20
  * Our side. Without order terms - only the halves and their points (a shape of the record; there is no proof and
@@ -23,7 +23,11 @@ import { buildOwnSide, halfHex, norm } from "./order.mjs";
23
23
  * THE HALF NAMES ARE THE ENGINE'S: the half is returned in the engine's form (BigInt), and it goes into the
24
24
  * record as a hex string, because a BigInt survives neither storage nor the recovery file's JSON.
25
25
  */
26
- export function ownSide(order = null) {
26
+ export function ownSide(order = null, known = null) {
27
+ if (known && known.spendHalf && known.viewHalf) {
28
+ const built = buildOwnSideFromHalves(order, known);
29
+ return built.side;
30
+ }
27
31
  if (order !== null && order !== undefined) {
28
32
  const built = buildOwnSide(order);
29
33
  return built.side;
@@ -40,9 +44,9 @@ export function ownSide(order = null) {
40
44
  * edPointLocker, edViewPointLocker). The counterparty's half cannot and need not be put in: it will come from
41
45
  * the order.
42
46
  */
43
- export function createRecord(input, { order = null } = {}) {
47
+ export function createRecord(input, { order = null, side: knownSide = null } = {}) {
44
48
  if (!input || typeof input !== "object") throw new SdkError("bad-input", { field: "input" });
45
- const side = ownSide(order);
49
+ const side = knownSide || ownSide(order);
46
50
  let swap = null;
47
51
  try { swap = engine.swap.createSwap(input); } catch { throw new SdkError("bad-input", { field: "input" }); }
48
52
  const escrow = { ...(swap.escrow || {}),
@@ -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
@@ -12,17 +12,23 @@ import * as engine from "./engine.mjs";
12
12
  import * as recordModule from "./record.mjs";
13
13
  // The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
14
14
  // the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
15
- import { contextOf, orderStep } from "./order.mjs";
15
+ import { buildOwnSideFromHalves, contextOf, orderStep } from "./order.mjs";
16
16
  // Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
17
17
  import * as lock from "./lock.mjs";
18
18
  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.
25
26
  import { createActions } from "./actions.mjs";
27
+ // THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
28
+ import {
29
+ commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
30
+ recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
31
+ } from "./recovery-key.mjs";
26
32
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
27
33
  import * as xmrArrival from "./xmr-arrival.mjs";
28
34
  // THE MAKER'S DECLINE (issue #40): the escrow's `Declined` event and the node's `declined` + `compliance` status
@@ -301,6 +307,24 @@ const recordInputOf = (request) => {
301
307
  // createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
302
308
  // the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
303
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
+
304
328
  export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
305
329
  // THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
306
330
  // and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
@@ -337,6 +361,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
337
361
  const value = config && config.limits ? config.limits[name] : null;
338
362
  return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
339
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
+ }
340
376
  const records = () => {
341
377
  const state = engine.store.loadState();
342
378
  const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
@@ -449,11 +485,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
449
485
  if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
450
486
  const network = swap.moneroNetwork || xmrNetwork || null;
451
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" });
452
491
  const wanted = {
453
492
  swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
454
- // THE RESTORE HEIGHT - FROM THE SWAP RECORD (the order puts it there), not invented: a scan from zero would take
455
- // tens of hours, and the arrivals would not be visible in a reasonable time.
456
- 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,
457
497
  chain: swap.network || swap.chain || null,
458
498
  };
459
499
  const body = {};
@@ -705,7 +745,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
705
745
  async start(request) {
706
746
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
707
747
  if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
708
- if (typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
748
+ // THE OLD PATH IS UNCHANGED; THE NEW ONE ASKS FOR A KEY INSTEAD OF A PER-SWAP FILE.
749
+ const wantsKey = request.recoveryKey !== undefined && request.recoveryKey !== null;
750
+ if (!wantsKey && typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
709
751
  if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
710
752
  // THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
711
753
  // quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
@@ -734,8 +776,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
734
776
  // record, and our side of the order is computed from the core's primitives.
735
777
  // THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
736
778
  // builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
779
+ let keySide = null;
737
780
  const makeRecord = typeof createRecord === "function" ? createRecord
738
- : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
781
+ : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order, side: keySide });
739
782
  const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
740
783
  if (typeof passphrase !== "string") throw new SdkError("bad-input", { field: "passphrase" });
741
784
  if (passphrase.length > 0 && passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
@@ -744,6 +787,32 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
744
787
  // values: the DLEQ proof would then be bound to nothing.
745
788
  const orderContext = contextOf(request.order);
746
789
 
790
+ // THE KEY RECORD, THE NUMBER AND THE SIDE. A key is made once and kept through the SDK's store; the
791
+ // number is reserved before the order is built, and it is retired only after the order exists on chain.
792
+ // No per-swap file is assembled on this path.
793
+ let recovery = null;
794
+ if (wantsKey) {
795
+ const storage = engine.store.storage();
796
+ const supplied = typeof request.recoveryKey === "string"
797
+ ? request.recoveryKey
798
+ : request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
799
+ const saved = loadRecoveryKey(storage);
800
+ if (saved && supplied && saved.mnemonic !== supplied) {
801
+ throw new SdkError("bad-input", { field: "recoveryKey", why: "different-key" });
802
+ }
803
+ if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
804
+ const key = saved || importRecoveryKey(supplied);
805
+ if (!saved) saveRecoveryKey(storage, key);
806
+ const chainId = request.order.chainId;
807
+ const index = request.recoveryIndex === undefined || request.recoveryIndex === null
808
+ ? reserveRecoveryIndex(storage, { chainId })
809
+ : Number(request.recoveryIndex);
810
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
811
+ const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
812
+ keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
813
+ recovery = { storage, key, index, newKey: !saved };
814
+ }
815
+
747
816
  let made = null;
748
817
  try { made = await makeRecord(request); } catch (error) {
749
818
  // THE CODE MATTERS, NOT THE CLASS: an error with a code (including from another instance of the module) passes
@@ -785,26 +854,51 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
785
854
  builtSide = step;
786
855
  }
787
856
 
788
- // 2a. THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
789
- // file: the person will rely on it. So we check on our side, before the screen.
790
- let payload = null;
791
- try {
792
- payload = engine.recoveryFile.buildPayload({
793
- swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
794
- receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
795
- });
796
- engine.recoveryFile.validatePayload(payload);
797
- } catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
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" }); }
798
865
 
866
+ // 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
867
+ // no file per swap: the same twelve words cover every swap, and a person who already saved them is not
868
+ // asked again. A new key is shown before the money moves - no saved key, no funding.
869
+ let payload = null;
799
870
  let file = null;
800
- try {
801
- file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
802
- } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
871
+ let confirmed = true;
872
+ if (!recovery) {
873
+ // THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
874
+ // file: the person will rely on it. So we check on our side, before the screen.
875
+ try {
876
+ payload = engine.recoveryFile.buildPayload({
877
+ swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
878
+ receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
879
+ });
880
+ engine.recoveryFile.validatePayload(payload);
881
+ } catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
803
882
 
804
- // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
805
- // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
806
- const confirmed = await request.onRecoveryFile(file);
807
- if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
883
+ try {
884
+ file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
885
+ } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
886
+
887
+ // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
888
+ // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
889
+ confirmed = await request.onRecoveryFile(file);
890
+ if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
891
+ } else if (recovery.newKey) {
892
+ if (typeof request.onRecoveryKey !== "function") throw new SdkError("bad-input", { field: "onRecoveryKey" });
893
+ try {
894
+ file = {
895
+ name: recoveryKeyFileName(recovery.key),
896
+ contents: await createRecoveryKeyFile({ mnemonic: recovery.key.mnemonic, createdAt: recovery.key.createdAt, passphrase }),
897
+ };
898
+ } catch { throw new SdkError("recovery-failed", { step: "encrypt-key" }); }
899
+ confirmed = await request.onRecoveryKey(file);
900
+ if (confirmed !== true) throw new SdkError("recovery-declined", { key: recovery.key.fingerprint });
901
+ }
808
902
 
809
903
  // 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
810
904
  // in it, and the core keeps no path into the chain of its own. The absence of a wallet is a REFUSAL WITH A CODE,
@@ -861,9 +955,16 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
861
955
  if (escrowAddress) {
862
956
  const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
863
957
  record.swap.escrow = { ...escrowRecord, address: escrowAddress,
864
- // THE FUNDING HEIGHT - FROM THE RECEIPT: the XMR watch and the recovery file take it from here.
865
- 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) };
866
962
  try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
963
+ // THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
964
+ // reservation, so a retry uses the same number and the later search has no gap.
965
+ if (recovery) {
966
+ try { commitRecoveryIndex(recovery.storage, recovery.index); } catch { /* the reservation stays; the key still works */ }
967
+ }
867
968
  }
868
969
 
869
970
  // 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
@@ -954,7 +1055,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
954
1055
  const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
955
1056
  // FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
956
1057
  // walking the whole chain every tick is a cost with no answer behind it.
957
- 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);
958
1060
  const chainDecline = { checked: false, error: null, logs: undefined };
959
1061
  const readDeclineLogs = async () => {
960
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.47.0";
9
+ export const SDK_VERSION = "0.48.1";