@ninsei-labs/sdk 0.52.0 → 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.
@@ -407,17 +407,18 @@ export declare function judgeDaemonAddress(
407
407
  options?: { network?: string; allowPlainLoopback?: boolean },
408
408
  ): DaemonAddressJudgement;
409
409
 
410
- /** A per-KB price -> the fee for a transaction of `bytes`, rounded UP. Throws on a non-integer or a negative input. */
411
- export declare function feeForBytes(feePerKB: bigint | number | string, bytes: bigint | number | string): string;
410
+ /** A per-byte price -> the fee for a transaction of `bytes`/weight. Exact integer multiplication. */
411
+ export declare function feeForBytes(feePerByte: bigint | number | string, bytes: bigint | number | string): string;
412
412
 
413
413
  export interface FeeEstimateOk {
414
414
  readonly ok: true;
415
415
  readonly url: string;
416
- /** The price per kilobyte for the next block, in atomic units, or null when the daemon did not send it. */
417
- readonly feePerKB: string | null;
418
- readonly medianFeePerKB: string | null;
419
- /** The daemon's own priority list, in its order, or null. */
416
+ /** The price per byte for the next block, in atomic units, or null when the daemon did not send it. */
417
+ readonly feePerByte: string | null;
418
+ /** The daemon's own priority list, per byte and in its order, or null. */
420
419
  readonly perPriority: readonly string[] | null;
420
+ /** The unit the final fee is rounded to, or null when the daemon did not send it. */
421
+ readonly quantizationMask: string | null;
421
422
  }
422
423
 
