@ninsei-labs/sdk 0.50.0 → 0.51.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.
@@ -385,6 +385,27 @@ function decodeOrderLog(log) {
385
385
  };
386
386
  }
387
387
 
388
+ // THE CLONE CARRIES THE ORDER, INCLUDING THE XMR AMOUNT. OrderCreated does not repeat `xmrAmount`, and the clone
389
+ // has no getter for it, but word 18 of the condition blob is in its code. Read it only from a clone of the exact
390
+ // shape: a contract of another size is not this order and must not yield a guessed number.
391
+ const CLONE_CODE_BYTES = 877;
392
+ const XMR_AMOUNT_WORD = 18;
393
+ const CLONE_PROXY_BYTES = 45;
394
+ const XMR_AMOUNT_OFFSET = CLONE_PROXY_BYTES + XMR_AMOUNT_WORD * 32;
395
+
396
+ function xmrAmountFromClone(code) {
397
+ if (typeof code !== "string" || !/^0x[0-9a-fA-F]+$/.test(code)) return { value: null, reason: "code" };
398
+ const bytes = (code.length - 2) / 2;
399
+ if (!Number.isInteger(bytes) || bytes !== CLONE_CODE_BYTES) return { value: null, reason: "not-clone" };
400
+ const word = code.slice(2 + XMR_AMOUNT_OFFSET * 2, 2 + (XMR_AMOUNT_OFFSET + 32) * 2);
401
+ if (word.length !== 64) return { value: null, reason: "not-clone" };
402
+ try {
403
+ return { value: BigInt("0x" + word).toString(), reason: null };
404
+ } catch {
405
+ return { value: null, reason: "code" };
406
+ }
407
+ }
408
+
388
409
  async function readLogs(evmLogs, request) {
389
410
  if (typeof evmLogs !== "function") throw bad("evmLogs");
390
411
  let logs;
@@ -397,6 +418,25 @@ async function readLogs(evmLogs, request) {
397
418
  return logs;
398
419
  }
399
420
 
421
+ /**
422
+ * Is this number already held by an order on chain?
423
+ *
424
+ * A browser store is per device. The same twelve words opened on a second device, or restored before any search, start
425
+ * at number 0 again; two swaps would then share the taker's halves, and a refund of one publishes a half the other's
426
+ * maker can use. The check is the factory's own `OrderCreated` log, matched by the spend commitment the number derives.
427
+ *
428
+ * The caller owns the log transport, as everywhere else in this module. An unreadable log request is a refusal, not
429
+ * "free": the number is not reserved on a guess.
430
+ */
431
+ export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, evmLogs, fromBlock = 0, toBlock = "latest" } = {}) {
432
+ if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
433
+ const own = takerHalves({ mnemonic, chainId, index });
434
+ const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPIC], fromBlock, toBlock });
435
+ return logs.map(decodeOrderLog).filter(Boolean).some((order) =>
436
+ order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
437
+ order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase());
438
+ }
439
+
400
440
  // THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
401
441
  // derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
402
442
  // block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
