@ninsei-labs/sdk 0.48.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 0.49.0 — 2026-10-21
2
+
3
+ A ONE-KEY RECOVERY CAN WITHDRAW WITHOUT A FULL SCAN, AND A CLAIM TIP IS CHECKED.
4
+
5
+ - `findSwaps` accepts `xmrNodes` and `moneroNetwork`: it asks the node for `get_info` and
6
+ `get_block_header_by_height` itself, so a page needs only `evmBlockTime`; the `startHeight` rule stays in the SDK.
7
+ - `sweep.restore({ window })` sets the wallet at the node tip, scans only the blocks from the swap's start to
8
+ `t1 + 10` through `scanTxs` (batches of 100), checks the wallet's key images at `/is_key_image_spent`, and does
9
+ not run `rescanBlockchain` after `scanTxs`. An empty window falls back to the full scan; `windowTxHashes` collects
10
+ the hashes from a node.
11
+ - `quotes.firm` refuses a `claimTip` above twice the SDK's own `claimGasGiftWei` estimate, read from the new
12
+ `evmBlock` seam. Zero tips (the buy side) do not call it; an unmeasured base fee is named.
13
+ - `claimTipVerdict` is exported for the same arithmetic outside the quote path.
14
+ - No breaking changes: the old recovery-file path, the full restore and zero-tip quotes behave as before.
15
+
1
16
  ## 0.48.1 — 2026-10-21
2
17
 
3
18
  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
@@ -1296,6 +1303,20 @@ export interface SweepRestoreRequest {
1296
1303
  readonly scanBack?: number;
1297
1304
  /** Run the second pass when coins exist but none is spendable. Default true. */
1298
1305
  readonly rescanWhenLocked?: boolean;
1306
+ /**
1307
+ * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1308
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1309
+ * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1310
+ */
1311
+ readonly window?: {
1312
+ readonly nodeUrl?: string;
1313
+ readonly txHashes?: readonly string[];
1314
+ readonly toHeight: number;
1315
+ readonly tipHeight: number;
1316
+ readonly fallback?: boolean;
1317
+ };
1318
+ /** The fetch used for `get_block`; absent - the global fetch. */
1319
+ readonly fetchImpl?: typeof fetch;
1299
1320
  }
1300
1321
 
1301
1322
  /** What `restore` found. Amounts are atomic units as strings; heights are numbers when the driver can read them. */
@@ -1310,6 +1331,18 @@ export interface SweepRestoreResult {
1310
1331
  readonly passes: number;
1311
1332
  /** Whether the second pass RAN - not whether it helped. */
1312
1333
  readonly lockedPass: boolean;
1334
+ /** Present when a window was requested: whether it was used, and why it was skipped. */
1335
+ readonly window?: {
1336
+ readonly used: boolean;
1337
+ readonly skipped: string | null;
1338
+ readonly fromHeight: number;
1339
+ readonly toHeight: number;
1340
+ readonly blocks: number;
1341
+ readonly txs: number;
1342
+ readonly spent?: number | null;
1343
+ readonly keyImages?: number;
1344
+ readonly spentWhy?: string;
1345
+ } | null;
1313
1346
  }
1314
1347
 
