@ninsei-labs/sdk 0.43.3 → 0.44.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,35 @@
1
+ ## 0.44.0 — 2026-10-19
2
+
3
+ THE WITHDRAWAL AND THE NODE NOW ASK THE PACKAGE INSTEAD OF CARRYING THEIR OWN COPIES. Every rule below was
4
+ implemented in the sweep page or in the node first, and each had a second reader or a second copy waiting to
5
+ diverge. Nothing here is a breaking change: the entries are new names, apart from two type-level declarations that
6
+ replace nothing.
7
+
8
+ - `sweep.restore` restores the wallet from a height with one second pass when coins exist but none is spendable,
9
+ and `recovery.restoreHeight` answers the height with its source: the service wins when it knows one, the file is
10
+ the fallback, and "no height" is a named state rather than a refusal.
11
+ - `sweep.wallet` builds the withdrawal wallet from the caller's monero-ts namespace. The network word becomes the
12
+ library's enum inside the package, and `xmr.networkFromNode` reads the three known answers while anything else
13
+ is null.
14
+ - `sweep.destination` compares a typed address with the address the recovery file promised and with the file's
15
+ network, and `sweep.sign` now reports `fee` and `receive`: a withdrawal moves the whole remainder, so the fee
16
+ comes out of it.
17
+ - `core.formatXmr` and `core.parseXmr` work in BigInt at twelve decimals. One atomic unit is `"0.000000000001"`,
18
+ never `"0"`, and more than twelve decimals is refused instead of rounded.
19
+ - `xmr.decodeAddressKeys`, `xmr.scalarToLeHex`, `xmr.viewKeyMatchesAddress` and `xmr.unlockStateOf` read an arrival
20
+ without a wallet: the address and its public keys, the daemon's little-endian scalar, the key check, and the
21
+ `unlock_time` rule with its boundary named.
22
+ - `xmr.judgeDaemonAddress` is one policy for a node address: https anywhere, http on loopback, and http to a remote
23
+ host only on stagenet and testnet. An unknown network is judged as mainnet.
24
+ - `xmr.moneroFeeEstimate` asks a node for `get_fee_estimate`, and `xmr.feeForBytes` turns a per-KB price into a sum,
25
+ rounded up. `xmr.probeMoneroNode` answers a preflight with a height, a network and a named reason when it cannot.
26
+ - `core.scaleOf` and `core.msFromOrderSeconds` keep seconds at the chain and in the signed terms, milliseconds
27
+ everywhere else, with one conversion at the boundary that refuses a value already in milliseconds.
28
+ - `ERROR_META` and `ERROR_SIDES` sit beside `ERROR_CODES`: whether another attempt has a chance, and who can act.
29
+ A machine check keeps the two vocabularies exactly equal.
30
+
31
+ Verified by the package battery: 22 checks green, and every new rule has its own guard with a breaking run.
32
+
1
33
  ## 0.43.3 — 2026-10-18
2
34
 
3
35
  THE SAME CONTENT AS 0.43.2, PUBLISHED UNDER A NEW NUMBER. The tag-triggered release for 0.43.2 stopped in
