@ninsei-labs/sdk 0.48.1 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## 0.50.0 — 2026-10-21
2
+
3
+ A CLAIM IS SENT BY ITS CLAIMER, AND A SALE IS RANKED AFTER THE TIP.
4
+
5
+ - `actions.claim` compares the wallet's address with `request.claimer` or `expect.claimer` before it signs. A
6
+ third-party wallet, a missing claimer or a wallet without an address is a named `bad-input`; `claimSenderVerdict`
7
+ exposes the same rule. This is a deliberate tightening: a claim reveals the half to its sender.
8
+ - `readQuote` keeps the signed `claimTip`, and `rankOffers` selects a sale by `amount × rate − claimTip`, not by
9
+ the headline rate. A buy, or a request without an amount, still ranks by rate.
10
+ - `quotes.watch` uses that ranking for the chosen offer, so moving margin from the price into the tip no longer
11
+ wins a sale.
12
+
13
+ ## 0.49.0 — 2026-10-21
14
+
15
+ A ONE-KEY RECOVERY CAN WITHDRAW WITHOUT A FULL SCAN, AND A CLAIM TIP IS CHECKED.
16
+
17
+ - `findSwaps` accepts `xmrNodes` and `moneroNetwork`: it asks the node for `get_info` and
18
+ `get_block_header_by_height` itself, so a page needs only `evmBlockTime`; the `startHeight` rule stays in the SDK.
19
+ - `sweep.restore({ window })` sets the wallet at the node tip, scans only the blocks from the swap's start to
20
+ `t1 + 10` through `scanTxs` (batches of 100), checks the wallet's key images at `/is_key_image_spent`, and does
21
+ not run `rescanBlockchain` after `scanTxs`. An empty window falls back to the full scan; `windowTxHashes` collects
22
+ the hashes from a node.
23
+ - `quotes.firm` refuses a `claimTip` above twice the SDK's own `claimGasGiftWei` estimate, read from the new
24
+ `evmBlock` seam. Zero tips (the buy side) do not call it; an unmeasured base fee is named.
25
+ - `claimTipVerdict` is exported for the same arithmetic outside the quote path.
26
+ - No breaking changes: the old recovery-file path, the full restore and zero-tip quotes behave as before.
27
+
1
28
  ## 0.48.1 — 2026-10-21
2
29
 
3
30
  A SWAP KEEPS ITS MONERO HEIGHT, AND A RECOVERED ORDER KNOWS WHERE TO SCAN.
