@ninsei-labs/sdk 0.44.3 → 0.46.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,28 @@
1
+ ## 0.46.0 — 2026-10-21
2
+
3
+ THE QUOTE CARRIES THE CLAIM TIP, AND THE CLAIM PAYS IT.
4
+
5
+ - `claimTip` is the last field of the signed order quote and of `FactoryQuote`, and it is encoded into
6
+ `createOrderAndFund`. The escrow pays it to whoever sends `claim` when that sender is not the claimer, out of the
7
+ payout and in the same transaction (`router-escrow-evm` #19). Zero means no tip.
8
+ - `ORDER_QUOTE_VERSION` is 9. The field set changed, so every quote signed by this package is a version 9 quote and
9
+ verifies only against an escrow that knows the field.
10
+ - A quote object without `claimTip` is refused by `quoteMissingFields` like any other absent condition: a missing tip
11
+ is not read as zero.
12
+
13
+ ## 0.45.0 — 2026-10-20
14
+
15
+ THE RECOVERY PATH WORKS WITHOUT THE APP, AND A PASSPHRASE IS A CHOICE.
16
+
17
+ - `sweep.plan` reads the counterparty's revealed half from the escrow with the `evmCall` the caller already
18
+ passes (`revealedHalf()`, selector `0x3f267bb6`). A recovery page served from a static host has no
19
+ `/escrow/revealed-half` route, so asking the app was the only path and the page could never withdraw there.
20
+ The app route remains as the fallback for a caller with no chain read that configured an API base.
21
+ - An empty passphrase writes the recovery file without encryption. The envelope is version 3 with
22
+ `alg: "NONE"` and carries the payload; `decryptFile` returns it without deriving a key, and `isEncrypted`
23
+ tells a page whether to ask for a passphrase. A non-empty passphrase keeps the twelve character floor.
24
+ - `swaps.start`, `sweep.plan`, `sweep.destination` and `recovery.restoreHeight` accept an empty passphrase.
25
+
1
26
  ## 0.44.3 — 2026-10-20
2
27
 
3
28
  A RESCAN THAT THE LIBRARY REFUSES DOES NOT STOP THE RESTORE.
@@ -32,6 +32,7 @@ export const ORDER_CREATED_TOPIC =
32
32
  export const QUOTE_FIELDS = [
33
33
  "provider", "quoteKey", "registry", "cashier", "locker", "claimer", "amount", "readyBy", "t1", "salt",
34
34
  "commitHalfClaimer", "edPointClaimer", "chainId", "feeBps", "fee", "feeRecipient", "validUntil", "nonce", "xmrAmount",
35
+ "claimTip",
35
36
  ];
36
37
  // The depositor fields are NOT in the quote: they go as separate arguments (into the escrow init code).
37
38
  const LOCKER_HALF_FIELDS = ["commitHalfLocker", "edPointLocker", "edViewPointLocker"];
@@ -89,6 +90,7 @@ function quoteWords(quote) {
89
90
  chainId: word(quote.chainId, 32), feeBps: word(quote.feeBps, 32), fee: word(quote.fee, 32),
90
91
  feeRecipient: word(quote.feeRecipient, 32), validUntil: word(quote.validUntil, 32), nonce: word(quote.nonce, 32),
91
92
  xmrAmount: word(quote.xmrAmount, 32),
93
+ claimTip: word(quote.claimTip, 32),
92
94
  };
93
95
  return QUOTE_FIELDS.map((n) => map[n]).join("");
94
96
  }
@@ -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.46.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
 
@@ -47,6 +47,8 @@ export interface FactoryQuote {
47
47
  readonly validUntil: string | number | bigint;
48
48
  readonly nonce: string | number | bigint;
49
49
  readonly xmrAmount: string | number | bigint;
50
+ /** The tip `claim` pays to a third-party sender out of the payout; zero means none. */
51
+ readonly claimTip: string | number | bigint;
50
52
  /** r || s || v over the quote digest, 65 bytes, with or without the `0x` prefix. */
51
53
  readonly signature: string;
52
54
  readonly commitHalfLocker: string;
@@ -110,6 +110,8 @@ export interface OrderQuote {
110
110
  readonly validUntil: string | number | bigint;
111
111
  readonly nonce: string | number | bigint;
112
112
  readonly xmrAmount: string | number | bigint;
113
+ /** The tip `claim` pays to a third-party sender out of the payout; zero means none. */
114
+ readonly claimTip: string | number | bigint;
113
115
  /** The escrow factory the domain names as `verifyingContract`. */
114
116
  readonly factory: string;
115
117
  /** r || s || v over the digest, 65 bytes, with or without the `0x` prefix; v is normalised to 27/28. */
@@ -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,
@@ -32,18 +32,22 @@
32
32
  // validUntil - the quote's own expiry, IN SECONDS (the order's readyBy/t1 and block.timestamp
33
33
  // scale): a quote past it is refused regardless of the price.
34
34
  // nonce - replay protection: the very same signed quote cannot be presented twice.
35
+ // claimTip - the tip the escrow pays to whoever sends `claim`, out of the payout, in the same
36
+ // transaction (#18). It is inside the signed set so a provider cannot raise it after the
37
+ // taker has seen the quote. Zero means no tip and is the only value a buy uses.
35
38
  //
36
39
  // The domain's verifyingContract is the escrow FACTORY taken from the order: the contract that will verify the
37
40
  // quote in EVM (stage 2). The type order, the domain name and the version are part of the protocol - they may
38
41
  // change only together with the quote version.
39
42
  export const ORDER_QUOTE_DOMAIN_NAME = "NinseiEscrowFactory";
40
43
  export const ORDER_QUOTE_DOMAIN_VERSION = "1";
41
- // 8: the signed EIP-712 field set grew `xmrAmount` (#110 step 3) - the XMR the level walk gives is now covered by
42
- // the provider's signature and verified ON CHAIN. This is the request-shape generation label, and it moves WITH the
44
+ // 9: the signed EIP-712 field set grew `claimTip` (#18) - the tip a third-party sender of `claim` takes from the
45
+ // payout, covered by the provider's signature like every other condition.
46
+ // 8: the field set grew `xmrAmount` (#110 step 3). This is the request-shape generation label, and it moves WITH the
43
47
  // field set (see the note above). The DOMAIN name and the domain version string are UNCHANGED by it: they name the
44
48
  // domain (the factory address and the chain), which did not change. So here the label and the domain version string
45
49
  // move apart, and that is deliberate - the label tracks the message shape, not the domain identity.
46
- export const ORDER_QUOTE_VERSION = 8;
50
+ export const ORDER_QUOTE_VERSION = 9;
47
51
 
48
52
  // The domain type string is assembled from parts ON PURPOSE. A single literal of it is a phrase of several
49
53
  // space-separated words, and the SDK check reads such literals as user-facing text. Do not "simplify" it.
@@ -72,6 +76,7 @@ export const ORDER_QUOTE_FIELDS = [
72
76
  ["validUntil", "uint64"],
73
77
  ["nonce", "uint256"],
74
78
  ["xmrAmount", "uint256"],
79
+ ["claimTip", "uint256"],
75
80
  ];
76
81
  export const ORDER_QUOTE_TYPE = "OrderQuote(" + ORDER_QUOTE_FIELDS.map((f) => f[1] + " " + f[0]).join(",") + ")";
77
82
 
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.46.0";