@ninsei-labs/sdk 0.51.2 → 0.53.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.
@@ -17,7 +17,7 @@ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/b
17
17
  import { wordlist } from "@scure/bip39/wordlists/english.js";
18
18
  import { createHalves } from "../engine/atomic/halves.js";
19
19
  import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
20
- import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
20
+ import { ORDER_CREATED_TOPIC, ORDER_CREATED_TOPICS } from "../engine/evm/factory.js";
21
21
  import { SdkError } from "./errors.mjs";
22
22
  import { probeMoneroNode } from "./probes.mjs";
23
23
  import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
@@ -27,9 +27,16 @@ const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
27
27
  /** A BIP39 mnemonic with 128 bits of entropy: twelve words. */
28
28
  export const RECOVERY_KEY_VERSION = 1;
29
29
  export const RECOVERY_KEY_WORDS = 12;
30
+ // The allocator's ceiling. Discovery must use the same range: a search that stops earlier can miss an order
31
+ // whose number was skipped by an abandoned start or by a swap on another chain.
32
+ export const RECOVERY_INDEX_MAX = 100000;
30
33
  export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
31
34
  export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
32
35
  export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
36
+ // THE SALT-BASED VERSION. A v1 number is the same at every factory and every device; a v2 binding names the fresh
37
+ // order salt, the factory and the role, so two orders cannot share a taker half even when the words are restored
38
+ // elsewhere. The salt is already public in `OrderCreated`; recovery reads it back instead of reserving a number.
39
+ export const RECOVERY_KEY_DOMAIN_V2 = "ninsei-taker-v2";
33
40
  // The counterparty's view half is derived from the SAME ONE KEY, in a domain of its own. The taker sends this
34
41
  // number to the maker (the node), which uses it as its own view half; the taker keeps it, so the joint view key
35
42
  // can be rebuilt from the twelve words without any server.
@@ -40,6 +47,7 @@ export const RECOVERY_KEY_ITER = 600000;
40
47
  const ITER_MIN = 600000;
41
48
  const ITER_MAX = 6000000;
42
49
 
50
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
43
51
  const te = new TextEncoder();
44
52
  const td = new TextDecoder();
45
53
 