package/index.d.ts CHANGED
@@ -108,6 +108,13 @@ export interface NinseiOptions {
108
108
  * the order's escrow and the topic is the package's own.
109
109
  */
110
110
  evmLogs?: EvmLogReader;
111
+ /**
112
+ * READING A SETTLEMENT BLOCK FROM OUTSIDE (`(tag) => block`): the claim-tip check needs the chain's current
113
+ * `baseFeePerGas`. A missing seam is `quote-tip-unchecked` when a quote carries a non-zero tip; zero tips (the
114
+ * buy side) never call it.
115
+ */
116
+ evmBlock?: (tag?: string) => Promise<{ readonly baseFeePerGas?: string | number | bigint | null;
117
+ readonly baseFeeWei?: string | number | bigint | null }>;
111
118
  /**
112
119
  * THE INTERFACE'S OWN ADMISSION LIST - policy, not proof. The chain (maker registry, providerOf, taken from the
113
120
  * quote) proves whose key signed; this says whom THIS interface trades with at all. An array of provider
@@ -500,6 +507,8 @@ export interface QuoteOffer {
500
507
  readonly currency: string | null;
501
508
  readonly rate: number;
502
509
  readonly rateQuote: number;
510
+ /** The claim tip in native-token wei; a sale is ranked by the payout AFTER it. */
511
+ readonly claimTip: string;
503
512
  readonly min: number | null;
504
513
  readonly max: number | null;
505
514
  readonly step: number | null;
@@ -1296,6 +1305,20 @@ export interface SweepRestoreRequest {
1296
1305
  readonly scanBack?: number;
1297
1306
  /** Run the second pass when coins exist but none is spendable. Default true. */
1298
1307
  readonly rescanWhenLocked?: boolean;
1308
+ /**
1309
+ * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1310
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1311
+ * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1312
+ */
1313
+ readonly window?: {
1314
+ readonly nodeUrl?: string;
1315
+ readonly txHashes?: readonly string[];
1316
+ readonly toHeight: number;
1317
+ readonly tipHeight: number;
1318
+ readonly fallback?: boolean;
1319
+ };
1320
+ /** The fetch used for `get_block`; absent - the global fetch. */
1321
+ readonly fetchImpl?: typeof fetch;
1299
1322
  }
1300
1323
 
1301
1324
  /** What `restore` found. Amounts are atomic units as strings; heights are numbers when the driver can read them. */
@@ -1310,6 +1333,18 @@ export interface SweepRestoreResult {
1310
1333
  readonly passes: number;
1311
1334
  /** Whether the second pass RAN - not whether it helped. */
1312
1335
  readonly lockedPass: boolean;
1336
+ /** Present when a window was requested: whether it was used, and why it was skipped. */
1337
+ readonly window?: {
1338
+ readonly used: boolean;
1339
+ readonly skipped: string | null;
1340
+ readonly fromHeight: number;
1341
+ readonly toHeight: number;
1342
+ readonly blocks: number;
1343
+ readonly txs: number;
1344
+ readonly spent?: number | null;
1345
+ readonly keyImages?: number;
1346
+ readonly spentWhy?: string;
1347
+ } | null;
1313
1348
  }
1314
1349
 
1315
1350
  /** What `wallet` needs: the library namespace and the facts the wallet is built from. */
@@ -1564,6 +1599,9 @@ export interface ActionsApi {
1564
1599
  export interface ActionsRequest {
1565
1600
  readonly escrow: string;
1566
1601
  readonly expect: Readonly<Record<string, unknown>>;
1602
+ /** The claimer whose half is revealed by `claim`; absent - `expect.claimer` is used. A claim without one is
1603
+ * refused: the package sends it only from the claimer's own wallet, never from a third-party sender. */
1604
+ readonly claimer?: string;
1567
1605
  readonly onStep?: (stage: string, detail?: string) => void;
1568
1606
  }
1569
1607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.48.1",
3
+ "version": "0.50.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/actions.mjs CHANGED
@@ -25,6 +25,18 @@ const walletOrFail = (wallet) => {
25
25
  return wallet;
26
26
  };
27
27
 
28
+ // THE CLAIM'S HALF IS REVEALED BY WHOEVER SENDS IT. The package sends a claim only from the claimer's own wallet:
29
+ // a third-party sender would see the half before the claim is mined, and the only reason to use one is the tip -
30
+ // not a reason to hand the half to another party. A caller with no claimer is refused, not sent blind.
31
+ export function claimSenderVerdict({ sender = null, claimer = null } = {}) {
32
+ const from = typeof sender === "string" ? sender.trim().toLowerCase() : "";
33
+ const to = typeof claimer === "string" ? claimer.trim().toLowerCase() : "";
34
+ if (!to) return { ok: false, code: "bad-input", field: "claimer" };
35
+ if (!from) return { ok: false, code: "bad-input", field: "wallet.address" };
36
+ if (from !== to) return { ok: false, code: "bad-input", field: "wallet", why: "not-the-claimer" };
37
+ return { ok: true };
38
+ }
39
+
28
40
  const requestOrFail = (request, extra) => {
29
41
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
30
42
  const missing = missingFields(request, ["escrow", "expect", ...extra]);
@@ -59,6 +71,9 @@ export function createActions({ call = null } = {}) {
59
71
  requestOrFail(request, ["halfClaimer"]);
60
72
  walletOrFail(wallet);
61
73
  const { escrow, halfClaimer, expect, onStep } = request;
74
+ const sender = typeof wallet.address === "function" ? await wallet.address() : null;
75
+ const verdict = claimSenderVerdict({ sender, claimer: request.claimer || (expect && expect.claimer) });
76
+ if (!verdict.ok) throw new SdkError(verdict.code, { field: verdict.field, why: verdict.why, surface: "actions.claim" });
62
77
  return await withCodes("claim", () => flow.claimOrder({ escrow, halfClaimer, expect, onStep, deps: deps(wallet) }));
63
78
  },
64
79
  /** Refunding: it also reveals a half. The core passes the expectations ALWAYS, though the engine does not require them. */
package/src/index.mjs CHANGED
@@ -91,6 +91,9 @@ const normalizeOptions = (options) => {
91
91
  // same provider the chain read uses. Same rule as evmCall: a READ of the escrow's own event, not a node
92
92
  // substitution.
93
93
  if (o.evmLogs !== undefined && typeof o.evmLogs !== "function") fail("bad-input", { field: "evmLogs" });
94
+ // THE BLOCK-READ SEAM: the claim tip is checked against the chain's current base fee, and that is a block read,
95
+ // not an `eth_call`. Same rule as evmCall - brought from outside, refused when it is not a function.
96
+ if (o.evmBlock !== undefined && typeof o.evmBlock !== "function") fail("bad-input", { field: "evmBlock" });
94
97
  // THE INTERFACE'S ALLOW-LIST (POLICY, NOT PROOF): an array of provider addresses (or providerIds) OR a
95
98
  // predicate. It is NOT the check itself - that is `providerOf` on chain, taken from the quote - but the
96
99
  // interface's own decision about whom it trades with. A wrong shape is a typo, not a silent no-op.
@@ -156,6 +159,7 @@ const normalizeOptions = (options) => {
156
159
  evmCall: o.evmCall || null,
157
160
  evmCode: o.evmCode || null,
158
161
  evmLogs: o.evmLogs || null,
162
+ evmBlock: o.evmBlock || null,
159
163
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
160
164
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
161
165
  knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
@@ -215,7 +219,8 @@ export function createNinsei(options) {
215
219
  ? new Set(o.denylist.map((a) => String(a).toLowerCase()))
216
220
  : null;
217
221
  const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
218
- evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
222
+ evmCode: o.evmCode || null, evmBlock: o.evmBlock || null,
223
+ providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
219
224
  denylist });
220
225
  // THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
221
226
  // part of the check goes past the wallet and part does not.
@@ -385,4 +385,11 @@ export declare function findSwaps(request: {
385
385
  moneroTip?: () => Promise<number>;
386
386
  /** A Monero block header timestamp, in seconds: (height) -> seconds. */
387
387
  moneroBlockTimestamp?: (height: number) => Promise<number>;
388
+ /** Convenience: the node URLs the caller already probed. When the two Monero readers above are absent, the SDK
389
+ * asks these for `get_info` and `get_block_header_by_height` itself. */
390
+ xmrNodes?: readonly (string | { readonly url: string })[];
391
+ /** The Monero network those nodes must name (mainnet, stagenet, testnet). */
392
+ moneroNetwork?: string | null;
393
+ /** The fetch used for those node requests; absent - the global fetch. */
394
+ fetchImpl?: typeof fetch;
388
395
  }): Promise<{ swaps: FoundSwap[]; scanned: number }>;
package/src/quotes.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  import { fail } from "./errors.mjs";
19
19
  import * as engine from "./engine.mjs";
20
20
  import { orderQuoteMissingFields, recoverOrderQuoteKey } from "./quoteEip712.mjs";
21
+ import { claimGasGiftWei } from "../engine/evm/claimGas.js";
21
22
  // THE CHAIN READ NEEDED TO ASK THE REGISTRY WHO OWNS THE KEY. The selector is COMPUTED from the signature, not
22
23
  // remembered: a single wrong hex digit would call another function and return a zero address - and a zero
23
24
  // address is exactly the answer "nobody owns this key", which the caller would take as the truth.
@@ -82,6 +83,13 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
82
83
  currencyNetwork: q.currencyNetwork ? String(q.currencyNetwork) : null,
83
84
  rateQuote,
84
85
  rate,
86
+ // THE CLAIM TIP IS IN THE MAKER'S NATIVE TOKEN (wei). A sale is ranked by what the seller receives AFTER it;
87
+ // the value is kept as a string, because a wei amount does not fit a float reliably.
88
+ claimTip: (() => {
89
+ if (q.claimTip === undefined || q.claimTip === null || q.claimTip === "") return "0";
90
+ try { const n = BigInt(q.claimTip); return n < 0n ? "0" : n.toString(); }
91
+ catch { return "0"; }
92
+ })(),
85
93
  min: num(q.min),
86
94
  max: num(q.max),
87
95
  step: num(q.step),
@@ -103,6 +111,27 @@ export function readQuote(pair, raw, owner = {}, data = {}) {
103
111
  };
104
112
  }
105
113
 
114
+ /**
115
+ * THE BEST OFFER IS THE ONE THAT PAYS THE MOST, NOT THE ONE WITH THE HIGHEST RATE. On a sale the maker may move
116
+ * its margin into `claimTip`: the escrow pays that tip out of the seller's payout, so a higher rate with a larger
117
+ * tip can leave the seller with less. With no amount there is nothing to compare beyond the rate, and a buy has
118
+ * no tip. Native-token wei are converted at 18 decimals: a sale settles in the chain's native coin.
119
+ */
120
+ export function rankOffers(offers = [], { direction = "buy", amount = null } = {}) {
121
+ const list = Array.isArray(offers) ? offers.filter((offer) => offer && Number(offer.rate) > 0) : [];
122
+ const amountXmr = Number(amount);
123
+ const score = (offer) => {
124
+ const rate = Number(offer.rate);
125
+ if (direction !== "sell" || !(amountXmr > 0)) return rate;
126
+ let tip = 0;
127
+ try { tip = Number(BigInt(offer.claimTip || "0")) / 1e18; } catch { tip = 0; }
128
+ return amountXmr * rate - tip;
129
+ };
130
+ const ranked = list.map((offer) => ({ offer, net: score(offer) }))
131
+ .sort((a, b) => (b.net - a.net) || (Number(b.offer.rate) - Number(a.offer.rate)));
132
+ return { best: ranked.length ? ranked[0].offer : null, net: ranked.length ? ranked[0].net : null, ranked };
133
+ }
134
+
106
135
  /**
107
136
  * Parsing the book in one place. The envelope is accepted in three forms (a map by pairs, a list of rows, a list
108
137
  * of providers) and is brought to a single list: the screen must not know which form the answer came in.
@@ -200,7 +229,7 @@ const compactOffer = (o) => ({
200
229
  assetNetwork: o.assetNetwork, currencyNetwork: o.currencyNetwork, networkStated: o.networkStated,
201
230
  networksDerived: o.networksDerived === true, networksUnnamed: o.networksUnnamed === true,
202
231
  // THE REASON (in the provider's words), THE PACKAGE'S SIGNATURE AND KEY, THE AGE BY ITS OWN MARK.
203
- why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
232
+ claimTip: o.claimTip, why: o.why, signature: o.signature, keyId: o.keyId, ageMs: o.ageMs,
204
233
  networks: { asset: o.assetNetwork, currency: o.currencyNetwork, stated: o.networkStated },
205
234
  });
206
235
 
@@ -208,7 +237,32 @@ const compactOffer = (o) => ({
208
237
  // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
238
  const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
239
 
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
240
+ /**
241
+ * A CLAIM TIP IS SIZED BY THE CLAIM, NOT BY THE MAKER'S MOOD. The node's own estimate uses the chain's current
242
+ * base fee and the same formula; a quote may carry up to twice that estimate (gas moves between the sizing and
243
+ * the check). Zero is the buy side and is always allowed. An unmeasured base fee is a refusal, not a free pass.
244
+ */
245
+ export function claimTipVerdict({ claimTipWei = 0, baseFeeWei = null, priorityFeeWei = 0, factor = 2 } = {}) {
246
+ let tip = 0n;
247
+ try { tip = BigInt(claimTipWei === undefined || claimTipWei === null || claimTipWei === "" ? 0 : claimTipWei); }
248
+ catch { return { ok: false, code: "quote-tip-unreadable" }; }
249
+ if (tip < 0n) return { ok: false, code: "quote-tip-unreadable" };
250
+ if (tip === 0n) return { ok: true, tipWei: "0", ceilingWei: null, estimateWei: null };
251
+ if (baseFeeWei === null || baseFeeWei === undefined || baseFeeWei === "") {
252
+ return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
253
+ }
254
+ const estimate = claimGasGiftWei({ baseFeeWei, priorityFeeWei });
255
+ if (estimate === null) return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
256
+ const f = BigInt(factor);
257
+ const ceiling = estimate * f;
258
+ if (tip > ceiling) {
259
+ return { ok: false, code: "quote-tip-too-large", tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
260
+ }
261
+ return { ok: true, tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
262
+ }
263
+
264
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, evmBlock = null,
265
+ providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
266
  // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
267
  // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
268
  // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
@@ -241,8 +295,11 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
241
295
  const asked = explicit ? Number(request.size) : size;
242
296
  const data = await http.json("/rate?amount=" + encodeURIComponent(asked) + "&side=" + encodeURIComponent(request.direction));
243
297
  const book = readBook(data, now());
244
- const offer = book.best;
245
298
  const amount = Number.isFinite(Number(request.amount)) ? Number(request.amount) : null;
299
+ // A SALE IS RANKED BY THE PAYOUT AFTER THE TIP; a buy has no tip. Without an amount the rate is the only
300
+ // comparable number, and the service's own best remains the fallback.
301
+ const ranked = rankOffers(book.offers, { direction: request.direction, amount });
302
+ const offer = ranked.best || book.best;
246
303
  // The size derived from the person's amount becomes the size of the NEXT request: the price does not depend
247
304
  // on volume, and the volume is needed by the provider to say whether it takes such a size. With a named size
248
305
  // there is nothing to derive - it stays what it was asked as.
@@ -507,6 +564,24 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
507
564
  if (String(quoted) !== expectedContext) {
508
565
  fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
509
566
  }
567
+ // THE CLAIM TIP: the seller pays it out of the payout, so a quote that asks for more than the claim can
568
+ // cost is refused before it is signed. The chain's base fee is read through the caller's block seam; a
569
+ // missing seam or an unreadable base fee is a named refusal, never a silently accepted tip.
570
+ const tipCheck = (() => {
571
+ try { return claimTipVerdict({ claimTipWei: quote.claimTip }); }
572
+ catch { return { ok: true, tipWei: "0" }; }
573
+ })();
574
+ if (!tipCheck.ok && tipCheck.code === "quote-tip-unreadable") {
575
+ fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
576
+ }
577
+ if (tipCheck.tipWei !== "0") {
578
+ if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
579
+ let block = null;
580
+ try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
581
+ const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
582
+ const verdict = claimTipVerdict({ claimTipWei: quote.claimTip, baseFeeWei });
583
+ if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
584
+ }
510
585
  return quote;
511
586
  },
512
587
  };
@@ -19,6 +19,7 @@ import { createHalves } from "../engine/atomic/halves.js";
19
19
  import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
20
20
  import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
21
21
  import { SdkError } from "./errors.mjs";
22
+ import { probeMoneroNode } from "./probes.mjs";
22
23
  import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
23
24
 
24
25
  const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
@@ -430,6 +431,45 @@ async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
430
431
  return low;
431
432
  }
432
433
 
434
+ // THE READERS A CALLER SHOULD NOT HAVE TO BUILD. A page already holds the node URLs it probed; these wrap
435
+ // `get_info` and `get_block_header_by_height` over one of them. The SDK still owns the binary search; only the
436
+ // transport is borrowed.
437
+ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
438
+ const urls = (Array.isArray(nodes) ? nodes : [])
439
+ .map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
440
+ if (!urls.length) return null;
441
+ const answers = await Promise.all(urls.map(async (url) => {
442
+ try { return { ...(await probeMoneroNode({ url, fetchImpl })), url }; }
443
+ catch { return { ok: false, why: "unreachable", url }; }
444
+ }));
445
+ const node = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
446
+ if (!node) return null;
447
+ const call = async (method, params) => {
448
+ let response;
449
+ try {
450
+ response = await fetchImpl(node.url + "/json_rpc", {
451
+ method: "POST", headers: { "content-type": "application/json" },
452
+ body: JSON.stringify({ jsonrpc: "2.0", id: method, method, params }),
453
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
454
+ });
455
+ } catch { throw new Error("monero-unreachable"); }
456
+ if (!response || response.ok !== true) throw new Error("monero-http");
457
+ let body = null;
458
+ try { body = await response.json(); } catch { throw new Error("monero-json"); }
459
+ if (!body || body.error || !body.result) throw new Error("monero-rpc");
460
+ return body.result;
461
+ };
462
+ return {
463
+ tip: async () => node.height,
464
+ headerTimestamp: async (height) => {
465
+ const result = await call("get_block_header_by_height", { height });
466
+ const ts = Number(result.block_header && result.block_header.timestamp);
467
+ if (!Number.isFinite(ts) || ts <= 0) throw new Error("monero-header");
468
+ return ts;
469
+ },
470
+ };
471
+ }
472
+
433
473
  async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
434
474
  if (order.blockNumber === null || order.blockNumber === undefined) {
435
475
  return { startHeight: null, startHeightReason: "no-block-number" };
@@ -460,7 +500,8 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
460
500
  */
461
501
  export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
462
502
  fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
463
- evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null } = {}) {
503
+ evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null,
504
+ xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
464
505
  if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
465
506
  const chain = numberField(chainId, "chainId");
466
507
  const max = numberField(maxIndex, "maxIndex") || 1000;
@@ -506,8 +547,14 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
506
547
  // EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
507
548
  // page: a widget and the recovery page must not derive two different answers from one chain.
508
549
  const cache = new Map();
550
+ let tip = moneroTip;
551
+ let header = moneroBlockTimestamp;
552
+ if ((typeof tip !== "function" || typeof header !== "function") && Array.isArray(xmrNodes) && xmrNodes.length) {
553
+ const readers = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
554
+ if (readers) { tip = tip || readers.tip; header = header || readers.headerTimestamp; }
555
+ }
509
556
  for (const order of found) {
510
- Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }));
557
+ Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
511
558
  }
