@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 +12 -0
- package/index.d.ts +17 -3
- package/package.json +1 -1
- package/src/order.mjs +23 -0
- package/src/record.mjs +8 -4
- package/src/swaps.mjs +75 -20
- package/src/version.mjs +1 -1
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
|
|
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
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
|
-
|
|
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
|
|
789
|
-
// file: the
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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.
|
|
9
|
+
export const SDK_VERSION = "0.48.0";
|