@@ -121,8 +129,38 @@ export function recoveryBinding(chainId, index) {
121
129
  * package is a hex string, and a BigInt that reaches JSON.stringify fails the whole record.
122
130
  */
123
131
  export function takerHalves({ mnemonic, chainId, index }) {
124
- const binding = recoveryBinding(chainId, index);
125
- const seed = seedOf(mnemonic);
132
+ return takerHalvesFromSeed(seedOf(mnemonic), chainId, index);
133
+ }
134
+
135
+ // THE SAME KEY, A PUBLIC PER-ORDER SALT. The factory and role are in the binding, so the same words, chain and
136
+ // salt at another factory produce another half. `role` is fixed for the taker's side; the maker's view half is
137
+ // separated by its own domain. Salt is the `salt` word from `OrderCreated`, never a secret.
138
+ export function recoveryBindingV2({ chainId, factory, role = "taker", salt } = {}) {
139
+ if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
140
+ if (typeof role !== "string" || !/^[a-z0-9-]{1,32}$/.test(role)) throw bad("role");
141
+ return `${RECOVERY_KEY_DOMAIN_V2}|${numberField(chainId, "chainId")}|${factory.toLowerCase()}|${role}|${recoverySaltOf(salt)}`;
142
+ }
143
+
144
+ export function takerHalvesV2({ mnemonic, chainId, factory, role = "taker", salt } = {}) {
145
+ const normalized = recoverySaltOf(salt);
146
+ const binding = recoveryBindingV2({ chainId, factory, role, salt: normalized });
147
+ return {
148
+ ...halvesFromBinding(seedOf(mnemonic), binding),
149
+ index: null,
150
+ derivation: "salt-v2",
151
+ factory: factory.toLowerCase(),
152
+ role,
153
+ salt: normalized,
154
+ };
155
+ }
156
+
157
+ // Discovery derives thousands of numbers from one mnemonic. The BIP39 seed is the expensive part (PBKDF2), so it
158
+ // is computed once by the caller and passed here; the per-number work is only HMAC and a curve point.
159
+ function takerHalvesFromSeed(seed, chainId, index) {
160
+ return { index: numberField(index, "index"), ...halvesFromBinding(seed, recoveryBinding(chainId, index)) };
161
+ }
162
+
163
+ function halvesFromBinding(seed, binding) {
126
164
  const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
127
165
  const viewHalf = halves.halfFromSeed(seed, binding, halves.VIEW_HALF_DOMAIN);
128
166
  // THE OTHER SIDE'S VIEW HALF. The same key, a third domain: the taker gives this number to the maker, so the
@@ -132,7 +170,6 @@ export function takerHalves({ mnemonic, chainId, index }) {
132
170
  const view = halves.publicHalves(viewHalf);
133
171
  const makerView = halves.publicHalves(makerViewHalf);
134
172
  return {
135
- index: numberField(index, "index"),
136
173
  binding,
137
174
  spendHalf: hex32(spendHalf),
138
175
  viewHalf: hex32(viewHalf),
@@ -144,6 +181,41 @@ export function takerHalves({ mnemonic, chainId, index }) {
144
181
  };
145
182
  }
146
183
 
184
+ // DISCOVERY NEEDS ONE COMMITMENT, NOT THREE POINTS. The view and maker-view halves are not compared during a
185
+ // search, and their Edwards points are the expensive part of `takerHalves`; deriving only the spend scalar keeps
186
+ // a full-range scan practical.
187
+ function spendCommitmentFromSeed(seed, chainId, index) {
188
+ const spendHalf = halves.halfFromSeed(seed, recoveryBinding(chainId, index), halves.HALF_DOMAIN);
189
+ return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
190
+ }
191
+
192
+ function spendCommitmentFromSeedV2(seed, { chainId, factory, role = "taker", salt }) {
193
+ const binding = recoveryBindingV2({ chainId, factory, role, salt });
194
+ const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
195
+ return halfCommitmentHex(spendHalf, secp256k1, keccak256).toLowerCase();
196
+ }
197
+
198
+ // The salt is the 32-byte word from the order, not a secret and not a tag. Lower-case it so the binding is stable
199
+ // across an event decoder and a caller that hand-wrote the value.
200
+ /** A fresh public salt for one v2 order. The client owns it; a maker must not hand the same value back twice. */
201
+ export function newRecoverySalt() {
202
+ return "0x" + Array.from(randomBytes(32), (byte) => byte.toString(16).padStart(2, "0")).join("");
203
+ }
204
+
205
+ export function recoverySaltOf(value) {
206
+ if (typeof value === "bigint") return saltField("0x" + value.toString(16).padStart(64, "0"));
207
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
208
+ return saltField("0x" + BigInt(value).toString(16).padStart(64, "0"));
209
+ }
210
+ return saltField(value);
211
+ }
212
+
213
+ function saltField(value) {
214
+ const salt = String(value == null ? "" : value).trim().toLowerCase();
215
+ if (!/^0x[0-9a-f]{64}$/.test(salt)) throw bad("salt");
216
+ return salt;
217
+ }
218
+
147
219
  // ---------------------------------------------------------------------------------------------------
148
220
  // THE BACKUP FILE
149
221
  // ---------------------------------------------------------------------------------------------------
@@ -274,23 +346,31 @@ export async function openRecoveryKeyFile(contents, passphrase = "") {
274
346
  // RESERVATION
275
347
  // ---------------------------------------------------------------------------------------------------
276
348
 
349
+ const storageFailure = (step) => new SdkError("storage-unavailable", { step });
277
350
  const readStore = (storage) => {
278
351
  if (!storage || typeof storage.get !== "function" || typeof storage.set !== "function" || typeof storage.remove !== "function") throw bad("storage");
279
- const raw = storage.get(RECOVERY_KEY_STORE);
280
- if (!raw) return null;
281
- try {
282
- const value = JSON.parse(raw);
283
- if (!value || value.version !== RECOVERY_KEY_VERSION || typeof value.mnemonic !== "string") return null;
284
- return value;
285
- } catch {
286
- return null;
287
- }
352
+ let raw;
353
+ try { raw = storage.get(RECOVERY_KEY_STORE); } catch { throw storageFailure("read"); }
354
+ // NOTHING STORED IS NOT AN UNREADABLE STORE. `null`/absent means no key yet; a throwing read or a corrupt
355
+ // record must be named, because the next start would otherwise overwrite a key it could not read.
356
+ if (raw === undefined || raw === null || raw === "") return null;
357
+ let value;
358
+ try { value = JSON.parse(raw); } catch { throw storageFailure("parse"); }
359
+ if (!value || value.version !== RECOVERY_KEY_VERSION || typeof value.mnemonic !== "string") throw storageFailure("parse");
360
+ return value;
361
+ };
362
+
363
+ const writeStore = (storage, value) => {
364
+ let text;
365
+ try { text = JSON.stringify(value); } catch { throw storageFailure("serialize"); }
366
+ try { storage.set(RECOVERY_KEY_STORE, text); } catch { throw storageFailure("write"); }
288
367
  };
289
368
 
290
369
  /** Save the key next to the swap records. The words are the secret; the store is the caller's. */
291
370
  export function saveRecoveryKey(storage, key) {
292
371
  const checked = recoveryKeyOf(key && key.mnemonic, key && key.createdAt);
293
- storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...checked, nextIndex: Number(key.nextIndex) || 0, reserved: key.reserved || {}, retired: key.retired || {} }));
372
+ writeStore(storage, { ...checked, nextIndex: Number(key.nextIndex) || 0,
373
+ reserved: key.reserved || {}, retired: key.retired || {}, backupConfirmed: key.backupConfirmed === true });
294
374
  return checked;
295
375
  }
