@ninsei-labs/sdk 0.48.0 → 0.49.0

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,31 @@
1
+ ## 0.49.0 — 2026-10-21
2
+
3
+ A ONE-KEY RECOVERY CAN WITHDRAW WITHOUT A FULL SCAN, AND A CLAIM TIP IS CHECKED.
4
+
5
+ - `findSwaps` accepts `xmrNodes` and `moneroNetwork`: it asks the node for `get_info` and
6
+ `get_block_header_by_height` itself, so a page needs only `evmBlockTime`; the `startHeight` rule stays in the SDK.
7
+ - `sweep.restore({ window })` sets the wallet at the node tip, scans only the blocks from the swap's start to
8
+ `t1 + 10` through `scanTxs` (batches of 100), checks the wallet's key images at `/is_key_image_spent`, and does
9
+ not run `rescanBlockchain` after `scanTxs`. An empty window falls back to the full scan; `windowTxHashes` collects
10
+ the hashes from a node.
11
+ - `quotes.firm` refuses a `claimTip` above twice the SDK's own `claimGasGiftWei` estimate, read from the new
12
+ `evmBlock` seam. Zero tips (the buy side) do not call it; an unmeasured base fee is named.
13
+ - `claimTipVerdict` is exported for the same arithmetic outside the quote path.
14
+ - No breaking changes: the old recovery-file path, the full restore and zero-tip quotes behave as before.
15
+
16
+ ## 0.48.1 — 2026-10-21
17
+
18
+ A SWAP KEEPS ITS MONERO HEIGHT, AND A RECOVERED ORDER KNOWS WHERE TO SCAN.
19
+
20
+ - `swaps.start` measures the start height from the configured Monero nodes before it hands over recovery data. A
21
+ node on another network, or no answer at all, is `xmr-no-nodes { step: "restore-height" }`; a swap without a
22
+ height is not funded.
23
+ - The measured height is kept on the swap record and written into the recovery file. `openWatch` sends that
24
+ Monero height; the settlement-chain block is named `evmBirthBlock` and stays the bound for the EVM log search.
25
+ - `findSwaps` derives a `startHeight` for every order it finds: the timestamp of the order's block, then the last
26
+ Monero header at or before it, minus 60 blocks. The readers are seams (`evmBlockTime`, `moneroTip`,
27
+ `moneroBlockTimestamp`); without them the order comes back with `startHeight: null` and a named reason.
28
+
1
29
  ## 0.48.0 — 2026-10-21
2
30
 
3
31
  A SWAP CAN START FROM THE ONE RECOVERY KEY.
package/index.d.ts CHANGED
@@ -108,6 +108,13 @@ export interface NinseiOptions {
108
108
  * the order's escrow and the topic is the package's own.
109
109
  */
110
110
  evmLogs?: EvmLogReader;
111
+ /**
112
+ * READING A SETTLEMENT BLOCK FROM OUTSIDE (`(tag) => block`): the claim-tip check needs the chain's current
113
+ * `baseFeePerGas`. A missing seam is `quote-tip-unchecked` when a quote carries a non-zero tip; zero tips (the
114
+ * buy side) never call it.
115
+ */
116
+ evmBlock?: (tag?: string) => Promise<{ readonly baseFeePerGas?: string | number | bigint | null;
117
+ readonly baseFeeWei?: string | number | bigint | null }>;
111
118
  /**
112
119
  * THE INTERFACE'S OWN ADMISSION LIST - policy, not proof. The chain (maker registry, providerOf, taken from the
113
120
  * quote) proves whose key signed; this says whom THIS interface trades with at all. An array of provider
@@ -1115,6 +1122,13 @@ export interface StartRequest {
1115
1122
  recoveryKey?: string | { readonly mnemonic: string };
1116
1123
  /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
1117
1124
  recoveryIndex?: number;
1125
+ /** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
1126
+ * the start if none answers on the swap's network. */
1127
+ restoreHeight?: number;
1128
+ /** Monero nodes for that one measurement; absent - the configured nodes for the swap's network. */
1129
+ xmrNodes?: readonly (string | { readonly url: string })[];
1130
+ /** The fetch used for the height measurement (tests); absent - the global fetch. */
1131
+ fetchImpl?: typeof fetch;
1118
1132
  /**
1119
1133
  * Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
1120
1134
  * `false` - the swap is not started. A key already in the store is not shown again.
@@ -1289,6 +1303,20 @@ export interface SweepRestoreRequest {
1289
1303
  readonly scanBack?: number;
1290
1304
  /** Run the second pass when coins exist but none is spendable. Default true. */
1291
1305
  readonly rescanWhenLocked?: boolean;
1306
+ /**
1307
+ * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1308
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1309
+ * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1310
+ */
1311
+ readonly window?: {
1312
+ readonly nodeUrl?: string;
1313
+ readonly txHashes?: readonly string[];
1314
+ readonly toHeight: number;
1315
+ readonly tipHeight: number;
1316
+ readonly fallback?: boolean;
1317
+ };
1318
+ /** The fetch used for `get_block`; absent - the global fetch. */
1319
+ readonly fetchImpl?: typeof fetch;
1292
1320
  }
