@ninsei-labs/sdk 0.51.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 0.51.1 — 2026-10-22
2
+
3
+ A BUSY NODE NO LONGER ENDS A WITHDRAWAL, AND MISSING HEIGHT DATA HAS A NAME.
4
+
5
+ - `windowTxHashes` retries a `429`, a timeout and a `5xx` with `Retry-After` or exponential backoff; only a read that
6
+ still fails reaches the fallback.
7
+ - `restoreWallet` runs the full scan when the window cannot be read and reports `window.fallback: true`,
8
+ `fallbackWhy: "window-read"` and the node's status. A malformed window is still a caller error, not a fallback.
9
+ - A null settlement block time is `no-block-time`, not timestamp 0. An unreachable Monero node
10
+ (`monero-nodes-unreachable`) and a reachable node on another network (`monero-network-mismatch`) no longer share
11
+ `no-height-readers`.
12
+ - `recoveryIndexTaken` is exported from `@ninsei-labs/sdk/recovery`.
13
+ - The on-chain recovery-number check starts at the registry's escrow deployment block, not block 0; an explicit
14
+ `fromBlock` still wins.
15
+
1
16
  ## 0.51.0 — 2026-10-22
2
17
 
3
18
  THE WITHDRAWAL AND THE ON-CHAIN SEARCH NOW CARRY EVERYTHING THE KEY CAN KNOW.
package/README.md CHANGED
@@ -173,6 +173,16 @@ When a token is needed, the order can be joined with an off-chain quote record b
173
173
  would have to put the token into the signed quote and the clone (and therefore a new order format and factory); only
174
174
  then can `findSwaps` return it from the chain and set `payTokenReason` to something other than `not-on-chain`.
175
175
 
176
+ The found order also carries the Monero start height and the reason it is absent. `no-block-time`,
177
+ `monero-nodes-unreachable` and `monero-network-mismatch` are different answers: a missing settlement timestamp is not
178
+ a dead node, and a node on another network is not an absent reader. A page that starts swaps without the SDK's start
179
+ path can call `recoveryIndexTaken` from `./recovery` before using a number; it checks the factory's `OrderCreated`
180
+ logs from the registry's deployment block.
181
+
182
+ The withdrawal window retries a `429`, a timeout or a `5xx` before it gives up. If the window still cannot be read,
183
+ `restoreWallet` falls back to the full scan and reports `window.fallback: true` with `fallbackWhy: "window-read"` and
184
+ the node's status; a withdrawal is not refused because the node was busy.
185
+
176
186
  ## Extending to other networks (Tron, Solana and beyond)
177
187
 
178
188
  The package name is deliberately common: the shared part — the quote client, the Monero leg, the state machine,