296
376
 
@@ -316,13 +396,13 @@ export function reserveRecoveryIndex(storage, { chainId, swapId = null } = {}) {
316
396
  for (let i = 0; i < index; i += 1) {
317
397
  if (!Object.prototype.hasOwnProperty.call(reserved, i) && !Object.prototype.hasOwnProperty.call(retired, i)) {
318
398
  reserved[i] = { chainId: chain, swapId, at: new Date().toISOString() };
319
- storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved, retired }));
399
+ writeStore(storage, { ...key, reserved, retired });
320
400
  return i;
321
401
  }
322
402
  }
323
- if (index >= 100000) throw failed("index-exhausted");
403
+ if (index >= RECOVERY_INDEX_MAX) throw failed("index-exhausted");
324
404
  reserved[index] = { chainId: chain, swapId, at: new Date().toISOString() };
325
- storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, nextIndex: index + 1, reserved, retired }));
405
+ writeStore(storage, { ...key, nextIndex: index + 1, reserved, retired });
326
406
  return index;
327
407
  }
328
408
 
@@ -331,7 +411,7 @@ export function releaseRecoveryIndex(storage, index) {
331
411
  if (!key) throw failed("key-missing");
332
412
  const reserved = { ...(key.reserved || {}) };
333
413
  delete reserved[String(numberField(index, "index"))];
334
- storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved }));
414
+ writeStore(storage, { ...key, reserved });
335
415
  }
336
416
 
337
417
  /** Retire a number after its order exists on chain: it is never reused, so a later scan has no collision. */
@@ -343,15 +423,13 @@ export function commitRecoveryIndex(storage, index) {
343
423
  const n = numberField(index, "index");
344
424
  delete reserved[String(n)];
345
425
  retired[String(n)] = true;
346
- storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved, retired }));
426
+ writeStore(storage, { ...key, reserved, retired });
347
427
  }
348
428
 
349
429
  // ---------------------------------------------------------------------------------------------------
350
430
  // THE SEARCH
351
431
  // ---------------------------------------------------------------------------------------------------
352
432
 