@@ -406,8 +446,11 @@ async function readLogs(evmLogs, request) {
406
446
  const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
407
447
 
408
448
  function secondsOf(value) {
449
+ // A MISSING BLOCK TIME IS NOT 1970. `Number(null)` is 0, so a reader that answered null used to place an order
450
+ // at the epoch and derive a Monero height from it. Null means "not read": the caller names no-block-time.
451
+ if (value === null || value === undefined) return null;
409
452
  const n = Number(value);
410
- if (!Number.isFinite(n) || n <= 0) return null;
453
+ if (!Number.isFinite(n) || n < 0) return null;
411
454
  return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
412
455
  }
413
456
 
@@ -437,13 +480,18 @@ async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
437
480
  async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
438
481
  const urls = (Array.isArray(nodes) ? nodes : [])
439
482
  .map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
440
- if (!urls.length) return null;
483
+ if (!urls.length) return { readers: null, reason: "no-height-readers" };
441
484
  const answers = await Promise.all(urls.map(async (url) => {
442
485
  try { return { ...(await probeMoneroNode({ url, fetchImpl })), url }; }
443
486
  catch { return { ok: false, why: "unreachable", url }; }
444
487
  }));
445
488
  const node = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
446
- if (!node) return null;
489
+ if (!node) {
490
+ // TWO DIFFERENT FAILURES, TWO DIFFERENT REASONS. A node that answered but runs another network is not the
491
+ // same as no node answering at all; a page must be able to tell the person which one to fix.
492
+ const answered = answers.some((answer) => answer.ok === true);
493
+ return { readers: null, reason: answered ? "monero-network-mismatch" : "monero-nodes-unreachable" };
494
+ }
447
495
  const call = async (method, params) => {
448
496
  let response;
449
497
  try {
@@ -460,34 +508,47 @@ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl =
460
508
  return body.result;
461
509
  };
462
510
  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;
511
+ reason: null,
512
+ readers: {
513
+ // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
514
+ // answers "greater than current top block"; the tip reader must name the top block itself.
515
+ tip: async () => Math.max(0, Number(node.height) - 1),
516
+ headerTimestamp: async (height) => {
517
+ const result = await call("get_block_header_by_height", { height });
518
+ const ts = Number(result.block_header && result.block_header.timestamp);
519
+ // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
520
+ // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
521
+ if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
522
+ return ts;
523
+ },
469
524
  },
470
525
  };
471
526
  }
472
527
 
473
- async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
528
+ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache, readersReason = "no-height-readers" }) {
474
529
  if (order.blockNumber === null || order.blockNumber === undefined) {
475
- return { startHeight: null, startHeightReason: "no-block-number" };
530
+ return { startHeight: null, startHeightReason: "no-block-number", blockTime: null };
476
531
  }
477
- if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
478
- return { startHeight: null, startHeightReason: "no-height-readers" };
532
+ // NOTHING TO READ WITH: no settlement block time and no Monero readers. That is a missing seam, not a missing date.
533
+ if (typeof evmBlockTime !== "function" && typeof moneroTip !== "function" && typeof moneroBlockTimestamp !== "function") {
534
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: null };
479
535
  }
536
+ // THE BLOCK TIME IS PART OF THE FOUND ORDER. The page used to read the block again just to show a date; the
537
+ // timestamp is already needed here for the Monero height, so it is returned with it.
480
538
  let timestamp = cache.get(order.blockNumber);
481
539
  if (timestamp === undefined) {
482
- timestamp = secondsOf(await evmBlockTime(order.blockNumber));
483
- if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
540
+ timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
541
+ if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
484
542
  cache.set(order.blockNumber, timestamp);
485
543
  }
544
+ if (typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
545
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: timestamp };
546
+ }
486
547
  try {
487
548
  const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
488
- return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
549
+ return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null, blockTime: timestamp };
489
550
  } catch {
490
- return { startHeight: null, startHeightReason: "monero-header" };
551
+ return { startHeight: null, startHeightReason: "monero-header", blockTime: timestamp };
491
552
  }
492
553
  }
493
554
 
@@ -499,14 +560,23 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
499
560
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
500
561
  */
501
562
  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,
563
+ fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = 1000,
564
+ evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
504
565
  xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
505
566
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
506
567
  const chain = numberField(chainId, "chainId");
507
568
  const max = numberField(maxIndex, "maxIndex") || 1000;
508
569
  const missesAllowed = numberField(gap, "gap") || 5;
509
- const base = { address: factory, fromBlock, toBlock };
570
+ // THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
571
+ // factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
572
+ // last resort for a caller that knows the factory existed at genesis.
573
+ if (deploymentBlock !== null && deploymentBlock !== undefined && !Number.isSafeInteger(Number(deploymentBlock))) {
574
+ throw bad("deploymentBlock");
575
+ }
576
+ const startBlock = fromBlock === null || fromBlock === undefined
577
+ ? (deploymentBlock === null || deploymentBlock === undefined ? 0 : Number(deploymentBlock))
578
+ : fromBlock;
579
+ const base = { address: factory, fromBlock: startBlock, toBlock };
510
580
  let logs = [];
