@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 +32 -0
- package/engine/core/deadlineScale.js +59 -0
- package/engine/core/xmrAmount.js +51 -0
- package/engine/monero/arrivalRead.js +95 -0
- package/engine/monero/daemonAddress.js +82 -0
- package/engine/monero/nodeNetwork.js +15 -0
- package/index.d.ts +149 -0
- package/package.json +15 -2
- package/src/adapters/monero.mjs +12 -0
- package/src/codes.mjs +56 -0
- package/src/destination.mjs +46 -0
- package/src/feeEstimate.mjs +80 -0
- package/src/index.mjs +3 -3
- package/src/probes.mjs +73 -0
- package/src/public/core.d.ts +37 -0
- package/src/public/core.mjs +16 -0
- package/src/public/xmr.d.ts +155 -0
- package/src/public/xmr.mjs +34 -0
- package/src/recovery.mjs +72 -1
- package/src/sweep-restore.mjs +130 -0
- package/src/sweep.mjs +90 -1
- package/src/version.mjs +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// WHERE THE WITHDRAWAL GOES, AND WHETHER THAT IS WHO THE FILE PROMISED.
|
|
2
|
+
//
|
|
3
|
+
// The recovery file names the recipient the swap was made for. The page compared the address the person typed with
|
|
4
|
+
// the file's and showed a warning - a rule about the file, implemented in the interface that happens to read it.
|
|
5
|
+
// The comparison is not presentation: sending XMR to another address is a different payment.
|
|
6
|
+
//
|
|
7
|
+
// THE SHAPE IS CHECKED AGAINST THE FILE'S NETWORK. A stagenet file and a mainnet address is not "close enough": the
|
|
8
|
+
// transfer would go to another chain. The checksum is checked when the library is available; when it is not, the
|
|
9
|
+
// answer says so (`verified: false`) instead of pretending.
|
|
10
|
+
//
|
|
11
|
+
// A FILE WITHOUT A PROMISED ADDRESS IS NOT A FAILURE: `matches` is null, and the caller decides what to say.
|
|
12
|
+
import { checkAddress } from "../engine/monero/address.js";
|
|
13
|
+
|
|
14
|
+
const text = (value) => (typeof value === "string" ? value.trim() : "");
|
|
15
|
+
|
|
16
|
+
/** The address the file promised, if it named one. */
|
|
17
|
+
export function promisedAddress(payload) {
|
|
18
|
+
const receive = payload && payload.receive;
|
|
19
|
+
const named = receive && typeof receive.address === "string" ? receive.address.trim() : "";
|
|
20
|
+
return named || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compare a destination with the file's promise.
|
|
25
|
+
*
|
|
26
|
+
* `ok` answers "is this a usable address on the file's network"; `matches` answers "is it the promised one" and is
|
|
27
|
+
* null when the file named none. `reason` is the library's own phrase when the shape is wrong.
|
|
28
|
+
*/
|
|
29
|
+
export async function checkDestination(payload, dest) {
|
|
30
|
+
const address = text(dest);
|
|
31
|
+
const promised = promisedAddress(payload);
|
|
32
|
+
const network = (payload && payload.moneroWallet && payload.moneroWallet.network) || null;
|
|
33
|
+
if (!address) {
|
|
34
|
+
return { ok: false, dest: address, promised, matches: promised === null ? null : false, network, verified: false, reason: "no address" };
|
|
35
|
+
}
|
|
36
|
+
const shape = await checkAddress(address, network ? { expectNetwork: network } : {});
|
|
37
|
+
return {
|
|
38
|
+
ok: shape.ok === true,
|
|
39
|
+
dest: address,
|
|
40
|
+
promised,
|
|
41
|
+
matches: promised === null ? null : address === promised,
|
|
42
|
+
network,
|
|
43
|
+
verified: shape.verified === true,
|
|
44
|
+
reason: shape.reason || null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// WHAT A TRANSACTION WILL COST, ASKED FROM THE NODE THAT WILL CARRY IT.
|
|
2
|
+
//
|
|
3
|
+
// THE FEE = THE PRICE OF A BYTE × THE SIZE OF THE TRANSACTION. The price belongs to the network, not to the wallet:
|
|
4
|
+
// on Monero it moves with the median block load, so it is not set but ASKED - `get_fee_estimate` returns a price per
|
|
5
|
+
// kilobyte for several priorities. The size is known only after the transaction is assembled (rings, inputs, outputs),
|
|
6
|
+
// so this module answers the PRICE, and `feeForBytes` is the only arithmetic that turns it into a sum.
|
|
7
|
+
//
|
|
8
|
+
// The page used to learn the fee only after signing; a person deciding whether to withdraw should see it before.
|
|
9
|
+
//
|
|
10
|
+
// THE DAEMON ANSWERS IN snake_case AND THE LIBRARY RENAMES IT - both forms are accepted. A field the node did not send
|
|
11
|
+
// is null, never zero: zero is a legal price and would read as a free transaction.
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
13
|
+
|
|
14
|
+
const text = (value) => (typeof value === "string" ? value.trim() : "");
|
|
15
|
+
|
|
16
|
+
/** A per-KB price -> the fee for a transaction of `bytes`, rounded UP: one unit short is still short. */
|
|
17
|
+
export function feeForBytes(feePerKB, bytes) {
|
|
18
|
+
let perKB;
|
|
19
|
+
let size;
|
|
20
|
+
try { perKB = BigInt(feePerKB); size = BigInt(bytes); } catch { throw new RangeError("feeForBytes: whole numbers only"); }
|
|
21
|
+
if (perKB < 0n || size < 0n) throw new RangeError("feeForBytes: negative input");
|
|
22
|
+
return ((perKB * size + 1023n) / 1024n).toString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const atomicField = (source, ...names) => {
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const value = source[name];
|
|
28
|
+
if (value === undefined || value === null || value === "") continue;
|
|
29
|
+
try { return BigInt(value).toString(); } catch { continue; }
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ASK A NODE WHAT A TRANSACTION WILL COST.
|
|
36
|
+
*
|
|
37
|
+
* The answer is a price per kilobyte (and the median, and the daemon's own priority list). The exact sum needs the
|
|
38
|
+
* assembled transaction; `feeForBytes` is the arithmetic that gets there. Never throws - a refusal names the problem.
|
|
39
|
+
*/
|
|
40
|
+
export async function moneroFeeEstimate({ url, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
41
|
+
// TRAILING SLASHES WITHOUT A REGEX: a pattern anchored on a run of slashes is flagged as a slow expression on
|
|
42
|
+
// hostile input, and this value can be typed by a person.
|
|
43
|
+
let base = text(url);
|
|
44
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
45
|
+
if (!base) return { ok: false, why: "no-url" };
|
|
46
|
+
if (typeof fetchImpl !== "function") return { ok: false, why: "no-fetch" };
|
|
47
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
48
|
+
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || DEFAULT_TIMEOUT_MS)) : null;
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await fetchImpl(base + "/json_rpc", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "0", method: "get_fee_estimate" }),
|
|
55
|
+
signal: controller ? controller.signal : undefined,
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
|
|
59
|
+
return { ok: false, why: aborted ? "timeout" : "unreachable" };
|
|
60
|
+
} finally {
|
|
61
|
+
if (timer) clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
if (!response || !response.ok) return { ok: false, why: "http", status: response ? response.status : null };
|
|
64
|
+
let body = null;
|
|
65
|
+
try { body = await response.json(); } catch { return { ok: false, why: "not-json" }; }
|
|
66
|
+
if (!body || body.error) return { ok: false, why: "rpc", rpc: body && body.error ? body.error.code ?? null : null };
|
|
67
|
+
const result = body.result || {};
|
|
68
|
+
const feePerKB = atomicField(result, "fee_per_kb", "feePerKB");
|
|
69
|
+
const medianFeePerKB = atomicField(result, "median_fee_per_kb", "medianFeePerKB");
|
|
70
|
+
const list = result.fees ?? result.feesPerKB ?? null;
|
|
71
|
+
let perPriority = null;
|
|
72
|
+
if (Array.isArray(list)) {
|
|
73
|
+
perPriority = [];
|
|
74
|
+
for (const value of list) {
|
|
75
|
+
try { perPriority.push(BigInt(value).toString()); } catch { perPriority = null; break; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (feePerKB === null && medianFeePerKB === null && !perPriority) return { ok: false, why: "no-estimate" };
|
|
79
|
+
return { ok: true, url: base, feePerKB, medianFeePerKB, perPriority };
|
|
80
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { contextOf, verifyCounterparty, jointAddress } from "./order.mjs";
|
|
|
19
19
|
import * as orderFlow from "./swap-flow.mjs";
|
|
20
20
|
import { createSweep } from "./sweep.mjs";
|
|
21
21
|
import { SdkError, fail } from "./errors.mjs";
|
|
22
|
-
import { ERROR_CODES, STEP_CODES, CHECK_CODES } from "./codes.mjs";
|
|
22
|
+
import { ERROR_CODES, ERROR_META, ERROR_SIDES, STEP_CODES, CHECK_CODES } from "./codes.mjs";
|
|
23
23
|
|
|
24
24
|
// A surface declared by the contract but answering with a code: that is more honest than returning an empty
|
|
25
25
|
// value that looks like data. The stage number is named so the interface understands what to expect
|
|
@@ -232,7 +232,7 @@ export function createNinsei(options) {
|
|
|
232
232
|
config,
|
|
233
233
|
wallet,
|
|
234
234
|
preflight,
|
|
235
|
-
recovery: createRecovery(),
|
|
235
|
+
recovery: createRecovery({ http, config }),
|
|
236
236
|
|
|
237
237
|
// THE ORDER: checking the counterparty's side and the joint address. Both checks - BEFORE signing: after it
|
|
238
238
|
// there is nothing to fix.
|
|
@@ -288,7 +288,7 @@ export function createNinsei(options) {
|
|
|
288
288
|
});
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
export { SdkError, ERROR_CODES, STEP_CODES, CHECK_CODES };
|
|
291
|
+
export { SdkError, ERROR_CODES, ERROR_META, ERROR_SIDES, STEP_CODES, CHECK_CODES };
|
|
292
292
|
export { SDK_VERSION } from "./version.mjs";
|
|
293
293
|
|
|
294
294
|
// THE LEVEL WALK IS ON THE PACKAGE SURFACE. A maker publishes a level set (chunks of volume with their own prices)
|
package/src/probes.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// IS THE NODE A WITHDRAWAL CAN USE, AND WHAT DOES IT SAY ABOUT ITSELF.
|
|
2
|
+
//
|
|
3
|
+
// The sweep page ran its own preflight: a request to get_info, a check for the height, a guess about the network.
|
|
4
|
+
// Every interface that withdraws needs the same answer, and a preflight that disagrees with the wallet builder is a
|
|
5
|
+
// page that says "ready" and then fails to open a wallet.
|
|
6
|
+
//
|
|
7
|
+
// ONE REQUEST, ONE ANSWER. `get_info` is what the node can be asked without a wallet: height, nettype, sync state and
|
|
8
|
+
// version. The answer names WHY it is not usable instead of a bare false - "unreachable" and "this node reports an
|
|
9
|
+
// unknown network" are different problems for the person.
|
|
10
|
+
//
|
|
11
|
+
// A TIMEOUT IS A REFUSAL, NOT A HANG: the request is abandoned after `timeoutMs` (8 seconds by default).
|
|
12
|
+
import { networkFromNode } from "../engine/monero/nodeNetwork.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
15
|
+
|
|
16
|
+
const text = (value) => (typeof value === "string" ? value.trim() : "");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ask a Monero node for `get_info`.
|
|
20
|
+
*
|
|
21
|
+
* `fetchImpl` is injected (the browser's `fetch` by default) so a Node consumer and a test can pass their own.
|
|
22
|
+
* The answer never throws: `{ ok: false, why }` names the problem.
|
|
23
|
+
*/
|
|
24
|
+
export async function probeMoneroNode({ url, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
25
|
+
// TRAILING SLASHES WITHOUT A REGEX: a pattern anchored on a run of slashes is flagged as a slow expression on
|
|
26
|
+
// input a person can type, and this address is exactly that.
|
|
27
|
+
let base = text(url);
|
|
28
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
29
|
+
if (!base) return { ok: false, why: "no-url" };
|
|
30
|
+
if (typeof fetchImpl !== "function") return { ok: false, why: "no-fetch" };
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
33
|
+
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || DEFAULT_TIMEOUT_MS)) : null;
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetchImpl(base + "/json_rpc", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "0", method: "get_info" }),
|
|
40
|
+
signal: controller ? controller.signal : undefined,
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
|
|
44
|
+
return { ok: false, why: aborted ? "timeout" : "unreachable", ms: Date.now() - started };
|
|
45
|
+
} finally {
|
|
46
|
+
if (timer) clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
if (!response || !response.ok) {
|
|
49
|
+
return { ok: false, why: "http", status: response ? response.status : null, ms: Date.now() - started };
|
|
50
|
+
}
|
|
51
|
+
let body = null;
|
|
52
|
+
try { body = await response.json(); } catch { return { ok: false, why: "not-json", ms: Date.now() - started }; }
|
|
53
|
+
if (!body || body.error) {
|
|
54
|
+
return { ok: false, why: "rpc", rpc: body && body.error ? body.error.code ?? null : null, ms: Date.now() - started };
|
|
55
|
+
}
|
|
56
|
+
const info = body.result || {};
|
|
57
|
+
const height = Number(info.height);
|
|
58
|
+
if (!Number.isInteger(height) || height < 0) return { ok: false, why: "no-height", ms: Date.now() - started };
|
|
59
|
+
// THE NETWORK THE NODE NAMES must be one the wallet builder accepts; an unknown word is not "probably stagenet".
|
|
60
|
+
const network = networkFromNode(info);
|
|
61
|
+
if (!network) return { ok: false, why: "nettype", nettype: info.nettype ?? null, ms: Date.now() - started };
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
url: base,
|
|
65
|
+
height,
|
|
66
|
+
network,
|
|
67
|
+
nettype: info.nettype ?? null,
|
|
68
|
+
synchronized: info.synchronized === true,
|
|
69
|
+
version: info.version ?? null,
|
|
70
|
+
status: info.status ?? null,
|
|
71
|
+
ms: Date.now() - started,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/public/core.d.ts
CHANGED
|
@@ -11,3 +11,40 @@
|
|
|
11
11
|
* ("1,234.5" is 1234.5), so what the field shows and what the rest of the code computes cannot diverge.
|
|
12
12
|
*/
|
|
13
13
|
export declare function parseAmount(raw: string | number | null | undefined): number | null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ATOMIC UNITS -> A DECIMAL STRING. One XMR is 10^12 atomic units; the conversion uses BigInt, so a value above
|
|
17
|
+
* 2^53 (about 9007 XMR in piconero) is not rounded. Dust does not vanish: 1 unit is "0.000000000001". `trim`
|
|
18
|
+
* drops trailing zeros in the fraction; the result is always a plain decimal, never grouped.
|
|
19
|
+
*
|
|
20
|
+
* Throws a RangeError when the input is not a whole number of atomic units.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatXmr(atomic: bigint | number | string, options?: { readonly trim?: boolean }): string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A DECIMAL AMOUNT TYPED BY A PERSON -> ATOMIC UNITS, or null when the text is not an amount. A comma without a
|
|
26
|
+
* dot is the decimal separator ("0,05"). More than twelve decimals is null: rounding an amount is a wrong payment
|
|
27
|
+
* that looks successful.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseXmr(text: string | number | null | undefined): bigint | null;
|
|
30
|
+
|
|
31
|
+
/** A value below this is in seconds. Separated from the millisecond threshold on purpose: the gap has no scale. */
|
|
32
|
+
export declare const SECONDS_SCALE_MAX: number;
|
|
33
|
+
/** A value from this on is in milliseconds. */
|
|
34
|
+
export declare const MS_SCALE_MIN: number;
|
|
35
|
+
export declare const DEADLINE_SCALE: Readonly<{ seconds: "seconds"; milliseconds: "milliseconds"; unknown: "unknown" }>;
|
|
36
|
+
export type DeadlineScale = "seconds" | "milliseconds" | "unknown";
|
|
37
|
+
|
|
38
|
+
/** THE SCALE OF A VALUE. Empty, zero, a non-number, or a value between the thresholds: unknown - never a guess. */
|
|
39
|
+
export declare function scaleOf(value: unknown): DeadlineScale;
|
|
40
|
+
export declare function isSecondsScale(value: unknown): boolean;
|
|
41
|
+
export declare function isMsScale(value: unknown): boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* THE ONLY CONVERSION from the signed order's seconds to milliseconds. Empty gives null; a value already in
|
|
45
|
+
* milliseconds is refused (a second conversion would multiply the thousand in silence).
|
|
46
|
+
*/
|
|
47
|
+
export declare function msFromOrderSeconds(value: unknown, what?: string): number | null;
|
|
48
|
+
|
|
49
|
+
/** A write guard: throws unless the value is in milliseconds. Returns true when it passes. */
|
|
50
|
+
export declare function assertMsScale(value: unknown, what?: string): true;
|
package/src/public/core.mjs
CHANGED
|
@@ -4,3 +4,19 @@
|
|
|
4
4
|
// consumer forced to import it from either would learn a false lesson about where it lives.
|
|
5
5
|
|
|
6
6
|
export { parseAmount } from "../../engine/core/format.js";
|
|
7
|
+
|
|
8
|
+
// XMR AMOUNTS IN ATOMIC UNITS: 12 decimals, bigint, no rounding. `formatXmr` for display and for a field,
|
|
9
|
+
// `parseXmr` for what a person typed.
|
|
10
|
+
export { formatXmr, parseXmr } from "../../engine/core/xmrAmount.js";
|
|
11
|
+
|
|
12
|
+
// SECONDS AT THE CHAIN, MILLISECONDS EVERYWHERE ELSE: the scale of a deadline, and the one conversion at the boundary.
|
|
13
|
+
export {
|
|
14
|
+
DEADLINE_SCALE,
|
|
15
|
+
SECONDS_SCALE_MAX,
|
|
16
|
+
MS_SCALE_MIN,
|
|
17
|
+
scaleOf,
|
|
18
|
+
isSecondsScale,
|
|
19
|
+
isMsScale,
|
|
20
|
+
msFromOrderSeconds,
|
|
21
|
+
assertMsScale,
|
|
22
|
+
} from "../../engine/core/deadlineScale.js";
|
package/src/public/xmr.d.ts
CHANGED
|
@@ -316,3 +316,158 @@ export declare function sellWindowSec(request?: {
|
|
|
316
316
|
readonly soft?: number;
|
|
317
317
|
readonly hard?: number;
|
|
318
318
|
}): number;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* WHAT NETWORK A NODE SAYS IT IS ON, from its `get_info` answer. The wallet must be built from this value: a
|
|
322
|
+
* mainnet wallet against a stagenet node looks like a broken withdrawal. An answer that is none of `mainnet`,
|
|
323
|
+
* `stagenet`, `testnet` comes back as null - a default would build a wallet for a chain the node may not serve.
|
|
324
|
+
*/
|
|
325
|
+
export declare function networkFromNode(info: unknown): "mainnet" | "stagenet" | "testnet" | null;
|
|
326
|
+
|
|
327
|
+
/** An address read as its network and its public keys. An integrated address's payment id is not returned. */
|
|
328
|
+
export interface AddressKeys {
|
|
329
|
+
readonly network: "mainnet" | "stagenet" | "testnet" | null;
|
|
330
|
+
/** The first payload byte, kept for callers that meet a prefix the table does not know. */
|
|
331
|
+
readonly netByte: number;
|
|
332
|
+
readonly spendPub: string;
|
|
333
|
+
readonly viewPub: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** AN ADDRESS -> ITS NETWORK AND PUBLIC KEYS. Throws when the text is not a Monero address. */
|
|
337
|
+
export declare function decodeAddressKeys(address: string): AddressKeys;
|
|
338
|
+
|
|
339
|
+
/** A SCALAR AS LITTLE-ENDIAN HEX, low byte first - the form the daemon expects. */
|
|
340
|
+
export declare function scalarToLeHex(scalar: bigint | number | string, bytes?: number): string;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* WHETHER A VIEW KEY IS THE ONE THE ADDRESS NAMES: true / false, or null when there is no public key to compare.
|
|
344
|
+
* `halves` is what `createHalves` returns, so the comparison uses the same arithmetic as the address build.
|
|
345
|
+
*/
|
|
346
|
+
export declare function viewKeyMatchesAddress(
|
|
347
|
+
viewScalar: bigint | number | string,
|
|
348
|
+
expectedViewPubHex: string,
|
|
349
|
+
halves: { publicHalves(scalar: bigint | number | string): { ed: string } },
|
|
350
|
+
): boolean | null;
|
|
351
|
+
|
|
352
|
+
/** A boundary up to this value is a block height; from it on, a unix time. */
|
|
353
|
+
export declare const UNLOCK_TIME_BLOCK_MAX: number;
|
|
354
|
+
|
|
355
|
+
export interface UnlockState {
|
|
356
|
+
readonly code: "unlocked" | "locked_height" | "locked_time";
|
|
357
|
+
readonly locked: boolean;
|
|
358
|
+
readonly untilHeight: number | null;
|
|
359
|
+
readonly untilTime: number | null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* WHETHER A COIN IS SPENDABLE. A height that is unknown does not open the coin: without the height there is no
|
|
364
|
+
* fact that would. `nowSec` defaults to the wall clock.
|
|
365
|
+
*/
|
|
366
|
+
export declare function unlockStateOf(
|
|
367
|
+
unlockTime: number | bigint | string,
|
|
368
|
+
options?: { height?: number | null; nowSec?: number | null },
|
|
369
|
+
): UnlockState;
|
|
370
|
+
|
|
371
|
+
/** The names that count as loopback: 127.0.0.1, ::1, localhost - not all of 127.0.0.0/8. */
|
|
372
|
+
export declare const LOOPBACK_HOSTS: ReadonlySet<string>;
|
|
373
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
374
|
+
|
|
375
|
+
/** An address read as scheme and host, or null when the text is not a URL. */
|
|
376
|
+
export declare function hostOfUrl(url: string): { scheme: string; host: string } | null;
|
|
377
|
+
|
|
378
|
+
export type DaemonCode = "daemon-not-https" | "daemon-address-unreadable" | "daemon-scheme-unknown";
|
|
379
|
+
export declare const DAEMON_CODES: Readonly<{ notHttps: DaemonCode; unreadable: DaemonCode; schemeUnknown: DaemonCode }>;
|
|
380
|
+
export declare const DAEMON_PLAIN_HTTP_NETWORKS: readonly string[];
|
|
381
|
+
export declare const DAEMON_NETWORK_MAINNET: string;
|
|
382
|
+
|
|
383
|
+
/** A setting in one shape: 0/false/no/off switches it off; anything else, a typo included, leaves it on. */
|
|
384
|
+
export declare function plainLoopbackAllowed(raw: unknown): boolean;
|
|
385
|
+
|
|
386
|
+
export interface DaemonAddressJudgement {
|
|
387
|
+
readonly ok: boolean;
|
|
388
|
+
/** False for an empty value: nothing to judge, not a refusal. */
|
|
389
|
+
readonly configured: boolean;
|
|
390
|
+
readonly scheme: string | null;
|
|
391
|
+
readonly host: string | null;
|
|
392
|
+
readonly loopback: boolean;
|
|
393
|
+
readonly plainLoopbackAllowed: boolean;
|
|
394
|
+
/** True when a remote plain-http daemon was accepted because the network is stagenet or testnet. */
|
|
395
|
+
readonly plainHttpAccepted: boolean;
|
|
396
|
+
readonly network: string;
|
|
397
|
+
readonly code: DaemonCode | null;
|
|
398
|
+
readonly reason: string | null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* MAY THIS DAEMON ADDRESS BE USED. https anywhere; http on loopback; http to a remote host only on stagenet and
|
|
403
|
+
* testnet. An unknown network is judged as mainnet.
|
|
404
|
+
*/
|
|
405
|
+
export declare function judgeDaemonAddress(
|
|
406
|
+
raw: string | null | undefined,
|
|
407
|
+
options?: { network?: string; allowPlainLoopback?: boolean },
|
|
408
|
+
): DaemonAddressJudgement;
|
|
409
|
+
|
|
410
|
+
/** A per-KB price -> the fee for a transaction of `bytes`, rounded UP. Throws on a non-integer or a negative input. */
|
|
411
|
+
export declare function feeForBytes(feePerKB: bigint | number | string, bytes: bigint | number | string): string;
|
|
412
|
+
|
|
413
|
+
export interface FeeEstimateOk {
|
|
414
|
+
readonly ok: true;
|
|
415
|
+
readonly url: string;
|
|
416
|
+
/** The price per kilobyte for the next block, in atomic units, or null when the daemon did not send it. */
|
|
417
|
+
readonly feePerKB: string | null;
|
|
418
|
+
readonly medianFeePerKB: string | null;
|
|
419
|
+
/** The daemon's own priority list, in its order, or null. */
|
|
420
|
+
readonly perPriority: readonly string[] | null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export interface FeeEstimateRefusal {
|
|
424
|
+
readonly ok: false;
|
|
425
|
+
/** no-url | no-fetch | timeout | unreachable | http | not-json | rpc | no-estimate */
|
|
426
|
+
readonly why: string;
|
|
427
|
+
readonly status?: number | null;
|
|
428
|
+
readonly rpc?: number | null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* ASK A NODE WHAT A TRANSACTION WILL COST (`get_fee_estimate`). The exact sum needs the assembled transaction, so
|
|
433
|
+
* this is a price per kilobyte; `feeForBytes` is the only arithmetic that turns it into a fee. Never throws.
|
|
434
|
+
*/
|
|
435
|
+
export declare function moneroFeeEstimate(options: {
|
|
436
|
+
url: string;
|
|
437
|
+
fetchImpl?: typeof fetch;
|
|
438
|
+
timeoutMs?: number;
|
|
439
|
+
}): Promise<FeeEstimateOk | FeeEstimateRefusal>;
|
|
440
|
+
|
|
441
|
+
/** The answer to a preflight: usable, or the named reason it is not. */
|
|
442
|
+
export interface NodeProbeOk {
|
|
443
|
+
readonly ok: true;
|
|
444
|
+
readonly url: string;
|
|
445
|
+
readonly height: number;
|
|
446
|
+
readonly network: "mainnet" | "stagenet" | "testnet";
|
|
447
|
+
readonly nettype: string | null;
|
|
448
|
+
readonly synchronized: boolean;
|
|
449
|
+
readonly version: string | null;
|
|
450
|
+
readonly status: string | null;
|
|
451
|
+
readonly ms: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export interface NodeProbeRefusal {
|
|
455
|
+
readonly ok: false;
|
|
456
|
+
/** no-url | no-fetch | timeout | unreachable | http | not-json | rpc | no-height | nettype */
|
|
457
|
+
readonly why: string;
|
|
458
|
+
readonly status?: number | null;
|
|
459
|
+
readonly nettype?: string | null;
|
|
460
|
+
readonly rpc?: number | null;
|
|
461
|
+
readonly ms?: number;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* ASK A NODE FOR `get_info`: the height, the network it names, whether it is synchronized. Never throws - the
|
|
466
|
+
* answer names the problem (`unreachable`, `timeout`, `nettype`, ...). `fetchImpl` is injected so a Node consumer
|
|
467
|
+
* or a test can pass its own; `timeoutMs` abandons the request (8 seconds by default).
|
|
468
|
+
*/
|
|
469
|
+
export declare function probeMoneroNode(options: {
|
|
470
|
+
url: string;
|
|
471
|
+
fetchImpl?: typeof fetch;
|
|
472
|
+
timeoutMs?: number;
|
|
473
|
+
}): Promise<NodeProbeOk | NodeProbeRefusal>;
|
package/src/public/xmr.mjs
CHANGED
|
@@ -27,3 +27,37 @@ export { createDleq2 } from "../../engine/atomic/dleq2.js";
|
|
|
27
27
|
export { scalarFromMoneroHex, spendKeysFromRevealedHalf } from "../../engine/monero/halvesSweep.js";
|
|
28
28
|
|
|
29
29
|
export { SELL_WINDOW, sellWindowSec } from "../../engine/core/sellWindow.js";
|
|
30
|
+
|
|
31
|
+
// WHAT NETWORK A NODE SAYS IT IS ON: the wallet must be built from that answer, and an unknown answer is null -
|
|
32
|
+
// a default here would build a wallet for a chain the node may not serve.
|
|
33
|
+
export { networkFromNode } from "../../engine/monero/nodeNetwork.js";
|
|
34
|
+
|
|
35
|
+
// READING AN ARRIVAL WITHOUT A WALLET: the address and its public keys, the little-endian scalar form the daemon
|
|
36
|
+
// expects, whether a view key matches the address, and whether the coin is still locked by unlock_time.
|
|
37
|
+
export {
|
|
38
|
+
decodeAddressKeys,
|
|
39
|
+
scalarToLeHex,
|
|
40
|
+
viewKeyMatchesAddress,
|
|
41
|
+
unlockStateOf,
|
|
42
|
+
UNLOCK_TIME_BLOCK_MAX,
|
|
43
|
+
} from "../../engine/monero/arrivalRead.js";
|
|
44
|
+
|
|
45
|
+
// WHERE A MONERO NODE MAY LIVE: https anywhere, http on loopback, http to a remote host only on stagenet/testnet -
|
|
46
|
+
// one policy for the node and for the page that lets an operator type an address.
|
|
47
|
+
export {
|
|
48
|
+
DAEMON_CODES,
|
|
49
|
+
DAEMON_NETWORK_MAINNET,
|
|
50
|
+
DAEMON_PLAIN_HTTP_NETWORKS,
|
|
51
|
+
LOOPBACK_HOSTS,
|
|
52
|
+
hostOfUrl,
|
|
53
|
+
isLoopbackHost,
|
|
54
|
+
judgeDaemonAddress,
|
|
55
|
+
plainLoopbackAllowed,
|
|
56
|
+
} from "../../engine/monero/daemonAddress.js";
|
|
57
|
+
|
|
58
|
+
// WHAT A TRANSACTION WILL COST: the daemon's price per kilobyte, by priority, and the arithmetic that turns it into
|
|
59
|
+
// a fee. The page learned the fee only after signing.
|
|
60
|
+
export { moneroFeeEstimate, feeForBytes } from "../feeEstimate.mjs";
|
|
61
|
+
|
|
62
|
+
// IS THE NODE USABLE, AND WHAT DOES IT SAY ABOUT ITSELF: one get_info request, an answer that names why not.
|
|
63
|
+
export { probeMoneroNode } from "../probes.mjs";
|
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
|
}
|