353
- const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
354
-
355
433
  function topicAddress(address) {
356
434
  if (typeof address !== "string" || !ADDRESS_RE.test(address)) throw bad("address");
357
435
  return pad32(address);
@@ -359,10 +437,15 @@ function topicAddress(address) {
359
437
 
360
438
  function decodeOrderLog(log) {
361
439
  const topics = Array.isArray(log && log.topics) ? log.topics : [];
362
- if (topics.length < 4 || String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) return null;
440
+ const topic0 = String(topics[0] || "").toLowerCase();
441
+ if (topics.length < 4 || !ORDER_CREATED_TOPICS.includes(topic0)) return null;
363
442
  const data = String(log.data || "").replace(/^0x/, "");
364
- if (data.length < 64 * 2) return null;
443
+ const modern = topic0 === ORDER_CREATED_TOPIC;
444
+ if (data.length < 64 * (modern ? 13 : 12)) return null;
365
445
  const word = (i) => "0x" + data.slice(i * 64, (i + 1) * 64).toLowerCase();
446
+ // THE FIELD OFFSETS MOVED WHEN `xmrAmount` JOINED THE EVENT. The old topic has no XMR amount; its orders fall
447
+ // back to the clone read, and a caller can still tell why from `xmrAmountReason`.
448
+ const shift = modern ? 1 : 0;
366
449
  return {
367
450
  escrow: "0x" + String(topics[1]).slice(26).toLowerCase(),
368
451
  locker: "0x" + String(topics[2]).slice(26).toLowerCase(),
@@ -373,12 +456,13 @@ function decodeOrderLog(log) {
373
456
  edPointClaimer: word(3),
374
457
  edViewPointLocker: word(4),
375
458
  amount: BigInt(word(5)).toString(),
376
- readyBy: Number(BigInt(word(6))),
377
- t1: Number(BigInt(word(7))),
378
- salt: word(8),
379
- registry: "0x" + data.slice(9 * 64 + 24, 10 * 64),
380
- provider: "0x" + data.slice(10 * 64 + 24, 11 * 64),
381
- quoteKey: "0x" + data.slice(11 * 64 + 24, 12 * 64),
459
+ xmrAmount: modern ? BigInt(word(6)).toString() : null,
460
+ readyBy: Number(BigInt(word(6 + shift))),
461
+ t1: Number(BigInt(word(7 + shift))),
462
+ salt: word(8 + shift),
463
+ registry: "0x" + data.slice((9 + shift) * 64 + 24, (10 + shift) * 64),
464
+ provider: "0x" + data.slice((10 + shift) * 64 + 24, (11 + shift) * 64),
465
+ quoteKey: "0x" + data.slice((11 + shift) * 64 + 24, (12 + shift) * 64),
382
466
  transactionHash: log.transactionHash || null,
383
467
  blockNumber: log.blockNumber != null ? Number(log.blockNumber) : null,
384
468
  logIndex: log.logIndex != null ? Number(log.logIndex) : null,
@@ -431,7 +515,7 @@ async function readLogs(evmLogs, request) {
431
515
  export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, evmLogs, fromBlock = 0, toBlock = "latest" } = {}) {
432
516
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
433
517
  const own = takerHalves({ mnemonic, chainId, index });
434
- const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPIC], fromBlock, toBlock });
518
+ const logs = await readLogs(evmLogs, { address: factory, topics: [ORDER_CREATED_TOPICS], fromBlock, toBlock });
435
519
  return logs.map(decodeOrderLog).filter(Boolean).some((order) =>
436
520
  order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
437
521
  order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase());
@@ -537,7 +621,13 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
537
621
  // timestamp is already needed here for the Monero height, so it is returned with it.
538
622
  let timestamp = cache.get(order.blockNumber);
539
623
  if (timestamp === undefined) {
540
- timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
624
+ // ONE UNREADABLE BLOCK TIME MUST NOT THROW AWAY THE ORDERS ALREADY MATCHED. The order is still a fact from
625
+ // its log; only the start height is unknown, and the reason says which read failed.
626
+ try {
627
+ timestamp = typeof evmBlockTime === "function" ? secondsOf(await evmBlockTime(order.blockNumber)) : null;
628
+ } catch {
629
+ return { startHeight: null, startHeightReason: "block-time-read", blockTime: null };
630
+ }
541
631
  if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
542
632
  cache.set(order.blockNumber, timestamp);
543
633
  }
@@ -560,13 +650,16 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
560
650
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
561
651
  */
562
652
  export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
563
- fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = 1000,
653
+ fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = RECOVERY_INDEX_MAX,
564
654
  evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
565
655
  xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
566
656
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
567
657
  const chain = numberField(chainId, "chainId");
568
- const max = numberField(maxIndex, "maxIndex") || 1000;
569
- const missesAllowed = numberField(gap, "gap") || 5;
658
+ // `gap` is retained in the signature for callers of the old API, but it is deliberately NOT used: five empty
659
+ // numbers are ordinary when a start was abandoned or the key was used on another chain, and stopping there
660
+ // hides a funded order whose log is already in `logs`.
661
+ void gap;
662
+ const max = Math.min(numberField(maxIndex, "maxIndex") || RECOVERY_INDEX_MAX, RECOVERY_INDEX_MAX);
570
663
  // THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
571
664
  // factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
572
665
  // last resort for a caller that knows the factory existed at genesis.
@@ -581,8 +674,8 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
581
674
  if (address) {
582
675
  const padded = topicAddress(address);
583
676
  const [asLocker, asClaimer] = await Promise.all([
584
- readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, padded, null] }),
585
- readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, null, padded] }),
677
+ readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, padded, null] }),
678
+ readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS, null, null, padded] }),
586
679
  ]);
