@ninsei-labs/sdk 0.47.0 → 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 CHANGED
@@ -1,3 +1,15 @@
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
+
1
13
  ## 0.47.0 — 2026-10-21
2
14
 
3
15
  ONE RECOVERY KEY, AND THE NODE'S VIEW HALF COMES FROM IT.
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 the file is saved; 5) locking the funds (signature);
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.47.0",
3
+ "version": "0.48.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
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/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 || {}),
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
- if (typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
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 RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
789
- // file: the person will rely on it. So we check on our side, before the screen.
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
- try {
801
- file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
802
- } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
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
- // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
805
- // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
806
- const confirmed = await request.onRecoveryFile(file);
807
- if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
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.47.0";
9
+ export const SDK_VERSION = "0.48.0";