1293
1321
 
1294
1322
  /** What `restore` found. Amounts are atomic units as strings; heights are numbers when the driver can read them. */
@@ -1303,6 +1331,18 @@ export interface SweepRestoreResult {
1303
1331
  readonly passes: number;
1304
1332
  /** Whether the second pass RAN - not whether it helped. */
1305
1333
  readonly lockedPass: boolean;
1334
+ /** Present when a window was requested: whether it was used, and why it was skipped. */
1335
+ readonly window?: {
1336
+ readonly used: boolean;
1337
+ readonly skipped: string | null;
1338
+ readonly fromHeight: number;
1339
+ readonly toHeight: number;
1340
+ readonly blocks: number;
1341
+ readonly txs: number;
1342
+ readonly spent?: number | null;
1343
+ readonly keyImages?: number;
1344
+ readonly spentWhy?: string;
1345
+ } | null;
1306
1346
  }
1307
1347
 
1308
1348
  /** What `wallet` needs: the library namespace and the facts the wallet is built from. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/index.mjs CHANGED
@@ -91,6 +91,9 @@ const normalizeOptions = (options) => {
91
91
  // same provider the chain read uses. Same rule as evmCall: a READ of the escrow's own event, not a node
92
92
  // substitution.
93
93
  if (o.evmLogs !== undefined && typeof o.evmLogs !== "function") fail("bad-input", { field: "evmLogs" });
94
+ // THE BLOCK-READ SEAM: the claim tip is checked against the chain's current base fee, and that is a block read,
95
+ // not an `eth_call`. Same rule as evmCall - brought from outside, refused when it is not a function.
96
+ if (o.evmBlock !== undefined && typeof o.evmBlock !== "function") fail("bad-input", { field: "evmBlock" });
94
97
  // THE INTERFACE'S ALLOW-LIST (POLICY, NOT PROOF): an array of provider addresses (or providerIds) OR a
95
98
  // predicate. It is NOT the check itself - that is `providerOf` on chain, taken from the quote - but the
96
99
  // interface's own decision about whom it trades with. A wrong shape is a typo, not a silent no-op.
@@ -156,6 +159,7 @@ const normalizeOptions = (options) => {
156
159
  evmCall: o.evmCall || null,
157
160
  evmCode: o.evmCode || null,
158
161
  evmLogs: o.evmLogs || null,
162
+ evmBlock: o.evmBlock || null,
159
163
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
160
164
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
161
165
  knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
@@ -215,7 +219,8 @@ export function createNinsei(options) {
215
219
  ? new Set(o.denylist.map((a) => String(a).toLowerCase()))
216
220
  : null;
217
221
  const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
218
- evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
222
+ evmCode: o.evmCode || null, evmBlock: o.evmBlock || null,
223
+ providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
219
224
  denylist });
220
225
  // THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
221
226
  // part of the check goes past the wallet and part does not.
@@ -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,17 @@ 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>;
388
+ /** Convenience: the node URLs the caller already probed. When the two Monero readers above are absent, the SDK
389
+ * asks these for `get_info` and `get_block_header_by_height` itself. */
390
+ xmrNodes?: readonly (string | { readonly url: string })[];
391
+ /** The Monero network those nodes must name (mainnet, stagenet, testnet). */
392
+ moneroNetwork?: string | null;
393
+ /** The fetch used for those node requests; absent - the global fetch. */
394
+ fetchImpl?: typeof fetch;
378
395
  }): Promise<{ swaps: FoundSwap[]; scanned: number }>;
