@ninsei-labs/sdk 0.44.3 → 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 CHANGED
@@ -1,3 +1,16 @@
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
+
1
14
  ## 0.44.3 — 2026-10-20
2
15
 
3
16
  A RESCAN THAT THE LIBRARY REFUSES DOES NOT STOP THE RESTORE.
@@ -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
- const READABLE_ENVELOPE_VERSIONS = [1, 2];
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
- if (!passphrase || passphrase.length < 12) throw new Error("Passphrase must be at least 12 characters");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.44.3",
3
+ "version": "0.45.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
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
 
@@ -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
- /** The recovery file object as it is written (`ninsei-<id>.json.enc`) and read back. */
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
- * Encrypts one payload into a recovery file envelope: a fresh salt and IV, the key derived from the passphrase, and
225
- * the header authenticated as AAD. A passphrase shorter than 12 characters is refused.
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
@@ -13,6 +13,9 @@ export {
13
13
  decryptFile,
14
14
  encryptPayload,
15
15
  envelopeAad,
16
+ isEncrypted,
17
+ ALG_NONE,
18
+ UNENCRYPTED_ENVELOPE_VERSION,
16
19
  recoveryFileName,
17
20
  scanStartHeight,
18
21
  validatePayload,
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
- if (typeof request.passphrase !== "string" || !request.passphrase) {
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.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_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.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 (!request.passphrase) throw new SdkError("bad-input", { field: "passphrase" });
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
- if (typeof request.passphrase !== "string" || request.passphrase.length < MIN_PASSPHRASE) {
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.44.3";
9
+ export const SDK_VERSION = "0.45.0";