package/index.d.ts CHANGED
@@ -1140,6 +1140,9 @@ export interface StartRequest {
1140
1140
  recoveryKey?: string | { readonly mnemonic: string };
1141
1141
  /** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
1142
1142
  recoveryIndex?: number;
1143
+ /** The first block for the on-chain number check. Absent - the chain registry's escrow deployment block, and
1144
+ * zero only when the registry has no height. */
1145
+ fromBlock?: string | number;
1143
1146
  /** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
1144
1147
  * the start if none answers on the swap's network. */
1145
1148
  restoreHeight?: number;
@@ -1328,8 +1331,9 @@ export interface SweepRestoreRequest {
1328
1331
  readonly rescanWhenLocked?: boolean;
1329
1332
  /**
1330
1333
  * THE BLOCK WINDOW AROUND THE LOCK. When present, the wallet is set at the node tip and only these blocks are
1331
- * scanned. An empty window falls back to the full scan unless `fallback` is false. `txHashes` may be supplied by
1332
- * the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1334
+ * scanned. An empty window falls back to the full scan unless `fallback` is false. A window read that fails
1335
+ * (429, timeout, 5xx) is retried; if it still fails, the full scan runs and `fallbackWhy` names the read.
1336
+ * `txHashes` may be supplied by the caller; otherwise the SDK reads `get_block` from `nodeUrl`.
1333
1337
  */
1334
1338
  readonly window?: {
1335
1339
  readonly nodeUrl?: string;
@@ -1357,8 +1361,18 @@ export interface SweepRestoreResult {
1357
1361
  /** Present when a window was requested: whether it was used, and why it was skipped. */
1358
1362
  readonly window?: {
1359
1363
  readonly used: boolean;
1360
- /** Whether the full scan ran because the window found nothing (or could not scan). */
1364
+ /** Whether the full scan ran because the window found nothing, could not scan, or could not be read. */
1361
1365
  readonly fallback: boolean;
1366
+ /** Why the fallback ran: `empty-window`, `window-read`, or a named skip. */
1367
+ readonly fallbackWhy?: string;
1368
+ /** The node's answer when the window read failed; absent when the window was read. */
1369
+ readonly error?: {
1370
+ readonly code: string;
1371
+ readonly surface: string;
1372
+ readonly status: number | null;
1373
+ readonly height: number | null;
1374
+ readonly why: string | null;
1375
+ };
1362
1376
  readonly skipped: string | null;
1363
1377
  readonly fromHeight: number;
1364
1378
  readonly toHeight: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -342,7 +342,7 @@ export interface FoundSwap {
342
342
  /** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
343
343
  * not pass the readers; `startHeightReason` then names why. */
344
344
  readonly startHeight: number | null;
345
- readonly startHeightReason: "no-block-number" | "no-height-readers" | "no-block-time" | "monero-header" | null;
345
+ readonly startHeightReason: "no-block-number" | "no-height-readers" | "monero-nodes-unreachable" | "monero-network-mismatch" | "no-block-time" | "monero-header" | null;
346
346
  }
347
347
 
348
348
  /** A storage adapter: the same shape the SDK store takes. */
@@ -376,6 +376,17 @@ export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: Recove
376
376
  export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown> }) | null;
377
377
  /** Reserves a swap number; the caller releases it if the request is abandoned. */
378
378
  export declare function reserveRecoveryIndex(storage: RecoveryKeyStorage, request: { chainId: number; swapId?: string | null }): number;
379
+ /** True when an order already holds this number's commitment on chain. A page that starts swaps without the SDK's
380
+ * start path calls this before using a number; an unreadable log is a refusal, not "free". */
381
+ export declare function recoveryIndexTaken(request: {
382
+ mnemonic: string;
383
+ chainId: number;
384
+ index: number;
385
+ factory: string;
386
+ evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
387
+ fromBlock?: number | string;
388
+ toBlock?: number | string;
389
+ }): Promise<boolean>;
379
390
  /** Releases a number that never reached the chain. */
380
391
  export declare function releaseRecoveryIndex(storage: RecoveryKeyStorage, index: number): void;
381
392
  /** Retires a number after its order exists on chain. */
@@ -41,6 +41,7 @@ export {
41
41
  loadRecoveryKey,
42
42
  openRecoveryKeyFile,
43
43
  recoveryBinding,
44
+ recoveryIndexTaken,
44
45
  recoveryKeyFileName,
45
46
  recoveryKeyText,
46
47
  releaseRecoveryIndex,
@@ -446,6 +446,9 @@ export async function recoveryIndexTaken({ mnemonic, chainId, index, factory, ev
446
446
  const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
447
447
 
448
448
  function secondsOf(value) {
449
+ // A MISSING BLOCK TIME IS NOT 1970. `Number(null)` is 0, so a reader that answered null used to place an order
450
+ // at the epoch and derive a Monero height from it. Null means "not read": the caller names no-block-time.
451
+ if (value === null || value === undefined) return null;
449
452
  const n = Number(value);
450
453
  if (!Number.isFinite(n) || n < 0) return null;
451
454
  return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
@@ -477,13 +480,18 @@ async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
477
480
  async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
478
481
  const urls = (Array.isArray(nodes) ? nodes : [])
479
482
  .map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
480
- if (!urls.length) return null;
483
+ if (!urls.length) return { readers: null, reason: "no-height-readers" };
481
484
  const answers = await Promise.all(urls.map(async (url) => {
482
485
  try { return { ...(await probeMoneroNode({ url, fetchImpl })), url }; }
483
486
  catch { return { ok: false, why: "unreachable", url }; }
484
487
  }));
485
488
  const node = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
486
- if (!node) return null;
489
+ if (!node) {
490
+ // TWO DIFFERENT FAILURES, TWO DIFFERENT REASONS. A node that answered but runs another network is not the
491
+ // same as no node answering at all; a page must be able to tell the person which one to fix.
492
+ const answered = answers.some((answer) => answer.ok === true);
493
+ return { readers: null, reason: answered ? "monero-network-mismatch" : "monero-nodes-unreachable" };
494
+ }
487
495
  const call = async (method, params) => {
488
496
  let response;
489
497
  try {
@@ -500,27 +508,30 @@ async function moneroReadersFromNodes({ nodes = [], network = null, fetchImpl =
500
508
  return body.result;
501
509
  };
502
510
  return {
503
- // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
504
- // answers "greater than current top block"; the tip reader must name the top block itself.
505
- tip: async () => Math.max(0, Number(node.height) - 1),
506
- headerTimestamp: async (height) => {
507
- const result = await call("get_block_header_by_height", { height });
508
- const ts = Number(result.block_header && result.block_header.timestamp);
509
- // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
510
- // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
511
- if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
512
- return ts;
511
+ reason: null,
512
+ readers: {
513
+ // `get_info.height` is the block COUNT, one past the top block. Asking a real node for a header at that height
514
+ // answers "greater than current top block"; the tip reader must name the top block itself.
515
+ tip: async () => Math.max(0, Number(node.height) - 1),
516
+ headerTimestamp: async (height) => {
517
+ const result = await call("get_block_header_by_height", { height });
518
+ const ts = Number(result.block_header && result.block_header.timestamp);
519
+ // GENESIS HAS TIMESTAMP 0. The binary search reads height 0 to prove the requested moment is before it; a
520
+ // `<= 0` refusal turned a legitimate genesis answer into "monero-header" and every old order lost its height.
521
+ if (!Number.isFinite(ts) || ts < 0) throw new Error("monero-header");
522
+ return ts;
523
+ },
513
524
  },
514
525
  };
515
526
  }
516
527
 
517
- async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
528
+ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache, readersReason = "no-height-readers" }) {
518
529
  if (order.blockNumber === null || order.blockNumber === undefined) {
519
530
  return { startHeight: null, startHeightReason: "no-block-number", blockTime: null };
520
531
  }
521
532
  // NOTHING TO READ WITH: no settlement block time and no Monero readers. That is a missing seam, not a missing date.
522
533
  if (typeof evmBlockTime !== "function" && typeof moneroTip !== "function" && typeof moneroBlockTimestamp !== "function") {
523
- return { startHeight: null, startHeightReason: "no-height-readers", blockTime: null };
534
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: null };
524
535
  }
525
536
  // THE BLOCK TIME IS PART OF THE FOUND ORDER. The page used to read the block again just to show a date; the
526
537
  // timestamp is already needed here for the Monero height, so it is returned with it.
@@ -531,7 +542,7 @@ async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBloc
531
542
  cache.set(order.blockNumber, timestamp);
532
543
  }
533
544
  if (typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
534
- return { startHeight: null, startHeightReason: "no-height-readers", blockTime: timestamp };
545
+ return { startHeight: null, startHeightReason: readersReason || "no-height-readers", blockTime: timestamp };
535
546
  }
536
547
  try {
537
548
  const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
@@ -608,9 +619,11 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
608
619
  const cache = new Map();
609
620
  let tip = moneroTip;
610
621
  let header = moneroBlockTimestamp;
622
+ let readersReason = "no-height-readers";
611
623
  if ((typeof tip !== "function" || typeof header !== "function") && Array.isArray(xmrNodes) && xmrNodes.length) {
612
- const readers = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
613
- if (readers) { tip = tip || readers.tip; header = header || readers.headerTimestamp; }
624
+ const probed = await moneroReadersFromNodes({ nodes: xmrNodes, network: moneroNetwork, fetchImpl });
625
+ if (probed.readers) { tip = tip || probed.readers.tip; header = header || probed.readers.headerTimestamp; }
626
+ else readersReason = probed.reason || "no-height-readers";
614
627
  }
615
628
  // THE ORDER IS NATIVE, AND THE PAYING TOKEN IS NOT ON THE CHAIN. The escrow receives the settlement network's
616
629
  // native coin (`amount` is wei); which token the person swapped into it (USDC, USDT, ...) belongs to the DEX/quote
@@ -622,7 +635,7 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
622
635
  order.payTokenReason = "not-on-chain";
623
636
  }
624
637
  for (const order of found) {
625
- Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
638
+ Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache, readersReason }));
626
639
  }
627
640
  // THE XMR AMOUNT IS IN THE CLONE, NOT IN THE LOG. A caller without a code reader gets null and a named reason;
628
641
  // an unreadable or differently-shaped contract does the same. This is data, not a withdrawal verdict.
package/src/swaps.mjs CHANGED
@@ -811,9 +811,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
811
811
  // log is asked before the number is used; a taken number is retired locally and the next one is reserved.
812
812
  // Without a log seam the previous behaviour is kept - the caller cannot be asked to invent a chain read.
813
813
  if (typeof chainLogs === "function" && request.order.factory) {
814
+ // THE CHAIN'S BIRTH HEIGHT IS THE DEFAULT START, NOT ZERO. The factory's own log cannot hold an order
815
+ // before it existed; asking from zero is tens of log queries per number on a public RPC. An explicit
816
+ // `fromBlock` still wins, including the literal zero.
817
+ const chain = config && Array.isArray(config.chains)
818
+ ? config.chains.find((entry) => Number(entry.chainId) === Number(chainId)) : null;
819
+ const deploymentBlock = chain && chain.escrow ? chain.escrow.deploymentBlock : null;
820
+ const fromBlock = request.fromBlock !== undefined
821
+ ? request.fromBlock
822
+ : (Number.isSafeInteger(deploymentBlock) ? deploymentBlock : 0);
814
823
  let hops = 0;
815
824
  while (await recoveryIndexTaken({ mnemonic: key.mnemonic, chainId, index, factory: request.order.factory,
816
- evmLogs: chainLogs, fromBlock: request.fromBlock === undefined ? 0 : request.fromBlock })) {
825
+ evmLogs: chainLogs, fromBlock })) {
817
826
  if (explicit) throw new SdkError("bad-input", { field: "recoveryIndex", why: "on-chain" });
818
827
  commitRecoveryIndex(storage, index);
819
828
  index = reserveRecoveryIndex(storage, { chainId });
@@ -64,7 +64,7 @@ const heightOf = async (driver, name) => {
64
64
 
65
65
  // THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
66
66
  // the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
67
- export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch } = {}) {
67
+ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch, sleep = defaultSleep, attempts = 3 } = {}) {
68
68
  let base = typeof nodeUrl === "string" ? nodeUrl.trim() : "";
69
69
  while (base.endsWith("/")) base = base.slice(0, -1);
70
70
  const from = Number(fromHeight);
@@ -73,17 +73,39 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
73
73
  if (!Number.isSafeInteger(from) || from < 0) throw new SdkError("bad-input", { field: "window.fromHeight" });
74
74
  if (!Number.isSafeInteger(to) || to < from) throw new SdkError("bad-input", { field: "window.toHeight" });
75
75
  if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetchImpl" });
76
+ const tries = Math.max(1, Math.min(5, Number(attempts) || 3));
76
77
  const hashes = [];
77
78
  for (let height = from; height <= to; height += 1) {
78
- let response;
79
- try {
80
- response = await fetchImpl(base + "/json_rpc", {
81
- method: "POST", headers: { "content-type": "application/json" },
82
- body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
83
- signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
84
- });
85
- } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height }); }
86
- if (!response || response.ok !== true) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, status: response ? response.status : null });
79
+ let response = null;
80
+ let last = null;
81
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
82
+ try {
83
+ response = await fetchImpl(base + "/json_rpc", {
84
+ method: "POST", headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
86
+ signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
87
+ });
88
+ } catch {
89
+ // A TIMEOUT OR A BROKEN CONNECTION IS RETRIED, NOT THE END OF THE WINDOW. The node is rate-limited by the
90
+ // same bursts the page sends; a withdrawal that stops on the first 429 or timeout is a refund waiting to
91
+ // happen (the page's measured failure: 93 of 150 parallel reads answered 429).
92
+ response = null;
93
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "transport" });
94
+ if (attempt < tries) { await sleep(backoffMs(attempt)); continue; }
95
+ throw last;
96
+ }
97
+ if (response && response.ok === true) { last = null; break; }
98
+ const status = response ? response.status : null;
99
+ last = new SdkError("wallet-rejected", { surface: "window-blocks", height, status });
100
+ // ONLY A RATE LIMIT AND A SERVER FAULT ARE WORTH ANOTHER READ. A 404 or a bad request will answer the same
101
+ // way every time, and retrying it only makes the fallback slower.
102
+ if ((status === 429 || (status !== null && status >= 500)) && attempt < tries) {
103
+ await sleep(retryAfterMs(response) ?? backoffMs(attempt));
104
+ continue;
105
+ }
106
+ throw last;
107
+ }
108
+ if (!response || response.ok !== true) throw last || new SdkError("wallet-rejected", { surface: "window-blocks", height });
87
109
  let body = null;
