@ninsei-labs/sdk 0.48.0 → 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 +28 -0
- package/index.d.ts +40 -0
- package/package.json +1 -1
- package/src/index.mjs +6 -1
- package/src/public/recovery.d.ts +17 -0
- package/src/quotes.mjs +45 -1
- package/src/recovery-key.mjs +111 -1
- package/src/swaps.mjs +53 -6
- package/src/sweep-restore.mjs +105 -0
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
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
|
+
|
|
16
|
+
## 0.48.1 — 2026-10-21
|
|
17
|
+
|
|
18
|
+
A SWAP KEEPS ITS MONERO HEIGHT, AND A RECOVERED ORDER KNOWS WHERE TO SCAN.
|
|
19
|
+
|
|
20
|
+
- `swaps.start` measures the start height from the configured Monero nodes before it hands over recovery data. A
|
|
21
|
+
node on another network, or no answer at all, is `xmr-no-nodes { step: "restore-height" }`; a swap without a
|
|
22
|
+
height is not funded.
|
|
23
|
+
- The measured height is kept on the swap record and written into the recovery file. `openWatch` sends that
|
|
24
|
+
Monero height; the settlement-chain block is named `evmBirthBlock` and stays the bound for the EVM log search.
|
|
25
|
+
- `findSwaps` derives a `startHeight` for every order it finds: the timestamp of the order's block, then the last
|
|
26
|
+
Monero header at or before it, minus 60 blocks. The readers are seams (`evmBlockTime`, `moneroTip`,
|
|
27
|
+
`moneroBlockTimestamp`); without them the order comes back with `startHeight: null` and a named reason.
|
|
28
|
+
|
|
1
29
|
## 0.48.0 — 2026-10-21
|
|
2
30
|
|
|
3
31
|
A SWAP CAN START FROM THE ONE RECOVERY KEY.
|
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
|
|
@@ -1115,6 +1122,13 @@ export interface StartRequest {
|
|
|
1115
1122
|
recoveryKey?: string | { readonly mnemonic: string };
|
|
1116
1123
|
/** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
|
|
1117
1124
|
recoveryIndex?: number;
|
|
1125
|
+
/** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
|
|
1126
|
+
* the start if none answers on the swap's network. */
|
|
1127
|
+
restoreHeight?: number;
|
|
1128
|
+
/** Monero nodes for that one measurement; absent - the configured nodes for the swap's network. */
|
|
1129
|
+
xmrNodes?: readonly (string | { readonly url: string })[];
|
|
1130
|
+
/** The fetch used for the height measurement (tests); absent - the global fetch. */
|
|
1131
|
+
fetchImpl?: typeof fetch;
|
|
1118
1132
|
/**
|
|
1119
1133
|
* Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
|
|
1120
1134
|
* `false` - the swap is not started. A key already in the store is not shown again.
|
|
@@ -1289,6 +1303,20 @@ export interface SweepRestoreRequest {
|
|
|
1289
1303
|
readonly scanBack?: number;
|
|
1290
1304
|
/** Run the second pass when coins exist but none is spendable. Default true. */
|
|
1291
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;
|
|
1292
1320
|
}
|
|
1293
1321
|
|
|
1294
1322
|
/** What `restore` found. Amounts are atomic units as strings; heights are numbers when the driver can read them. */
|
|
@@ -1303,6 +1331,18 @@ export interface SweepRestoreResult {
|
|
|
1303
1331
|
readonly passes: number;
|
|
1304
1332
|
/** Whether the second pass RAN - not whether it helped. */
|
|
1305
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;
|
|
1306
1346
|
}
|
|
1307
1347
|
|
|
1308
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
|
@@ -327,6 +327,10 @@ export interface FoundSwap {
|
|
|
327
327
|
readonly logIndex: number | null;
|
|
328
328
|
readonly index: number;
|
|
329
329
|
readonly side: "locker" | "claimer";
|
|
330
|
+
/** The Monero height a withdrawal may start from, derived from the order's block time. Null when the caller did
|
|
331
|
+
* not pass the readers; `startHeightReason` then names why. */
|
|
332
|
+
readonly startHeight: number | null;
|
|
333
|
+
readonly startHeightReason: "no-block-number" | "no-height-readers" | "no-block-time" | "monero-header" | null;
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
/** A storage adapter: the same shape the SDK store takes. */
|
|
@@ -375,4 +379,17 @@ export declare function findSwaps(request: {
|
|
|
375
379
|
toBlock?: number | string;
|
|
376
380
|
gap?: number;
|
|
377
381
|
maxIndex?: number;
|
|
382
|
+
/** Reads the settlement block timestamp, in seconds: (blockNumber) -> seconds. */
|
|
383
|
+
evmBlockTime?: (blockNumber: number) => Promise<number>;
|
|
384
|
+
/** The Monero tip at the time of the search: () -> height. */
|
|
385
|
+
moneroTip?: () => Promise<number>;
|
|
386
|
+
/** A Monero block header timestamp, in seconds: (height) -> seconds. */
|
|
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;
|
|
378
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 });
|
|
@@ -396,6 +397,100 @@ async function readLogs(evmLogs, request) {
|
|
|
396
397
|
return logs;
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
// THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
|
|
401
|
+
// derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
|
|
402
|
+
// block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
|
|
403
|
+
//
|
|
404
|
+
// The two readers are seams, like evmLogs: the SDK owns the rule, the caller owns the transport. Without them the
|
|
405
|
+
// order is still returned, with startHeight null and a named reason, instead of a guessed height.
|
|
406
|
+
const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
|
|
407
|
+
|
|
408
|
+
function secondsOf(value) {
|
|
409
|
+
const n = Number(value);
|
|
410
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
411
|
+
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
|
|
415
|
+
const top = Number(await tip());
|
|
416
|
+
if (!Number.isSafeInteger(top) || top < 0) throw new Error("monero-tip");
|
|
417
|
+
const at = async (height) => secondsOf(await headerTimestamp(height));
|
|
418
|
+
const topTime = await at(top);
|
|
419
|
+
if (topTime !== null && topTime <= timestamp) return top;
|
|
420
|
+
let low = 0;
|
|
421
|
+
let high = top;
|
|
422
|
+
const zeroTime = await at(0);
|
|
423
|
+
if (zeroTime !== null && zeroTime > timestamp) return 0;
|
|
424
|
+
while (low < high) {
|
|
425
|
+
const mid = Math.ceil((low + high) / 2);
|
|
426
|
+
const ts = await at(mid);
|
|
427
|
+
if (ts === null) throw new Error("monero-header");
|
|
428
|
+
if (ts <= timestamp) low = mid;
|
|
429
|
+
else high = mid - 1;
|
|
430
|
+
}
|
|
431
|
+
return low;
|
|
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
|
+
|
|
473
|
+
async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
|
|
474
|
+
if (order.blockNumber === null || order.blockNumber === undefined) {
|
|
475
|
+
return { startHeight: null, startHeightReason: "no-block-number" };
|
|
476
|
+
}
|
|
477
|
+
if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
|
|
478
|
+
return { startHeight: null, startHeightReason: "no-height-readers" };
|
|
479
|
+
}
|
|
480
|
+
let timestamp = cache.get(order.blockNumber);
|
|
481
|
+
if (timestamp === undefined) {
|
|
482
|
+
timestamp = secondsOf(await evmBlockTime(order.blockNumber));
|
|
483
|
+
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
|
|
484
|
+
cache.set(order.blockNumber, timestamp);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
|
|
488
|
+
return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
|
|
489
|
+
} catch {
|
|
490
|
+
return { startHeight: null, startHeightReason: "monero-header" };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
399
494
|
/**
|
|
400
495
|
* Find orders made with this key.
|
|
401
496
|
*
|
|
@@ -403,7 +498,10 @@ async function readLogs(evmLogs, request) {
|
|
|
403
498
|
* search asks for it as the locker and as the claimer (two queries; the RPC topic filter cannot say OR across two
|
|
404
499
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
405
500
|
*/
|
|
406
|
-
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
501
|
+
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
502
|
+
fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
|
|
503
|
+
evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null,
|
|
504
|
+
xmrNodes = null, moneroNetwork = null, fetchImpl = globalThis.fetch } = {}) {
|
|
407
505
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
408
506
|
const chain = numberField(chainId, "chainId");
|
|
409
507
|
const max = numberField(maxIndex, "maxIndex") || 1000;
|
|
@@ -446,5 +544,17 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
446
544
|
found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
|
|
447
545
|
}
|
|
448
546
|
}
|
|
547
|
+
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
548
|
+
// page: a widget and the recovery page must not derive two different answers from one chain.
|
|
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
|
+
}
|
|
556
|
+
for (const order of found) {
|
|
557
|
+
Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip: tip, moneroBlockTimestamp: header, cache }));
|
|
558
|
+
}
|
|
449
559
|
return { swaps: found, scanned };
|
|
450
560
|
}
|
package/src/swaps.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { vmOfChain, legFor } from "./legs/index.mjs";
|
|
|
19
19
|
// THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
|
|
20
20
|
// with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
|
|
21
21
|
import { primitives } from "./primitives.mjs";
|
|
22
|
+
import { probeMoneroNode } from "./probes.mjs";
|
|
22
23
|
// MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
|
|
23
24
|
// the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
|
|
24
25
|
// the XMR" and "we allowed the settlement to be taken" would diverge.
|
|
@@ -306,6 +307,24 @@ const recordInputOf = (request) => {
|
|
|
306
307
|
// createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
|
|
307
308
|
// the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
|
|
308
309
|
// confirmation) is written and checked already now, rather than appearing together with the browser part.
|
|
310
|
+
/**
|
|
311
|
+
* The Monero height a swap starts at, measured from the configured nodes. Exported for the guard, not as a
|
|
312
|
+
* package entry: the start is the only production caller. A node that answers on another network is not a height
|
|
313
|
+
* for this deal, and a missing answer is a named refusal, never a scan from zero.
|
|
314
|
+
*/
|
|
315
|
+
export async function startHeightFromNodes({ nodes = [], network = null, fetchImpl = globalThis.fetch } = {}) {
|
|
316
|
+
const urls = (Array.isArray(nodes) ? nodes : [])
|
|
317
|
+
.map((entry) => typeof entry === "string" ? entry : entry && entry.url).filter(Boolean);
|
|
318
|
+
const answers = await Promise.all(urls.map(async (url) => {
|
|
319
|
+
try { return await probeMoneroNode({ url, fetchImpl }); }
|
|
320
|
+
catch { return { ok: false, why: "unreachable" }; }
|
|
321
|
+
}));
|
|
322
|
+
const good = answers.find((answer) => answer.ok === true && (!network || answer.network === network));
|
|
323
|
+
if (good) return good.height;
|
|
324
|
+
const why = answers.find((answer) => answer.ok === true) ? "network" : (answers[0] && answers[0].why) || "no-node";
|
|
325
|
+
throw new SdkError("xmr-no-nodes", { step: "restore-height", network: network || null, why });
|
|
326
|
+
}
|
|
327
|
+
|
|
309
328
|
export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
|
|
310
329
|
// THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
|
|
311
330
|
// and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
|
|
@@ -342,6 +361,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
342
361
|
const value = config && config.limits ? config.limits[name] : null;
|
|
343
362
|
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
|
|
344
363
|
};
|
|
364
|
+
|
|
365
|
+
// THE MONERO HEIGHT A SWAP STARTS AT. It is measured before the recovery data is handed over: a file without
|
|
366
|
+
// it scans Monero from block zero, and a watch opened at an EVM block sees nothing. The node must name the same
|
|
367
|
+
// network as the swap; a different network is not a height for this deal.
|
|
368
|
+
async function measuredStartHeight(request, moneroNetwork) {
|
|
369
|
+
const explicit = Number(request.restoreHeight);
|
|
370
|
+
if (Number.isSafeInteger(explicit) && explicit > 0) return explicit;
|
|
371
|
+
const listed = Array.isArray(request.xmrNodes) ? request.xmrNodes
|
|
372
|
+
: (config && typeof config.nodesFor === "function" ? config.nodesFor(moneroNetwork) : []);
|
|
373
|
+
return startHeightFromNodes({ nodes: listed, network: moneroNetwork,
|
|
374
|
+
fetchImpl: request.fetchImpl || (typeof fetch === "function" ? fetch : null) });
|
|
375
|
+
}
|
|
345
376
|
const records = () => {
|
|
346
377
|
const state = engine.store.loadState();
|
|
347
378
|
const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
|
|
@@ -454,11 +485,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
454
485
|
if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
|
|
455
486
|
const network = swap.moneroNetwork || xmrNetwork || null;
|
|
456
487
|
if (!network) throw new SdkError("bad-input", { field: "moneroNetwork" });
|
|
488
|
+
const restoreHeight = Number(swap.restoreHeight) > 0 ? Number(swap.restoreHeight)
|
|
489
|
+
: (Number(e.moneroRestoreHeight) > 0 ? Number(e.moneroRestoreHeight) : null);
|
|
490
|
+
if (!(restoreHeight > 0)) throw new SdkError("bad-input", { field: "restoreHeight", step: "watch" });
|
|
457
491
|
const wanted = {
|
|
458
492
|
swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
|
|
459
|
-
// THE RESTORE HEIGHT -
|
|
460
|
-
//
|
|
461
|
-
|
|
493
|
+
// THE RESTORE HEIGHT - THE MONERO HEIGHT, NEVER THE EVM BLOCK. It is measured before the file is handed
|
|
494
|
+
// over and kept on the record; `birthHeight`/`evmBirthBlock` is a settlement-chain block for log search and
|
|
495
|
+
// is far above any Monero tip, so using it here opened the watch nowhere near the money.
|
|
496
|
+
restoreHeight,
|
|
462
497
|
chain: swap.network || swap.chain || null,
|
|
463
498
|
};
|
|
464
499
|
const body = {};
|
|
@@ -819,6 +854,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
819
854
|
builtSide = step;
|
|
820
855
|
}
|
|
821
856
|
|
|
857
|
+
// THE HEIGHT IS MEASURED BEFORE THE FILE OR THE WATCH: the node is asked once, while the swap is still in
|
|
858
|
+
// front of us. A swap without it is not funded - a recovery file that scans from zero is a file that looks
|
|
859
|
+
// usable and is not.
|
|
860
|
+
const measuredHeight = await measuredStartHeight(request, record.swap.moneroNetwork || xmrNetwork || null);
|
|
861
|
+
record.swap.restoreHeight = measuredHeight;
|
|
862
|
+
record.restoreHeight = measuredHeight;
|
|
863
|
+
record.swap.escrow = { ...(record.swap.escrow || {}), moneroRestoreHeight: measuredHeight };
|
|
864
|
+
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-restore-height" }); }
|
|
865
|
+
|
|
822
866
|
// 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
|
|
823
867
|
// no file per swap: the same twelve words cover every swap, and a person who already saved them is not
|
|
824
868
|
// asked again. A new key is shown before the money moves - no saved key, no funding.
|
|
@@ -911,8 +955,10 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
911
955
|
if (escrowAddress) {
|
|
912
956
|
const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
|
|
913
957
|
record.swap.escrow = { ...escrowRecord, address: escrowAddress,
|
|
914
|
-
// THE
|
|
915
|
-
|
|
958
|
+
// THE SETTLEMENT-CHAIN HEIGHT, NAMED AS SUCH. It is for the OrderCreated/Declined log search; the XMR
|
|
959
|
+
// watch and the recovery file take the Monero height measured above. The old name `birthHeight` stays
|
|
960
|
+
// readable on records written before this split.
|
|
961
|
+
evmBirthBlock: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.evmBirthBlock ?? escrowRecord.birthHeight ?? null) };
|
|
916
962
|
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
|
|
917
963
|
// THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
|
|
918
964
|
// reservation, so a retry uses the same number and the later search has no gap.
|
|
@@ -1009,7 +1055,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
1009
1055
|
const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
|
|
1010
1056
|
// FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
|
|
1011
1057
|
// walking the whole chain every tick is a cost with no answer behind it.
|
|
1012
|
-
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1058
|
+
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1059
|
+
? (record.escrow.evmBirthBlock ?? record.escrow.birthHeight) : null);
|
|
1013
1060
|
const chainDecline = { checked: false, error: null, logs: undefined };
|
|
1014
1061
|
const readDeclineLogs = async () => {
|
|
1015
1062
|
chainDecline.checked = false; chainDecline.error = null; chainDecline.logs = undefined;
|
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";
|