@ninsei-labs/sdk 0.43.3 → 0.44.1

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/src/recovery.mjs CHANGED
@@ -3,6 +3,12 @@
3
3
  //
4
4
  // The file arrives from the interface as a string or as `{ contents }`: the SDK knows neither about
5
5
  // <input type=file> nor about buffers - that is the interface's business.
6
+ //
7
+ // THE RESTORE HEIGHT IS A RULE, NOT A LOOKUP THE PAGE PERFORMS. Two sources exist: the height written into the
8
+ // file when it was issued, and the height the service learned when it registered the wallet (it watches the
9
+ // address, so its row is often more precise). The order between them is the package's: the service's height wins
10
+ // when it is known, the file's is the fallback, and NO height is a named state - not a refusal, because the
11
+ // person can still type one.
6
12
  import * as engine from "./engine.mjs";
7
13
  import { SdkError } from "./errors.mjs";
8
14
 
@@ -12,7 +18,7 @@ const contentsOf = (file) => {
12
18
  return null;
13
19
  };
14
20
 
15
- export function createRecovery() {
21
+ export function createRecovery({ http = null, config = null } = {}) {
16
22
  return {
17
23
  async open(file, passphrase) {
18
24
  const contents = contentsOf(file);
@@ -36,5 +42,70 @@ export function createRecovery() {
36
42
  swapId: typeof payload.swapId === "string" ? payload.swapId : null,
37
43
  };
38
44
  },
45
+
46
+ // WHICH HEIGHT TO SCAN FROM, AND WHERE IT CAME FROM. The page used to run this lookup itself: it read the
47
+ // file, then asked the service by address. Two readers of one rule is how a page and a package drift, so the
48
+ // lookup lives here and the answer names its source.
49
+ //
50
+ // A SERVICE THAT DOES NOT ANSWER IS NOT A REFUSAL. The file's height is still a height; with neither, the
51
+ // answer is `source: "none"` and the interface asks the person. The problem is a token, never a message.
52
+ async restoreHeight(request = {}) {
53
+ const contents = contentsOf(request.file);
54
+ if (contents === null) throw new SdkError("bad-input", { field: "file" });
55
+ if (typeof request.passphrase !== "string" || !request.passphrase) {
56
+ throw new SdkError("bad-input", { field: "passphrase" });
57
+ }
58
+ // THE ENVELOPE IS PARSED HERE: `restoreFromFile` takes the OBJECT, while the file arrives as text. Doing it
59
+ // in one place keeps "not JSON" a named stage instead of a library message.
60
+ let envelope;
61
+ try {
62
+ envelope = JSON.parse(contents);
63
+ } catch {
64
+ throw new SdkError("recovery-failed", { stage: "parse" });
65
+ }
66
+ // ONLY THE PAYLOAD IS NEEDED, NOT A RESTORED WALLET: the height and the address live in the decrypted
67
+ // payload, while restoring a wallet would rebuild keys from the halves - work this answer does not use, and
68
+ // work that needs the browser's crypto vendor.
69
+ let payload;
70
+ try {
71
+ payload = await engine.recoveryFile.decryptFile(envelope, request.passphrase);
72
+ engine.recoveryFile.validatePayload(payload);
73
+ } catch {
74
+ throw new SdkError("recovery-failed", { stage: "restore" });
75
+ }
76
+ const wallet = payload.moneroWallet || {};
77
+ const address = typeof wallet.address === "string" && wallet.address ? wallet.address : null;
78
+ const fileHeight = Number(wallet.restoreHeight) || 0;
79
+
80
+ let appHeight = 0;
81
+ let appAsked = false;
82
+ let appProblem = null;
83
+ if (http && config && address) {
84
+ appAsked = true;
85
+ try {
86
+ const answer = await http.json(config.route("swaps"));
87
+ const rows = Array.isArray(answer) ? answer : ((answer && answer.swaps) || []);
88
+ const row = rows.find((x) => String((x && x.address) || "") === String(address)) || null;
89
+ appHeight = Number(row && row.restoreHeight) || 0;
90
+ if (!row) appProblem = "no-row";
91
+ } catch {
92
+ appProblem = "unreachable";
93
+ }
94
+ }
95
+
96
+ const height = appHeight > 0 ? appHeight : fileHeight;
97
+ const source = appHeight > 0 ? "app" : (fileHeight > 0 ? "file" : "none");
98
+ return {
99
+ address,
100
+ height,
101
+ source,
102
+ fileHeight,
103
+ appHeight,
104
+ appAsked,
105
+ // Why there is no height, when there is none: a token the interface turns into words. Null when a height
106
+ // was found, so a reader cannot mistake "found" for "asked and failed".
107
+ appProblem: source === "none" ? (appProblem || "no-height") : null,
108
+ };
109
+ },
39
110
  };
40
111
  }
@@ -0,0 +1,130 @@
1
+ // RESTORING THE WITHDRAWAL WALLET: THE PASS BEFORE ANYTHING IS SIGNED.
2
+ //
3
+ // WHY THIS IS IN THE PACKAGE. The sweep page used to run this itself: set the restore height ten blocks below
4
+ // the one the file names, rescan, sync, read the balance, and - if the balance was non-zero while nothing was
5
+ // spendable - rescan the spent status and sync again. The second pass was not a guess: coins are locked for ten
6
+ // blocks after they arrive, and the spent status of outputs restored from a key is not known until the wallet
7
+ // asks the chain. Both are facts about wallet2, not about a page, and a node or a CLI needs the same pass.
8
+ //
9
+ // WHAT IS NOT HERE: creating the wallet, opening it and closing it. The wallet arrives as the same withdrawal
10
+ // adapter the signing step takes (`sync`, `unlockedBalance`, `driver`); who built it and where the WASM and the
11
+ // worker live is the caller's business.
12
+ //
13
+ // REFUSALS CARRY CODES. A missing method, a missing height and a library error all come out as `SdkError`; a
14
+ // low-level exception never escapes with `code: null`, because the interface has no phrase for it and a check
15
+ // cannot assert it.
16
+ import { SdkError } from "./errors.mjs";
17
+
18
+ // THE SECOND PASS IS BOUNDED: a wallet that reports a balance but no spendable coins twice is not helped by a
19
+ // third pass, and repeating it would only make an empty answer slower to arrive.
20
+ const PASSES = 2;
21
+ // HOW FAR BELOW THE NAMED HEIGHT THE SCAN STARTS. A gap in an arrival block is worth more than the ten blocks it
22
+ // costs: scanning from the exact height has already missed an arrival one block earlier.
23
+ const SCAN_BACK = 10;
24
+
25
+ const asAmount = (value, field) => {
26
+ try {
27
+ return BigInt(value);
28
+ } catch {
29
+ throw new SdkError("bad-input", { field });
30
+ }
31
+ };
32
+
33
+ const driverOf = (wallet) => {
34
+ if (!wallet || typeof wallet.driver !== "function") throw new SdkError("bad-input", { field: "wallet.driver" });
35
+ const driver = wallet.driver();
36
+ if (!driver || typeof driver !== "object") throw new SdkError("bad-input", { field: "wallet.driver" });
37
+ const missing = ["setRestoreHeight", "getBalance"].filter((name) => typeof driver[name] !== "function");
38
+ if (missing.length) throw new SdkError("bad-input", { field: "wallet.driver", missing });
39
+ return driver;
40
+ };
41
+
42
+ // A WALLET STEP THAT FAILS IS A REFUSAL WITH A CODE, never raw library text. The surface is named so a reader can
43
+ // tell which call went wrong.
44
+ const step = async (surface, fn) => {
45
+ try {
46
+ return await fn();
47
+ } catch (error) {
48
+ if (error instanceof SdkError) throw error;
49
+ throw new SdkError("wallet-rejected", { surface, why: String((error && error.message) || error) });
50
+ }
51
+ };
52
+
53
+ const heightOf = async (driver, name) => {
54
+ if (typeof driver[name] !== "function") return null;
55
+ try {
56
+ const value = await driver[name]();
57
+ const number = Number(value);
58
+ return Number.isFinite(number) ? number : null;
59
+ } catch {
60
+ // A missing height is not a refusal: the balance is the answer this pass exists for.
61
+ return null;
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Restore the wallet from a height and report what it holds.
67
+ *
68
+ * @param {object} request
69
+ * @param {object} request.wallet the withdrawal adapter: `sync`, `unlockedBalance`, `driver`
70
+ * @param {number} request.fromHeight the height the file names; the scan starts a little below it
71
+ * @param {number} [request.scanBack] how far below, default 10
72
+ * @param {boolean} [request.rescanWhenLocked] run the second pass, default true
73
+ * @returns {Promise<{total: string, unlocked: string, scanHeight: number, walletHeight: number|null,
74
+ * nodeHeight: number|null, passes: number, lockedPass: boolean}>}
75
+ */
76
+ export async function restoreWallet(request = {}) {
77
+ const wallet = request.wallet;
78
+ if (!wallet || typeof wallet !== "object") throw new SdkError("bad-input", { field: "wallet" });
79
+ const missing = ["sync", "unlockedBalance"].filter((name) => typeof wallet[name] !== "function");
80
+ if (missing.length) throw new SdkError("bad-input", { field: "wallet", missing });
81
+
82
+ const fromHeight = Number(request.fromHeight);
83
+ if (!Number.isFinite(fromHeight) || fromHeight <= 0) throw new SdkError("bad-input", { field: "restoreHeight" });
84
+
85
+ const scanBack = Number.isFinite(Number(request.scanBack)) && Number(request.scanBack) >= 0
86
+ ? Math.floor(Number(request.scanBack))
87
+ : SCAN_BACK;
88
+ const scanHeight = Math.max(0, Math.floor(fromHeight) - scanBack);
89
+ const driver = driverOf(wallet);
90
+
91
+ await step("setRestoreHeight", () => driver.setRestoreHeight(scanHeight));
92
+ // A RESCAN IS REQUIRED, NOT OPTIONAL: without it the height only applies to a wallet that has not scanned yet,
93
+ // and a wallet restored from a key has. A driver that cannot rescan is the caller's mistake, named here.
94
+ if (typeof driver.rescanBlockchain !== "function") throw new SdkError("bad-input", { field: "wallet.driver.rescanBlockchain" });
95
+ await step("rescanBlockchain", () => driver.rescanBlockchain());
96
+
97
+ // A FAILED SYNC IS BEST-EFFORT, exactly as in the signing step: what blocks a withdrawal is the spendable
98
+ // remainder, and it is read below. The library throws low-level while scanning a chain it cannot read; that must
99
+ // not pre-empt the verdict.
100
+ try { await wallet.sync(); } catch { /* the remainder decides */ }
101
+
102
+ let total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
103
+ let unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
104
+ let passes = 1;
105
+ let lockedPass = false;
106
+
107
+ // THE SECOND PASS: coins exist but none is spendable. The spent status is re-checked and the wallet is synced
108
+ // again; the arrival may have been locked by time rather than lost.
109
+ const rescanWhenLocked = request.rescanWhenLocked !== false;
110
+ if (rescanWhenLocked && total > 0n && unlocked === 0n && typeof driver.rescanSpent === "function" && passes < PASSES) {
111
+ lockedPass = true;
112
+ await step("rescanSpent", () => driver.rescanSpent());
113
+ try { await wallet.sync(); } catch { /* as above */ }
114
+ total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
115
+ unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
116
+ passes += 1;
117
+ }
118
+
119
+ return {
120
+ total: total.toString(),
121
+ unlocked: unlocked.toString(),
122
+ scanHeight,
123
+ walletHeight: await heightOf(driver, "getHeight"),
124
+ nodeHeight: await heightOf(driver, "getDaemonHeight"),
125
+ passes,
126
+ // Whether the second pass RAN - not whether it helped. A reader must be able to tell "nothing was locked" from
127
+ // "it was locked and stayed locked".
128
+ lockedPass,
129
+ };
130
+ }
package/src/sweep.mjs CHANGED
@@ -7,6 +7,9 @@
7
7
  import { SdkError } from "./errors.mjs";
8
8
  import { primitives } from "./primitives.mjs";
9
9
  import * as engine from "./engine.mjs";
10
+ import { restoreWallet } from "./sweep-restore.mjs";
11
+ import { moneroWalletAdapter } from "./adapters/monero-wallet.mjs";
12
+ import { checkDestination } from "./destination.mjs";
10
13
 
11
14
  // The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
12
15
  const MIN_PASSPHRASE = 12;
@@ -30,6 +33,23 @@ const walletStep = async (surface, step) => {
30
33
  }
31
34
  };
32
35
 
36
+ // THE FEE A WALLET REPORTS FOR A SIGNED TRANSACTION. Optional on purpose: the adapter contract does not require
37
+ // it, and an adapter without it is not broken - the answer is simply unknown. The library's own shape is tried
38
+ // (`getFee()` on the transaction or on the signed wrapper), because that is what monero-ts returns; a value that is
39
+ // not a non-negative integer string is refused as unknown rather than trusted.
40
+ const feeOf = async (wallet, signed) => {
41
+ if (!wallet || typeof wallet.fee !== "function") return null;
42
+ let value;
43
+ try {
44
+ value = await wallet.fee(signed);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (value === null || value === undefined) return null;
49
+ const text = String(value);
50
+ return /^\d+$/.test(text) ? text : null;
51
+ };
52
+
33
53
  export function createSweep({ http, config }) {
34
54
  // THE NAME IS NEEDED BY THE OBJECT ITSELF: `run` calls `api.plan(...)` - the step order is held by the plan and
35
55
  // the withdrawal living in one object, not in two copies. During a manual merge of branches the object was left
@@ -40,7 +60,52 @@ export function createSweep({ http, config }) {
40
60
  const sessions = new Map();
41
61
  let sessionSeq = 0;
42
62
 
63
+ // THE NETWORK NAME MONERO-TS TAKES. The adapter speaks a string ("stagenet") because that is what a node says and
64
+ // what a human reads; the library wants its own enum. That translation used to be a callback on the sweep page -
65
+ // the last thing a caller had to know about the library to open a wallet.
66
+ const NETWORK_ENUM = { mainnet: "MAINNET", stagenet: "STAGENET", testnet: "TESTNET" };
67
+
43
68
  const api = {
69
+ // BUILD THE WITHDRAWAL WALLET from the library namespace the caller has. The page used to pass a `createWallet`
70
+ // callback that mapped the enum itself; that mapping is here now, so a caller passes `moneroTs` and the keys.
71
+ wallet(request = {}) {
72
+ const moneroTs = request.moneroTs;
73
+ if (!moneroTs || typeof moneroTs !== "object") throw new SdkError("bad-input", { field: "moneroTs" });
74
+ if (typeof moneroTs.createWalletFull !== "function") throw new SdkError("bad-input", { field: "moneroTs.createWalletFull" });
75
+ const network = String(request.network || "").trim().toLowerCase();
76
+ const enumName = NETWORK_ENUM[network] || null;
77
+ const enumValue = enumName && moneroTs.MoneroNetworkType ? moneroTs.MoneroNetworkType[enumName] : undefined;
78
+ if (enumValue === undefined || enumValue === null) {
79
+ throw new SdkError("bad-input", { field: "network", known: Object.keys(NETWORK_ENUM), got: request.network });
80
+ }
81
+ if (typeof request.serverUri !== "string" || !request.serverUri) throw new SdkError("bad-input", { field: "serverUri" });
82
+ return moneroWalletAdapter({
83
+ createWallet: (config) => moneroTs.createWalletFull({ ...config, networkType: enumValue }),
84
+ network,
85
+ serverUri: request.serverUri,
86
+ password: request.password,
87
+ keys: request.keys || {},
88
+ restoreHeight: request.restoreHeight,
89
+ expectedAddress: request.expectedAddress || null,
90
+ });
91
+ },
92
+
93
+ // WHERE THE WITHDRAWAL GOES. The file names the recipient the swap was made for; this compares the address the
94
+ // person typed with that promise and with the file's network. `matches` is null when the file named nobody -
95
+ // that is not a failure, it is an absence the caller can phrase.
96
+ async destination(request = {}) {
97
+ const contents = contentsOf(request.file);
98
+ if (!contents) throw new SdkError("bad-input", { field: "file" });
99
+ if (!request.passphrase) throw new SdkError("bad-input", { field: "passphrase" });
100
+ if (typeof request.dest !== "string" || !request.dest.trim()) throw new SdkError("bad-input", { field: "dest" });
101
+ let payload = null;
102
+ try { payload = await engine.recoveryFile.decryptFile(JSON.parse(contents), request.passphrase); }
103
+ catch { throw new SdkError("recovery-failed", { step: "decrypt" }); }
104
+ try { engine.recoveryFile.validatePayload(payload); }
105
+ catch { throw new SdkError("recovery-failed", { step: "validate" }); }
106
+ return checkDestination(payload, request.dest);
107
+ },
108
+
44
109
  async plan(request) {
45
110
  if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
46
111
  const contents = contentsOf(request.file);
@@ -145,6 +210,13 @@ export function createSweep({ http, config }) {
145
210
  }
146
211
  },
147
212
 
213
+ // THE PASS BEFORE SIGNING: set the height, rescan, sync, read the balance, and - when coins exist but none is
214
+ // spendable - re-check the spent status once. The wallet is the same withdrawal adapter `sign` takes; the page
215
+ // no longer decides when a second pass is needed.
216
+ async restore(request) {
217
+ return await restoreWallet(request || {});
218
+ },
219
+
148
220
  // THE WITHDRAWAL ITSELF. The wallet arrives as an adapter (the WASM and the worker live with whoever builds the
149
221
  // page), while the core holds the order: open -> sync -> look at the free remainder -> SIGN (relay: false) ->
150
222
  // send -> close.
@@ -202,9 +274,26 @@ export function createSweep({ http, config }) {
202
274
  // zero, and the refusal would look like success.
203
275
  if (unlocked <= 0n) throw new SdkError("insufficient-funds", { unlocked: unlocked.toString() });
204
276
  const signed = await walletStep("sweepUnlocked", () => wallet.sweepUnlocked({ address: request.to, relay: false }));
277
+ // WHAT THE TRANSFER COSTS, WHEN THE WALLET CAN SAY. A withdrawal moves the whole free remainder, so the fee
278
+ // is not extra money on top - it is taken out of it, and the recipient receives the remainder MINUS the fee.
279
+ // An adapter that cannot report a fee answers null; publishing 0 there would be a claim the package cannot
280
+ // support, and the interface would show a free transfer.
281
+ const fee = await feeOf(wallet, signed);
282
+ const feeValue = fee === null ? null : BigInt(fee);
205
283
  const token = "sweep-" + (++sessionSeq);
206
284
  sessions.set(token, { wallet, plan, signed });
207
- return { token, amount: unlocked.toString(), to: request.to, swept: plan.address };
285
+ return {
286
+ token,
287
+ amount: unlocked.toString(),
288
+ // The fee in atomic units, or null when the wallet could not report it.
289
+ fee,
290
+ // What the recipient receives: the remainder minus the fee. Null whenever the fee is unknown, and null
291
+ // rather than a negative number when a fee claims more than the remainder (that is a broken report, not
292
+ // a transfer).
293
+ receive: feeValue === null || feeValue >= unlocked ? null : (unlocked - feeValue).toString(),
294
+ to: request.to,
295
+ swept: plan.address,
296
+ };
208
297
  } catch (error) {
209
298
  // There is no signature - we do not leave an open wallet. Closing must not hide the refusal's reason.
210
299
  try { await wallet.close(); } catch { /* the reason matters more than closing */ }
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.43.3";
9
+ export const SDK_VERSION = "0.44.1";