@ninsei-labs/sdk 0.46.2 → 0.48.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 +17 -3
- package/package.json +4 -2
- package/src/order.mjs +23 -0
- package/src/public/recovery.d.ts +117 -0
- package/src/public/recovery.mjs +28 -0
- package/src/record.mjs +8 -4
- package/src/recovery-key.mjs +450 -0
- package/src/swaps.mjs +75 -20
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 0.48.0 — 2026-10-21
|
|
2
|
+
|
|
3
|
+
A SWAP CAN START FROM THE ONE RECOVERY KEY.
|
|
4
|
+
|
|
5
|
+
- `swaps.start` accepts `recoveryKey` (a BIP39 mnemonic or a key object). It derives the spend and view halves
|
|
6
|
+
for the reserved swap number instead of generating a random pair, and writes no per-swap recovery file.
|
|
7
|
+
- A key created by the call is handed to `onRecoveryKey` once, before the funds are locked; a key already in the
|
|
8
|
+
store is not shown again. The number is reserved before the order and retired only after the escrow exists.
|
|
9
|
+
- The old path is unchanged: without `recoveryKey`, `onRecoveryFile` still assembles and confirms a per-swap file.
|
|
10
|
+
- `buildOwnSideFromHalves` builds the order side and its DLEQ proof from supplied halves; `createRecord` accepts
|
|
11
|
+
that side, so the recovery key remains the only taker secret.
|
|
12
|
+
|
|
13
|
+
## 0.47.0 — 2026-10-21
|
|
14
|
+
|
|
15
|
+
ONE RECOVERY KEY, AND THE NODE'S VIEW HALF COMES FROM IT.
|
|
16
|
+
|
|
17
|
+
- `@ninsei-labs/sdk/recovery` exposes a BIP39 12-word taker key: creation, import, fingerprint, the
|
|
18
|
+
`ninsei-recovery-key-DO-NOT-DELETE-...` backup file (plain or AES-GCM/PBKDF2), and a storage adapter for the key.
|
|
19
|
+
- `takerHalves({ mnemonic, chainId, index })` derives the taker spend/view halves with
|
|
20
|
+
`ninsei-taker-v1|<chainId>|<index>`, and the counterparty view half with `ninsei-maker-view-v1`. The taker sends
|
|
21
|
+
the latter to the node (`viewHalfClaimer` on a direct quote, `viewHalfLocker` on a reverse ticket); the node
|
|
22
|
+
accepts it and keeps the joint view key.
|
|
23
|
+
- `findSwaps({ mnemonic, chainId, factory, address?, evmLogs, ... })` reads `OrderCreated`, filters by the indexed
|
|
24
|
+
locker/claimer when an address is given, matches `commitHalfLocker`/`commitHalfClaimer` locally, and stops after
|
|
25
|
+
five numbers in a row with no order.
|
|
26
|
+
- Numbers are reserved in the store: released numbers are reused, numbers seen on chain are retired.
|
|
27
|
+
- The current per-swap recovery file and `swaps.start` are unchanged. The switch is a separate change.
|
|
28
|
+
|
|
1
29
|
## 0.46.2 — 2026-10-21
|
|
2
30
|
|
|
3
31
|
THE TESTNET CONFIG NAMES THE CLAIMTIP DEPLOYMENT.
|
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,24 @@ 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
|
+
/**
|
|
1119
|
+
* Hand the key backup to the interface, ONCE, when this call created the key. `true` - the person saved it;
|
|
1120
|
+
* `false` - the swap is not started. A key already in the store is not shown again.
|
|
1121
|
+
*/
|
|
1122
|
+
onRecoveryKey?(file: RecoveryFile): Promise<boolean>;
|
|
1109
1123
|
}
|
|
1110
1124
|
|
|
1111
1125
|
export interface RecoveryFile {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninsei-labs/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"check:factory-selectors": "node tools/check-factory-selectors.mjs",
|
|
43
43
|
"check:fee-estimate": "node tools/check-fee-estimate.mjs",
|
|
44
44
|
"check:deadline-scale": "node tools/check-deadline-scale.mjs",
|
|
45
|
-
"check:node-probe": "node tools/check-node-probe.mjs"
|
|
45
|
+
"check:node-probe": "node tools/check-node-probe.mjs",
|
|
46
|
+
"check:recovery-key": "node tools/check.mjs --only=recovery-key"
|
|
46
47
|
},
|
|
47
48
|
"types": "./index.d.ts",
|
|
48
49
|
"exports": {
|
|
@@ -120,6 +121,7 @@
|
|
|
120
121
|
"dependencies": {
|
|
121
122
|
"@noble/curves": "^2.4.0",
|
|
122
123
|
"@noble/hashes": "^2.4.0",
|
|
124
|
+
"@scure/bip39": "2.4.0",
|
|
123
125
|
"keccak256": "^1.0.6"
|
|
124
126
|
},
|
|
125
127
|
"sideEffects": false
|
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
|
@@ -259,3 +259,120 @@ export declare function createRecoveryFile(payload: RecoveryPayload, passphrase:
|
|
|
259
259
|
* yet. Stable, so a repeated download shows up as a second copy of one name rather than a new random file.
|
|
260
260
|
*/
|
|
261
261
|
export declare function recoveryFileName(swapId?: string | null): string;
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------------------------------
|
|
265
|
+
// ONE RECOVERY KEY PER BROWSER (sdk#79)
|
|
266
|
+
// ---------------------------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/** The version of the stored/backup key record. */
|
|
269
|
+
export declare const RECOVERY_KEY_VERSION: number;
|
|
270
|
+
/** Twelve words, by the BIP39 rules. */
|
|
271
|
+
export declare const RECOVERY_KEY_WORDS: number;
|
|
272
|
+
/** The store key a browser adapter keeps the key under. */
|
|
273
|
+
export declare const RECOVERY_KEY_STORE: string;
|
|
274
|
+
/** The first line of a plain backup file. */
|
|
275
|
+
export declare const RECOVERY_KEY_FILE_MAGIC: string;
|
|
276
|
+
/** The domain half derivation binds to; the chain id and swap number join it. */
|
|
277
|
+
export declare const RECOVERY_KEY_DOMAIN: string;
|
|
278
|
+
/** The domain of the counterparty's view half, derived from the same one key. */
|
|
279
|
+
export declare const MAKER_VIEW_HALF_DOMAIN: string;
|
|
280
|
+
/** The encrypted backup envelope version. */
|
|
281
|
+
export declare const RECOVERY_KEY_ENVELOPE_VERSION: number;
|
|
282
|
+
/** The encrypted backup algorithm identifier. */
|
|
283
|
+
export declare const RECOVERY_KEY_ALG: string;
|
|
284
|
+
/** The PBKDF2 iteration count written into an encrypted backup. */
|
|
285
|
+
export declare const RECOVERY_KEY_ITER: number;
|
|
286
|
+
|
|
287
|
+
/** A key as it is kept: the words are the secret, the fingerprint is its short public name. */
|
|
288
|
+
export interface RecoveryKey {
|
|
289
|
+
readonly version: number;
|
|
290
|
+
readonly mnemonic: string;
|
|
291
|
+
readonly fingerprint: string;
|
|
292
|
+
readonly createdAt: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A derived swap number: the halves cross JSON as hex scalars. */
|
|
296
|
+
export interface TakerHalves {
|
|
297
|
+
readonly index: number;
|
|
298
|
+
readonly binding: string;
|
|
299
|
+
readonly spendHalf: string;
|
|
300
|
+
readonly viewHalf: string;
|
|
301
|
+
readonly spendPoint: string;
|
|
302
|
+
readonly viewPoint: string;
|
|
303
|
+
readonly makerViewHalf: string;
|
|
304
|
+
readonly makerViewPoint: string;
|
|
305
|
+
readonly spendCommitment: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** One order found by the key's commitments. */
|
|
309
|
+
export interface FoundSwap {
|
|
310
|
+
readonly escrow: string;
|
|
311
|
+
readonly locker: string;
|
|
312
|
+
readonly claimer: string;
|
|
313
|
+
readonly commitHalfLocker: string;
|
|
314
|
+
readonly commitHalfClaimer: string;
|
|
315
|
+
readonly edPointLocker: string;
|
|
316
|
+
readonly edPointClaimer: string;
|
|
317
|
+
readonly edViewPointLocker: string;
|
|
318
|
+
readonly amount: string;
|
|
319
|
+
readonly readyBy: number;
|
|
320
|
+
readonly t1: number;
|
|
321
|
+
readonly salt: string;
|
|
322
|
+
readonly registry: string;
|
|
323
|
+
readonly provider: string;
|
|
324
|
+
readonly quoteKey: string;
|
|
325
|
+
readonly transactionHash: string | null;
|
|
326
|
+
readonly blockNumber: number | null;
|
|
327
|
+
readonly logIndex: number | null;
|
|
328
|
+
readonly index: number;
|
|
329
|
+
readonly side: "locker" | "claimer";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A storage adapter: the same shape the SDK store takes. */
|
|
333
|
+
export interface RecoveryKeyStorage {
|
|
334
|
+
get(key: string): string | null;
|
|
335
|
+
set(key: string, value: string): unknown;
|
|
336
|
+
remove(key: string): unknown;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Makes a new 12-word key. The caller saves it; nothing is stored here. */
|
|
340
|
+
export declare function createRecoveryKey(options?: { createdAt?: string | Date }): RecoveryKey;
|
|
341
|
+
/** Imports and validates words. */
|
|
342
|
+
export declare function importRecoveryKey(mnemonic: string, options?: { createdAt?: string | Date }): RecoveryKey;
|
|
343
|
+
/** The short public fingerprint (four hex characters) of the words. */
|
|
344
|
+
export declare function fingerprintOf(mnemonic: string): string;
|
|
345
|
+
/** The binding for a chain and swap number: `ninsei-taker-v1|<chainId>|<index>`. */
|
|
346
|
+
export declare function recoveryBinding(chainId: number, index: number): string;
|
|
347
|
+
/** Derives the taker spend and view halves for one swap number. */
|
|
348
|
+
export declare function takerHalves(request: { mnemonic: string; chainId: number; index: number }): TakerHalves;
|
|
349
|
+
/** The stable backup name, with `DO-NOT-DELETE` in it on purpose. */
|
|
350
|
+
export declare function recoveryKeyFileName(request: { mnemonic?: string; fingerprint?: string; createdAt?: string | Date }): string;
|
|
351
|
+
/** A plain backup file: readable header above the words. */
|
|
352
|
+
export declare function recoveryKeyText(request: { mnemonic: string; createdAt?: string | Date }): string;
|
|
353
|
+
/** A backup file, optionally encrypted with a passphrase of at least 12 characters. */
|
|
354
|
+
export declare function createRecoveryKeyFile(request: { mnemonic: string; createdAt?: string | Date; passphrase?: string }): Promise<string>;
|
|
355
|
+
/** Reads a plain or encrypted backup file and validates the words. */
|
|
356
|
+
export declare function openRecoveryKeyFile(contents: string, passphrase?: string): Promise<RecoveryKey>;
|
|
357
|
+
/** Saves the key through a storage adapter. */
|
|
358
|
+
export declare function saveRecoveryKey(storage: RecoveryKeyStorage, key: RecoveryKey & { nextIndex?: number; reserved?: Record<string, unknown>; retired?: Record<string, unknown> }): RecoveryKey;
|
|
359
|
+
/** Reads the key from a storage adapter, or null. */
|
|
360
|
+
export declare function loadRecoveryKey(storage: RecoveryKeyStorage): (RecoveryKey & { nextIndex: number; reserved: Record<string, unknown>; retired: Record<string, unknown> }) | null;
|
|
361
|
+
/** Reserves a swap number; the caller releases it if the request is abandoned. */
|
|
362
|
+
export declare function reserveRecoveryIndex(storage: RecoveryKeyStorage, request: { chainId: number; swapId?: string | null }): number;
|
|
363
|
+
/** Releases a number that never reached the chain. */
|
|
364
|
+
export declare function releaseRecoveryIndex(storage: RecoveryKeyStorage, index: number): void;
|
|
365
|
+
/** Retires a number after its order exists on chain. */
|
|
366
|
+
export declare function commitRecoveryIndex(storage: RecoveryKeyStorage, index: number): void;
|
|
367
|
+
/** Finds orders whose commitment was made from the key's halves. */
|
|
368
|
+
export declare function findSwaps(request: {
|
|
369
|
+
mnemonic: string;
|
|
370
|
+
chainId: number;
|
|
371
|
+
factory: string;
|
|
372
|
+
address?: string | null;
|
|
373
|
+
evmLogs: (request: { address: string; topics: (string | null)[]; fromBlock: number | string; toBlock: number | string }) => Promise<unknown[]>;
|
|
374
|
+
fromBlock?: number | string;
|
|
375
|
+
toBlock?: number | string;
|
|
376
|
+
gap?: number;
|
|
377
|
+
maxIndex?: number;
|
|
378
|
+
}): Promise<{ swaps: FoundSwap[]; scanned: number }>;
|
package/src/public/recovery.mjs
CHANGED
|
@@ -20,3 +20,31 @@ export {
|
|
|
20
20
|
scanStartHeight,
|
|
21
21
|
validatePayload,
|
|
22
22
|
} from "../../engine/recovery/recoveryFile.js";
|
|
23
|
+
|
|
24
|
+
// ONE RECOVERY KEY FOR EVERY SWAP (sdk#79): the taker's key, its derivation, its file and the on-chain search.
|
|
25
|
+
export {
|
|
26
|
+
RECOVERY_KEY_ALG,
|
|
27
|
+
RECOVERY_KEY_DOMAIN,
|
|
28
|
+
RECOVERY_KEY_ENVELOPE_VERSION,
|
|
29
|
+
RECOVERY_KEY_FILE_MAGIC,
|
|
30
|
+
RECOVERY_KEY_ITER,
|
|
31
|
+
RECOVERY_KEY_STORE,
|
|
32
|
+
RECOVERY_KEY_VERSION,
|
|
33
|
+
MAKER_VIEW_HALF_DOMAIN,
|
|
34
|
+
RECOVERY_KEY_WORDS,
|
|
35
|
+
commitRecoveryIndex,
|
|
36
|
+
createRecoveryKey,
|
|
37
|
+
createRecoveryKeyFile,
|
|
38
|
+
findSwaps,
|
|
39
|
+
fingerprintOf,
|
|
40
|
+
importRecoveryKey,
|
|
41
|
+
loadRecoveryKey,
|
|
42
|
+
openRecoveryKeyFile,
|
|
43
|
+
recoveryBinding,
|
|
44
|
+
recoveryKeyFileName,
|
|
45
|
+
recoveryKeyText,
|
|
46
|
+
releaseRecoveryIndex,
|
|
47
|
+
reserveRecoveryIndex,
|
|
48
|
+
saveRecoveryKey,
|
|
49
|
+
takerHalves,
|
|
50
|
+
} from "../recovery-key.mjs";
|
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 || {}),
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// ONE RECOVERY KEY FOR EVERY SWAP: THE TAKER'S KEY, ITS HALVES, ITS FILE, AND THE SEARCH.
|
|
2
|
+
//
|
|
3
|
+
// The taker used to make a random secret per swap and ask a person to save a file before funding. One BIP39 key,
|
|
4
|
+
// made once and saved once, removes that step. The seeds of this module are the ONLY source of the taker's
|
|
5
|
+
// halves: the spend and view halves for swap number i come from halfFromSeed with a binding that is known at
|
|
6
|
+
// recovery time. The factory is deliberately not part of the binding: numbers then run across a redeployment,
|
|
7
|
+
// and the search reads every factory the caller names.
|
|
8
|
+
//
|
|
9
|
+
// THE KEY NEVER LEAVES THE CLIENT. The search sends an address and the OrderCreated topic to a log reader; it
|
|
10
|
+
// never sends the words, the seed, a half or a commitment. A service that answered "which of these are mine"
|
|
11
|
+
// would learn that the orders belong to one person.
|
|
12
|
+
//
|
|
13
|
+
// THIS MODULE DOES NOT SWITCH swaps.start. It is the foundation: the current per-swap flow keeps working until
|
|
14
|
+
// the node accepts the taker's maker view half and the page is rebuilt around the key.
|
|
15
|
+
|
|
16
|
+
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
17
|
+
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
18
|
+
import { createHalves } from "../engine/atomic/halves.js";
|
|
19
|
+
import { halfCommitmentHex } from "../engine/atomic/orderHalfCommit.js";
|
|
20
|
+
import { ORDER_CREATED_TOPIC } from "../engine/evm/factory.js";
|
|
21
|
+
import { SdkError } from "./errors.mjs";
|
|
22
|
+
import { ed25519, keccak256, randomBytes, secp256k1 } from "./primitives.mjs";
|
|
23
|
+
|
|
24
|
+
const halves = createHalves({ ed25519, secp256k1, keccak256, randomBytes });
|
|
25
|
+
|
|
26
|
+
/** A BIP39 mnemonic with 128 bits of entropy: twelve words. */
|
|
27
|
+
export const RECOVERY_KEY_VERSION = 1;
|
|
28
|
+
export const RECOVERY_KEY_WORDS = 12;
|
|
29
|
+
export const RECOVERY_KEY_STORE = "ninsei.recovery.key.v1";
|
|
30
|
+
export const RECOVERY_KEY_FILE_MAGIC = "NINSEI RECOVERY KEY";
|
|
31
|
+
export const RECOVERY_KEY_DOMAIN = "ninsei-taker-v1";
|
|
32
|
+
// The counterparty's view half is derived from the SAME ONE KEY, in a domain of its own. The taker sends this
|
|
33
|
+
// number to the maker (the node), which uses it as its own view half; the taker keeps it, so the joint view key
|
|
34
|
+
// can be rebuilt from the twelve words without any server.
|
|
35
|
+
export const MAKER_VIEW_HALF_DOMAIN = "ninsei-maker-view-v1";
|
|
36
|
+
export const RECOVERY_KEY_ENVELOPE_VERSION = 1;
|
|
37
|
+
export const RECOVERY_KEY_ALG = "AES-GCM-256/PBKDF2-SHA256";
|
|
38
|
+
export const RECOVERY_KEY_ITER = 600000;
|
|
39
|
+
const ITER_MIN = 600000;
|
|
40
|
+
const ITER_MAX = 6000000;
|
|
41
|
+
|
|
42
|
+
const te = new TextEncoder();
|
|
43
|
+
const td = new TextDecoder();
|
|
44
|
+
|
|
45
|
+
const hex32 = (value) => "0x" + BigInt(value).toString(16).padStart(64, "0");
|
|
46
|
+
const pad32 = (value) => "0x" + String(value).toLowerCase().replace(/^0x/, "").padStart(64, "0");
|
|
47
|
+
const b64 = (bytes) => {
|
|
48
|
+
let text = "";
|
|
49
|
+
for (const byte of bytes) text += String.fromCharCode(byte);
|
|
50
|
+
return btoa(text);
|
|
51
|
+
};
|
|
52
|
+
const unb64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
53
|
+
|
|
54
|
+
const bad = (field) => new SdkError("bad-input", { field });
|
|
55
|
+
const failed = (stage) => new SdkError("recovery-failed", { stage });
|
|
56
|
+
|
|
57
|
+
function normalizeWords(value) {
|
|
58
|
+
if (typeof value !== "string") throw bad("mnemonic");
|
|
59
|
+
const mnemonic = value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
60
|
+
if (!mnemonic || mnemonic.split(" ").length !== RECOVERY_KEY_WORDS) throw bad("mnemonic");
|
|
61
|
+
if (!validateMnemonic(mnemonic, wordlist)) throw bad("mnemonic");
|
|
62
|
+
return mnemonic;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createdAtOf(value) {
|
|
66
|
+
const date = value instanceof Date ? value : value ? new Date(value) : new Date();
|
|
67
|
+
if (Number.isNaN(date.getTime())) throw bad("createdAt");
|
|
68
|
+
return date.toISOString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A new 12-word key. The caller stores it; this function keeps nothing. */
|
|
72
|
+
export function createRecoveryKey({ createdAt } = {}) {
|
|
73
|
+
const mnemonic = generateMnemonic(wordlist, 128);
|
|
74
|
+
return recoveryKeyOf(mnemonic, createdAt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A key imported from words. Validation happens here, once. */
|
|
78
|
+
export function importRecoveryKey(mnemonic, { createdAt } = {}) {
|
|
79
|
+
return recoveryKeyOf(mnemonic, createdAt);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function recoveryKeyOf(mnemonic, createdAt) {
|
|
83
|
+
const words = normalizeWords(mnemonic);
|
|
84
|
+
return {
|
|
85
|
+
version: RECOVERY_KEY_VERSION,
|
|
86
|
+
mnemonic: words,
|
|
87
|
+
fingerprint: fingerprintOf(words),
|
|
88
|
+
createdAt: createdAtOf(createdAt),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The first four hex characters of the key's hash: a name a person can compare, not a secret. */
|
|
93
|
+
export function fingerprintOf(mnemonic) {
|
|
94
|
+
const words = typeof mnemonic === "string" ? mnemonic.trim().toLowerCase().replace(/\s+/g, " ") : "";
|
|
95
|
+
if (!words) throw bad("mnemonic");
|
|
96
|
+
return Array.from(keccak256(te.encode(words)), (b) => b.toString(16).padStart(2, "0")).join("").slice(0, 4);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function seedOf(mnemonic) {
|
|
100
|
+
// BIP39 defines the seed; no wallet password is used here. A wallet password is a DIFFERENT feature and would
|
|
101
|
+
// silently produce different halves.
|
|
102
|
+
return mnemonicToSeedSync(normalizeWords(mnemonic));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function numberField(value, field) {
|
|
106
|
+
const n = Number(value);
|
|
107
|
+
if (!Number.isSafeInteger(n) || n < 0) throw bad(field);
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The binding a swap number is derived from. The chain id keeps testnet and mainnet keys apart. */
|
|
112
|
+
export function recoveryBinding(chainId, index) {
|
|
113
|
+
return `${RECOVERY_KEY_DOMAIN}|${numberField(chainId, "chainId")}|${numberField(index, "index")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The taker's two halves for swap number `index`.
|
|
118
|
+
*
|
|
119
|
+
* The halves are returned as 0x-prefixed scalars (64 hex digits), not BigInt: every crossing of JSON in this
|
|
120
|
+
* package is a hex string, and a BigInt that reaches JSON.stringify fails the whole record.
|
|
121
|
+
*/
|
|
122
|
+
export function takerHalves({ mnemonic, chainId, index }) {
|
|
123
|
+
const binding = recoveryBinding(chainId, index);
|
|
124
|
+
const seed = seedOf(mnemonic);
|
|
125
|
+
const spendHalf = halves.halfFromSeed(seed, binding, halves.HALF_DOMAIN);
|
|
126
|
+
const viewHalf = halves.halfFromSeed(seed, binding, halves.VIEW_HALF_DOMAIN);
|
|
127
|
+
// THE OTHER SIDE'S VIEW HALF. The same key, a third domain: the taker gives this number to the maker, so the
|
|
128
|
+
// maker's view half no longer comes from a seed the taker cannot reproduce.
|
|
129
|
+
const makerViewHalf = halves.halfFromSeed(seed, binding, MAKER_VIEW_HALF_DOMAIN);
|
|
130
|
+
const spend = halves.publicHalves(spendHalf);
|
|
131
|
+
const view = halves.publicHalves(viewHalf);
|
|
132
|
+
const makerView = halves.publicHalves(makerViewHalf);
|
|
133
|
+
return {
|
|
134
|
+
index: numberField(index, "index"),
|
|
135
|
+
binding,
|
|
136
|
+
spendHalf: hex32(spendHalf),
|
|
137
|
+
viewHalf: hex32(viewHalf),
|
|
138
|
+
spendPoint: spend.ed,
|
|
139
|
+
viewPoint: view.ed,
|
|
140
|
+
makerViewHalf: hex32(makerViewHalf),
|
|
141
|
+
makerViewPoint: makerView.ed,
|
|
142
|
+
spendCommitment: halfCommitmentHex(spendHalf, secp256k1, keccak256),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------------------------------
|
|
147
|
+
// THE BACKUP FILE
|
|
148
|
+
// ---------------------------------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const stampOf = (createdAt) => createdAtOf(createdAt).slice(0, 10);
|
|
151
|
+
|
|
152
|
+
/** The stable name a person can search for. `DO-NOT-DELETE` is part of the name on purpose. */
|
|
153
|
+
export function recoveryKeyFileName({ mnemonic, fingerprint, createdAt } = {}) {
|
|
154
|
+
const fp = fingerprint || fingerprintOf(mnemonic);
|
|
155
|
+
return `ninsei-recovery-key-DO-NOT-DELETE-${stampOf(createdAt)}-${fp}.txt`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function headerOf({ fingerprint, createdAt }) {
|
|
159
|
+
return [
|
|
160
|
+
RECOVERY_KEY_FILE_MAGIC,
|
|
161
|
+
"This file restores every swap made with this key.",
|
|
162
|
+
"Restore at https://recovery.ninsei.finance",
|
|
163
|
+
"Never share it with anyone, not even Ninsei.",
|
|
164
|
+
"This is not a wallet seed. Do not import it into a wallet.",
|
|
165
|
+
`Key ${fingerprint}, created ${stampOf(createdAt)}`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The plain file: a readable header above the words. */
|
|
170
|
+
export function recoveryKeyText({ mnemonic, createdAt } = {}) {
|
|
171
|
+
const key = recoveryKeyOf(mnemonic, createdAt);
|
|
172
|
+
return `${headerOf(key)}\n\n${key.mnemonic}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function deriveKey(passphrase, salt, iter) {
|
|
176
|
+
if (!globalThis.crypto || !globalThis.crypto.subtle) throw failed("crypto-unavailable");
|
|
177
|
+
const material = await crypto.subtle.importKey("raw", te.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
|
|
178
|
+
return crypto.subtle.deriveKey(
|
|
179
|
+
{ name: "PBKDF2", salt, iterations: iter, hash: "SHA-256" },
|
|
180
|
+
material,
|
|
181
|
+
{ name: "AES-GCM", length: 256 },
|
|
182
|
+
false,
|
|
183
|
+
["encrypt", "decrypt"]
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const aadOf = (header) => JSON.stringify({
|
|
188
|
+
kind: "ninsei-recovery-key",
|
|
189
|
+
v: header.v,
|
|
190
|
+
alg: header.alg,
|
|
191
|
+
iter: header.iter,
|
|
192
|
+
salt: header.salt,
|
|
193
|
+
iv: header.iv,
|
|
194
|
+
meta: header.meta,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The backup file, optionally protected by a password. An empty password is a choice, not an omission: the
|
|
199
|
+
* file then says so in plain text, as the unencrypted recovery envelope does.
|
|
200
|
+
*/
|
|
201
|
+
export async function createRecoveryKeyFile({ mnemonic, createdAt, passphrase = "" } = {}) {
|
|
202
|
+
const key = recoveryKeyOf(mnemonic, createdAt);
|
|
203
|
+
if (typeof passphrase !== "string") throw bad("passphrase");
|
|
204
|
+
if (passphrase === "") return recoveryKeyText(key);
|
|
205
|
+
if (passphrase.length < 12) throw bad("passphrase");
|
|
206
|
+
if (!globalThis.crypto || !globalThis.crypto.subtle) throw failed("crypto-unavailable");
|
|
207
|
+
|
|
208
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
209
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
210
|
+
const header = {
|
|
211
|
+
v: RECOVERY_KEY_ENVELOPE_VERSION,
|
|
212
|
+
alg: RECOVERY_KEY_ALG,
|
|
213
|
+
iter: RECOVERY_KEY_ITER,
|
|
214
|
+
salt: b64(salt),
|
|
215
|
+
iv: b64(iv),
|
|
216
|
+
meta: { fingerprint: key.fingerprint, createdAt: key.createdAt },
|
|
217
|
+
};
|
|
218
|
+
const aes = await deriveKey(passphrase, salt, RECOVERY_KEY_ITER);
|
|
219
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
220
|
+
{ name: "AES-GCM", iv, tagLength: 128, additionalData: te.encode(aadOf(header)) },
|
|
221
|
+
aes,
|
|
222
|
+
te.encode(JSON.stringify({ mnemonic: key.mnemonic }))
|
|
223
|
+
);
|
|
224
|
+
return JSON.stringify({ ...header, ciphertext: b64(new Uint8Array(ciphertext)) }, null, 2);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Read a plain or an encrypted key file. The returned key is always validated. */
|
|
228
|
+
export async function openRecoveryKeyFile(contents, passphrase = "") {
|
|
229
|
+
if (typeof contents !== "string" || !contents.trim()) throw bad("file");
|
|
230
|
+
if (typeof passphrase !== "string") throw bad("passphrase");
|
|
231
|
+
const text = contents.trim();
|
|
232
|
+
|
|
233
|
+
if (!text.startsWith("{")) {
|
|
234
|
+
// THE PLAIN FILE. The header is checked by its first line, not by a loose search: a random text file must
|
|
235
|
+
// not be mistaken for a key.
|
|
236
|
+
const lines = text.split(/\r?\n/);
|
|
237
|
+
if (lines[0].trim() !== RECOVERY_KEY_FILE_MAGIC) throw failed("parse");
|
|
238
|
+
const words = lines.slice(1).map((line) => line.trim()).find((line) => line && validateMnemonic(line.toLowerCase(), wordlist));
|
|
239
|
+
if (!words) throw failed("parse");
|
|
240
|
+
return recoveryKeyOf(words);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let envelope;
|
|
244
|
+
try {
|
|
245
|
+
envelope = JSON.parse(text);
|
|
246
|
+
} catch {
|
|
247
|
+
throw failed("parse");
|
|
248
|
+
}
|
|
249
|
+
if (!envelope || envelope.v !== RECOVERY_KEY_ENVELOPE_VERSION || envelope.alg !== RECOVERY_KEY_ALG) throw failed("parse");
|
|
250
|
+
for (const field of ["salt", "iv", "ciphertext"]) if (typeof envelope[field] !== "string" || !envelope[field]) throw failed("parse");
|
|
251
|
+
const iter = Number(envelope.iter);
|
|
252
|
+
if (!Number.isInteger(iter) || iter < ITER_MIN || iter > ITER_MAX) throw failed("parse");
|
|
253
|
+
if (passphrase.length < 12) throw failed("passphrase");
|
|
254
|
+
|
|
255
|
+
let payload;
|
|
256
|
+
try {
|
|
257
|
+
const aes = await deriveKey(passphrase, unb64(envelope.salt), iter);
|
|
258
|
+
const plain = await crypto.subtle.decrypt(
|
|
259
|
+
{ name: "AES-GCM", iv: unb64(envelope.iv), tagLength: 128, additionalData: te.encode(aadOf(envelope)) },
|
|
260
|
+
aes,
|
|
261
|
+
unb64(envelope.ciphertext)
|
|
262
|
+
);
|
|
263
|
+
payload = JSON.parse(td.decode(plain));
|
|
264
|
+
} catch {
|
|
265
|
+
throw failed("decrypt");
|
|
266
|
+
}
|
|
267
|
+
const key = recoveryKeyOf(payload && payload.mnemonic);
|
|
268
|
+
if (envelope.meta && envelope.meta.fingerprint && envelope.meta.fingerprint !== key.fingerprint) throw failed("fingerprint");
|
|
269
|
+
return key;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------------------------------
|
|
273
|
+
// RESERVATION
|
|
274
|
+
// ---------------------------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const readStore = (storage) => {
|
|
277
|
+
if (!storage || typeof storage.get !== "function" || typeof storage.set !== "function" || typeof storage.remove !== "function") throw bad("storage");
|
|
278
|
+
const raw = storage.get(RECOVERY_KEY_STORE);
|
|
279
|
+
if (!raw) return null;
|
|
280
|
+
try {
|
|
281
|
+
const value = JSON.parse(raw);
|
|
282
|
+
if (!value || value.version !== RECOVERY_KEY_VERSION || typeof value.mnemonic !== "string") return null;
|
|
283
|
+
return value;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Save the key next to the swap records. The words are the secret; the store is the caller's. */
|
|
290
|
+
export function saveRecoveryKey(storage, key) {
|
|
291
|
+
const checked = recoveryKeyOf(key && key.mnemonic, key && key.createdAt);
|
|
292
|
+
storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...checked, nextIndex: Number(key.nextIndex) || 0, reserved: key.reserved || {}, retired: key.retired || {} }));
|
|
293
|
+
return checked;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function loadRecoveryKey(storage) {
|
|
297
|
+
const value = readStore(storage);
|
|
298
|
+
if (!value) return null;
|
|
299
|
+
return { ...value, fingerprint: value.fingerprint || fingerprintOf(value.mnemonic) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Reserve a number for a request. A reservation is not a spent number: if the request is abandoned, the caller
|
|
304
|
+
* releases it and the same number is used again. The chain check belongs to the caller: a number is retired only
|
|
305
|
+
* when an order with its commitment exists.
|
|
306
|
+
*/
|
|
307
|
+
export function reserveRecoveryIndex(storage, { chainId, swapId = null } = {}) {
|
|
308
|
+
const key = loadRecoveryKey(storage);
|
|
309
|
+
if (!key) throw failed("key-missing");
|
|
310
|
+
const chain = String(numberField(chainId, "chainId"));
|
|
311
|
+
const reserved = { ...(key.reserved || {}) };
|
|
312
|
+
const retired = { ...(key.retired || {}) };
|
|
313
|
+
const index = Number(key.nextIndex) || 0;
|
|
314
|
+
// Holes left by RELEASED numbers are reused; a retired number was seen on chain and is never reused.
|
|
315
|
+
for (let i = 0; i < index; i += 1) {
|
|
316
|
+
if (!Object.prototype.hasOwnProperty.call(reserved, i) && !Object.prototype.hasOwnProperty.call(retired, i)) {
|
|
317
|
+
reserved[i] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
318
|
+
storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved, retired }));
|
|
319
|
+
return i;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (index >= 100000) throw failed("index-exhausted");
|
|
323
|
+
reserved[index] = { chainId: chain, swapId, at: new Date().toISOString() };
|
|
324
|
+
storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, nextIndex: index + 1, reserved, retired }));
|
|
325
|
+
return index;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function releaseRecoveryIndex(storage, index) {
|
|
329
|
+
const key = loadRecoveryKey(storage);
|
|
330
|
+
if (!key) throw failed("key-missing");
|
|
331
|
+
const reserved = { ...(key.reserved || {}) };
|
|
332
|
+
delete reserved[String(numberField(index, "index"))];
|
|
333
|
+
storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved }));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Retire a number after its order exists on chain: it is never reused, so a later scan has no collision. */
|
|
337
|
+
export function commitRecoveryIndex(storage, index) {
|
|
338
|
+
const key = loadRecoveryKey(storage);
|
|
339
|
+
if (!key) throw failed("key-missing");
|
|
340
|
+
const reserved = { ...(key.reserved || {}) };
|
|
341
|
+
const retired = { ...(key.retired || {}) };
|
|
342
|
+
const n = numberField(index, "index");
|
|
343
|
+
delete reserved[String(n)];
|
|
344
|
+
retired[String(n)] = true;
|
|
345
|
+
storage.set(RECOVERY_KEY_STORE, JSON.stringify({ ...key, reserved, retired }));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------------------------------
|
|
349
|
+
// THE SEARCH
|
|
350
|
+
// ---------------------------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
353
|
+
|
|
354
|
+
function topicAddress(address) {
|
|
355
|
+
if (typeof address !== "string" || !ADDRESS_RE.test(address)) throw bad("address");
|
|
356
|
+
return pad32(address);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function decodeOrderLog(log) {
|
|
360
|
+
const topics = Array.isArray(log && log.topics) ? log.topics : [];
|
|
361
|
+
if (topics.length < 4 || String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) return null;
|
|
362
|
+
const data = String(log.data || "").replace(/^0x/, "");
|
|
363
|
+
if (data.length < 64 * 2) return null;
|
|
364
|
+
const word = (i) => "0x" + data.slice(i * 64, (i + 1) * 64).toLowerCase();
|
|
365
|
+
return {
|
|
366
|
+
escrow: "0x" + String(topics[1]).slice(26).toLowerCase(),
|
|
367
|
+
locker: "0x" + String(topics[2]).slice(26).toLowerCase(),
|
|
368
|
+
claimer: "0x" + String(topics[3]).slice(26).toLowerCase(),
|
|
369
|
+
commitHalfLocker: word(0),
|
|
370
|
+
commitHalfClaimer: word(1),
|
|
371
|
+
edPointLocker: word(2),
|
|
372
|
+
edPointClaimer: word(3),
|
|
373
|
+
edViewPointLocker: word(4),
|
|
374
|
+
amount: BigInt(word(5)).toString(),
|
|
375
|
+
readyBy: Number(BigInt(word(6))),
|
|
376
|
+
t1: Number(BigInt(word(7))),
|
|
377
|
+
salt: word(8),
|
|
378
|
+
registry: "0x" + data.slice(9 * 64 + 24, 10 * 64),
|
|
379
|
+
provider: "0x" + data.slice(10 * 64 + 24, 11 * 64),
|
|
380
|
+
quoteKey: "0x" + data.slice(11 * 64 + 24, 12 * 64),
|
|
381
|
+
transactionHash: log.transactionHash || null,
|
|
382
|
+
blockNumber: log.blockNumber != null ? Number(log.blockNumber) : null,
|
|
383
|
+
logIndex: log.logIndex != null ? Number(log.logIndex) : null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readLogs(evmLogs, request) {
|
|
388
|
+
if (typeof evmLogs !== "function") throw bad("evmLogs");
|
|
389
|
+
let logs;
|
|
390
|
+
try {
|
|
391
|
+
logs = await evmLogs(request);
|
|
392
|
+
} catch {
|
|
393
|
+
throw failed("logs");
|
|
394
|
+
}
|
|
395
|
+
if (!Array.isArray(logs)) throw failed("logs");
|
|
396
|
+
return logs;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Find orders made with this key.
|
|
401
|
+
*
|
|
402
|
+
* `evmLogs` is the caller's read seam: ({ address, topics, fromBlock, toBlock }) -> logs. With an address, the
|
|
403
|
+
* search asks for it as the locker and as the claimer (two queries; the RPC topic filter cannot say OR across two
|
|
404
|
+
* positions) and merges the answers. Without one, every OrderCreated of the factory is read and matched locally.
|
|
405
|
+
*/
|
|
406
|
+
export async function findSwaps({ mnemonic, chainId, factory, address = null, evmLogs, fromBlock = 0, toBlock = "latest", gap = 5, maxIndex = 1000 } = {}) {
|
|
407
|
+
if (typeof factory !== "string" || !ADDRESS_RE.test(factory)) throw bad("factory");
|
|
408
|
+
const chain = numberField(chainId, "chainId");
|
|
409
|
+
const max = numberField(maxIndex, "maxIndex") || 1000;
|
|
410
|
+
const missesAllowed = numberField(gap, "gap") || 5;
|
|
411
|
+
const base = { address: factory, fromBlock, toBlock };
|
|
412
|
+
let logs = [];
|
|
413
|
+
if (address) {
|
|
414
|
+
const padded = topicAddress(address);
|
|
415
|
+
const [asLocker, asClaimer] = await Promise.all([
|
|
416
|
+
readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, padded, null] }),
|
|
417
|
+
readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC, null, null, padded] }),
|
|
418
|
+
]);
|
|
419
|
+
const seen = new Set();
|
|
420
|
+
for (const log of asLocker.concat(asClaimer)) {
|
|
421
|
+
const id = `${log.transactionHash || ""}:${log.logIndex ?? ""}`;
|
|
422
|
+
if (seen.has(id)) continue;
|
|
423
|
+
seen.add(id);
|
|
424
|
+
logs.push(log);
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
logs = await readLogs(evmLogs, { ...base, topics: [ORDER_CREATED_TOPIC] });
|
|
428
|
+
}
|
|
429
|
+
const orders = logs.map(decodeOrderLog).filter(Boolean);
|
|
430
|
+
const found = [];
|
|
431
|
+
let misses = 0;
|
|
432
|
+
let scanned = 0;
|
|
433
|
+
for (let index = 0; index < max && misses < missesAllowed; index += 1) {
|
|
434
|
+
scanned = index + 1;
|
|
435
|
+
const own = takerHalves({ mnemonic, chainId: chain, index });
|
|
436
|
+
const matches = orders.filter((order) =>
|
|
437
|
+
order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ||
|
|
438
|
+
order.commitHalfClaimer.toLowerCase() === own.spendCommitment.toLowerCase()
|
|
439
|
+
);
|
|
440
|
+
if (matches.length === 0) {
|
|
441
|
+
misses += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
misses = 0;
|
|
445
|
+
for (const order of matches) {
|
|
446
|
+
found.push({ ...order, index, side: order.commitHalfLocker.toLowerCase() === own.spendCommitment.toLowerCase() ? "locker" : "claimer" });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return { swaps: found, scanned };
|
|
450
|
+
}
|
package/src/swaps.mjs
CHANGED
|
@@ -12,7 +12,7 @@ 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";
|
|
@@ -23,6 +23,11 @@ import { primitives } from "./primitives.mjs";
|
|
|
23
23
|
// the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
|
|
24
24
|
// the XMR" and "we allowed the settlement to be taken" would diverge.
|
|
25
25
|
import { createActions } from "./actions.mjs";
|
|
26
|
+
// THE ONE RECOVERY KEY (sdk#76): the taker's halves come from it, not from a fresh random pair per swap.
|
|
27
|
+
import {
|
|
28
|
+
commitRecoveryIndex, createRecoveryKeyFile, importRecoveryKey, loadRecoveryKey,
|
|
29
|
+
recoveryKeyFileName, reserveRecoveryIndex, saveRecoveryKey, takerHalves,
|
|
30
|
+
} from "./recovery-key.mjs";
|
|
26
31
|
// THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
|
|
27
32
|
import * as xmrArrival from "./xmr-arrival.mjs";
|
|
28
33
|
// THE MAKER'S DECLINE (issue #40): the escrow's `Declined` event and the node's `declined` + `compliance` status
|
|
@@ -705,7 +710,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
705
710
|
async start(request) {
|
|
706
711
|
if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
|
|
707
712
|
if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
|
|
708
|
-
|
|
713
|
+
// THE OLD PATH IS UNCHANGED; THE NEW ONE ASKS FOR A KEY INSTEAD OF A PER-SWAP FILE.
|
|
714
|
+
const wantsKey = request.recoveryKey !== undefined && request.recoveryKey !== null;
|
|
715
|
+
if (!wantsKey && typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
|
|
709
716
|
if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
|
|
710
717
|
// THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
|
|
711
718
|
// quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
|
|
@@ -734,8 +741,9 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
734
741
|
// record, and our side of the order is computed from the core's primitives.
|
|
735
742
|
// THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
|
|
736
743
|
// builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
|
|
744
|
+
let keySide = null;
|
|
737
745
|
const makeRecord = typeof createRecord === "function" ? createRecord
|
|
738
|
-
: async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
|
|
746
|
+
: async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order, side: keySide });
|
|
739
747
|
const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
|
|
740
748
|
if (typeof passphrase !== "string") throw new SdkError("bad-input", { field: "passphrase" });
|
|
741
749
|
if (passphrase.length > 0 && passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
|
|
@@ -744,6 +752,32 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
744
752
|
// values: the DLEQ proof would then be bound to nothing.
|
|
745
753
|
const orderContext = contextOf(request.order);
|
|
746
754
|
|
|
755
|
+
// THE KEY RECORD, THE NUMBER AND THE SIDE. A key is made once and kept through the SDK's store; the
|
|
756
|
+
// number is reserved before the order is built, and it is retired only after the order exists on chain.
|
|
757
|
+
// No per-swap file is assembled on this path.
|
|
758
|
+
let recovery = null;
|
|
759
|
+
if (wantsKey) {
|
|
760
|
+
const storage = engine.store.storage();
|
|
761
|
+
const supplied = typeof request.recoveryKey === "string"
|
|
762
|
+
? request.recoveryKey
|
|
763
|
+
: request.recoveryKey && typeof request.recoveryKey.mnemonic === "string" ? request.recoveryKey.mnemonic : null;
|
|
764
|
+
const saved = loadRecoveryKey(storage);
|
|
765
|
+
if (saved && supplied && saved.mnemonic !== supplied) {
|
|
766
|
+
throw new SdkError("bad-input", { field: "recoveryKey", why: "different-key" });
|
|
767
|
+
}
|
|
768
|
+
if (!saved && !supplied) throw new SdkError("bad-input", { field: "recoveryKey" });
|
|
769
|
+
const key = saved || importRecoveryKey(supplied);
|
|
770
|
+
if (!saved) saveRecoveryKey(storage, key);
|
|
771
|
+
const chainId = request.order.chainId;
|
|
772
|
+
const index = request.recoveryIndex === undefined || request.recoveryIndex === null
|
|
773
|
+
? reserveRecoveryIndex(storage, { chainId })
|
|
774
|
+
: Number(request.recoveryIndex);
|
|
775
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "recoveryIndex" });
|
|
776
|
+
const derived = takerHalves({ mnemonic: key.mnemonic, chainId, index });
|
|
777
|
+
keySide = buildOwnSideFromHalves(request.order, { spendHalf: derived.spendHalf, viewHalf: derived.viewHalf }).side;
|
|
778
|
+
recovery = { storage, key, index, newKey: !saved };
|
|
779
|
+
}
|
|
780
|
+
|
|
747
781
|
let made = null;
|
|
748
782
|
try { made = await makeRecord(request); } catch (error) {
|
|
749
783
|
// THE CODE MATTERS, NOT THE CLASS: an error with a code (including from another instance of the module) passes
|
|
@@ -785,26 +819,42 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
785
819
|
builtSide = step;
|
|
786
820
|
}
|
|
787
821
|
|
|
788
|
-
// 2a. THE
|
|
789
|
-
// file: the
|
|
822
|
+
// 2a. THE KEY IS CONFIRMED ONCE; THE OLD PATH STILL ASSEMBLES A PER-SWAP FILE. On the key path there is
|
|
823
|
+
// no file per swap: the same twelve words cover every swap, and a person who already saved them is not
|
|
824
|
+
// asked again. A new key is shown before the money moves - no saved key, no funding.
|
|
790
825
|
let payload = null;
|
|
791
|
-
try {
|
|
792
|
-
payload = engine.recoveryFile.buildPayload({
|
|
793
|
-
swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
|
|
794
|
-
receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
|
|
795
|
-
});
|
|
796
|
-
engine.recoveryFile.validatePayload(payload);
|
|
797
|
-
} catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
|
|
798
|
-
|
|
799
826
|
let file = null;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
827
|
+
let confirmed = true;
|
|
828
|
+
if (!recovery) {
|
|
829
|
+
// THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
|
|
830
|
+
// file: the person will rely on it. So we check on our side, before the screen.
|
|
831
|
+
try {
|
|
832
|
+
payload = engine.recoveryFile.buildPayload({
|
|
833
|
+
swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
|
|
834
|
+
receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
|
|
835
|
+
});
|
|
836
|
+
engine.recoveryFile.validatePayload(payload);
|
|
837
|
+
} catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
|
|
803
838
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
839
|
+
try {
|
|
840
|
+
file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
|
|
841
|
+
} catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
|
|
842
|
+
|
|
843
|
+
// 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
|
|
844
|
+
// up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
|
|
845
|
+
confirmed = await request.onRecoveryFile(file);
|
|
846
|
+
if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
|
|
847
|
+
} else if (recovery.newKey) {
|
|
848
|
+
if (typeof request.onRecoveryKey !== "function") throw new SdkError("bad-input", { field: "onRecoveryKey" });
|
|
849
|
+
try {
|
|
850
|
+
file = {
|
|
851
|
+
name: recoveryKeyFileName(recovery.key),
|
|
852
|
+
contents: await createRecoveryKeyFile({ mnemonic: recovery.key.mnemonic, createdAt: recovery.key.createdAt, passphrase }),
|
|
853
|
+
};
|
|
854
|
+
} catch { throw new SdkError("recovery-failed", { step: "encrypt-key" }); }
|
|
855
|
+
confirmed = await request.onRecoveryKey(file);
|
|
856
|
+
if (confirmed !== true) throw new SdkError("recovery-declined", { key: recovery.key.fingerprint });
|
|
857
|
+
}
|
|
808
858
|
|
|
809
859
|
// 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
|
|
810
860
|
// 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,
|
|
@@ -864,6 +914,11 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
864
914
|
// THE FUNDING HEIGHT - FROM THE RECEIPT: the XMR watch and the recovery file take it from here.
|
|
865
915
|
birthHeight: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.birthHeight ?? null) };
|
|
866
916
|
try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
|
|
917
|
+
// THE NUMBER IS SPENT ONLY NOW, WHEN THE ORDER EXISTS ON CHAIN. A failed or abandoned start keeps the
|
|
918
|
+
// reservation, so a retry uses the same number and the later search has no gap.
|
|
919
|
+
if (recovery) {
|
|
920
|
+
try { commitRecoveryIndex(recovery.storage, recovery.index); } catch { /* the reservation stays; the key still works */ }
|
|
921
|
+
}
|
|
867
922
|
}
|
|
868
923
|
|
|
869
924
|
// 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
|
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.0";
|