587
680
  const seen = new Set();
588
681
  for (const log of asLocker.concat(asClaimer)) {
@@ -592,26 +685,67 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
592
685
  logs.push(log);
593
686
  }
594
687
  } else {
595
- logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC] });
688
+ logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
596
689
  }
597
690
  const orders = logs.map(decodeOrderLog).filter(Boolean);
691
+ for (const order of orders) order.factory = factory;
692
+ const orderKey = (order) => `${order.transactionHash || ""}:${order.logIndex ?? ""}`;
693
+ const seed = seedOf(mnemonic);
598
694
  const found = [];
599
- let misses = 0;
600
- let scanned = 0;
601
- for (let index = 0; index < max && misses < missesAllowed; index += 1) {
602
- scanned = index + 1;
603
- const own = takerHalves({ mnemonic, chainId: chain, index });
604
- const matches = orders.filter((order) =>
605
- order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
606
- order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase()
607
- );
608
- if (matches.length === 0) {
609
- misses += 1;
695
+ const foundKeys = new Set();
696
+ const v2Matched = new Set();
697
+
698
+ // A V2 ORDER IS FOUND FROM ITS OWN SALT. No index is reserved and no range is scanned: the binding names the
699
+ // factory, the role and the public `OrderCreated` salt, so the same words on another device or at another
700
+ // factory produce a different half. The v2 pass runs first; v1 orders are left for the index scan below.
701
+ for (const order of orders) {
702
+ let commitment;
703
+ try {
704
+ commitment = spendCommitmentFromSeedV2(seed, { chainId: chain, factory, role: "taker", salt: order.salt });
705
+ } catch {
610
706
  continue;
611
707
  }
612
- misses = 0;
613
- for (const order of matches) {
614
- found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
708
+ const locker = order.commitHalfLocker.toLowerCase();
709
+ const claimer = order.commitHalfClaimer.toLowerCase();
710
+ const side = commitment === locker ? "locker" : commitment === claimer ? "claimer" : null;
711
+ if (!side) continue;
712
+ const key = orderKey(order);
713
+ if (foundKeys.has(key)) continue;
714
+ foundKeys.add(key);
715
+ v2Matched.add(key);
716
+ found.push({ ...order, index: null, derivation: "salt-v2", side });
717
+ }
718
+
719
+ // MATCH V1 BY COMMITMENT, NOT BY A DENSE INDEX RANGE. The logs are already in hand; the only cost of a missing
720
+ // index is one derivation. Building the map first also keeps each order from being compared against every
721
+ // index.
722
+ const byCommit = new Map();
723
+ for (const order of orders) {
724
+ if (v2Matched.has(orderKey(order))) continue;
725
+ const locker = order.commitHalfLocker.toLowerCase();
726
+ const claimer = order.commitHalfClaimer.toLowerCase();
727
+ if (locker) {
728
+ if (!byCommit.has(locker)) byCommit.set(locker, []);
729
+ byCommit.get(locker).push({ order, side: "locker" });
730
+ }
731
+ if (claimer && claimer !== locker) {
732
+ if (!byCommit.has(claimer)) byCommit.set(claimer, []);
733
+ byCommit.get(claimer).push({ order, side: "claimer" });
734
+ }
735
+ }
736
+ const v1Needed = orders.some((order) => !v2Matched.has(orderKey(order)));
737
+ let scanned = 0;
738
+ if (v1Needed) {
739
+ for (let index = 0; index < max; index += 1) {
740
+ scanned = index + 1;
741
+ const commitment = spendCommitmentFromSeed(seed, chain, index);
742
+ const matches = byCommit.get(commitment) || [];
743
+ for (const { order, side } of matches) {
744
+ const key = orderKey(order);
745
+ if (foundKeys.has(key)) continue;
746
+ foundKeys.add(key);
747
+ found.push({ ...order, index, derivation: "index-v1", side });
748
+ }
615
749
  }
616
750
  }
617
751
  // EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
@@ -637,9 +771,14 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
637
771
  for (const order of found) {
638
772
  Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
639
773
  }
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.
774
+ // THE XMR AMOUNT COMES FROM THE EVENT IN THE CURRENT FACTORY. The previous event has no field; those orders fall
775
+ // back to the clone read, where word 18 is read only from a clone of the exact shape. This is data, not a
776
+ // withdrawal verdict.
642
777
  for (const order of found) {
778
+ if (order.xmrAmount !== null && order.xmrAmount !== undefined) {
779
+ order.xmrAmountReason = "event";
780
+ continue;
781
+ }
643
782
  if (typeof evmCode !== "function") {
644
783
  order.xmrAmount = null;
645
784
  order.xmrAmountReason = "no-code-reader";
@@ -657,5 +796,15 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
657
796
  order.xmrAmount = read.value;
658
797
  order.xmrAmountReason = read.reason;
659
798
  }
660
- return { swaps: found, scanned };
799
+ const complete = !v1Needed || max >= RECOVERY_INDEX_MAX;
800
+ return {
801
+ swaps: found,
802
+ scanned,
803
+ // A CALLER MUST NOT READ "NO SWAPS" AS "THE WHOLE RANGE WAS SEARCHED". The allocator ceiling is the only
804
+ // complete range; a smaller caller-supplied bound says where the search stopped. A result made only of v2
805
+ // orders has no range to search and is complete by construction.
806
+ searchedUpTo: scanned ? scanned - 1 : null,
807
+ complete,
808
+ ...(complete ? {} : { incompleteReason: "max-index" }),
809
+ };
661
810
  }
package/src/recovery.mjs CHANGED
@@ -23,9 +23,17 @@ export function createRecovery({ http = null, config = null } = {}) {
23
23
  async open(file, passphrase) {
24
24
  const contents = contentsOf(file);
25
25
  if (contents === null) throw new SdkError("bad-input", { field: "file" });
26
+ // THE ENVELOPE IS PARSED AT THE BOUNDARY: `restoreFromFile` takes the OBJECT, while the file arrives as text.
27
+ // A text passed through reached the parser as a string and every valid file failed as `stage: restore`.
28
+ let envelope;
29
+ try {
30
+ envelope = JSON.parse(contents);
31
+ } catch {
32
+ throw new SdkError("recovery-failed", { stage: "parse" });
33
+ }
26
34
  let restored;
27
35
  try {
28
- restored = await engine.restore.restoreFromFile(contents, passphrase);
36
+ restored = await engine.restore.restoreFromFile(envelope, passphrase);
29
37
  } catch (error) {
30
38
  // Parsing, decryption and the address check live in the engine; a code goes out, not the engine's wording.
31
39
  throw new SdkError("recovery-failed", { stage: "restore" });
package/src/swap-flow.mjs CHANGED
@@ -193,7 +193,8 @@ export async function runAtomicOrder({
193
193
  // string, and that turned the check into a ritual: the stand-in's proof was bound to a different string,
194
194
  // that is, to nothing. Now exactly the path a real provider would take is checked.
195
195
  const mock = await worker.buildSide(orderContext);
196
- counter = { proof: mock.proof, edPub: mock.pub.ed, mock, viewHalf: mock.viewHalf, viewPoint: mock.viewPub };
196
+ counter = { proof: mock.proof, edPub: mock.pub.ed, mock, viewHalf: mock.viewHalf, viewPoint: mock.viewPub,
197
+ commitHalfClaimer: (await worker.halfCommitment(mock.half)).commitment };
197
198
  }
198
199
  // THE COUNTERPARTY CHECK REMAINS ONE AND IT IS THE MAIN ONE: the DLEQ proof in the context of this order.
199
200
  // It confirms that both public points stand for ONE scalar - that is, that the half declared for the Monero
@@ -203,7 +204,7 @@ export async function runAtomicOrder({
203
204
  // receive: without that check a correct proof would guarantee nothing about the chain.
204
205
  say("verify", "dleq");
205
206
  const verdict = await worker.verifyCounterparty(orderContext, {
206
- proof: counter.proof, edPub: counter.edPub,
207
+ proof: counter.proof, edPub: counter.edPub, commitHalfClaimer: counter.commitHalfClaimer,
207
208
  }, null);
208
209
  if (!verdict.ok) throw new SdkError("quote-refused", { step: "proof", why: "rejected" });
209
210