511
581
  if (address) {
512
582
  const padded = topicAddress(address);
@@ -549,12 +619,43 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
549
619
  const cache = new Map();
550
620
  let tip = moneroTip;
551
621
  let header = moneroBlockTimestamp;
622
+ let readersReason = "no-height-readers";
552
623
  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; }
624
+ const probed = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
625
+ if (probed.readers) { tip = tip || probed.readers.tip; header = header || probed.readers.headerTimestamp; }
626
+ else readersReason = probed.reason || "no-height-readers";
627
+ }
628
+ // THE ORDER IS NATIVE, AND THE PAYING TOKEN IS NOT ON THE CHAIN. The escrow receives the settlement network's
629
+ // native coin (`amount` is wei); which token the person swapped into it (USDC, USDT, ...) belongs to the DEX/quote
630
+ // record, not to OrderCreated or the clone. `payToken: null` plus a reason is the honest answer here; an invented
631
+ // "USDC" would be a claim the chain does not support.
632
+ for (const order of found) {
633
+ order.settlementToken = "native";
634
+ order.payToken = null;
635
+ order.payTokenReason = "not-on-chain";
636
+ }
637
+ for (const order of found) {
638
+ Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
555
639
  }
640
+ // THE XMR AMOUNT IS IN THE CLONE, NOT IN THE LOG. A caller without a code reader gets null and a named reason;
641
+ // an unreadable or differently-shaped contract does the same. This is data, not a withdrawal verdict.
556
642
  for (const order of found) {
557
- Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
643
+ if (typeof evmCode !== "function") {
644
+ order.xmrAmount = null;
645
+ order.xmrAmountReason = "no-code-reader";
646
+ continue;
647
+ }
648
+ let code;
649
+ try {
650
+ code = await evmCode(order.escrow);
651
+ } catch {
652
+ order.xmrAmount = null;
653
+ order.xmrAmountReason = "code";
654
+ continue;
655
+ }
656
+ const read = xmrAmountFromClone(code);
657
+ order.xmrAmount = read.value;
658
+ order.xmrAmountReason = read.reason;
558
659
  }
559
660
  return { swaps: found, scanned };
560
661
  }
package/src/swaps.mjs CHANGED
@@ -27,7 +27,7 @@ import { createActions } from "./actions.mjs";
27
27
  // THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
28
28
  import {
29
29
  commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
30
- recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
30
+ recoveryIndexTaken, recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
31
31
  } from "./recovery-key.mjs";
32
32
  // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
33
33
  import * as xmrArrival from "./xmr-arrival.mjs";
@@ -804,10 +804,32 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
804
804
  const key = saved || importRecoveryKey(supplied);
805
805
  if (!saved) saveRecoveryKey(storage, key);
806
806
  const chainId = request.order.chainId;
807
- const index = request.recoveryIndex === undefined || request.recoveryIndex === null
808
- ? reserveRecoveryIndex(storage, { chainId })
809
- : Number(request.recoveryIndex);
807
+ const explicit = request.recoveryIndex !== undefined && request.recoveryIndex !== null;
808
+ let index = explicit ? Number(request.recoveryIndex) : reserveRecoveryIndex(storage, { chainId });
810
809
  if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
810
+ // A BROWSER STORE IS PER DEVICE. The same words opened elsewhere start at the same number, so the factory's own
811
+ // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
812
+ // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
813
+ if (typeof chainLogs === "function" && request.order.factory) {
814
+ // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
815
+ // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
816
+ // `fromBlock` still wins, including the literal zero.
817
+ const chain = config && Array.isArray(config.chains)
818
+ ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
819
+ const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
820
+ const fromBlock = request.fromBlock !== undefined
821
+ ? request.fromBlock
822
+ : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
823
+ let hops = 0;
824
+ while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
825
+ evmLogs: chainLogs, fromBlock })) {
826
+ if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
827
+ commitRecoveryIndex(storage, index);
828
+ index = reserveRecoveryIndex(storage, { chainId });
829
+ hops += 1;
830
+ if (!Number.isSafeInteger(index) || index < 0 || hops > 1000) throw new SdkError("bad-input", { field: "recoveryIndex", why: "exhausted" });
831
+ }
832
+ }
811
833
  const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