423
424
  export interface FeeEstimateRefusal {
@@ -430,7 +431,7 @@ export interface FeeEstimateRefusal {
430
431
 
431
432
  /**
432
433
  * ASK A NODE WHAT A TRANSACTION WILL COST (`get_fee_estimate`). The exact sum needs the assembled transaction, so
433
- * this is a price per kilobyte; `feeForBytes` is the only arithmetic that turns it into a fee. Never throws.
434
+ * this is a price per byte; `feeForBytes` is the only arithmetic that turns it into a fee. Never throws.
434
435
  */
435
436
  export declare function moneroFeeEstimate(options: {
436
437
  url: string;
@@ -483,6 +484,19 @@ export interface ScannedMatch {
483
484
  [field: string]: unknown;
484
485
  }
485
486
 
487
+ /** A range scan's answer: the per-address totals and whether every block was read. */
488
+ export interface ScanRangeResult {
489
+ readonly fromHeight: number;
490
+ readonly toHeight: number;
491
+ readonly results: ScannedMatch[];
492
+ /** False when at least one block or its transactions could not be read. */
493
+ readonly complete: boolean;
494
+ /** The heights that failed; empty when `complete` is true. */
495
+ readonly missingHeights: number[];
496
+ /** `blocks-unreadable` when `complete` is false. */
497
+ readonly incompleteReason?: "blocks-unreadable";
498
+ }
499
+
486
500
  /** Walk a block range and return the transactions that pay a watched address. */
487
501
  export declare function scanRange(options: {
488
502
  daemonUrl: string;
@@ -492,4 +506,8 @@ export declare function scanRange(options: {
492
506
  log?: (event: unknown) => void;
493
507
  concurrency?: number;
494
508
  nowSec?: number | null;
495
- }): Promise<ScannedMatch[]>;
509
+ /** The chain tip used to judge a height lock; defaults to `toHeight`. Inclusion height still drives confirmations. */
510
+ tipHeight?: number | null;
511
+ /** The fetch used for the daemon requests; absent - the global fetch. */
512
+ fetchImpl?: typeof fetch;
513
+ }): Promise<ScanRangeResult>;
package/src/quotes.mjs CHANGED
@@ -16,9 +16,11 @@
16
16
  // 3) a quote older than its own ttlMs is not shown, and a row that contradicts itself (the named sides disagree
17
17
  // with the pair's name) is not read at all.
18
18
  import { fail } from "./errors.mjs";
19
+ import { SDK_VERSION } from "./version.mjs";
19
20
  import * as engine from "./engine.mjs";
20
21
  import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
21
22
  import { claimGasGiftWei } from "../engine/evm/claimGas.js";
23
+ import { parseXmr } from "../engine/core/xmrAmount.js";
22
24
  // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
23
25
  // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
24
26
  // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
@@ -398,7 +400,12 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
398
400
  // plus the priority part of the gas price. A buy quote has no tip; a sale is the only route where one exists.
399
401
  async sale(request) {
400
402
  if (!request || typeof request !== "object") fail("bad-input", { field: "request" });
401
- const payload = request.request && typeof request.request === "object" ? request : { request };
403
+ // THE NODE GATES THE THREE BROWSER BODY ROUTES ON THE CLIENT'S SDK LINE. The value is the package's own
404
+ // baked-in version, not a caller field: a caller cannot claim a line it was not built from.
405
+ // THE ENVELOPE IS THE REQUEST. The node accepts both `{ request: {...} }` and a bare body; every check below
406
+ // must read the same object the node priced, not the outer envelope that carries only `sdkVersion`.
407
+ const saleRequest = request.request && typeof request.request === "object" ? request.request : request;
408
+ const payload = { ...(request.request && typeof request.request === "object" ? request : { request }), sdkVersion: SDK_VERSION };
402
409
  let res = null;
403
410
  try {
404
411
  res = await http.tryJson(config.route("orderSell"), {
@@ -412,6 +419,162 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
412
419
  const ticket = res.body;
413
420
  const quote = (ticket.handoff && ticket.handoff.quote) || ticket.quote || null;
414
421
  if (!quote || typeof quote !== "object") fail("quote-field-unbound", { step: "order-sell", field: "quote" });
422
+ // THE SALE TICKET IS A SIGNED QUOTE, AND IT GETS THE SAME GATES AS `firm`: every field of the signed set,
423
+ // its expiry, the EIP-712 signature and the named key, the registry's provider mapping, the interface's
424
+ // allow-list, and the factory/implementation policy. The one difference is the binding below: a sale has no
425
+ // caller-supplied order terms, so its context is checked against `ticket.order` when the node returns it.
426
+ // A MISSING FIELD OF THE SIGNED SET IS NOT "PROBABLY ZERO": the digest cannot be built, so the quote is
427
+ // not verifiable and is refused (its own code, so the interface can say which field is unbound).
428
+ const missingFields = orderQuoteMissingFields(quote);
429
+ if (missingFields.length) fail("quote-field-unbound", { step: "signature", missing: missingFields });
430
+ // THE QUOTE'S OWN EXPIRY, checked against the clock the core already has. It is bound in the signature too,
431
+ // but a stale quote is refused by its own code rather than looking like a signature failure.
432
+ const validUntil = Number(quote.validUntil);
433
+ // THE SCALE IS SECONDS: the same as the order's deadlines and block.timestamp. The core's clock is in
434
+ // milliseconds (Date.now), so exactly ONE boundary is converted - "now", not the quote value itself: one
435
+ // unit for the value, one conversion at the input.
436
+ const nowSec = Math.floor(now() / 1000);
437
+ if (Number.isFinite(validUntil) && validUntil > 0 && validUntil <= nowSec) {
438
+ fail("quote-expired", { step: "signature", validUntil });
439
+ }
440
+ // RECOVER, THEN MATCH THE NAMED KEY. A signature that does not recover is one refusal; a valid signature
441
+ // that recovers to a DIFFERENT address is another (the quote names a key that did not sign it).
442
+ const recoveredKey = recoverOrderQuoteKey(quote);
443
+ if (recoveredKey === null) fail("quote-signature-invalid", { step: "signature" });
444
+ const declaredKey = String(quote.quoteKey).toLowerCase();
445
+ if (recoveredKey !== declaredKey) fail("quote-key-mismatch", { step: "signature", got: recoveredKey, want: declaredKey });
446
+ // WHO OWNS THE KEY - ASKED OF THE REGISTRY NAMED BY THE QUOTE. Both fields are part of the signed set, so
447
+ // they cannot be swapped after signing; this is what turns "a key signed it" into "this provider's key
448
+ // signed it". No chain-read seam - refuse rather than take the signature on trust.
449
+ const registry = String(quote.registry || "").toLowerCase();
450
+ if (!/^0x[0-9a-f]{40}$/.test(registry) || registry === ZERO_ADDRESS) {
451
+ fail("quote-field-unbound", { step: "registry", field: "registry" });
452
+ }
453
+ const declaredProvider = String(quote.provider || "").toLowerCase();
454
+ if (!/^0x[0-9a-f]{40}$/.test(declaredProvider) || declaredProvider === ZERO_ADDRESS) {
455
+ fail("quote-field-unbound", { step: "registry", field: "provider" });
456
+ }
457
+ if (typeof evmCall !== "function") fail("quote-registry-unchecked", { step: "registry", why: "no-chain-read" });
458
+ let mappedProvider = null;
459
+ try {
460
+ mappedProvider = addressFromWord(await evmCall({ to: registry, data: providerOfData(declaredKey) }));
461
+ } catch {
462
+ fail("quote-registry-unchecked", { step: "registry", why: "read-failed", registry });
463
+ }
464
+ if (mappedProvider === null || mappedProvider === ZERO_ADDRESS) {
465
+ fail("quote-key-unknown", { step: "registry", registry, quoteKey: declaredKey });
466
+ }
467
+ if (mappedProvider !== declaredProvider) {
468
+ fail("quote-provider-mismatch", { step: "registry", got: mappedProvider, want: declaredProvider });
469
+ }
470
+ // THE INTERFACE'S OWN ADMISSION LIST - POLICY, NOT PROOF. The chain says whose key it is; whether this
471
+ // interface trades with that provider at all is the interface's decision, and it rides as a hook.
472
+ if (typeof providerAllowed === "function" && providerAllowed(declaredProvider) !== true) {
473
+ fail("quote-provider-not-allowed", { step: "policy", provider: declaredProvider });
474
+ }
475
+ // THE FACTORY: NAMED BY THE PROVIDER'S RECORD, CHECKED BY ITS CODE (#103). The factory will verify the
476
+ // quote in EVM (it is the EIP-712 verifyingContract), so it must be the one the record names AND its
477
+ // code must be a build this interface knows. The record lookup always runs; the code check runs when a
478
+ // policy list was supplied (nothing to pin against otherwise).
479
+ const declaredFactory = String(quote.factory || "").toLowerCase();
480
+ if (!/^0x[0-9a-f]{40}$/.test(declaredFactory) || declaredFactory === ZERO_ADDRESS) {
481
+ fail("quote-field-unbound", { step: "factory", field: "factory" });
482
+ }
483
+ let recordedFactory = null;
484
+ try {
485
+ recordedFactory = addressFromWord(await evmCall({ to: registry, data: factoryOfData(declaredProvider) }));
486
+ } catch {
487
+ fail("quote-factory-unchecked", { step: "factory", why: "read-failed", registry });
488
+ }
489
+ if (recordedFactory === null || recordedFactory === ZERO_ADDRESS) {
490
+ fail("quote-factory-unknown", { step: "factory", registry, provider: declaredProvider });
491
+ }
492
+ if (recordedFactory !== declaredFactory) {
493
+ fail("quote-factory-mismatch", { step: "factory", got: declaredFactory, want: recordedFactory });
494
+ }
495
+ if (factoryPolicy) {
496
+ if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
497
+ let codeHex = null;
498
+ try { codeHex = await evmCode(declaredFactory); } catch { fail("quote-factory-unchecked", { step: "factory", why: "code-read-failed", factory: declaredFactory }); }
499
+ const codeBytes = hexToBytesQ(codeHex);
500
+ // EXTCODEHASH SEMANTICS: keccak256 of the runtime code. An address with no code (empty/0x) hashes to
501
+ // the keccak of empty, which is not on any build list - refused like any unknown build.
502
+ const codeHash = bytesToHexQ(keccak256(codeBytes || new Uint8Array(0)));
503
+ if (!factoryPolicy.includes(codeHash)) {
504
+ fail("quote-factory-code-unknown", { step: "factory", factory: declaredFactory, codeHash });
505
+ }
506
+ }
507
+ // THE IMPLEMENTATION THE CLONES WILL EXECUTE (#114). A pinned factory CODE is not enough on its own: the
508
+ // implementation ADDRESS lives in the factory's STORAGE, so the client re-reads the pair the factory itself
509
+ // checks (implementation() / implementationCodeHash()) and the CODE at that address, then applies the same
510
+ // verdict (verifyFactoryImplementationOnChain). A delegatecall to an address WITHOUT code SUCCEEDS, so a
511
+ // clone without an implementation would take the ETH and its markReady/claim/refund would appear to go
512
+ // through while doing nothing - that is exactly the case this refuses. The live code is re-hashed here, so
513
+ // the verdict does not trust the factory's own answer alone.
514
+ if (implementationPolicy) {
515
+ if (typeof evmCall !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-chain-read" });
516
+ if (typeof evmCode !== "function") fail("quote-factory-unchecked", { step: "factory", why: "no-code-read" });
517
+ const verdict = await engine.evm.verifyFactoryImplementationOnChain({
518
+ factory: declaredFactory,
519
+ read: evmCall,
520
+ code: (address) => evmCode(address),
521
+ policy: implementationPolicy,
522
+ hashCode: (codeHex) => bytesToHexQ(keccak256(hexToBytesQ(codeHex) || new Uint8Array(0))),
523
+ });
524
+ if (!verdict.ok) {
525
+ const unknown = verdict.why === "implementation-code-unknown" ||
526
+ verdict.why === "implementation-code-mismatch" || verdict.why === "implementation-has-no-code";
527
+ fail(unknown ? "quote-factory-code-unknown" : "quote-factory-unchecked",
528
+ { step: "factory", why: verdict.why, factory: declaredFactory, implementation: verdict.implementation || null });
529
+ }
530
+ }
531
+ // THE PERSON SELLING IS THE CLAIMER. The interface's denylist is applied to that address, not to a
532
+ // caller-chosen value: a ticket that names a denied claimer must not reach the funding step.
533
+ const saleClaimer = String((saleRequest && saleRequest.claimer) || quote.claimer || "").toLowerCase();
534
+ if (saleClaimer && denylist && denylist.has(saleClaimer)) fail("address-denied", { step: "order-sell" });
535
+ if (saleRequest && saleRequest.claimer && quote.claimer && String(saleRequest.claimer).toLowerCase() !== String(quote.claimer).toLowerCase()) {
536
+ fail("quote-refused", { step: "order-sell", why: "claimer-not-bound" });
537
+ }
538
+ // THE XMR THE CALLER ASKED FOR IS THE XMR THE QUOTE SIGNED. A ticket is paid against the signed `xmrAmount`;
539
+ // accepting a different requested amount shows one figure and settles another.
540
+ const requestedXmr = saleRequest && (saleRequest.xmr !== undefined ? saleRequest.xmr
541
+ : (saleRequest.xmrAmount !== undefined ? saleRequest.xmrAmount : null));
542
+ if (requestedXmr !== null && requestedXmr !== undefined) {
543
+ let wanted = null;
544
+ try { wanted = BigInt(String(requestedXmr)); } catch { wanted = null; }
545
+ // A whole-XMR request (the public `xmr` field) is converted with the package's own parser; digits are
546
+ // already atomic. A value that cannot be read is a refusal, not a skipped check.
547
+ if (wanted === null || wanted < 10n ** 12n) {
548
+ try { wanted = parseXmr(String(requestedXmr)); } catch { wanted = null; }
549
+ }
550
+ let signedAtomic = null;
551
+ try { signedAtomic = quote.xmrAmount === undefined || quote.xmrAmount === null || quote.xmrAmount === ""
552
+ ? null : BigInt(String(quote.xmrAmount)); } catch { signedAtomic = null; }
553
+ if (signedAtomic === null || wanted === null || wanted !== signedAtomic) {
554
+ fail("quote-refused", { step: "order-sell", why: "xmr-amount" });
555
+ }
556
+ }
557
+ // THE CONTEXT WHEN THE TICKET CARRIES ITS ORDER. The signed quote binds the order terms; when the node
558
+ // returns the order it priced, recompute the context with our own engine and refuse a mismatch.
559
+ if (ticket.order && typeof ticket.order === "object") {
560
+ // THE RETURNED ORDER MUST BE THE SIGNED ONE FIELD BY FIELD. `quote.context` is a derived label, not a
561
+ // signed field; comparing only it lets a node return another amount with a matching relabelled context.
562
+ const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
563
+ === String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
564
+ for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt"]) {
565
+ if (!sameValue(ticket.order[name], quote[name])) {
566
+ fail("quote-refused", { step: "order-sell", why: "other-terms", field: name });
567
+ }
568
+ }
569
+ // The context remains a useful cross-check when both sides carry it.
570
+ if (quote.context) {
571
+ let expectedSaleContext = null;
572
+ try { expectedSaleContext = engine.orderContext.orderContextString(ticket.order); } catch { expectedSaleContext = null; }
573
+ if (expectedSaleContext !== null && String(quote.context) !== expectedSaleContext) {
574
+ fail("quote-refused", { step: "order-sell", why: "other-terms", field: "context" });
575
+ }
576
+ }
577
+ }
415
578
  // THE TICKET'S TIP MUST BE THE SIGNED ONE. The node returns both the ticket's `claimTip` object and the quote it
416
579
  // signed; a difference means the figure shown to the person is not the figure the escrow will pay.
417
580
  const shown = ticket.claimTip && ticket.claimTip.wei !== undefined && ticket.claimTip.wei !== null
@@ -467,7 +630,7 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
467
630
  method: "POST",
468
631
  headers: { "content-type": "application/json" },
469
632
  body: JSON.stringify({ providerId: request.providerId, order: request.order,
470
- ...(viewHalfClaimer === null ? {} : { viewHalfClaimer }) }),
633
+ ...(viewHalfClaimer === null ? {} : { viewHalfClaimer }), sdkVersion: SDK_VERSION }),
471
634
  });
472
635
  } catch (error) {
473
636
  if (error && error.code === "bad-input") throw error;
@@ -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);
@@ -543,7 +621,13 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
543
621
  // timestamp is already needed here for the Monero height, so it is returned with it.
544
622
  let timestamp = cache.get(order.blockNumber);
545
623
  if (timestamp === undefined) {
546
- 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
+ }
547
631
  if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time", blockTime: null };
548
632
  cache.set(order.blockNumber, timestamp);
549
633
  }
@@ -566,13 +650,16 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
566
650
  * positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
567
651
  */
568
652
  export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
569
- fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = 1000,
653
+ fromBlock = null, deploymentBlock = null, toBlock = "latest", gap = 5, maxIndex = RECOVERY_INDEX_MAX,
570
654
  evmBlockTime = null, evmCode = null, moneroTip = null, moneroBlockTimestamp = null,
571
655
  xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
572
656
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
573
657
  const chain = numberField(chainId, "chainId");
574
- const max = numberField(maxIndex, "maxIndex") || 1000;
575
- 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);
576
663
  // THE CHAIN SAYS WHERE ITS ORDERS CAN START. An explicit fromBlock wins; otherwise the caller can pass the
577
664
  // factory's deployment block (the SDK's chain registry exposes it as escrow.deploymentBlock). Zero remains the
578
665
  // last resort for a caller that knows the factory existed at genesis.
@@ -601,23 +688,64 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
601
688
  logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPICS] });
602
689
  }
603
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);
604
694
  const found = [];
605
- let misses = 0;
606
- let scanned = 0;
607
- for (let index = 0; index < max && misses < missesAllowed; index += 1) {
608
- scanned = index + 1;
609
- const own = takerHalves({ mnemonic, chainId: chain, index });
610
- const matches = orders.filter((order) =>
611
- order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
612
- order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase()
613
- );
614
- if (matches.length === 0) {
615
- 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 {
616
706
  continue;
617
707
  }
618
- misses = 0;
619
- for (const order of matches) {
620
- 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
+ }
621
749
  }
622
750
  }
623
751
  // EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
@@ -668,5 +796,15 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
668
796
  order.xmrAmount = read.value;
669
797
  order.xmrAmountReason = read.reason;
670
798
  }
671
- 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
+ };
672
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