512
559
  return { swaps: found, scanned };
513
560
  }
@@ -62,6 +62,45 @@ const heightOf = async (driver, name) => {
62
62
  }
63
63
  };
64
64
 
65
+ // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
+ // the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
67
+ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch } = {}) {
68
+ let base = typeof nodeUrl === "string" ? nodeUrl.trim() : "";
69
+ while (base.endsWith("/")) base = base.slice(0, -1);
70
+ const from = Number(fromHeight);
71
+ const to = Number(toHeight);
72
+ if (!base) throw new SdkError("bad-input", { field: "window.nodeUrl" });
73
+ if (!Number.isSafeInteger(from) || from < 0) throw new SdkError("bad-input", { field: "window.fromHeight" });
74
+ if (!Number.isSafeInteger(to) || to < from) throw new SdkError("bad-input", { field: "window.toHeight" });
75
+ if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetchImpl" });
76
+ const hashes = [];
77
+ for (let height = from; height <= to; height += 1) {
78
+ let response;
79
+ try {
80
+ response = await fetchImpl(base + "/json_rpc", {
81
+ method: "POST", headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
83
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
84
+ });
85
+ } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height }); }
86
+ if (!response || response.ok !== true) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, status: response ? response.status : null });
87
+ let body = null;
88
+ try { body = await response.json(); } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "not-json" }); }
89
+ if (!body || body.error || !body.result) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "rpc" });
90
+ const result = body.result;
91
+ let list = Array.isArray(result.tx_hashes) ? result.tx_hashes : null;
92
+ if (!list && typeof result.json === "string") {
93
+ try {
94
+ const json = JSON.parse(result.json);
95
+ list = Array.isArray(json.tx_hashes) ? json.tx_hashes : null;
96
+ } catch { list = null; }
97
+ }
98
+ if (!list) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "no-tx-hashes" });
99
+ hashes.push(...list.map(String));
100
+ }
101
+ return hashes;
102
+ }
103
+
65
104
  /**
66
105
  * Restore the wallet from a height and report what it holds.
67
106
  *
@@ -88,6 +127,71 @@ export async function restoreWallet(request = {}) {
88
127
  const scanHeight = Math.max(0, Math.floor(fromHeight) - scanBack);
89
128
  const driver = driverOf(wallet);
90
129
 
130
+ // THE WINDOW PATH: set the wallet at the node tip, scan the lock's blocks, and only fall back to a full scan
131
+ // when that window holds nothing. An empty window is "not found", not "no funds".
132
+ const requestWindow = request.window && typeof request.window === "object" ? request.window : null;
133
+ let windowReport = null;
134
+ let windowTotal = 0n;
135
+ let windowUnlocked = 0n;
136
+ if (requestWindow) {
137
+ const tipHeight = Number(requestWindow.tipHeight);
138
+ if (!Number.isSafeInteger(tipHeight) || tipHeight <= 0) throw new SdkError("bad-input", { field: "window.tipHeight" });
139
+ let hashes = Array.isArray(requestWindow.txHashes) ? requestWindow.txHashes.map(String) : null;
140
+ if (hashes === null) {
141
+ hashes = await windowTxHashes({
142
+ nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
+ });
144
+ }
145
+ windowReport = { used: false, skipped: null, fromHeight, toHeight: Number(requestWindow.toHeight),
146
+ blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
147
+ if (hashes.length && typeof driver.scanTxs === "function") {
148
+ await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
149
+ await step("scanTxs", () => driver.scanTxs(hashes));
150
+ try { await wallet.sync(); } catch { /* the balance below decides */ }
151
+ windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
152
+ windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
153
+ windowReport.used = true;
154
+ // THE KEY IMAGES OF THE WALLET, CHECKED AT THE NODE. `scanTxs` finds an output; whether the chain already
155
+ // spent it is a separate fact, and the caller must not sign on a guess.
156
+ if (requestWindow.nodeUrl && typeof driver.getKeyImages === "function") {
157
+ try {
158
+ const exported = await driver.getKeyImages();
159
+ const images = Array.isArray(exported) ? exported
160
+ : (exported && typeof exported.getKeyImages === "function" ? exported.getKeyImages() : []);
161
+ const hexes = images.map((image) => typeof image === "string" ? image
162
+ : (image && typeof image.getHex === "function" ? image.getHex() : image && image.hex)).filter(Boolean);
163
+ if (hexes.length) {
164
+ let base = String(requestWindow.nodeUrl);
165
+ while (base.endsWith("/")) base = base.slice(0, -1);
166
+ const response = await (request.fetchImpl || globalThis.fetch)(base + "/is_key_image_spent", {
167
+ method: "POST", headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({ key_images: hexes }),
169
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
170
+ });
171
+ const body = response && response.ok === true ? await response.json() : null;
172
+ if (body && Array.isArray(body.spent_status)) {
173
+ windowReport.spent = body.spent_status.filter((value) => value === true || value === 1).length;
174
+ windowReport.keyImages = hexes.length;
175
+ } else windowReport.spent = null;
176
+ } else { windowReport.spent = null; windowReport.spentWhy = "no-key-images"; }
177
+ } catch { windowReport.spent = null; windowReport.spentWhy = "check-failed"; }
178
+ } else if (requestWindow.nodeUrl) {
179
+ windowReport.spent = null;
180
+ windowReport.spentWhy = "no-key-images";
181
+ }
182
+ // A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
183
+ // explicitly turned the fallback off.
184
+ if (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false) {
185
+ return {
186
+ total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight, walletHeight: await heightOf(driver, "getHeight"),
187
+ nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
188
+ };
189
+ }
190
+ } else if (hashes.length) {
191
+ windowReport.skipped = "no-scan-txs";
192
+ }
193
+ }
194
+
91
195
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
92
196
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
93
197
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
@@ -129,5 +233,6 @@ export async function restoreWallet(request = {}) {
129
233
  // Whether the second pass RAN - not whether it helped. A reader must be able to tell "nothing was locked" from
130
234
  // "it was locked and stayed locked".
131
235
  lockedPass,
236
+ window: windowReport,
132
237
  };
133
238
  }
package/src/version.mjs CHANGED
@@ -6,4 +6,4 @@
6
6
  // tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
7
7
  // The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
8
8
 
9
- export const SDK_VERSION = "0.48.1";
9
+ export const SDK_VERSION = "0.50.0";