812
834
  keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
813
835
  recovery = { storage, key, index, newKey: !saved };
@@ -0,0 +1,14 @@
1
+ // THE REVEALED HALF IS A 32-BYTE SCALAR, NOT AN ADDRESS.
2
+ //
3
+ // `revealedHalf()` returns one ABI word: 32 bytes of the ed25519 scalar the settlement published. The withdrawal path
4
+ // used to read it as an address (`hex.slice(24)`), keeping only the last 20 bytes; `(s_a + s_b)·G` then never matched the
5
+ // order's spend point and every real withdrawal was refused. The parser is separate from the chain read so a guard can
6
+ // hand it the exact 32-byte word without a wallet or an RPC.
7
+ const WORD_RE = /^[0-9a-f]{64}$/;
8
+
9
+ export function revealedHalfWord(word) {
10
+ const raw = String(word === undefined || word === null ? "" : word).trim();
11
+ const hex = (raw.startsWith("0x") || raw.startsWith("0X") ? raw.slice(2) : raw).toLowerCase();
12
+ if (!WORD_RE.test(hex) || /^0+$/.test(hex)) return null;
13
+ return "0x" + hex;
14
+ }
@@ -64,7 +64,7 @@ const heightOf = async (driver, name) => {
64
64
 
65
65
  // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
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 } = {}) {
67
+ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch, sleep = defaultSleep, attempts = 3 } = {}) {
68
68
  let base = typeof nodeUrl === "string" ? nodeUrl.trim() : "";
69
69
  while (base.endsWith("/")) base = base.slice(0, -1);
70
70
  const from = Number(fromHeight);
@@ -73,17 +73,39 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
73
73
  if (!Number.isSafeInteger(from) || from < 0) throw new SdkError("bad-input", { field: "window.fromHeight" });
74
74
  if (!Number.isSafeInteger(to) || to < from) throw new SdkError("bad-input", { field: "window.toHeight" });
75
75
  if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetchImpl" });
76
+ const tries = Math.max(1, Math.min(5, Number(attempts) || 3));
76
77
  const hashes = [];
77
78
  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 });
79
+ let response = null;
80
+ let last = null;
81
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
82
+ try {
83
+ response = await fetchImpl(base + "/json_rpc", {
84
+ method: "POST", headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
86
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
87
+ });
88
+ } catch {
89
+ // A TIMEOUT OR A BROKEN CONNECTION IS RETRIED, NOT THE END OF THE WINDOW. The node is rate-limited by the
90
+ // same bursts the page sends; a withdrawal that stops on the first 429 or timeout is a refund waiting to
91
+ // happen (the page's measured failure: 93 of 150 parallel reads answered 429).
92
+ response = null;
93
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "transport" });
94
+ if (attempt < tries) { await sleep(backoffMs(attempt)); continue; }
95
+ throw last;
96
+ }
97
+ if (response && response.ok === true) { last = null; break; }
98
+ const status = response ? response.status : null;
99
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, status });
100
+ // ONLY A RATE LIMIT AND A SERVER FAULT ARE WORTH ANOTHER READ. A 404 or a bad request will answer the same
101
+ // way every time, and retrying it only makes the fallback slower.
102
+ if ((status === 429 || (status !== null && status >= 500)) && attempt < tries) {
103
+ await sleep(retryAfterMs(response) ?? backoffMs(attempt));
104
+ continue;
105
+ }
106
+ throw last;
107
+ }
108
+ if (!response || response.ok !== true) throw last || new SdkError("wallet-rejected", { surface: "window-blocks", height });
87
109
  let body = null;
