@ninsei-labs/sdk 0.46.2 → 0.47.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 +16 -0
- package/package.json +4 -2
- package/src/public/recovery.d.ts +117 -0
- package/src/public/recovery.mjs +28 -0
- package/src/recovery-key.mjs +450 -0
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 0.47.0 — 2026-10-21
|
|
2
|
+
|
|
3
|
+
ONE RECOVERY KEY, AND THE NODE'S VIEW HALF COMES FROM IT.
|
|
4
|
+
|
|
5
|
+
- `@ninsei-labs/sdk/recovery` exposes a BIP39 12-word taker key: creation, import, fingerprint, the
|
|
6
|
+
`ninsei-recovery-key-DO-NOT-DELETE-...` backup file (plain or AES-GCM/PBKDF2), and a storage adapter for the key.
|
|
7
|
+
- `takerHalves({ mnemonic, chainId, index })` derives the taker spend/view halves with
|
|
8
|
+
`ninsei-taker-v1|<chainId>|<index>`, and the counterparty view half with `ninsei-maker-view-v1`. The taker sends
|
|
9
|
+
the latter to the node (`viewHalfClaimer` on a direct quote, `viewHalfLocker` on a reverse ticket); the node
|
|
10
|
+
accepts it and keeps the joint view key.
|
|
11
|
+
- `findSwaps({ mnemonic, chainId, factory, address?, evmLogs, ... })` reads `OrderCreated`, filters by the indexed
|
|
12
|
+
locker/claimer when an address is given, matches `commitHalfLocker`/`commitHalfClaimer` locally, and stops after
|
|
13
|
+
five numbers in a row with no order.
|
|
14
|
+
- Numbers are reserved in the store: released numbers are reused, numbers seen on chain are retired.
|
|
15
|
+
- The current per-swap recovery file and `swaps.start` are unchanged. The switch is a separate change.
|
|
16
|
+
|
|
1
17
|
## 0.46.2 — 2026-10-21
|
|
2
18
|
|
|
3
19
|
THE TESTNET CONFIG NAMES THE CLAIMTIP DEPLOYMENT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninsei-labs/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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/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";
|
|
@@ -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/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.47.0";
|