1315
1348
  /** What `wallet` needs: the library namespace and the facts the wallet is built from. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
package/src/index.mjs CHANGED
@@ -91,6 +91,9 @@ const normalizeOptions = (options) => {
91
91
  // same provider the chain read uses. Same rule as evmCall: a READ of the escrow's own event, not a node
92
92
  // substitution.
93
93
  if (o.evmLogs !== undefined && typeof o.evmLogs !== "function") fail("bad-input", { field: "evmLogs" });
94
+ // THE BLOCK-READ SEAM: the claim tip is checked against the chain's current base fee, and that is a block read,
95
+ // not an `eth_call`. Same rule as evmCall - brought from outside, refused when it is not a function.
96
+ if (o.evmBlock !== undefined && typeof o.evmBlock !== "function") fail("bad-input", { field: "evmBlock" });
94
97
  // THE INTERFACE'S ALLOW-LIST (POLICY, NOT PROOF): an array of provider addresses (or providerIds) OR a
95
98
  // predicate. It is NOT the check itself - that is `providerOf` on chain, taken from the quote - but the
96
99
  // interface's own decision about whom it trades with. A wrong shape is a typo, not a silent no-op.
@@ -156,6 +159,7 @@ const normalizeOptions = (options) => {
156
159
  evmCall: o.evmCall || null,
157
160
  evmCode: o.evmCode || null,
158
161
  evmLogs: o.evmLogs || null,
162
+ evmBlock: o.evmBlock || null,
159
163
  allowedProviders: o.allowedProviders === undefined ? null : o.allowedProviders,
160
164
  knownFactoryCodes: o.knownFactoryCodes === undefined ? null : o.knownFactoryCodes,
161
165
  knownImplementationCodes: o.knownImplementationCodes === undefined ? null : o.knownImplementationCodes,
@@ -215,7 +219,8 @@ export function createNinsei(options) {
215
219
  ? new Set(o.denylist.map((a) => String(a).toLowerCase()))
216
220
  : null;
217
221
  const quotes = createQuotes({ config, http, now: o.now || (() => Date.now()), evmCall: o.evmCall || null,
218
- evmCode: o.evmCode || null, providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
222
+ evmCode: o.evmCode || null, evmBlock: o.evmBlock || null,
223
+ providerAllowed, knownFactoryCodes: o.knownFactoryCodes, knownImplementationCodes: o.knownImplementationCodes,
219
224
  denylist });
220
225
  // THE SAME CHAIN-READ SEAM AS THE LOCK AND THE CORE: checking the slots and signing must go one way, otherwise
221
226
  // part of the check goes past the wallet and part does not.
@@ -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.
@@ -208,7 +209,32 @@ const compactOffer = (o) => ({
208
209
  // the price of the failed row to tell the person WHY there is no offer (and not just "none").
209
210
  const compactRefusal = (r) => ({ ...compactOffer(r), code: r.code, why: r.why });
210
211
 
211
- export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
+ /**
213
+ * A CLAIM TIP IS SIZED BY THE CLAIM, NOT BY THE MAKER'S MOOD. The node's own estimate uses the chain's current
214
+ * base fee and the same formula; a quote may carry up to twice that estimate (gas moves between the sizing and
215
+ * the check). Zero is the buy side and is always allowed. An unmeasured base fee is a refusal, not a free pass.
216
+ */
217
+ export function claimTipVerdict({ claimTipWei = 0, baseFeeWei = null, priorityFeeWei = 0, factor = 2 } = {}) {
218
+ let tip = 0n;
219
+ try { tip = BigInt(claimTipWei === undefined || claimTipWei === null || claimTipWei === "" ? 0 : claimTipWei); }
220
+ catch { return { ok: false, code: "quote-tip-unreadable" }; }
221
+ if (tip < 0n) return { ok: false, code: "quote-tip-unreadable" };
222
+ if (tip === 0n) return { ok: true, tipWei: "0", ceilingWei: null, estimateWei: null };
223
+ if (baseFeeWei === null || baseFeeWei === undefined || baseFeeWei === "") {
224
+ return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
225
+ }
226
+ const estimate = claimGasGiftWei({ baseFeeWei, priorityFeeWei });
227
+ if (estimate === null) return { ok: false, code: "quote-tip-unchecked", tipWei: tip.toString() };
228
+ const f = BigInt(factor);
229
+ const ceiling = estimate * f;
230
+ if (tip > ceiling) {
231
+ return { ok: false, code: "quote-tip-too-large", tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
232
+ }
233
+ return { ok: true, tipWei: tip.toString(), estimateWei: estimate.toString(), ceilingWei: ceiling.toString() };
234
+ }
235
+
236
+ export function createQuotes({ config, http, now = () => Date.now(), evmCall = null, evmCode = null, evmBlock = null,
237
+ providerAllowed = null, knownFactoryCodes = null, knownImplementationCodes = null, denylist = null }) {
212
238
  // THE INTERFACE'S FACTORY POLICY (#103): the code hashes of factories it is willing to trade through
213
239
  // (extcodehash semantics = keccak256 of the runtime code). An EMPTY list is "no policy" (like
214
240
  // allowedProviders); a NON-EMPTY one refuses any factory whose code is not on it. Lower-cased once.
@@ -507,6 +533,24 @@ export function createQuotes({ config, http, now = () => Date.now(), evmCall = n
507
533
  if (String(quoted) !== expectedContext) {
508
534
  fail("quote-refused", { step: "context", why: quoted ? "other-terms" : "not-reported" });
509
535
  }
536
+ // THE CLAIM TIP: the seller pays it out of the payout, so a quote that asks for more than the claim can
537
+ // cost is refused before it is signed. The chain's base fee is read through the caller's block seam; a
538
+ // missing seam or an unreadable base fee is a named refusal, never a silently accepted tip.
539
+ const tipCheck = (() => {
540
+ try { return claimTipVerdict({ claimTipWei: quote.claimTip }); }
541
+ catch { return { ok: true, tipWei: "0" }; }
542
+ })();
543
+ if (!tipCheck.ok && tipCheck.code === "quote-tip-unreadable") {
544
+ fail("quote-field-unbound", { step: "claimTip", field: "claimTip" });
545
+ }
546
+ if (tipCheck.tipWei !== "0") {
547
+ if (typeof evmBlock !== "function") fail("quote-tip-unchecked", { step: "claimTip", why: "no-block-read" });
548
+ let block = null;
549
+ try { block = await evmBlock("latest"); } catch { fail("quote-tip-unchecked", { step: "claimTip", why: "block-read-failed" }); }
550
+ const baseFeeWei = block && (block.baseFeePerGas !== undefined ? block.baseFeePerGas : block.baseFeeWei);
551
+ const verdict = claimTipVerdict({ claimTipWei: quote.claimTip, baseFeeWei });
552
+ if (!verdict.ok) fail(verdict.code, { step: "claimTip", ...verdict });
553
+ }
510
554
  return quote;
511
555
  },
512
556
  };
@@ -19,6 +19,7 @@ import { createHalves } from "../engine/atomic/halves.js";
19
19
  import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
20
20
  import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
21
21
  import { SdkError } from "./errors.mjs";
22
+ import { probeMoneroNode } from "./probes.mjs";
22
23
  import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
23
24
 
24
25
  const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
@@ -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.49.0";