88
110
  try { body = await response.json(); } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "not-json" }); }
89
111
  if (!body || body.error || !body.result) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "rpc" });
@@ -101,6 +123,21 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
101
123
  return hashes;
102
124
  }
103
125
 
126
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
127
+ const backoffMs = (attempt) => Math.min(4000, 250 * (2 ** Math.max(0, attempt - 1)));
128
+ function retryAfterMs(response) {
129
+ try {
130
+ const value = response && response.headers && typeof response.headers.get === "function"
131
+ ? response.headers.get("retry-after") : null;
132
+ if (value === null || value === undefined || value === "") return null;
133
+ const seconds = Number(value);
134
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(5000, Math.floor(seconds * 1000));
135
+ const date = Date.parse(String(value));
136
+ if (Number.isFinite(date)) return Math.min(5000, Math.max(0, date - Date.now()));
137
+ } catch { /* a bad header is not a reason to stop */ }
138
+ return null;
139
+ }
140
+
104
141
  /**
105
142
  * Restore the wallet from a height and report what it holds.
106
143
  *
@@ -136,14 +173,34 @@ export async function restoreWallet(request = {}) {
136
173
  if (requestWindow) {
137
174
  const tipHeight = Number(requestWindow.tipHeight);
138
175
  if (!Number.isSafeInteger(tipHeight) || tipHeight <= 0) throw new SdkError("bad-input", { field: "window.tipHeight" });
176
+ windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
177
+ blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: 0 };
139
178
  let hashes = Array.isArray(requestWindow.txHashes) ? requestWindow.txHashes.map(String) : null;
140
179
  if (hashes === null) {
141
- hashes = await windowTxHashes({
142
- nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
- });
180
+ try {
181
+ hashes = await windowTxHashes({
182
+ nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
183
+ });
184
+ } catch (e) {
185
+ // A MALFORMED WINDOW IS NOT A BUSY NODE. A bad height or a missing URL is the caller's error and is
186
+ // rethrown; only a read the node refused reaches the fallback.
187
+ if (e && e.code === "bad-input") throw e;
188
+ // THE WINDOW READ FAILED: FALL BACK TO THE FULL SCAN, NOT TO A REFUSAL. A 429 from a busy node, a timeout
189
+ // or a 5xx is a fact about the node, not about the coins; the scan below can still find them. The reason is
190
+ // carried on the report so the caller does not present this as a normal empty window.
191
+ windowReport.fallback = true;
192
+ windowReport.fallbackWhy = "window-read";
193
+ windowReport.error = {
194
+ code: e && e.code ? e.code : "wallet-rejected",
195
+ surface: e && e.params && e.params.surface ? e.params.surface : "window-blocks",
196
+ status: e && e.params && e.params.status !== undefined ? e.params.status : null,
197
+ height: e && e.params && e.params.height !== undefined ? e.params.height : null,
198
+ why: e && e.params && e.params.why ? e.params.why : null,
199
+ };
200
+ hashes = [];
201
+ }
144
202
  }
145
- windowReport = { used: false, skipped: null, fromHeight, toHeight: Number(requestWindow.toHeight),
146
- blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
203
+ windowReport.txs = hashes.length;
147
204
  if (hashes.length && typeof driver.scanTxs === "function") {
148
205
  await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
149
206
  await step("scanTxs", () => driver.scanTxs(hashes));
@@ -192,6 +249,12 @@ export async function restoreWallet(request = {}) {
192
249
  }
193
250
  }
194
251
 
252
+ // THE WINDOW DID NOT ANSWER: the full scan below is the fallback. Say so on the report, so the caller does not
253
+ // have to infer it from `used: false` and a balance.
254
+ if (windowReport) {
255
+ windowReport.fallback = true;
256
+ if (!windowReport.fallbackWhy) windowReport.fallbackWhy = "empty-window";
257
+ }
195
258
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
196
259
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
197
260
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
package/src/sweep.mjs CHANGED
@@ -10,6 +10,9 @@ import * as engine from "./engine.mjs";
10
10
  import { restoreWallet } from "./sweep-restore.mjs";
11
11
  import { moneroWalletAdapter } from "./adapters/monero-wallet.mjs";
12
12
  import { checkDestination } from "./destination.mjs";
13
+ import { revealedHalfWord } from "./sweep-half.mjs";
14
+ import { takerHalves } from "./recovery-key.mjs";
15
+ import { jointAddress } from "./order.mjs";
13
16
 
14
17
  // The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
15
18
  const MIN_PASSPHRASE = 12;
@@ -19,6 +22,71 @@ const REVEALED_HALF_SELECTOR = "0x3f267bb6";
19
22
 
20
23
  const contentsOf = (file) => (typeof file === "string" ? file : file && typeof file.contents === "string" ? file.contents : null);
21
24
 
25
+ const bare = (value) => String(value == null ? "" : value).replace(/^0x/i, "").toLowerCase();
26
+
27
+ // A FOUND ORDER AND THE KEY, WITHOUT A FILE. The recovery page used to write the order into the SDK's own payload
28
+ // (buildPayload) in memory; that rule belongs here, next to the withdrawal it feeds. The page keeps the order and
29
+ // the key; this function decides which side is the person, checks the chain's points against the key, builds the
30
+ // joint address and returns the same payload a recovery file would carry.
31
+ const payloadFromOrder = async (request) => {
32
+ const order = request && request.order;
33
+ if (!order || typeof order !== "object") throw new SdkError("bad-input", { field: "order" });
34
+ if (typeof request.mnemonic !== "string" || !request.mnemonic.trim()) throw new SdkError("bad-input", { field: "mnemonic" });
35
+ const chainId = Number(request.chainId);
36
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new SdkError("bad-input", { field: "chainId" });
37
+ const index = Number(order.index);
38
+ if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "order.index" });
39
+ const network = typeof request.network === "string" ? request.network.trim() : "";
40
+ const moneroNetwork = typeof request.moneroNetwork === "string" ? request.moneroNetwork.trim() : "";
41
+ if (!network) throw new SdkError("bad-input", { field: "network" });
42
+ if (!moneroNetwork) throw new SdkError("bad-input", { field: "moneroNetwork" });
43
+ const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
44
+ if (passphrase && passphrase.length < MIN_PASSPHRASE) {
45
+ throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
46
+ }
47
+
48
+ const side = order.side === "locker" ? "buy" : order.side === "claimer" ? "sell" : null;
49
+ if (!side) throw new SdkError("bad-input", { field: "order.side" });
50
+ const halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
51
+ const buy = side === "buy";
52
+ if (bare(buy ? order.edPointLocker : order.edPointClaimer) !== bare(halves.spendPoint)) {
53
+ throw new SdkError("bad-input", { field: "order", why: "spend-point" });
54
+ }
55
+ if (bare(order.edViewPointLocker) !== bare(buy ? halves.viewPoint : halves.makerViewPoint)) {
56
+ throw new SdkError("bad-input", { field: "order", why: buy ? "own-view-point" : "maker-view-point" });
57
+ }
58
+
59
+ const otherSpendPoint = "0x" + bare(buy ? order.edPointClaimer : order.edPointLocker);
60
+ const otherViewPoint = "0x" + bare(halves.makerViewPoint);
61
+ const joint = jointAddress({
62
+ ownSpendHalf: halves.spendHalf, ownViewHalf: halves.viewHalf,
63
+ otherSpendPoint, otherViewPoint, network: moneroNetwork,
64
+ });
65
+ const escrow = { address: order.escrow, readyBy: order.readyBy, t1: order.t1 };
66
+ const payload = engine.recoveryFile.buildPayload({
67
+ swap: {
68
+ id: order.escrow, side, network, moneroNetwork,
69
+ createdAt: order.blockTime ? Number(order.blockTime) * 1000 : null,
70
+ payToken: null, payAmount: null, xmrAmount: null, rate: null, maker: null, quoteSignature: null,
71
+ escrow, timeline: null,
72
+ },
73
+ swapWallet: {
74
+ source: "halves", network: moneroNetwork, address: joint.address,
75
+ spendHalf: halves.spendHalf, viewHalf: halves.viewHalf,
76
+ otherSpendPoint, otherViewPoint, otherViewHalf: halves.makerViewHalf,
77
+ escrowAddress: order.escrow,
78
+ },
79
+ escrow,
80
+ receiveAddress: null,
81
+ restoreHeight: Number(order.startHeight) > 0 ? Number(order.startHeight) : null,
82
+ });
83
+ return {
84
+ payload,
85
+ contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase),
86
+ address: joint.address,
87
+ };
88
+ };
89
+
22
90
  // A LOW-LEVEL WALLET ERROR IS STILL A REFUSAL, AND EVERY REFUSAL CARRIES A CODE.
23
91
  //
24
92
  // The library brings its own exceptions with no code at all - monero-ts answers "failed to get hashes" while
@@ -68,6 +136,13 @@ export function createSweep({ http, config, call = null }) {
68
136
  const NETWORK_ENUM = { mainnet: "MAINNET", stagenet: "STAGENET", testnet: "TESTNET" };
69
137
 
70
138
  const api = {
139
+ // BUILD THE WITHDRAWAL MATERIAL FROM A FOUND ORDER AND THE KEY. No recovery file, no temporary payload in the
140
+ // page: the returned `contents` is the same file `plan`/`keys` already accept, and `payload` is for callers
141
+ // that want the fields directly.
142
+ async fromOrder(request = {}) {
143
+ return await payloadFromOrder(request);
144
+ },
145
+
71
146
  // BUILD THE WITHDRAWAL WALLET from the library namespace the caller has. The page used to pass a `createWallet`
72
147
  // callback that mapped the enum itself; that mapping is here now, so a caller passes `moneroTs` and the keys.
73
148
  wallet(request = {}) {
@@ -164,10 +239,10 @@ export function createSweep({ http, config, call = null }) {
164
239
  if (escrowAddress && typeof call === "function") {
165
240
  try {
166
241
  const word = String((await call({ to: escrowAddress, data: REVEALED_HALF_SELECTOR })) || "");
167
- const hex = word.startsWith("0x") ? word.slice(2) : word;
168
- if (/^[0-9a-fA-F]{64}$/.test(hex) && !/^0+$/.test(hex)) {
242
+ const half = revealedHalfWord(word);
243
+ if (half !== null) {
169
244
  revealed = true;
170
- revealedValue = "0x" + hex.slice(24);
245
+ revealedValue = half;
171
246
  }
172
247
  } catch { revealed = false; }
173
248
  }
@@ -276,8 +351,13 @@ export function createSweep({ http, config, call = null }) {
276
351
  async sign(request) {
277
352
  const plan = await api.plan(request);
278
353
  if (!request || typeof request.to !== "string" || !request.to) throw new SdkError("bad-input", { field: "to" });
279
- if (typeof request.connectWallet !== "function") throw new SdkError("bad-input", { field: "connectWallet" });
280
- const wallet = await request.connectWallet(plan);
354
+ // ONE ADAPTER FOR RESTORE AND SIGN. A caller that already holds the opened and synced wallet passes it as
355
+ // `wallet`; a caller that wants the SDK to build one passes `connectWallet`.
356
+ const wallet = request.wallet !== undefined && request.wallet !== null
357
+ ? request.wallet
358
+ : (typeof request.connectWallet === "function"
359
+ ? await request.connectWallet(plan)
360
+ : (() => { throw new SdkError("bad-input", { field: "connectWallet" }); })());
281
361
  const role = ["open", "sync", "unlockedBalance", "sweepUnlocked", "relay", "close"];
282
362
  const missing = role.filter((name) => !wallet || typeof wallet[name] !== "function");
283
363
  if (missing.length) throw new SdkError("bad-input", { field: "wallet", missing });
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.50.0";
9
+ export const SDK_VERSION = "0.51.1";