@@ -0,0 +1,59 @@
1
+ // SECONDS AT THE CHAIN, MILLISECONDS EVERYWHERE ELSE - AND ONE CONVERSION BETWEEN THEM.
2
+ //
3
+ // WHAT WENT WRONG WITHOUT THIS RULE (a live log, not a theory). One line carried deadlines in different scales:
4
+ // ready_by = 1791828827 (seconds), t1 = 1791832427 (seconds), funded_at = 1791825228449 (milliseconds). The payout
5
+ // path read ready_by as it was and subtracted a margin in MILLISECONDS: "1791828827 - 1800000" is a moment around
6
+ // 1970, so the deadline looked long past at once and the check never asked the money-origin hook even once.
7
+ //
8
+ // THE RULE IS NOT "MILLISECONDS EVERYWHERE", because two values are outside our control and stay in seconds:
9
+ // * the escrow deadlines readyBy/t1 travel in the SIGNED order terms as 32-bit words;
10
+ // * EVM block time is whole seconds.
11
+ // So: seconds at the chain and in the signed terms; milliseconds for everything a program keeps, compares with its own
12
+ // clock or hands out over the wire.
13
+ //
14
+ // THE CONVERSION HAPPENS ONCE, at the boundary (`msFromOrderSeconds`), and it REFUSES a value that is already in
15
+ // milliseconds - otherwise the thousand would be multiplied a second time, in silence.
16
+ //
17
+ // THE SCALE IS DISTINGUISHABLE BY MAGNITUDE: in 2026 seconds are about 1.79e9 and milliseconds about 1.79e12. A value
18
+ // in the gap between the two thresholds has NO scale - guessing "probably seconds" is exactly how the defect passes.
19
+ export const SECONDS_SCALE_MAX = 1e11;
20
+ export const MS_SCALE_MIN = 1e12;
21
+ export const DEADLINE_SCALE = Object.freeze({ seconds: "seconds", milliseconds: "milliseconds", unknown: "unknown" });
22
+
23
+ /** THE SCALE OF A VALUE. Empty, zero, a non-number or a value between the thresholds: unknown. */
24
+ export function scaleOf(value) {
25
+ const n = Number(value);
26
+ if (value === null || value === undefined || value === "" || !Number.isFinite(n) || n <= 0) return DEADLINE_SCALE.unknown;
27
+ if (n < SECONDS_SCALE_MAX) return DEADLINE_SCALE.seconds;
28
+ if (n >= MS_SCALE_MIN) return DEADLINE_SCALE.milliseconds;
29
+ return DEADLINE_SCALE.unknown;
30
+ }
31
+
32
+ export function isSecondsScale(value) { return scaleOf(value) === DEADLINE_SCALE.seconds; }
33
+ export function isMsScale(value) { return scaleOf(value) === DEADLINE_SCALE.milliseconds; }
34
+
35
+ /**
36
+ * THE ONLY CONVERSION FROM THE SIGNED ORDER'S SECONDS TO MILLISECONDS.
37
+ *
38
+ * An empty value gives null: "no deadline named" stays unnamed rather than becoming zero. A value already in
39
+ * milliseconds is REFUSED - this function is called at one boundary, and a second conversion is a silent thousand.
40
+ */
41
+ export function msFromOrderSeconds(value, what = "the value") {
42
+ if (value === null || value === undefined || value === "") return null;
43
+ const n = Number(value);
44
+ if (!Number.isFinite(n) || n <= 0) throw new Error(what + " is not a positive number: " + JSON.stringify(value));
45
+ const scale = scaleOf(n);
46
+ if (scale === DEADLINE_SCALE.milliseconds) {
47
+ throw new Error(what + " is already in milliseconds (" + n + "); the single conversion at the boundary would multiply the thousand a second time");
48
+ }
49
+ if (scale !== DEADLINE_SCALE.seconds) throw new Error(what + " is neither seconds nor milliseconds (" + n + ")");
50
+ return n * 1000;
51
+ }
52
+
53
+ /** A WRITE GUARD: a value of the wrong scale stops the write instead of being stored. Returns true when it passes. */
54
+ export function assertMsScale(value, what = "the value") {
55
+ if (scaleOf(value) !== DEADLINE_SCALE.milliseconds) {
56
+ throw new Error(what + " is not in milliseconds: " + JSON.stringify(value) + " (scale " + scaleOf(value) + ")");
57
+ }
58
+ return true;
59
+ }
@@ -0,0 +1,51 @@
1
+ // XMR AMOUNTS AS TEXT: ATOMIC UNITS IN, A HUMAN NUMBER OUT (AND BACK).
2
+ //
3
+ // WHY NOT `Number`. One XMR is 10^12 atomic units (piconero). A JavaScript number is exact only up to 2^53, which
4
+ // is about 9007 XMR in piconero: a balance or a quote above it would be rounded in silence. The sweep page kept its
5
+ // own `ATOMIC_UNITS = 10^12n` and its own formatting; a second copy of this rule is exactly how a page shows
6
+ // 0.000000000001 and a node reports 0.
7
+ //
8
+ // THE FORMAT IS PLAIN DECIMAL, NO GROUPING: a value to be copied, compared or put into a field. The interface may
9
+ // group digits for reading, but the string this module returns must remain a number.
10
+ //
11
+ // DUST DOES NOT VANISH: 1 atomic unit formats as "0.000000000001", never as "0".
12
+ const DECIMALS = 12;
13
+ const SCALE = 10n ** BigInt(DECIMALS);
14
+
15
+ /** Atomic units (bigint | number | string) -> a decimal string. Throws on a value that is not a whole number. */
16
+ export function formatXmr(atomic, { trim = true } = {}) {
17
+ let value;
18
+ try {
19
+ value = BigInt(atomic);
20
+ } catch {
21
+ throw new RangeError("formatXmr: expected whole atomic units");
22
+ }
23
+ // A fractional input (0.5) silently truncated by BigInt() would be a wrong amount, not a formatting choice.
24
+ if (typeof atomic === "number" && !Number.isInteger(atomic)) throw new RangeError("formatXmr: expected whole atomic units");
25
+ const negative = value < 0n;
26
+ const abs = negative ? -value : value;
27
+ const whole = abs / SCALE;
28
+ const fraction = (abs % SCALE).toString().padStart(DECIMALS, "0");
29
+ let text = trim ? fraction.replace(/0+$/, "") : fraction;
30
+ const out = text ? `${whole}.${text}` : `${whole}`;
31
+ return negative ? `-${out}` : out;
32
+ }
33
+
34
+ /**
35
+ * A decimal amount typed by a person -> atomic units (bigint), or null when the text is not an amount.
36
+ *
37
+ * A comma without a dot is the decimal separator ("0,05"), as in the amount field. MORE THAN TWELVE DECIMALS IS
38
+ * NOT ROUNDED AND NOT TRUNCATED: null. Rounding someone's amount is a wrong payment that looks successful;
39
+ * refusing is a question the interface can ask again.
40
+ */
41
+ export function parseXmr(text) {
42
+ const raw = String(text == null ? "" : text).trim().replace(/\s+/g, "");
43
+ if (!raw) return null;
44
+ if (raw.includes(".") && raw.includes(",")) return null;
45
+ const normalized = raw.replace(",", ".");
46
+ if (!/^\d+(\.\d*)?$/.test(normalized)) return null;
47
+ const [whole, fraction = ""] = normalized.split(".");
48
+ const digits = fraction.replace(/0+$/, "");
49
+ if (digits.length > DECIMALS) return null;
50
+ return BigInt(whole) * SCALE + BigInt((digits || "0").padEnd(DECIMALS, "0"));
51
+ }
@@ -0,0 +1,95 @@
1
+ // READING AN XMR ARRIVAL WITHOUT A WALLET: WHAT THE ADDRESS IS, WHETHER A KEY MATCHES IT, AND WHETHER THE COIN IS
2
+ // LOCKED.
3
+ //
4
+ // WHY THIS MODULE. A node that pays XMR holds only ONE half of the view key, so it cannot open a wallet on the
5
+ // address it pays to - and it still has to answer three questions before it treats a transfer as money it can
6
+ // spend. The sweep page needs the same answers when it verifies a recovery file. Both used to carry their own
7
+ // copies: base58 decoding, the little-endian scalar form the daemon expects, the view-key comparison, and the
8
+ // unlock_time rule. A second copy of the last one is the expensive kind - money at the address that cannot be
9
+ // spent yet reads as an arrival.
10
+ //
11
+ // WHAT IS HERE: reading only. No signing, no sending, no network, no wallet.
12
+ //
13
+ // THE NETWORK TABLE IS DERIVED from the package's own prefix table rather than written out with numbers: a prefix
14
+ // typed from memory (stagenet is not 18) already produced an address that no wallet would open.
15
+ import { decodeMoneroBase58, PREFIX_BY_NETWORK } from "./address.js";
16
+
17
+ const NETWORK_BY_PREFIX = Object.fromEntries(
18
+ Object.entries(PREFIX_BY_NETWORK).map(([network, kinds]) => [kinds.primary, network]),
19
+ );
20
+
21
+ const toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
22
+
23
+ /**
24
+ * AN ADDRESS -> ITS NETWORK AND ITS PUBLIC KEYS, or an error when the text is not an address.
25
+ * A primary address carries a 65-byte payload; an integrated one 73 (the extra 8 are the payment id, not returned).
26
+ */
27
+ export function decodeAddressKeys(address) {
28
+ const raw = decodeMoneroBase58(String(address == null ? "" : address).trim());
29
+ if (!raw || raw.length < 69) throw new Error("not a Monero address: " + (raw ? raw.length + " bytes" : "not base58"));
30
+ const netByte = raw[0];
31
+ return {
32
+ network: NETWORK_BY_PREFIX[netByte] || null,
33
+ netByte,
34
+ spendPub: toHex(raw.slice(1, 33)),
35
+ viewPub: toHex(raw.slice(33, 65)),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * THE SCALAR IN THE FORM THE DAEMON EXPECTS: little-endian hex, low byte first.
41
+ *
42
+ * A number written high byte first is a DIFFERENT key, and nothing refuses it: the wallet is created without an
43
+ * error and simply sees no arrival.
44
+ */
45
+ export function scalarToLeHex(scalar, bytes = 32) {
46
+ const size = Number(bytes);
47
+ if (!Number.isInteger(size) || size <= 0) throw new Error("scalarToLeHex: bad width");
48
+ let value;
49
+ try { value = BigInt(scalar); } catch { throw new Error("scalarToLeHex: not an integer"); }
50
+ if (value < 0n || value >= 1n << BigInt(size * 8)) throw new Error("scalarToLeHex: value does not fit " + size + " bytes");
51
+ const out = new Uint8Array(size);
52
+ let rest = value;
53
+ for (let i = 0; i < size; i++) { out[i] = Number(rest & 0xffn); rest >>= 8n; }
54
+ return toHex(out);
55
+ }
56
+
57
+ /**
58
+ * IS THIS VIEW KEY THE ONE THE ADDRESS NAMES.
59
+ *
60
+ * `halves` is the object `createHalves` returns: the comparison must use the same point arithmetic as the address
61
+ * build, otherwise a key that opens the wallet looks wrong.
62
+ *
63
+ * Answers: true - the key matches; false - it belongs to another address; null - there is no public key to compare
64
+ * with (an address without a view key, which is not a case this code builds).
65
+ */
66
+ export function viewKeyMatchesAddress(viewScalar, expectedViewPubHex, halves) {
67
+ const want = String(expectedViewPubHex || "").trim().toLowerCase().replace(/^0x/, "");
68
+ if (!want) return null;
69
+ if (!halves || typeof halves.publicHalves !== "function") throw new Error("viewKeyMatchesAddress: createHalves is required");
70
+ const got = String(halves.publicHalves(viewScalar).ed || "").toLowerCase().replace(/^0x/, "");
71
+ return got === want;
72
+ }
73
+
74
+ /** A boundary up to this value is a BLOCK HEIGHT; from it on, a unix TIME. Monero's own convention. */
75
+ export const UNLOCK_TIME_BLOCK_MAX = 500_000_000;
76
+
77
+ /**
78
+ * IS THE COIN SPENDABLE AT THIS MOMENT, AND IF NOT, WHAT IS THE BOUNDARY.
79
+ *
80
+ * `unlocked` / `locked_height` / `locked_time`, with the boundary named in `untilHeight` or `untilTime`. A height
81
+ * that is not known is NOT "unlocked": without a height the answer stays locked, because the caller has no fact
82
+ * that would open it. `nowSec` defaults to the wall clock, and is a parameter so a check can pin it.
83
+ */
84
+ export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
85
+ const u = Number(unlockTime) || 0;
86
+ if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
87
+ if (u >= UNLOCK_TIME_BLOCK_MAX) {
88
+ const now = nowSec === null || nowSec === undefined ? Math.floor(Date.now() / 1000) : Number(nowSec);
89
+ const open = Number.isFinite(now) && now >= u;
90
+ return { code: open ? "unlocked" : "locked_time", locked: !open, untilHeight: null, untilTime: u };
91
+ }
92
+ const h = height === null || height === undefined ? null : Number(height);
93
+ const open = h !== null && Number.isFinite(h) && h >= u;
94
+ return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
95
+ }
@@ -0,0 +1,82 @@
1
+ // WHERE A MONERO NODE MAY LIVE: ONE POLICY FOR EVERY CLIENT.
2
+ //
3
+ // WHY. An open `http://` connection to a machine across the network shows anyone on the path what is asked and what
4
+ // is answered: addresses, amounts, transfer ids. A node refuses that on mainnet, and the sweep page - which lets an
5
+ // operator type their own node address - accepts it today. Two answers to one question is how one of them ends up
6
+ // allowing exactly what the other forbids.
7
+ //
8
+ // THE RULE. `https` anywhere; `http` on loopback (where the kernel, not the network, answers for the wire); `http`
9
+ // to a remote host only on stagenet and testnet, where the public nodes known to us serve http only. Everything
10
+ // else is refused with a code, and an unknown network is judged as mainnet: a network we cannot name cannot be
11
+ // judged safe.
12
+ //
13
+ // LOOPBACK IS THREE NAMES, NOT ALL OF 127.0.0.0/8. That is how the node already counted, and widening the concept
14
+ // under the cover of a fix would change behaviour nobody measured.
15
+ export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
16
+
17
+ export function isLoopbackHost(host) {
18
+ return LOOPBACK_HOSTS.has(String(host || "").toLowerCase());
19
+ }
20
+
21
+ /** ADDRESS -> { scheme, host }, or null when the text is not a URL. A port and a path are allowed and ignored. */
22
+ export function hostOfUrl(url) {
23
+ try {
24
+ const u = new URL(String(url));
25
+ const scheme = String(u.protocol || "").replace(":", "").toLowerCase();
26
+ const host = String(u.hostname || "").replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
27
+ return { scheme, host };
28
+ } catch { return null; }
29
+ }
30
+
31
+ export const DAEMON_CODES = Object.freeze({
32
+ notHttps: "daemon-not-https",
33
+ unreadable: "daemon-address-unreadable",
34
+ schemeUnknown: "daemon-scheme-unknown",
35
+ });
36
+
37
+ /** The networks where a remote plain-http daemon is accepted. Every other name, including an unknown one, is not. */
38
+ export const DAEMON_PLAIN_HTTP_NETWORKS = Object.freeze(["stagenet", "testnet"]);
39
+ export const DAEMON_NETWORK_MAINNET = "mainnet";
40
+
41
+ /**
42
+ * A SETTING READ IN ONE SHAPE: switching off is a named 0/false/no/off; anything else, a typo included, leaves the
43
+ * exception ON, because the safe default here is "loopback allowed, outside not".
44
+ */
45
+ export function plainLoopbackAllowed(raw) {
46
+ if (raw === undefined || raw === null || String(raw).trim() === "") return true;
47
+ return !/^(0|false|no|off)$/i.test(String(raw).trim());
48
+ }
49
+
50
+ /**
51
+ * THE DECISION FOR ONE ADDRESS.
52
+ *
53
+ * `ok` - may this address be used. `configured: false` for an empty value (nothing to judge, not a refusal).
54
+ * `code` names a refusal; `reason` is a short technical phrase for the caller's log, not interface text.
55
+ */
56
+ export function judgeDaemonAddress(raw, { network = DAEMON_NETWORK_MAINNET, allowPlainLoopback = true } = {}) {
57
+ const value = String(raw == null ? "" : raw).trim();
58
+ const allowed = allowPlainLoopback === undefined || allowPlainLoopback === null ? true : Boolean(allowPlainLoopback);
59
+ const net = String(network == null ? "" : network).trim().toLowerCase() || DAEMON_NETWORK_MAINNET;
60
+ const base = { configured: Boolean(value), scheme: null, host: null, loopback: false, plainLoopbackAllowed: allowed, plainHttpAccepted: false, network: net };
61
+ if (!value) return { ...base, ok: true, code: null, reason: null };
62
+ const target = hostOfUrl(value);
63
+ if (!target) return { ...base, ok: false, code: DAEMON_CODES.unreadable, reason: "not a URL" };
64
+ // "node.example:18081" parses as a URL whose scheme is "node.example" and whose host is empty - a missing
65
+ // scheme, not a foreign one. A daemon address without a host is unreadable either way.
66
+ if (!target.host) return { ...base, ok: false, scheme: target.scheme || null, code: DAEMON_CODES.unreadable, reason: "no host in the address" };
67
+ const loopback = isLoopbackHost(target.host);
68
+ if (target.scheme !== "http" && target.scheme !== "https") {
69
+ return { ...base, scheme: target.scheme, host: target.host, loopback, ok: false, code: DAEMON_CODES.schemeUnknown, reason: "scheme " + JSON.stringify(target.scheme) + " is neither http nor https" };
70
+ }
71
+ const seen = { ...base, scheme: target.scheme, host: target.host, loopback };
72
+ if (target.scheme === "https") return { ...seen, ok: true, code: null, reason: null };
73
+ if (loopback) {
74
+ return allowed
75
+ ? { ...seen, ok: true, code: null, reason: null }
76
+ : { ...seen, ok: false, code: DAEMON_CODES.notHttps, reason: "plain http on loopback, and the loopback exception is switched off" };
77
+ }
78
+ if (DAEMON_PLAIN_HTTP_NETWORKS.includes(net)) {
79
+ return { ...seen, ok: true, plainHttpAccepted: true, code: null, reason: null };
80
+ }
81
+ return { ...seen, ok: false, code: DAEMON_CODES.notHttps, reason: "plain http to a remote host on " + net };
82
+ }
@@ -0,0 +1,15 @@
1
+ // THE NETWORK A NODE SAYS IT IS ON.
2
+ //
3
+ // A node answers `get_info` with `nettype` ("mainnet", "stagenet", "testnet"). A wallet MUST be built from that
4
+ // answer: a mainnet wallet against a stagenet node looks like a broken sweep, not like a wrong address. The
5
+ // mapping used to be a private function of the sweep page, which made "what a node may say" knowledge of one
6
+ // interface.
7
+ //
8
+ // AN UNKNOWN ANSWER IS NULL, NOT A GUESS. Defaulting to a network would build a wallet for a chain the node may not
9
+ // serve; the caller can then say which answer it got and stop.
10
+ const KNOWN = new Set(["mainnet", "stagenet", "testnet"]);
11
+
12
+ export function networkFromNode(info) {
13
+ const type = String((info && info.nettype) || "").trim().toLowerCase();
14
+ return KNOWN.has(type) ? type : null;
15
+ }
package/index.d.ts CHANGED
@@ -1230,8 +1230,27 @@ export type TerminalCode = "success" | "refunded_eth" | "closed" | "declined";
1230
1230
  * WASM - while the core stays DOM-free.
1231
1231
  */
1232
1232
  export interface SweepApi {
1233
+ /**
1234
+ * BUILD THE WITHDRAWAL WALLET from the caller's monero-ts namespace. The page used to pass a `createWallet`
1235
+ * callback that translated the network name into the library's enum; that translation is the package's now, so a
1236
+ * caller passes the namespace, the network, the node and the keys.
1237
+ */
1238
+ wallet(request: SweepWalletRequest): SweepWallet;
1239
+
1240
+ * COMPARE A DESTINATION WITH THE FILE'S PROMISE. `ok` is "usable address on the file's network", `matches` is
1241
+ * "the promised one" and is null when the file named nobody. Sending XMR elsewhere is a different payment, so the
1242
+ * comparison is not presentation.
1243
+ */
1244
+ destination(request: SweepDestinationRequest): Promise<SweepDestination>;
1233
1245
  /** What is known about the withdrawal from the recovery file: the address, the restore height, what we can dispose of. */
1234
1246
  plan(request: SweepPlanRequest): Promise<SweepPlan>;
1247
+ /**
1248
+ * RESTORE THE WALLET FROM A HEIGHT AND REPORT WHAT IT HOLDS. Sets the restore height a little below the one given,
1249
+ * rescans, syncs and reads the total and the spendable balance. Coins that exist but are locked get ONE more pass
1250
+ * with a spent-status re-check - the page used to decide that itself, and the rule belongs to wallet2, not to an
1251
+ * interface. A failed sync is not the verdict: the remainder is.
1252
+ */
1253
+ restore(request: SweepRestoreRequest): Promise<SweepRestoreResult>;
1235
1254
  /**
1236
1255
  * Assemble the spend and view keys from the revealed half - with the mandatory check `(s_a + s_b)*G` against the
1237
1256
  * order's public key. It did not match - a refusal, not "let us try to send".
@@ -1256,6 +1275,68 @@ export interface SweepApi {
1256
1275
  run(request: SweepRunRequest): Promise<SweepRunResult>;
1257
1276
  }
1258
1277
 
1278
+ /** What `restore` needs: the withdrawal adapter and the height named by the file. */
1279
+ export interface SweepRestoreRequest {
1280
+ /** The withdrawal adapter: `sync`, `unlockedBalance`, `driver`. The wallet is built by the caller. */
1281
+ readonly wallet: {
1282
+ sync(): Promise<void>;
1283
+ unlockedBalance(): Promise<bigint | number | string>;
1284
+ driver(): unknown;
1285
+ };
1286
+ /** The height from the recovery file. The scan starts `scanBack` blocks below it. */
1287
+ readonly fromHeight: number;
1288
+ /** How far below the named height the scan starts. Default 10. */
1289
+ readonly scanBack?: number;
1290
+ /** Run the second pass when coins exist but none is spendable. Default true. */
1291
+ readonly rescanWhenLocked?: boolean;
1292
+ }
1293
+
1294
+ /** What `restore` found. Amounts are atomic units as strings; heights are numbers when the driver can read them. */
1295
+ export interface SweepRestoreResult {
1296
+ readonly total: string;
1297
+ readonly unlocked: string;
1298
+ /** The height the scan actually started from. */
1299
+ readonly scanHeight: number;
1300
+ readonly walletHeight: number | null;
1301
+ readonly nodeHeight: number | null;
1302
+ /** How many sync passes ran: 1, or 2 when the locked pass ran. */
1303
+ readonly passes: number;
1304
+ /** Whether the second pass RAN - not whether it helped. */
1305
+ readonly lockedPass: boolean;
1306
+ }
1307
+
1308
+ /** What `wallet` needs: the library namespace and the facts the wallet is built from. */
1309
+ export interface SweepWalletRequest {
1310
+ /** The monero-ts namespace (the browser bundle's default export, or the module in Node). */
1311
+ readonly moneroTs: {
1312
+ readonly MoneroNetworkType?: Record<string, unknown>;
1313
+ readonly createWalletFull: (config: Record<string, unknown>) => Promise<unknown>;
1314
+ };
1315
+ /** `mainnet`, `stagenet` or `testnet` - what a node says. */
1316
+ readonly network: string;
1317
+ /** The node (in production - a proxy). */
1318
+ readonly serverUri: string;
1319
+ readonly password?: string;
1320
+ /** `{ seed }` or `{ privateSpendKey, privateViewKey, primaryAddress }`. */
1321
+ readonly keys?: Record<string, unknown>;
1322
+ readonly restoreHeight?: number;
1323
+ /** When set, the derived address must match, or the wallet refuses to open. */
1324
+ readonly expectedAddress?: string | null;
1325
+ }
1326
+
1327
+ /** The wallet `sweep.wallet` returns: the SDK wallet adapter with the Monero surface on top. */
1328
+ export interface SweepWallet {
1329
+ readonly vm: "monero";
1330
+ connect(): Promise<void>;
1331
+ disconnect(): Promise<void>;
1332
+ address(): string;
1333
+ chainId(): string;
1334
+ unlockedBalance(): Promise<bigint | number | string>;
1335
+ sync(): Promise<void>;
1336
+ close(): Promise<void>;
1337
+ driver(): unknown;
1338
+ }
1339
+
1259
1340
  /** The withdrawal mark: the swap identifier and the transfer hash. */
1260
1341
  export interface SweepReportRequest {
1261
1342
  readonly swapId: string;
@@ -1331,11 +1412,42 @@ export interface MoneroWalletAdapter {
1331
1412
  }
1332
1413
 
1333
1414
  /** What signing returned: the open session's token and what lies under it. */
1415
+ /** What `destination` needs: the file, its passphrase, and the address the person typed. */
1416
+ export interface SweepDestinationRequest {
1417
+ readonly file: { readonly contents: string };
1418
+ readonly passphrase: string;
1419
+ /** The address the person typed. Empty is refused (`bad-input`, field `dest`). */
1420
+ readonly dest: string;
1421
+ }
1422
+
1423
+ export interface SweepDestination {
1424
+ /** True when the address is usable on the file's network. */
1425
+ readonly ok: boolean;
1426
+ readonly dest: string;
1427
+ /** The recipient the file promised, or null when it named none. */
1428
+ readonly promised: string | null;
1429
+ /** True/false against the promise; null when the file named nobody. */
1430
+ readonly matches: boolean | null;
1431
+ /** The file's Monero network (stagenet, mainnet, testnet), or null. */
1432
+ readonly network: string | null;
1433
+ /** True when the checksum was actually checked; false when the library was not available. */
1434
+ readonly verified: boolean;
1435
+ /** The library's phrase when the shape or the network is wrong. */
1436
+ readonly reason: string | null;
1437
+ }
1438
+
1334
1439
  export interface SweepSignResult {
1335
1440
  /** The open session's token for `relay`. It contains no keys - they are owned by the library wallet. */
1336
1441
  readonly token: string;
1337
1442
  /** The free remainder at the moment of signing, in atomic units (string). */
1338
1443
  readonly amount: string;
1444
+ /**
1445
+ * THE FEE OF THE SIGNED TRANSFER, in atomic units, or null when the wallet could not report one. A withdrawal
1446
+ * moves the whole remainder, so the fee comes out of it - `receive` is what the recipient gets.
1447
+ */
1448
+ readonly fee: string | null;
1449
+ /** The remainder minus the fee. Null when the fee is unknown, or when a reported fee claims more than the remainder. */
1450
+ readonly receive: string | null;
1339
1451
  /** Where the transfer was signed to. */
1340
1452
  readonly to: string;
1341
1453
  /** The address the XMR leaves from (the swap's joint address). */
@@ -1451,6 +1563,29 @@ export interface ActionsRequest {
1451
1563
  export interface RecoveryApi {
1452
1564
  /** Parse the recovery file. Without the right passphrase - a refusal with a code, not an exception. */
1453
1565
  open(file: { readonly contents: string } | string, passphrase?: string): Promise<RecoveredWallet>;
1566
+ /**
1567
+ * WHICH HEIGHT TO SCAN FROM, AND WHERE IT CAME FROM. The service's height wins when it knows one (it watches
1568
+ * the address), the file's is the fallback, and no height is a named state - the person can type one.
1569
+ */
1570
+ restoreHeight(request: RestoreHeightRequest): Promise<RestoreHeightResult>;
1571
+ }
1572
+
1573
+ export interface RestoreHeightRequest {
1574
+ readonly file: { readonly contents: string } | string;
1575
+ readonly passphrase: string;
1576
+ }
1577
+
1578
+ export interface RestoreHeightResult {
1579
+ readonly address: string | null;
1580
+ /** 0 when no source knows a height. */
1581
+ readonly height: number;
1582
+ readonly source: "app" | "file" | "none";
1583
+ readonly fileHeight: number;
1584
+ readonly appHeight: number;
1585
+ /** Whether the service was asked at all (it is absent in a package without a service). */
1586
+ readonly appAsked: boolean;
1587
+ /** When `source` is "none": "unreachable" | "no-row" | "no-height". Null when a height was found. */
1588
+ readonly appProblem: string | null;
1454
1589
  }
1455
1590
 
1456
1591
  export interface RecoveredWallet {
@@ -1569,6 +1704,20 @@ export declare const CHECK_CODES: readonly CheckCode[];
1569
1704
  /** The error codes the SDK refuses with (`error.code`, `SdkError.code`): the same list as the `ErrorCode` type. */
1570
1705
  export declare const ERROR_CODES: readonly ErrorCode[];
1571
1706
 
1707
+ /** WHO CAN ACT on a refusal, and whether another attempt has a chance. */
1708
+ export type ErrorSide = "caller" | "wallet" | "chain" | "provider" | "service" | "unknown";
1709
+
1710
+ export interface ErrorMeta {
1711
+ /** Whether another attempt has a chance. Not permission to retry a payment. */
1712
+ readonly retryable: boolean;
1713
+ readonly side: ErrorSide;
1714
+ }
1715
+
1716
+ /** What each refusal means: the interface's advice, kept beside the vocabulary and checked by machine. */
1717
+ export declare const ERROR_META: Readonly<Record<ErrorCode, ErrorMeta>>;
1718
+
1719
+ export declare const ERROR_SIDES: readonly ErrorSide[];
1720
+
1572
1721
  export declare function createNinsei(options: NinseiOptions): Ninsei;
1573
1722
 
1574
1723
  // --- The maker's level set: the level walk the node and the client agree on ------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninsei-labs/sdk",
3
- "version": "0.43.3",
3
+ "version": "0.44.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -28,7 +28,20 @@
28
28
  "check:terminal": "node tools/check.mjs --only=terminal",
29
29
  "check:terminal-teeth": "node tools/check-terminal-teeth.mjs",
30
30
  "check:sweep": "node tools/sweep-refusal.test.mjs",
31
- "sync:version": "node tools/sync-version.mjs"
31
+ "sync:version": "node tools/sync-version.mjs",
32
+ "check:restore": "node tools/check-restore.mjs",
33
+ "check:restore-height": "node tools/check-restore-height.mjs",
34
+ "check:wallet-factory": "node tools/check-wallet-factory.mjs",
35
+ "check:xmr-amount": "node tools/check-xmr-amount.mjs",
36
+ "check:sign-preview": "node tools/check-sign-preview.mjs",
37
+ "check:destination": "node tools/check-destination.mjs",
38
+ "check:codes-meta": "node tools/check-codes-meta.mjs",
39
+ "check:arrival-read": "node tools/check-arrival-read.mjs",
40
+ "check:daemon-address": "node tools/check-daemon-address.mjs",
41
+ "check:ci-scripts": "node tools/check-ci-scripts.mjs",
42
+ "check:fee-estimate": "node tools/check-fee-estimate.mjs",
43
+ "check:deadline-scale": "node tools/check-deadline-scale.mjs",
44
+ "check:node-probe": "node tools/check-node-probe.mjs"
32
45
  },
33
46
  "types": "./index.d.ts",
34
47
  "exports": {
@@ -40,6 +40,18 @@ export function moneroWallet({ createWallet, networkType, serverUri, password =
40
40
  need();
41
41
  return await wallet.sweepUnlocked({ address, relay });
42
42
  },
43
+ // THE FEE OF WHAT WAS SIGNED, when the library reports one. `sweepUnlocked` answers with the signed
44
+ // transactions; monero-ts puts the fee on each transaction. Optional by contract - the core treats a missing
45
+ // method as "unknown", so this adapter says what it can and nothing more.
46
+ async fee(signed) {
47
+ need();
48
+ const list = Array.isArray(signed) ? signed : [signed];
49
+ const tx = list[0];
50
+ if (!tx || typeof tx.getFee !== "function") return null;
51
+ const value = tx.getFee();
52
+ return value === null || value === undefined ? null : String(value);
53
+ },
54
+
43
55
  async relay(signed) {
44
56
  need();
45
57
  return await wallet.relayTxs(signed);
package/src/codes.mjs CHANGED
@@ -125,6 +125,62 @@ export const ERROR_CODES = [
125
125
  "unknown",
126
126
  ];
127
127
 
128
+ // WHAT EACH CODE MEANS FOR THE INTERFACE: may it offer "try again", and whose problem is it. The page kept two
129
+ // hand-written lists (retryable, and "which of these are ours") next to a code dictionary; a code added to the
130
+ // vocabulary and forgotten there showed the person the wrong advice. Here the table is beside the vocabulary, and a
131
+ // machine checks that they name exactly the same codes.
132
+ //
133
+ // `retryable` answers "would another attempt have a chance"; it is not "was it transient" and it is not permission
134
+ // to retry a payment. `side` names who can act:
135
+ // caller - the person or the caller fixes an input;
136
+ // wallet - the wallet's state or the person's decision in it;
137
+ // chain - the chain, a node or a transaction;
138
+ // provider - a maker or a quote;
139
+ // service - our own service;
140
+ // unknown - the code is `unknown`, and pretending otherwise would be a guess.
141
+ export const ERROR_SIDES = ["caller", "wallet", "chain", "provider", "service", "unknown"];
142
+
143
+ export const ERROR_META = Object.freeze({
144
+ "not-implemented": { retryable: false, side: "caller" },
145
+ "bad-input": { retryable: false, side: "caller" },
146
+ "wallet-not-connected": { retryable: false, side: "wallet" },
147
+ "wallet-rejected": { retryable: false, side: "wallet" },
148
+ "wrong-chain": { retryable: false, side: "wallet" },
149
+ "quote-unavailable": { retryable: true, side: "provider" },
150
+ "quote-refused": { retryable: false, side: "provider" },
151
+ "address-denied": { retryable: false, side: "caller" },
152
+ "quote-stale": { retryable: true, side: "provider" },
153
+ "quote-signature-invalid": { retryable: false, side: "provider" },
154
+ "quote-key-mismatch": { retryable: false, side: "provider" },
155
+ "quote-field-unbound": { retryable: false, side: "provider" },
156
+ "quote-expired": { retryable: true, side: "provider" },
157
+ "quote-registry-unchecked": { retryable: true, side: "chain" },
158
+ "quote-key-unknown": { retryable: false, side: "chain" },
159
+ "quote-provider-mismatch": { retryable: false, side: "chain" },
160
+ "quote-provider-not-allowed": { retryable: false, side: "caller" },
161
+ "quote-factory-unchecked": { retryable: true, side: "chain" },
162
+ "quote-factory-unknown": { retryable: false, side: "chain" },
163
+ "quote-factory-mismatch": { retryable: false, side: "chain" },
164
+ "quote-factory-code-unknown": { retryable: false, side: "chain" },
165
+ "recovery-declined": { retryable: false, side: "wallet" },
166
+ "recovery-failed": { retryable: false, side: "wallet" },
167
+ "storage-unavailable": { retryable: true, side: "service" },
168
+ "server-unavailable": { retryable: true, side: "service" },
169
+ "server-refused": { retryable: false, side: "service" },
170
+ "xmr-timeout": { retryable: true, side: "chain" },
171
+ "xmr-locked": { retryable: true, side: "chain" },
172
+ "xmr-nodes-disagree": { retryable: true, side: "chain" },
173
+ "xmr-no-nodes": { retryable: true, side: "chain" },
174
+ "xmr-underpaid": { retryable: false, side: "chain" },
175
+ "xmr-too-late": { retryable: false, side: "chain" },
176
+ "contract-reverted": { retryable: false, side: "chain" },
177
+ "insufficient-funds": { retryable: false, side: "wallet" },
178
+ "gas-refused-by-maker": { retryable: false, side: "provider" },
179
+ "gas-not-arrived": { retryable: true, side: "chain" },
180
+ "gas-too-little": { retryable: false, side: "provider" },
181
+ "unknown": { retryable: false, side: "unknown" },
182
+ });
183
+
128
184
  // GUARD KIND -> CHECK CODE. Kinds come from the engine (www/js/core/preSignGuards.js), codes are our public
129
185
  // vocabulary. The table is checked by machine: every kind the engine can return must be here, otherwise the
130
186
  // interface gets a code that is not in the contract.