package/src/quotes.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  import { fail } from "./errors.mjs";
19
19
  import * as engine from "./engine.mjs";
20
20
  import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
21
+ import { claimGasGiftWei } from "../engine/evm/claimGas.js";
21
22
  // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
22
23
  // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
23
24
  // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
@@ -208,7 +209,32 @@ const compactOffer = (o) => ({
208
209
  // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
210
  const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
211
 
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
+ /**
213
+ * A CLAIM TIP IS SIZED BY THE CLAIM, NOT BY THE MAKER'S MOOD. The node's own estimate uses the chain's current
214
+ * base fee and the same formula; a quote may carry up to twice that estimate (gas moves between the sizing and
215
+ * the check). Zero is the buy side and is always allowed. An unmeasured base fee is a refusal, not a free pass.
216
+ */
217
+ export function claimTipVerdict({ claimTipWei = 0, baseFeeWei = null, priorityFeeWei = 0, factor = 2 } = {}) {
218
+ let tip = 0n;
219
+ try { tip = BigInt(claimTipWei === undefined || claimTipWei === null || claimTipWei === "" ? 0 : claimTipWei); }
220
+ catch { return { ok: false, code: "quote-tip-unreadable" }; }
221
+ if (tip < 0n) return { ok: false, code: "quote-tip-unreadable" };
222
+ if (tip === 0n) return { ok: true, tipWei: "0", ceilingWei: null, estimateWei: null };
223
+ if (baseFeeWei === null || baseFeeWei === undefined || baseFeeWei === "") {
224
+ return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
225
+ }
226
+ const estimate = claimGasGiftWei({ baseFeeWei, priorityFeeWei });
227
+ if (estimate === null) return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
228
+ const f = BigInt(factor);
229
+ const ceiling = estimate * f;
230
+ if (tip > ceiling) {
231
+ return { ok: false, code: "quote-tip-too-large", tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
232
+ }
233
+ return { ok: true, tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
234
+ }
235
+
236
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, evmBlock = null,
237
+ providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
238
  // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
239
  // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
240
  // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
@@ -507,6 +533,24 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
507
533
  if (String(quoted) !== expectedContext) {
508
534
  fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
509
535
  }
536
+ // THE CLAIM TIP: the seller pays it out of the payout, so a quote that asks for more than the claim can
537
+ // cost is refused before it is signed. The chain's base fee is read through the caller's block seam; a
538
+ // missing seam or an unreadable base fee is a named refusal, never a silently accepted tip.
539
+ const tipCheck = (() => {
540
+ try { return claimTipVerdict({ claimTipWei: quote.claimTip }); }
541
+ catch { return { ok: true, tipWei: "0" }; }
542
+ })();
543
+ if (!tipCheck.ok && tipCheck.code === "quote-tip-unreadable") {
544
+ fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
545
+ }
546
+ if (tipCheck.tipWei !== "0") {
547
+ if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
548
+ let block = null;
549
+ try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
550
+ const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
551
+ const verdict = claimTipVerdict({ claimTipWei: quote.claimTip, baseFeeWei });
552
+ if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
553
+ }
510
554
  return quote;
511
555
  },
512
556
  };
@@ -19,6 +19,7 @@ import { createHalves } from "../engine/atomic/halves.js";
19
19
  import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
20
20
  import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
21
21
  import { SdkError } from "./errors.mjs";
22
+ import { probeMoneroNode } from "./probes.mjs";
22
23
  import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
23
24
 
24
25
  const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
@@ -396,6 +397,100 @@ async function readLogs(evmLogs, request) {
396
397
  return logs;
397
398
  }
398
399
 
400
+ // THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
401
+ // derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
402
+ // block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
403
+ //
404
+ // The two readers are seams, like evmLogs: the SDK owns the rule, the caller owns the transport. Without them the
405
+ // order is still returned, with startHeight null and a named reason, instead of a guessed height.
406
+ const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
407
+
408
+ function secondsOf(value) {
409
+ const n = Number(value);
410
+ if (!Number.isFinite(n) || n <= 0) return null;
411
+ return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
412
+ }
413
+
414
+ async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
415
+ const top = Number(await tip());
416
+ if (!Number.isSafeInteger(top) || top < 0) throw new Error("monero-tip");
417
+ const at = async (height) => secondsOf(await headerTimestamp(height));
418
+ const topTime = await at(top);
419
+ if (topTime !== null && topTime <= timestamp) return top;
420
+ let low = 0;
421
+ let high = top;
422
+ const zeroTime = await at(0);
423
+ if (zeroTime !== null && zeroTime > timestamp) return 0;
424
+ while (low < high) {
425
+ const mid = Math.ceil((low + high) / 2);
426
+ const ts = await at(mid);
427
+ if (ts === null) throw new Error("monero-header");
428
+ if (ts <= timestamp) low = mid;
429
+ else high = mid - 1;
430
+ }
431
+ return low;
432
+ }
433
+
434
+ // THE READERS A CALLER SHOULD NOT HAVE TO BUILD. A page already holds the node URLs it probed; these wrap
435
+ // `get_info` and `get_block_header_by_height` over one of them. The SDK still owns the binary search; only the
436
+ // transport is borrowed.
437
+ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
438
+ const urls = (Array.isArray(nodes) ? nodes : [])
439
+ .map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
440
+ if (!urls.length) return null;
441
+ const answers = await Promise.all(urls.map(async (url) => {
442
+ try { return { ...(await probeMoneroNode({ url, fetchImpl })), url }; }
443
+ catch { return { ok: false, why: "unreachable", url }; }
444
+ }));
445
+ const node = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
446
+ if (!node) return null;
447
+ const call = async (method, params) => {
448
+ let response;
449
+ try {
450
+ response = await fetchImpl(node.url + "/json_rpc", {
451
+ method: "POST", headers: { "content-type": "application/json" },
452
+ body: JSON.stringify({ jsonrpc: "2.0", id: method, method, params }),
453
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
454
+ });
455
+ } catch { throw new Error("monero-unreachable"); }
456
+ if (!response || response.ok !== true) throw new Error("monero-http");
457
+ let body = null;
458
+ try { body = await response.json(); } catch { throw new Error("monero-json"); }
459
+ if (!body || body.error || !body.result) throw new Error("monero-rpc");
460
+ return body.result;
461
+ };
462
+ return {
463
+ tip: async () => node.height,
464
+ headerTimestamp: async (height) => {
465
+ const result = await call("get_block_header_by_height", { height });
466
+ const ts = Number(result.block_header && result.block_header.timestamp);
467
+ if (!Number.isFinite(ts) || ts <= 0) throw new Error("monero-header");
468
+ return ts;
469
+ },
470
+ };
471
+ }
472
+
473
+ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
474
+ if (order.blockNumber === null || order.blockNumber === undefined) {
475
+ return { startHeight: null, startHeightReason: "no-block-number" };
476
+ }
477
+ if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
478
+ return { startHeight: null, startHeightReason: "no-height-readers" };
479
+ }
480
+ let timestamp = cache.get(order.blockNumber);
481
+ if (timestamp === undefined) {
482
+ timestamp = secondsOf(await evmBlockTime(order.blockNumber));
483
+ if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
484
+ cache.set(order.blockNumber, timestamp);
485
+ }
486
+ try {
487
+ const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
488
+ return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
489
+ } catch {
490
+ return { startHeight: null, startHeightReason: "monero-header" };
491
+ }
492
+ }
493
+
399
494
  /**
400
495
  * Find orders made with this key.
401
496
  *
@@ -403,7 +498,10 @@ async function readLogs(evmLogs, request) {
403
498
  * search asks for it as the locker and as the claimer (two queries; the RPC topic filter cannot say OR across two
404
499
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
405
500
  */
