@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 +15 -0
- package/index.d.ts +33 -0
- package/package.json +1 -1
- package/src/index.mjs +6 -1
- package/src/public/recovery.d.ts +7 -0
- package/src/quotes.mjs +45 -1
- package/src/recovery-key.mjs +49 -2
- package/src/sweep-restore.mjs +105 -0
- package/src/version.mjs +1 -1
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
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,
|
|
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.
|
package/src/public/recovery.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/recovery-key.mjs
CHANGED
|
@@ -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
|
}
|
package/src/sweep-restore.mjs
CHANGED
|
@@ -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.
|
|
9
|
+
export const SDK_VERSION = "0.49.0";
|