@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 +15 -0
- package/README.md +10 -0
- package/index.d.ts +17 -3
- package/package.json +1 -1
- package/src/public/recovery.d.ts +12 -1
- package/src/public/recovery.mjs +1 -0
- package/src/recovery-key.mjs +31 -18
- package/src/swaps.mjs +10 -1
- package/src/sweep-restore.mjs +76 -16
- package/src/version.mjs +1 -1
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.
|
|
1332
|
-
*
|
|
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
|
|
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
package/src/public/recovery.d.ts
CHANGED
|
@@ -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. */
|
package/src/public/recovery.mjs
CHANGED
package/src/recovery-key.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|
|
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 });
|
package/src/sweep-restore.mjs
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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 =
|
|
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)
|
|
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.
|
|
9
|
+
export const SDK_VERSION = "0.51.1";
|