406
- export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs, fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000 } = {}) {
501
+ export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
502
+ fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
503
+ evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null,
504
+ xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
407
505
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
408
506
  const chain = numberField(chainId, "chainId");
409
507
  const max = numberField(maxIndex, "maxIndex") || 1000;
@@ -446,5 +544,17 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
446
544
  found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
447
545
  }
448
546
  }
547
+ // EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
548
+ // page: a widget and the recovery page must not derive two different answers from one chain.
549
+ const cache = new Map();
550
+ let tip = moneroTip;
551
+ let header = moneroBlockTimestamp;
552
+ if ((typeof tip !== "function" || typeof header !== "function") && Array.isArray(xmrNodes) && xmrNodes.length) {
553
+ const readers = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
554
+ if (readers) { tip = tip || readers.tip; header = header || readers.headerTimestamp; }
555
+ }
556
+ for (const order of found) {
557
+ Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
558
+ }
449
559
  return { swaps: found, scanned };
450
560
  }
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;
@@ -62,6 +62,45 @@ const heightOf = async (driver, name) => {
62
62
  }
63
63
  };
64
64
 
65
+ // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
+ // the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
67
+ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch } = {}) {
68
+ let base = typeof nodeUrl === "string" ? nodeUrl.trim() : "";
69
+ while (base.endsWith("/")) base = base.slice(0, -1);
70
+ const from = Number(fromHeight);
71
+ const to = Number(toHeight);
72
+ if (!base) throw new SdkError("bad-input", { field: "window.nodeUrl" });
73
+ if (!Number.isSafeInteger(from) || from < 0) throw new SdkError("bad-input", { field: "window.fromHeight" });
74
+ if (!Number.isSafeInteger(to) || to < from) throw new SdkError("bad-input", { field: "window.toHeight" });
75
+ if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetchImpl" });
76
+ const hashes = [];
77
+ for (let height = from; height <= to; height += 1) {
78
+ let response;
79
+ try {
80
+ response = await fetchImpl(base + "/json_rpc", {
81
+ method: "POST", headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
83
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
84
+ });
85
+ } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height }); }
86
+ if (!response || response.ok !== true) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, status: response ? response.status : null });
87
+ let body = null;
88
+ try { body = await response.json(); } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "not-json" }); }
89
+ if (!body || body.error || !body.result) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "rpc" });
90
+ const result = body.result;
91
+ let list = Array.isArray(result.tx_hashes) ? result.tx_hashes : null;
92
+ if (!list && typeof result.json === "string") {
93
+ try {
94
+ const json = JSON.parse(result.json);
95
+ list = Array.isArray(json.tx_hashes) ? json.tx_hashes : null;
96
+ } catch { list = null; }
97
+ }
98
+ if (!list) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "no-tx-hashes" });
99
+ hashes.push(...list.map(String));
100
+ }
101
+ return hashes;
102
+ }
103
+
65
104
  /**
66
105
  * Restore the wallet from a height and report what it holds.
67
106
  *
@@ -88,6 +127,71 @@ export async function restoreWallet(request = {}) {
88
127
  const scanHeight = Math.max(0, Math.floor(fromHeight) - scanBack);
89
128
  const driver = driverOf(wallet);
90
129
 
130
+ // THE WINDOW PATH: set the wallet at the node tip, scan the lock's blocks, and only fall back to a full scan
131
+ // when that window holds nothing. An empty window is "not found", not "no funds".
132
+ const requestWindow = request.window && typeof request.window === "object" ? request.window : null;
133
+ let windowReport = null;
134
+ let windowTotal = 0n;
135
+ let windowUnlocked = 0n;
136
+ if (requestWindow) {
137
+ const tipHeight = Number(requestWindow.tipHeight);
138
+ if (!Number.isSafeInteger(tipHeight) || tipHeight <= 0) throw new SdkError("bad-input", { field: "window.tipHeight" });
139
+ let hashes = Array.isArray(requestWindow.txHashes) ? requestWindow.txHashes.map(String) : null;
140
+ if (hashes === null) {
141
+ hashes = await windowTxHashes({
142
+ nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
+ });
144
+ }
145
+ windowReport = { used: false, skipped: null, fromHeight, toHeight: Number(requestWindow.toHeight),
146
+ blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
147
+ if (hashes.length && typeof driver.scanTxs === "function") {
148
+ await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
149
+ await step("scanTxs", () => driver.scanTxs(hashes));
150
+ try { await wallet.sync(); } catch { /* the balance below decides */ }
151
+ windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
152
+ windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
153
+ windowReport.used = true;
154
+ // THE KEY IMAGES OF THE WALLET, CHECKED AT THE NODE. `scanTxs` finds an output; whether the chain already
155
+ // spent it is a separate fact, and the caller must not sign on a guess.
156
+ if (requestWindow.nodeUrl && typeof driver.getKeyImages === "function") {
157
+ try {
158
+ const exported = await driver.getKeyImages();
159
+ const images = Array.isArray(exported) ? exported
160
+ : (exported && typeof exported.getKeyImages === "function" ? exported.getKeyImages() : []);
161
+ const hexes = images.map((image) => typeof image === "string" ? image
162
+ : (image && typeof image.getHex === "function" ? image.getHex() : image && image.hex)).filter(Boolean);
163
+ if (hexes.length) {
164
+ let base = String(requestWindow.nodeUrl);
165
+ while (base.endsWith("/")) base = base.slice(0, -1);
166
+ const response = await (request.fetchImpl || globalThis.fetch)(base + "/is_key_image_spent", {
167
+ method: "POST", headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({ key_images: hexes }),
169
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
170
+ });
171
+ const body = response && response.ok === true ? await response.json() : null;
172
+ if (body && Array.isArray(body.spent_status)) {
173
+ windowReport.spent = body.spent_status.filter((value) => value === true || value === 1).length;
174
+ windowReport.keyImages = hexes.length;
175
+ } else windowReport.spent = null;
176
+ } else { windowReport.spent = null; windowReport.spentWhy = "no-key-images"; }
177
+ } catch { windowReport.spent = null; windowReport.spentWhy = "check-failed"; }
178
+ } else if (requestWindow.nodeUrl) {
179
+ windowReport.spent = null;
180
+ windowReport.spentWhy = "no-key-images";
181
+ }
182
+ // A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
183
+ // explicitly turned the fallback off.
184
+ if (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false) {
185
+ return {
186
+ total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight, walletHeight: await heightOf(driver, "getHeight"),
187
+ nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
188
+ };
189
+ }
190
+ } else if (hashes.length) {
191
+ windowReport.skipped = "no-scan-txs";
192
+ }
193
+ }
194
+
91
195
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
92
196
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
93
197
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
@@ -129,5 +233,6 @@ export async function restoreWallet(request = {}) {
129
233
  // Whether the second pass RAN - not whether it helped. A reader must be able to tell "nothing was locked" from
130
234
  // "it was locked and stayed locked".
131
235
  lockedPass,
236
+ window: windowReport,
132
237
  };
133
238
  }
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.49.0";