88
110
  try { body = await response.json(); } catch { throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "not-json" }); }
89
111
  if (!body || body.error || !body.result) throw new SdkError("wallet-rejected", { surface: "window-blocks", height, why: "rpc" });
@@ -101,6 +123,21 @@ export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl
101
123
  return hashes;
102
124
  }
103
125
 
126
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
127
+ const backoffMs = (attempt) => Math.min(4000, 250 * (2 ** Math.max(0, attempt - 1)));
128
+ function retryAfterMs(response) {
129
+ try {
130
+ const value = response && response.headers && typeof response.headers.get === "function"
131
+ ? response.headers.get("retry-after") : null;
132
+ if (value === null || value === undefined || value === "") return null;
133
+ const seconds = Number(value);
134
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(5000, Math.floor(seconds * 1000));
135
+ const date = Date.parse(String(value));
136
+ if (Number.isFinite(date)) return Math.min(5000, Math.max(0, date - Date.now()));
137
+ } catch { /* a bad header is not a reason to stop */ }
138
+ return null;
139
+ }
140
+
104
141
  /**
105
142
  * Restore the wallet from a height and report what it holds.
106
143
  *
@@ -136,14 +173,34 @@ export async function restoreWallet(request = {}) {
136
173
  if (requestWindow) {
137
174
  const tipHeight = Number(requestWindow.tipHeight);
138
175
  if (!Number.isSafeInteger(tipHeight) || tipHeight <= 0) throw new SdkError("bad-input", { field: "window.tipHeight" });
176
+ windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
177
+ blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: 0 };
139
178
  let hashes = Array.isArray(requestWindow.txHashes) ? requestWindow.txHashes.map(String) : null;
140
179
  if (hashes === null) {
141
- hashes = await windowTxHashes({
142
- nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
143
- });
180
+ try {
181
+ hashes = await windowTxHashes({
182
+ nodeUrl: requestWindow.nodeUrl, fromHeight, toHeight: requestWindow.toHeight, fetchImpl: request.fetchImpl,
183
+ });
184
+ } catch (e) {
185
+ // A MALFORMED WINDOW IS NOT A BUSY NODE. A bad height or a missing URL is the caller's error and is
186
+ // rethrown; only a read the node refused reaches the fallback.
187
+ if (e && e.code === "bad-input") throw e;
188
+ // THE WINDOW READ FAILED: FALL BACK TO THE FULL SCAN, NOT TO A REFUSAL. A 429 from a busy node, a timeout
189
+ // or a 5xx is a fact about the node, not about the coins; the scan below can still find them. The reason is
190
+ // carried on the report so the caller does not present this as a normal empty window.
191
+ windowReport.fallback = true;
192
+ windowReport.fallbackWhy = "window-read";
193
+ windowReport.error = {
194
+ code: e && e.code ? e.code : "wallet-rejected",
195
+ surface: e && e.params && e.params.surface ? e.params.surface : "window-blocks",
196
+ status: e && e.params && e.params.status !== undefined ? e.params.status : null,
197
+ height: e && e.params && e.params.height !== undefined ? e.params.height : null,
198
+ why: e && e.params && e.params.why ? e.params.why : null,
199
+ };
200
+ hashes = [];
201
+ }
144
202
  }
145
- windowReport = { used: false, skipped: null, fallback: false, fromHeight, toHeight: Number(requestWindow.toHeight),
146
- blocks: Math.max(0, Number(requestWindow.toHeight) - fromHeight + 1), txs: hashes.length };
203
+ windowReport.txs = hashes.length;
147
204
  if (hashes.length && typeof driver.scanTxs === "function") {
148
205
  await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
149
206
  await step("scanTxs", () => driver.scanTxs(hashes));
@@ -194,7 +251,10 @@ export async function restoreWallet(request = {}) {
194
251
 
195
252
  // THE WINDOW DID NOT ANSWER: the full scan below is the fallback. Say so on the report, so the caller does not
196
253
  // have to infer it from `used: false` and a balance.
197
- if (windowReport) windowReport.fallback = true;
254
+ if (windowReport) {
255
+ windowReport.fallback = true;
256
+ if (!windowReport.fallbackWhy) windowReport.fallbackWhy = "empty-window";
257
+ }
198
258
  await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
199
259
  // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
200
260
  // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
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.51.0";
9
+ export const SDK_VERSION = "0.51.1";