@ninsei-labs/sdk 0.44.2 → 0.45.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 +22 -0
- package/engine/recovery/recoveryFile.js +23 -2
- package/package.json +1 -1
- package/src/index.mjs +1 -1
- package/src/public/recovery.d.ts +22 -4
- package/src/public/recovery.mjs +3 -0
- package/src/recovery.mjs +2 -1
- package/src/swaps.mjs +2 -1
- package/src/sweep-restore.mjs +4 -1
- package/src/sweep.mjs +22 -4
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.45.0 — 2026-10-20
|
|
2
|
+
|
|
3
|
+
THE RECOVERY PATH WORKS WITHOUT THE APP, AND A PASSPHRASE IS A CHOICE.
|
|
4
|
+
|
|
5
|
+
- `sweep.plan` reads the counterparty's revealed half from the escrow with the `evmCall` the caller already
|
|
6
|
+
passes (`revealedHalf()`, selector `0x3f267bb6`). A recovery page served from a static host has no
|
|
7
|
+
`/escrow/revealed-half` route, so asking the app was the only path and the page could never withdraw there.
|
|
8
|
+
The app route remains as the fallback for a caller with no chain read that configured an API base.
|
|
9
|
+
- An empty passphrase writes the recovery file without encryption. The envelope is version 3 with
|
|
10
|
+
`alg: "NONE"` and carries the payload; `decryptFile` returns it without deriving a key, and `isEncrypted`
|
|
11
|
+
tells a page whether to ask for a passphrase. A non-empty passphrase keeps the twelve character floor.
|
|
12
|
+
- `swaps.start`, `sweep.plan`, `sweep.destination` and `recovery.restoreHeight` accept an empty passphrase.
|
|
13
|
+
|
|
14
|
+
## 0.44.3 — 2026-10-20
|
|
15
|
+
|
|
16
|
+
A RESCAN THAT THE LIBRARY REFUSES DOES NOT STOP THE RESTORE.
|
|
17
|
+
|
|
18
|
+
- `sweep.restore` still asks for the rescan first. If the wallet library refuses it, the pass continues to the
|
|
19
|
+
sync and the balance, exactly as a failed sync already did. A wallet opened from keys carries its height from
|
|
20
|
+
creation, so a library that cannot rescan a chain can still serve the withdrawal. The spendable balance stays
|
|
21
|
+
the verdict, not the rescan.
|
|
22
|
+
|
|
1
23
|
## 0.44.2 — 2026-10-20
|
|
2
24
|
|
|
3
25
|
REGTEST HAS A NAME THE PACKAGE KNOWS.
|
|
@@ -78,7 +78,12 @@ export const FILE_MAGIC = "NINSEI-SWAP-RECOVERY";
|
|
|
78
78
|
// BOTH must be readable: people hold version-1 files and they must not be lost. The version is raised when the
|
|
79
79
|
// ENVELOPE itself changes, not the payload inside.
|
|
80
80
|
export const ENVELOPE_VERSION = 2;
|
|
81
|
-
|
|
81
|
+
// v3 - NO ENCRYPTION. An empty passphrase writes the payload in the clear and the envelope says so. It is a
|
|
82
|
+
// deliberate choice by the owner: a forgotten passphrase locks a person out of their own money, and the file is
|
|
83
|
+
// still the only way to the XMR. Readers of every version must keep working.
|
|
84
|
+
export const UNENCRYPTED_ENVELOPE_VERSION = 3;
|
|
85
|
+
export const ALG_NONE = "NONE";
|
|
86
|
+
const READABLE_ENVELOPE_VERSIONS = [1, 2, 3];
|
|
82
87
|
export const PAYLOAD_VERSION = 3;
|
|
83
88
|
// v1 - the very first files (seedHex and both keys as separate fields), v2 - the mnemonic as the single source,
|
|
84
89
|
// v3 - HALVES. All three must be readable: people hold files of every version, and a recovery file is the only way
|
|
@@ -264,7 +269,13 @@ export function validatePayload(payload) {
|
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
export async function encryptPayload(payload, passphrase) {
|
|
267
|
-
|
|
272
|
+
const metaOf = () => ({ id: payload.swapId, side: payload.side, createdAt: payload.createdAt, termsHash: payload.termsHash || null });
|
|
273
|
+
// AN EMPTY PASSPHRASE MEANS NO ENCRYPTION, AND THE ENVELOPE SAYS SO. A provided one keeps the twelve character
|
|
274
|
+
// floor: a short password is worse than none because it looks like protection.
|
|
275
|
+
if (passphrase === "") {
|
|
276
|
+
return { v: UNENCRYPTED_ENVELOPE_VERSION, alg: ALG_NONE, meta: metaOf(), payload };
|
|
277
|
+
}
|
|
278
|
+
if (typeof passphrase !== "string" || passphrase.length < 12) throw new Error("Passphrase must be at least 12 characters");
|
|
268
279
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
269
280
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
270
281
|
const key = await deriveKey(passphrase, salt, ITER);
|
|
@@ -282,9 +293,19 @@ export async function encryptPayload(payload, passphrase) {
|
|
|
282
293
|
// Decryption. AES-GCM errors (wrong password, altered ciphertext) arrive as OperationError with no detail - we
|
|
283
294
|
// turn them into a human message, else the user sees "operation failed" and cannot tell a wrong password from a
|
|
284
295
|
// corrupt file.
|
|
296
|
+
// WHETHER A FILE ASKS FOR A PASSPHRASE. A page reads this before showing the field: an unencrypted file has none.
|
|
297
|
+
export function isEncrypted(fileObject) {
|
|
298
|
+
return !!(fileObject && typeof fileObject === "object" && fileObject.alg !== ALG_NONE);
|
|
299
|
+
}
|
|
300
|
+
|
|
285
301
|
export async function decryptFile(fileObject, passphrase) {
|
|
286
302
|
if (!fileObject || typeof fileObject !== "object") throw new Error("Not a recovery file (expecting JSON)");
|
|
287
303
|
if (!READABLE_ENVELOPE_VERSIONS.includes(fileObject.v)) throw new Error(`Unsupported recovery file version: ${fileObject.v}`);
|
|
304
|
+
if (fileObject.v === UNENCRYPTED_ENVELOPE_VERSION) {
|
|
305
|
+
if (fileObject.alg !== ALG_NONE) throw new Error(`Unknown algorithm: ${fileObject.alg}`);
|
|
306
|
+
if (!fileObject.payload || typeof fileObject.payload !== "object") throw new Error('Recovery file is missing "payload"');
|
|
307
|
+
return fileObject.payload;
|
|
308
|
+
}
|
|
288
309
|
if (fileObject.alg !== ALG_ID) throw new Error(`Unknown algorithm: ${fileObject.alg}`);
|
|
289
310
|
for (const field of ["salt", "iv", "ciphertext"]) {
|
|
290
311
|
if (typeof fileObject[field] !== "string" || !fileObject[field]) throw new Error(`Recovery file is missing "${field}"`);
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -284,7 +284,7 @@ export function createNinsei(options) {
|
|
|
284
284
|
createRecord: o.createRecord }),
|
|
285
285
|
// XMR WITHDRAWAL: parsing the file and requesting data work; the withdrawal itself is named (the Monero
|
|
286
286
|
// wallet as an adapter).
|
|
287
|
-
sweep: createSweep({ http, config }),
|
|
287
|
+
sweep: createSweep({ http, config, call: o.evmCall }),
|
|
288
288
|
});
|
|
289
289
|
}
|
|
290
290
|
|
package/src/public/recovery.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export declare const ENVELOPE_VERSION: number;
|
|
|
19
19
|
/** The payload version (deal content), raised independently of the envelope: 1 keys as fields, 2 the mnemonic, 3 halves. */
|
|
20
20
|
export declare const PAYLOAD_VERSION: number;
|
|
21
21
|
|
|
22
|
+
/** The envelope version of a file written WITHOUT a passphrase: the payload travels in the clear and the header says so. */
|
|
23
|
+
export declare const UNENCRYPTED_ENVELOPE_VERSION: number;
|
|
24
|
+
|
|
25
|
+
/** The algorithm identifier of an unencrypted envelope. */
|
|
26
|
+
export declare const ALG_NONE: string;
|
|
27
|
+
|
|
22
28
|
/** The mark every Ninsei recovery file carries in its payload, so a foreign file is refused by name. */
|
|
23
29
|
export declare const FILE_MAGIC: string;
|
|
24
30
|
|
|
@@ -47,7 +53,7 @@ export interface RecoveryEnvelopeHeader {
|
|
|
47
53
|
readonly iv: string;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
/**
|
|
56
|
+
/** An encrypted recovery file object as it is written (`ninsei-<id>.json.enc`) and read back. */
|
|
51
57
|
export interface RecoveryEnvelope extends RecoveryEnvelopeHeader {
|
|
52
58
|
/** The AES-GCM ciphertext of the payload, base64. */
|
|
53
59
|
readonly ciphertext: string;
|
|
@@ -55,6 +61,14 @@ export interface RecoveryEnvelope extends RecoveryEnvelopeHeader {
|
|
|
55
61
|
readonly meta?: RecoveryEnvelopeMeta | null;
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** A recovery file written WITHOUT a passphrase: the payload is readable, the envelope says so, and no key is derived. */
|
|
65
|
+
export interface RecoveryPlainEnvelope {
|
|
66
|
+
readonly v: number;
|
|
67
|
+
readonly alg: string;
|
|
68
|
+
readonly meta?: RecoveryEnvelopeMeta | null;
|
|
69
|
+
readonly payload: RecoveryPayload;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
/**
|
|
59
73
|
* The AAD string: the header fields, in one FIXED order, that AES-GCM authenticates beside the ciphertext.
|
|
60
74
|
*
|
|
@@ -221,10 +235,14 @@ export interface RecoveryValidation {
|
|
|
221
235
|
export declare function validatePayload(payload: RecoveryPayload): RecoveryValidation;
|
|
222
236
|
|
|
223
237
|
/**
|
|
224
|
-
*
|
|
225
|
-
* the header
|
|
238
|
+
* Writes one payload into a recovery file envelope. A non-empty passphrase derives a key from a fresh salt and IV
|
|
239
|
+
* and authenticates the header as AAD; a passphrase shorter than 12 characters is refused. An EMPTY passphrase
|
|
240
|
+
* writes the payload in the clear, with the envelope naming that fact.
|
|
226
241
|
*/
|
|
227
|
-
export declare function encryptPayload(payload: RecoveryPayload, passphrase: string): Promise<RecoveryEnvelope>;
|
|
242
|
+
export declare function encryptPayload(payload: RecoveryPayload, passphrase: string): Promise<RecoveryEnvelope | RecoveryPlainEnvelope>;
|
|
243
|
+
|
|
244
|
+
/** Whether the file asks for a passphrase. False for an unencrypted envelope. */
|
|
245
|
+
export declare function isEncrypted(fileObject: unknown): boolean;
|
|
228
246
|
|
|
229
247
|
/**
|
|
230
248
|
* Decrypts a recovery file: the envelope version, the algorithm, the required fields and the iteration count are
|
package/src/public/recovery.mjs
CHANGED
package/src/recovery.mjs
CHANGED
|
@@ -52,7 +52,8 @@ export function createRecovery({ http = null, config = null } = {}) {
|
|
|
52
52
|
async restoreHeight(request = {}) {
|
|
53
53
|
const contents = contentsOf(request.file);
|
|
54
54
|
if (contents === null) throw new SdkError("bad-input", { field: "file" });
|
|
55
|
-
|
|
55
|
+
// AN EMPTY PASSPHRASE IS A VALID ONE: the file may be unencrypted on purpose.
|
|
56
|
+
if (typeof request.passphrase !== "string") {
|
|
56
57
|
throw new SdkError("bad-input", { field: "passphrase" });
|
|
57
58
|
}
|
|
58
59
|
// THE ENVELOPE IS PARSED HERE: `restoreFromFile` takes the OBJECT, while the file arrives as text. Doing it
|
package/src/swaps.mjs
CHANGED
|
@@ -737,7 +737,8 @@ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNet
|
|
|
737
737
|
const makeRecord = typeof createRecord === "function" ? createRecord
|
|
738
738
|
: async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
|
|
739
739
|
const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
|
|
740
|
-
if (passphrase
|
|
740
|
+
if (typeof passphrase !== "string") throw new SdkError("bad-input", { field: "passphrase" });
|
|
741
|
+
if (passphrase.length > 0 && passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
|
|
741
742
|
// THE ORDER TERMS ARE CHECKED BEFORE THE RECORD. The context is assembled by the engine from the very fields the
|
|
742
743
|
// firm quote's signature is bound to; incomplete terms - a refusal with a named field, not a substitution of empty
|
|
743
744
|
// values: the DLEQ proof would then be bound to nothing.
|
package/src/sweep-restore.mjs
CHANGED
|
@@ -92,7 +92,10 @@ export async function restoreWallet(request = {}) {
|
|
|
92
92
|
// A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
|
|
93
93
|
// and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
|
|
94
94
|
if (typeof driver.rescanBlockchain !== "function") throw new SdkError("bad-input", { field: "wallet.driver.rescanBlockchain" });
|
|
95
|
-
|
|
95
|
+
// A RESCAN IS BEST-EFFORT, LIKE THE SYNC BELOW. A wallet built from keys already carries the height it was opened
|
|
96
|
+
// with, and a library that cannot rescan a chain (monero-ts against a regtest node answers "failed to get hashes")
|
|
97
|
+
// would otherwise stop a withdrawal the sync can still serve. The balance below is the verdict, not the rescan.
|
|
98
|
+
try { await step("rescanBlockchain", () => driver.rescanBlockchain()); } catch { /* the balance decides */ }
|
|
96
99
|
|
|
97
100
|
// A FAILED SYNC IS BEST-EFFORT, exactly as in the signing step: what blocks a withdrawal is the spendable
|
|
98
101
|
// remainder, and it is read below. The library throws low-level while scanning a chain it cannot read; that must
|
package/src/sweep.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { checkDestination } from "./destination.mjs";
|
|
|
14
14
|
// The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
|
|
15
15
|
const MIN_PASSPHRASE = 12;
|
|
16
16
|
const STEPS = ["unlock", "sync", "sweep", "relay"];
|
|
17
|
+
// `revealedHalf()` on the escrow. Public data: the function returns the half the settlement published.
|
|
18
|
+
const REVEALED_HALF_SELECTOR = "0x3f267bb6";
|
|
17
19
|
|
|
18
20
|
const contentsOf = (file) => (typeof file === "string" ? file : file && typeof file.contents === "string" ? file.contents : null);
|
|
19
21
|
|
|
@@ -50,7 +52,7 @@ const feeOf = async (wallet, signed) => {
|
|
|
50
52
|
return /^\d+$/.test(text) ? text : null;
|
|
51
53
|
};
|
|
52
54
|
|
|
53
|
-
export function createSweep({ http, config }) {
|
|
55
|
+
export function createSweep({ http, config, call = null }) {
|
|
54
56
|
// THE NAME IS NEEDED BY THE OBJECT ITSELF: `run` calls `api.plan(...)` - the step order is held by the plan and
|
|
55
57
|
// the withdrawal living in one object, not in two copies. During a manual merge of branches the object was left
|
|
56
58
|
// unnamed, and the withdrawal failed with "api is not defined": not a refusal, but a breakdown. `node --check`
|
|
@@ -96,7 +98,7 @@ export function createSweep({ http, config }) {
|
|
|
96
98
|
async destination(request = {}) {
|
|
97
99
|
const contents = contentsOf(request.file);
|
|
98
100
|
if (!contents) throw new SdkError("bad-input", { field: "file" });
|
|
99
|
-
if (
|
|
101
|
+
if (typeof request.passphrase !== "string") throw new SdkError("bad-input", { field: "passphrase" });
|
|
100
102
|
if (typeof request.dest !== "string" || !request.dest.trim()) throw new SdkError("bad-input", { field: "dest" });
|
|
101
103
|
let payload = null;
|
|
102
104
|
try { payload = await engine.recoveryFile.decryptFile(JSON.parse(contents), request.passphrase); }
|
|
@@ -110,7 +112,8 @@ export function createSweep({ http, config }) {
|
|
|
110
112
|
if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
|
|
111
113
|
const contents = contentsOf(request.file);
|
|
112
114
|
if (!contents) throw new SdkError("bad-input", { field: "file" });
|
|
113
|
-
|
|
115
|
+
// EMPTY IS ALLOWED: it opens an unencrypted file. A non-empty one keeps the floor.
|
|
116
|
+
if (typeof request.passphrase !== "string" || (request.passphrase.length > 0 && request.passphrase.length < MIN_PASSPHRASE)) {
|
|
114
117
|
throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
|
|
115
118
|
}
|
|
116
119
|
|
|
@@ -150,10 +153,25 @@ export function createSweep({ http, config }) {
|
|
|
150
153
|
// the named path, body { network, address }, where address is the ESCROW ADDRESS (not the Monero address).
|
|
151
154
|
// The half is published by the settlement that moves the ETH: a claim or a refund. Before that no one has the
|
|
152
155
|
// spend key, so "not revealed yet" is not an error but a state, and that is exactly where it lands in `missing`.
|
|
156
|
+
// THE COUNTERPARTY'S REVEALED HALF - FROM THE CHAIN WHEN THE CALLER HAS A CHAIN READ. The half is public:
|
|
157
|
+
// `claim` and `refund` store it in `revealedHalf()` and emit `HalfRevealed`. A recovery page served from a
|
|
158
|
+
// static host has no `/escrow/revealed-half` route, so asking the app was the only path and a static page
|
|
159
|
+
// could never withdraw. The app route stays as the fallback for a caller that has no chain read and did
|
|
160
|
+
// configure an API base.
|
|
153
161
|
let revealed = false;
|
|
154
162
|
let revealedValue = null;
|
|
155
163
|
const escrowAddress = wallet.escrowAddress || (payload.escrow && payload.escrow.address) || null;
|
|
156
|
-
if (escrowAddress) {
|
|
164
|
+
if (escrowAddress && typeof call === "function") {
|
|
165
|
+
try {
|
|
166
|
+
const word = String((await call({ to: escrowAddress, data: REVEALED_HALF_SELECTOR })) || "");
|
|
167
|
+
const hex = word.startsWith("0x") ? word.slice(2) : word;
|
|
168
|
+
if (/^[0-9a-fA-F]{64}$/.test(hex) && !/^0+$/.test(hex)) {
|
|
169
|
+
revealed = true;
|
|
170
|
+
revealedValue = "0x" + hex.slice(24);
|
|
171
|
+
}
|
|
172
|
+
} catch { revealed = false; }
|
|
173
|
+
}
|
|
174
|
+
if (!revealed && escrowAddress && config && config.apiBase) {
|
|
157
175
|
try {
|
|
158
176
|
const half = await http.json(config.route("revealedHalf"), {
|
|
159
177
|
method: "POST",
|
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.45.0";
|