@ninsei-labs/sdk 0.47.0 → 0.48.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 +25 -0
- package/index.d.ts +24 -3
- package/package.json +1 -1
- package/src/order.mjs +23 -0
- package/src/public/recovery.d.ts +10 -0
- package/src/record.mjs +8 -4
- package/src/recovery-key.mjs +64 -1
- package/src/swaps.mjs +128 -26
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## 0.48.1 — 2026-10-21
|
|
2
|
+
|
|
3
|
+
A SWAP KEEPS ITS MONERO HEIGHT, AND A RECOVERED ORDER KNOWS WHERE TO SCAN.
|
|
4
|
+
|
|
5
|
+
- `swaps.start` measures the start height from the configured Monero nodes before it hands over recovery data. A
|
|
6
|
+
node on another network, or no answer at all, is `xmr-no-nodes { step: "restore-height" }`; a swap without a
|
|
7
|
+
height is not funded.
|
|
8
|
+
- The measured height is kept on the swap record and written into the recovery file. `openWatch` sends that
|
|
9
|
+
Monero height; the settlement-chain block is named `evmBirthBlock` and stays the bound for the EVM log search.
|
|
10
|
+
- `findSwaps` derives a `startHeight` for every order it finds: the timestamp of the order's block, then the last
|
|
11
|
+
Monero header at or before it, minus 60 blocks. The readers are seams (`evmBlockTime`, `moneroTip`,
|
|
12
|
+
`moneroBlockTimestamp`); without them the order comes back with `startHeight: null` and a named reason.
|
|
13
|
+
|
|
14
|
+
## 0.48.0 — 2026-10-21
|
|
15
|
+
|
|
16
|
+
A SWAP CAN START FROM THE ONE RECOVERY KEY.
|
|
17
|
+
|
|
18
|
+
- `swaps.start` accepts `recoveryKey` (a BIP39 mnemonic or a key object). It derives the spend and view halves
|
|
19
|
+
for the reserved swap number instead of generating a random pair, and writes no per-swap recovery file.
|
|
20
|
+
- A key created by the call is handed to `onRecoveryKey` once, before the funds are locked; a key already in the
|
|
21
|
+
store is not shown again. The number is reserved before the order and retired only after the escrow exists.
|
|
22
|
+
- The old path is unchanged: without `recoveryKey`, `onRecoveryFile` still assembles and confirms a per-swap file.
|
|
23
|
+
- `buildOwnSideFromHalves` builds the order side and its DLEQ proof from supplied halves; `createRecord` accepts
|
|
24
|
+
that side, so the recovery key remains the only taker secret.
|
|
25
|
+
|
|
1
26
|
## 0.47.0 — 2026-10-21
|
|
2
27
|
|
|
3
28
|
ONE RECOVERY KEY, AND THE NODE'S VIEW HALF COMES FROM IT.
|
package/index.d.ts
CHANGED
|
@@ -717,7 +717,7 @@ export interface SwapsApi {
|
|
|
717
717
|
/**
|
|
718
718
|
* Start a swap. The SDK holds the step order, not the screen:
|
|
719
719
|
* 1) guards (preflight); 2) the swap record (createSwap); 3) the order - it GENERATES the key halves;
|
|
720
|
-
* 4) the recovery file and the interface's confirmation that
|
|
720
|
+
* 4) the recovery file / one key and the interface's confirmation that it is saved; 5) locking the funds (signature);
|
|
721
721
|
* 6) saving on the server; 7) opening the watch on the Monero address.
|
|
722
722
|
* HALVES DO NOT EXIST BEFORE THE ORDER: the recovery file carries MY half of the spend key and the counterparty's
|
|
723
723
|
* open points, and they are taken from the signed order (www/js/atomic/order-worker.js, createOrderBuilder; in the
|
|
@@ -1102,10 +1102,31 @@ export interface StartRequest {
|
|
|
1102
1102
|
*/
|
|
1103
1103
|
readonly wallet?: WalletAdapter;
|
|
1104
1104
|
/**
|
|
1105
|
-
* Hand the recovery file to the interface and wait for the person's decision.
|
|
1105
|
+
* Hand the OLD per-swap recovery file to the interface and wait for the person's decision.
|
|
1106
1106
|
* `true` - the person confirmed the file is saved; `false` - the swap is not started.
|
|
1107
|
+
* Not used when `recoveryKey` is present: that path has one key, saved once.
|
|
1107
1108
|
*/
|
|
1108
|
-
onRecoveryFile(file: RecoveryFile): Promise<boolean>;
|
|
1109
|
+
onRecoveryFile?(file: RecoveryFile): Promise<boolean>;
|
|
1110
|
+
/**
|
|
1111
|
+
* THE ONE RECOVERY KEY (sdk#76). A BIP39 mnemonic, or a key object from `@ninsei-labs/sdk/recovery`.
|
|
1112
|
+
* When present, the swap's spend and view halves are derived from it instead of being generated, and no
|
|
1113
|
+
* per-swap recovery file is written. The key is kept through the SDK's storage adapter.
|
|
1114
|
+
*/
|
|
1115
|
+
recoveryKey?: string | { readonly mnemonic: string };
|
|
1116
|
+
/** The swap number, when the caller already reserved one. Absent - the SDK reserves the next free number. */
|
|
1117
|
+
recoveryIndex?: number;
|
|
1118
|
+
/** The Monero height the swap starts at. Absent - the SDK asks the configured nodes (`get_info`) and refuses
|
|
1119
|
+
* the start if none answers on the swap's network. */
|
|
1120
|
+
restoreHeight?: number;
|
|
1121
|
+
/** Monero nodes for that one measurement; absent - the configured nodes for the swap's network. */
|
|
1122
|
+
xmrNodes?: readonly (string | { readonly url: string })[];
|
|
1123
|
+
/** The fetch used for the height measurement (tests); absent - the global fetch. */
|
|
1124
|
+
fetchImpl?: typeof fetch;
|
|
1125
|
+
/**
|
|
1126
|
+
* Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
|
|
1127
|
+
* `false` - the swap is not started. A key already in the store is not shown again.
|
|
1128
|
+
*/
|
|
1129
|
+
onRecoveryKey?(file: RecoveryFile): Promise<boolean>;
|
|
1109
1130
|
}
|
|
1110
1131
|
|
|
1111
1132
|
export interface RecoveryFile {
|
package/package.json
CHANGED
package/src/order.mjs
CHANGED
|
@@ -97,6 +97,29 @@ export function buildOwnSide(order, onBit) {
|
|
|
97
97
|
return { context, side, spendPoint: norm(side.pub.ed), viewPoint: norm(side.viewPub), encPub: norm(side.enc.pub) };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* OUR SIDE FROM HALVES WE ALREADY HAVE - the one recovery key case. A random side would be a second secret,
|
|
102
|
+
* which is exactly what one key removes. The proof is still bound to this order and built by the same engine
|
|
103
|
+
* builder; only the source of the two numbers changes.
|
|
104
|
+
*/
|
|
105
|
+
export function buildOwnSideFromHalves(order, { spendHalf, viewHalf } = {}) {
|
|
106
|
+
const context = contextOf(order);
|
|
107
|
+
const b = builder();
|
|
108
|
+
const scalar = (value, name) => {
|
|
109
|
+
const n = BigInt(value);
|
|
110
|
+
if (n <= 0n || n >= BigInt(b.halves.LIMIT)) throw new SdkError("bad-input", { field: name });
|
|
111
|
+
return n;
|
|
112
|
+
};
|
|
113
|
+
const half = scalar(spendHalf, "spendHalf");
|
|
114
|
+
const view = scalar(viewHalf, "viewHalf");
|
|
115
|
+
const pub = b.halves.publicHalves(half);
|
|
116
|
+
const viewPub = b.halves.publicHalves(view).ed;
|
|
117
|
+
const encPair = b.enc.recipientKeyPair();
|
|
118
|
+
const proof = b.dleq.prove(half, context);
|
|
119
|
+
const side = { half, pub, viewHalf: view, viewPub, enc: encPair, proof, standIn: false };
|
|
120
|
+
return { context, side, spendPoint: norm(pub.ed), viewPoint: norm(viewPub), encPub: norm(encPair.pub) };
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
/**
|
|
101
124
|
* The counterparty's side: accept and verify. The check is ONE for both sides and is done by the engine
|
|
102
125
|
* (order.js, verifySide): the proof stands in the CONTEXT OF THIS order, the proven point matches the one that
|
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,10 @@ 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>;
|
|
378
388
|
}): Promise<{ swaps: FoundSwap[]; scanned: number }>;
|
package/src/record.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { SdkError } from "./errors.mjs";
|
|
15
15
|
import { primitives } from "./primitives.mjs";
|
|
16
16
|
import * as engine from "./engine.mjs";
|
|
17
|
-
import { buildOwnSide, halfHex, norm } from "./order.mjs";
|
|
17
|
+
import { buildOwnSide, buildOwnSideFromHalves, halfHex, norm } from "./order.mjs";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Our side. Without order terms - only the halves and their points (a shape of the record; there is no proof and
|
|
@@ -23,7 +23,11 @@ import { buildOwnSide, halfHex, norm } from "./order.mjs";
|
|
|
23
23
|
* THE HALF NAMES ARE THE ENGINE'S: the half is returned in the engine's form (BigInt), and it goes into the
|
|
24
24
|
* record as a hex string, because a BigInt survives neither storage nor the recovery file's JSON.
|
|
25
25
|
*/
|
|
26
|
-
export function ownSide(order = null) {
|
|
26
|
+
export function ownSide(order = null, known = null) {
|
|
27
|
+
if (known && known.spendHalf && known.viewHalf) {
|
|
28
|
+
const built = buildOwnSideFromHalves(order, known);
|
|
29
|
+
return built.side;
|
|
30
|
+
}
|
|
27
31
|
if (order !== null && order !== undefined) {
|
|
28
32
|
const built = buildOwnSide(order);
|
|
29
33
|
return built.side;
|
|
@@ -40,9 +44,9 @@ export function ownSide(order = null) {
|
|
|
40
44
|
* edPointLocker, edViewPointLocker). The counterparty's half cannot and need not be put in: it will come from
|
|
41
45
|
* the order.
|
|
42
46
|
*/
|
|
43
|
-
export function createRecord(input, { order = null } = {}) {
|
|
47
|
+
export function createRecord(input, { order = null, side: knownSide = null } = {}) {
|
|
44
48
|
if (!input || typeof input !== "object") throw new SdkError("bad-input", { field: "input" });
|
|
45
|
-
const side = ownSide(order);
|
|
49
|
+
const side = knownSide || ownSide(order);
|
|
46
50
|
let swap = null;
|
|
47
51
|
try { swap = engine.swap.createSwap(input); } catch { throw new SdkError("bad-input", { field: "input" }); }
|
|
48
52
|
const escrow = { ...(swap.escrow || {}),
|
package/src/recovery-key.mjs
CHANGED
|
@@ -396,6 +396,61 @@ async function readLogs(evmLogs, request) {
|
|
|
396
396
|
return logs;
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
// THE MONERO HEIGHT A RECOVERED ORDER STARTED AT. With one key there is no per-swap file to carry it, so it is
|
|
400
|
+
// derived from the settlement chain: the timestamp of the block that holds OrderCreated, then the last Monero
|
|
401
|
+
// block at or before it. A Monero block's timestamp may run ahead of wall time, hence the safety margin.
|
|
402
|
+
//
|
|
403
|
+
// The two readers are seams, like evmLogs: the SDK owns the rule, the caller owns the transport. Without them the
|
|
404
|
+
// order is still returned, with startHeight null and a named reason, instead of a guessed height.
|
|
405
|
+
const MONERO_HEIGHT_SAFETY_BLOCKS = 60;
|
|
406
|
+
|
|
407
|
+
function secondsOf(value) {
|
|
408
|
+
const n = Number(value);
|
|
409
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
410
|
+
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function heightAtOrBefore(timestamp, { tip, headerTimestamp }) {
|
|
414
|
+
const top = Number(await tip());
|
|
415
|
+
if (!Number.isSafeInteger(top) || top < 0) throw new Error("monero-tip");
|
|
416
|
+
const at = async (height) => secondsOf(await headerTimestamp(height));
|
|
417
|
+
const topTime = await at(top);
|
|
418
|
+
if (topTime !== null && topTime <= timestamp) return top;
|
|
419
|
+
let low = 0;
|
|
420
|
+
let high = top;
|
|
421
|
+
const zeroTime = await at(0);
|
|
422
|
+
if (zeroTime !== null && zeroTime > timestamp) return 0;
|
|
423
|
+
while (low < high) {
|
|
424
|
+
const mid = Math.ceil((low + high) / 2);
|
|
425
|
+
const ts = await at(mid);
|
|
426
|
+
if (ts === null) throw new Error("monero-header");
|
|
427
|
+
if (ts <= timestamp) low = mid;
|
|
428
|
+
else high = mid - 1;
|
|
429
|
+
}
|
|
430
|
+
return low;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }) {
|
|
434
|
+
if (order.blockNumber === null || order.blockNumber === undefined) {
|
|
435
|
+
return { startHeight: null, startHeightReason: "no-block-number" };
|
|
436
|
+
}
|
|
437
|
+
if (typeof evmBlockTime !== "function" || typeof moneroTip !== "function" || typeof moneroBlockTimestamp !== "function") {
|
|
438
|
+
return { startHeight: null, startHeightReason: "no-height-readers" };
|
|
439
|
+
}
|
|
440
|
+
let timestamp = cache.get(order.blockNumber);
|
|
441
|
+
if (timestamp === undefined) {
|
|
442
|
+
timestamp = secondsOf(await evmBlockTime(order.blockNumber));
|
|
443
|
+
if (timestamp === null) return { startHeight: null, startHeightReason: "no-block-time" };
|
|
444
|
+
cache.set(order.blockNumber, timestamp);
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const height = await heightAtOrBefore(timestamp, { tip: moneroTip, headerTimestamp: moneroBlockTimestamp });
|
|
448
|
+
return { startHeight: Math.max(0, height - MONERO_HEIGHT_SAFETY_BLOCKS), startHeightReason: null };
|
|
449
|
+
} catch {
|
|
450
|
+
return { startHeight: null, startHeightReason: "monero-header" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
399
454
|
/**
|
|
400
455
|
* Find orders made with this key.
|
|
401
456
|
*
|
|
@@ -403,7 +458,9 @@ async function readLogs(evmLogs, request) {
|
|
|
403
458
|
* search asks for it as the locker and as the claimer (two queries; the RPC topic filter cannot say OR across two
|
|
404
459
|
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
405
460
|
*/
|
|
406
|
-
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
461
|
+
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs,
|
|
462
|
+
fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000,
|
|
463
|
+
evmBlockTime = null, moneroTip = null, moneroBlockTimestamp = null } = {}) {
|
|
407
464
|
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
408
465
|
const chain = numberField(chainId, "chainId");
|
|
409
466
|
const max = numberField(maxIndex, "maxIndex") || 1000;
|
|
@@ -446,5 +503,11 @@ export async function findSwaps({ mnemonic, chainId, factory, address = null, ev
|
|
|
446
503
|
found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
|
|
447
504
|
}
|
|
448
505
|
}
|
|
506
|
+
// EACH FOUND ORDER CARRIES THE MONERO HEIGHT ITS WITHDRAWAL MAY START FROM. The rule lives here, not in a
|
|
507
|
+
// page: a widget and the recovery page must not derive two different answers from one chain.
|
|
508
|
+
const cache = new Map();
|
|
509
|
+
for (const order of found) {
|
|
510
|
+
Object.assign(order, await recoveredStartHeight(order, { evmBlockTime, moneroTip, moneroBlockTimestamp, cache }));
|
|
511
|
+
}
|
|
449
512
|
return { swaps: found, scanned };
|
|
450
513
|
}
|
package/src/swaps.mjs
CHANGED
|
@@ -12,17 +12,23 @@ import * as engine from "./engine.mjs";
|
|
|
12
12
|
import * as recordModule from "./record.mjs";
|
|
13
13
|
// The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
|
|
14
14
|
// the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
|
|
15
|
-
import { contextOf, orderStep } from "./order.mjs";
|
|
15
|
+
import { buildOwnSideFromHalves, contextOf, orderStep } from "./order.mjs";
|
|
16
16
|
// Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
|
|
17
17
|
import * as lock from "./lock.mjs";
|
|
18
18
|
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.
|
|
25
26
|
import { createActions } from "./actions.mjs";
|
|
27
|
+
// THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
|
|
28
|
+
import {
|
|
29
|
+
commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
|
|
30
|
+
recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
|
|
31
|
+
} from "./recovery-key.mjs";
|
|
26
32
|
// THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
|
|
27
33
|
import * as xmrArrival from "./xmr-arrival.mjs";
|
|
28
34
|
// THE MAKER'S DECLINE (issue #40): the escrow's `Declined` event and the node's `declined` + `compliance` status
|
|
@@ -301,6 +307,24 @@ const recordInputOf = (request) => {
|
|
|
301
307
|
// createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
|
|
302
308
|
// the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
|
|
303
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
|
+
|
|
304
328
|
export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
|
|
305
329
|
// THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
|
|
306
330
|
// and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
|
|
@@ -337,6 +361,18 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
337
361
|
const value = config && config.limits ? config.limits[name] : null;
|
|
338
362
|
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
|
|
339
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
|
+
}
|
|
340
376
|
const records = () => {
|
|
341
377
|
const state = engine.store.loadState();
|
|
342
378
|
const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
|
|
@@ -449,11 +485,15 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
449
485
|
if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
|
|
450
486
|
const network = swap.moneroNetwork || xmrNetwork || null;
|
|
451
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" });
|
|
452
491
|
const wanted = {
|
|
453
492
|
swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
|
|
454
|
-
// THE RESTORE HEIGHT -
|
|
455
|
-
//
|
|
456
|
-
|
|
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,
|
|
457
497
|
chain: swap.network || swap.chain || null,
|
|
458
498
|
};
|
|
459
499
|
const body = {};
|
|
@@ -705,7 +745,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
705
745
|
async start(request) {
|
|
706
746
|
if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
|
|
707
747
|
if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
|
|
708
|
-
|
|
748
|
+
// THE OLD PATH IS UNCHANGED; THE NEW ONE ASKS FOR A KEY INSTEAD OF A PER-SWAP FILE.
|
|
749
|
+
const wantsKey = request.recoveryKey !== undefined && request.recoveryKey !== null;
|
|
750
|
+
if (!wantsKey && typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
|
|
709
751
|
if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
|
|
710
752
|
// THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
|
|
711
753
|
// quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
|
|
@@ -734,8 +776,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
734
776
|
// record, and our side of the order is computed from the core's primitives.
|
|
735
777
|
// THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
|
|
736
778
|
// builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
|
|
779
|
+
let keySide = null;
|
|
737
780
|
const makeRecord = typeof createRecord === "function" ? createRecord
|
|
738
|
-
: async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
|
|
781
|
+
: async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order, side: keySide });
|
|
739
782
|
const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
|
|
740
783
|
if (typeof passphrase !== "string") throw new SdkError("bad-input", { field: "passphrase" });
|
|
741
784
|
if (passphrase.length > 0 && passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
|
|
@@ -744,6 +787,32 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
744
787
|
// values: the DLEQ proof would then be bound to nothing.
|
|
745
788
|
const orderContext = contextOf(request.order);
|
|
746
789
|
|
|
790
|
+
// THE KEY RECORD, THE NUMBER AND THE SIDE. A key is made once and kept through the SDK's store; the
|
|
791
|
+
// number is reserved before the order is built, and it is retired only after the order exists on chain.
|
|
792
|
+
// No per-swap file is assembled on this path.
|
|
793
|
+
let recovery = null;
|
|
794
|
+
if (wantsKey) {
|
|
795
|
+
const storage = engine.store.storage();
|
|
796
|
+
const supplied = typeof request.recoveryKey === "string"
|
|
797
|
+
? request.recoveryKey
|
|
798
|
+
: request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
|
|
799
|
+
const saved = loadRecoveryKey(storage);
|
|
800
|
+
if (saved && supplied && saved.mnemonic !== supplied) {
|
|
801
|
+
throw new SdkError("bad-input", { field: "recoveryKey", why: "different-key" });
|
|
802
|
+
}
|
|
803
|
+
if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
|
|
804
|
+
const key = saved || importRecoveryKey(supplied);
|
|
805
|
+
if (!saved) saveRecoveryKey(storage, key);
|
|
806
|
+
const chainId = request.order.chainId;
|
|
807
|
+
const index = request.recoveryIndex === undefined || request.recoveryIndex === null
|
|
808
|
+
? reserveRecoveryIndex(storage, { chainId })
|
|
809
|
+
: Number(request.recoveryIndex);
|
|
810
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
|
|
811
|
+
const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
|
|
812
|
+
keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
|
|
813
|
+
recovery = { storage, key, index, newKey: !saved };
|
|
814
|
+
}
|
|
815
|
+
|
|
747
816
|
let made = null;
|
|
748
817
|
try { made = await makeRecord(request); } catch (error) {
|
|
749
818
|
// THE CODE MATTERS, NOT THE CLASS: an error with a code (including from another instance of the module) passes
|
|
@@ -785,26 +854,51 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
785
854
|
builtSide = step;
|
|
786
855
|
}
|
|
787
856
|
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
engine.recoveryFile.validatePayload(payload);
|
|
797
|
-
} catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
|
|
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" }); }
|
|
798
865
|
|
|
866
|
+
// 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
|
|
867
|
+
// no file per swap: the same twelve words cover every swap, and a person who already saved them is not
|
|
868
|
+
// asked again. A new key is shown before the money moves - no saved key, no funding.
|
|
869
|
+
let payload = null;
|
|
799
870
|
let file = null;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
871
|
+
let confirmed = true;
|
|
872
|
+
if (!recovery) {
|
|
873
|
+
// THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
|
|
874
|
+
// file: the person will rely on it. So we check on our side, before the screen.
|
|
875
|
+
try {
|
|
876
|
+
payload = engine.recoveryFile.buildPayload({
|
|
877
|
+
swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
|
|
878
|
+
receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
|
|
879
|
+
});
|
|
880
|
+
engine.recoveryFile.validatePayload(payload);
|
|
881
|
+
} catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
|
|
803
882
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
883
|
+
try {
|
|
884
|
+
file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
|
|
885
|
+
} catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
|
|
886
|
+
|
|
887
|
+
// 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
|
|
888
|
+
// up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
|
|
889
|
+
confirmed = await request.onRecoveryFile(file);
|
|
890
|
+
if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
|
|
891
|
+
} else if (recovery.newKey) {
|
|
892
|
+
if (typeof request.onRecoveryKey !== "function") throw new SdkError("bad-input", { field: "onRecoveryKey" });
|
|
893
|
+
try {
|
|
894
|
+
file = {
|
|
895
|
+
name: recoveryKeyFileName(recovery.key),
|
|
896
|
+
contents: await createRecoveryKeyFile({ mnemonic: recovery.key.mnemonic, createdAt: recovery.key.createdAt, passphrase }),
|
|
897
|
+
};
|
|
898
|
+
} catch { throw new SdkError("recovery-failed", { step: "encrypt-key" }); }
|
|
899
|
+
confirmed = await request.onRecoveryKey(file);
|
|
900
|
+
if (confirmed !== true) throw new SdkError("recovery-declined", { key: recovery.key.fingerprint });
|
|
901
|
+
}
|
|
808
902
|
|
|
809
903
|
// 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
|
|
810
904
|
// in it, and the core keeps no path into the chain of its own. The absence of a wallet is a REFUSAL WITH A CODE,
|
|
@@ -861,9 +955,16 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
861
955
|
if (escrowAddress) {
|
|
862
956
|
const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
|
|
863
957
|
record.swap.escrow = { ...escrowRecord, address: escrowAddress,
|
|
864
|
-
// THE
|
|
865
|
-
|
|
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) };
|
|
866
962
|
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
|
|
963
|
+
// THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
|
|
964
|
+
// reservation, so a retry uses the same number and the later search has no gap.
|
|
965
|
+
if (recovery) {
|
|
966
|
+
try { commitRecoveryIndex(recovery.storage, recovery.index); } catch { /* the reservation stays; the key still works */ }
|
|
967
|
+
}
|
|
867
968
|
}
|
|
868
969
|
|
|
869
970
|
// 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
|
|
@@ -954,7 +1055,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
954
1055
|
const escrowAddress = request.escrow || (record && record.escrow && record.escrow.address) || null;
|
|
955
1056
|
// FROM THE ESCROW'S BIRTH HEIGHT when the record knows it: the event cannot be older than the funding, and
|
|
956
1057
|
// walking the whole chain every tick is a cost with no answer behind it.
|
|
957
|
-
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1058
|
+
const fromBlock = blockBoundaryOf(record && record.escrow
|
|
1059
|
+
? (record.escrow.evmBirthBlock ?? record.escrow.birthHeight) : null);
|
|
958
1060
|
const chainDecline = { checked: false, error: null, logs: undefined };
|
|
959
1061
|
const readDeclineLogs = async () => {
|
|
960
1062
|
chainDecline.checked = false; chainDecline.error = null; chainDecline.logs = undefined;
|
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.48.1";
|