@ninsei-labs/sdk 0.43.1 → 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 +61 -0
- package/README.md +1 -1
- 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 +22 -3
- 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/provider/envFile.mjs +106 -0
- package/src/provider/identity.mjs +43 -0
- package/src/provider/sign.mjs +164 -0
- package/src/public/core.d.ts +37 -0
- package/src/public/core.mjs +16 -0
- package/src/public/order.d.ts +12 -0
- package/src/public/order.mjs +5 -0
- package/src/public/provider.d.ts +93 -0
- package/src/public/provider.mjs +39 -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
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.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// THE SERVICE SETTINGS FILE: WHERE IT LIES AND HOW IT IS READ.
|
|
2
|
+
//
|
|
3
|
+
// The module deliberately imports NOTHING from config.mjs: the entry point connects it BEFORE the config loads, so that
|
|
4
|
+
// the values get into the environment in time. config.mjs reads process.env at module load time, and static
|
|
5
|
+
// imports in ESM run before any code of the file - so 'read the file, then import the config' is
|
|
6
|
+
// possible only through a dynamic import and only from a separate module.
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
// WHERE TO LOOK WHEN THE FLAG IS NOT GIVEN: the current directory (the command is run from the node tree), then rfq/ under it (the layout of the tree this node was cut from)
|
|
12
|
+
// (the same run from the repository root), then .env - that is also a frequent name for a settings file. What was found
|
|
13
|
+
// is printed: a silent read of 'some' file is exactly the case where people later look for why the settings are wrong.
|
|
14
|
+
//
|
|
15
|
+
// ORDER: THE LOCAL FILE OUTRANKS THE PRODUCTION ONE. The local contour puts its settings BESIDE the production ones
|
|
16
|
+
// (rfq.env.local), and while the search went from the plain name to .local, the developer tool picked up the production
|
|
17
|
+
// file: the wallet path /opt/ninsei/..., the production price feed and its age - and refused with 'nothing to price the order with' where
|
|
18
|
+
// the settings were in place (the quote checks fell exactly on this, and `npm run payout` with them).
|
|
19
|
+
// The reverse order can err in one direction only: on the server there is no .local file at all, and the production
|
|
20
|
+
// one remains the only one. An explicit --env= still outranks everything.
|
|
21
|
+
export const ENV_CANDIDATES = ["rfq.env.local", "rfq.env.local", "rfq.env", "rfq.env", ".env.local", ".env"];
|
|
22
|
+
|
|
23
|
+
export function findEnvFile(cwd, candidates = ENV_CANDIDATES) {
|
|
24
|
+
for (const rel of candidates) {
|
|
25
|
+
const full = join(cwd, rel);
|
|
26
|
+
if (existsSync(full)) return full;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A PATH OF A FOREIGN MACHINE IN A SETTINGS FILE IS AN ERROR, NOT A SETTING.
|
|
32
|
+
//
|
|
33
|
+
// The trap was found in battle on 25.09.2026. The local files (*.env.local) travelled to the server with the directory upload
|
|
34
|
+
// (the documented rsync commands had no --exclude '*.env.local'), the tool honestly took the local file -
|
|
35
|
+
// it outranks the production one, see below - start directory and the result was:
|
|
36
|
+
//
|
|
37
|
+
// /opt/ninsei/keys
|
|
38
|
+
//
|
|
39
|
+
// The check is one-sided DELIBERATELY: the reverse case - production paths /opt/... on a developer machine - is legitimate,
|
|
40
|
+
// because the repository holds copies of the production files, and they must be readable. The function is pure: the platform comes
|
|
41
|
+
// as a parameter, so its behaviour is checkable on any machine.
|
|
42
|
+
export function foreignPathProblem(file, values, platform = process.platform) {
|
|
43
|
+
if (platform === "win32") return null;
|
|
44
|
+
for (const [key, value] of Object.entries(values || {})) {
|
|
45
|
+
const v = String(value).trim();
|
|
46
|
+
if (/^(?:[A-Za-z]:[\\/]|\\\\)/.test(v)) return { file, key, value: v };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function foreignPathMessage(p) {
|
|
52
|
+
return "settings file " + p.file + " sets a Windows path in setting " + p.key + " (" + p.value
|
|
53
|
+
+ "), and the system is not Windows. This is how a local file (*.env.local) copied from a developer "
|
|
54
|
+
+ "machine looks: it must not be on the server, the service reads <service>/<service>.env. Delete the local "
|
|
55
|
+
+ "file and repeat.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// We read by the systemd rules: comments and broken lines are skipped, export is stripped, quotes are stripped,
|
|
59
|
+
// spaces are trimmed.
|
|
60
|
+
export function readEnvFile(path) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
|
63
|
+
const line = raw.trim();
|
|
64
|
+
if (!line || line.startsWith("#")) continue;
|
|
65
|
+
const eq = line.indexOf("=");
|
|
66
|
+
if (eq < 1) continue;
|
|
67
|
+
const key = line.slice(0, eq).replace(/^export\s+/, "").trim();
|
|
68
|
+
let value = line.slice(eq + 1).trim();
|
|
69
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
70
|
+
value = value.slice(1, -1);
|
|
71
|
+
}
|
|
72
|
+
if (key) out[key] = value;
|
|
73
|
+
}
|
|
74
|
+
// The file is read, but the values may be FROM A FOREIGN MACHINE - then it is a refusal, not a setting.
|
|
75
|
+
const foreign = foreignPathProblem(path, out);
|
|
76
|
+
if (foreign) throw new Error(foreignPathMessage(foreign));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// WE APPLY THE FILE TO THE ENVIRONMENT. The file is the SERVICE's configuration, so it outranks what happened to be
|
|
81
|
+
// exported in the shell; explicit command flags are parsed later and outrank both. We return the key names
|
|
82
|
+
// without values: printing setting values into the log is not allowed.
|
|
83
|
+
// VARIABLE SUBSTITUTION LIVES HERE, NOT IN EVERY TOOL. In the local file the settings are written as
|
|
84
|
+
// ${STACK_DATA}/rfq-wallet.json, and they are substituted by the launcher (the stack itself). A tool that does not do this
|
|
85
|
+
// sees literally "${STACK_DATA}" - and the consequences are not only 'wallet not found': by the same literal path it
|
|
86
|
+
// creates an EMPTY quote database and then reports 'the log is empty' on a live contour, that is, the refusal looks like
|
|
87
|
+
// absence of data. Before, the substitution was copied into one tool; a copy is a sign that the place is shared.
|
|
88
|
+
export function substituteEnvVars(value, { dataDir, root }) {
|
|
89
|
+
return String(value)
|
|
90
|
+
.replace(/\$\{STACK_DATA\}/g, dataDir)
|
|
91
|
+
.replace(/\$\{ROOT\}/g, root);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function applyEnvFile(path, target = process.env) {
|
|
95
|
+
const values = readEnvFile(path);
|
|
96
|
+
const dataDir = target.STACK_DATA || join(tmpdir(), "ninsei-stack");
|
|
97
|
+
const root = target.ROOT || process.cwd();
|
|
98
|
+
const substituted = {};
|
|
99
|
+
for (const [k, v] of Object.entries(values)) substituted[k] = substituteEnvVars(v, { dataDir, root });
|
|
100
|
+
// THE SUBSTITUTION MAY BRING A FOREIGN PATH: ${STACK_DATA} expands to the directory of THE machine where it is run.
|
|
101
|
+
// We check after the substitution too - that is exactly how the production case with /opt/ninsei/ surfaced.
|
|
102
|
+
const foreign = foreignPathProblem(path, substituted);
|
|
103
|
+
if (foreign) throw new Error(foreignPathMessage(foreign));
|
|
104
|
+
for (const [k, v] of Object.entries(substituted)) target[k] = v;
|
|
105
|
+
return Object.keys(values);
|
|
106
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// RFQ NODE IDENTITY: the code from the PROVIDER ADDRESS and a human-readable name.
|
|
2
|
+
//
|
|
3
|
+
// THIS IS THE NODE'S RESPONSIBILITY, NOT OUR BACKEND'S. The node derives its code from its address itself, picks the
|
|
4
|
+
// name itself and only sends them in the packet. Our backend accepts and CHECKS the shape, but invents nothing:
|
|
5
|
+
// if we assigned the code or the name, we would be defining the provider's identity instead of him.
|
|
6
|
+
//
|
|
7
|
+
// WHY FROM THE ADDRESS AND NOT FROM THE KEY. A provider's identity is his EVM address: a quote signature is made with an EIP-712
|
|
8
|
+
// wallet key, and the CHAIN checks that the key belongs to the address (maker registry, providerOf). The Ed25519 key
|
|
9
|
+
// stays with the node, but does something else: the Monero halves are derived from it and the market packets are signed with it. The code
|
|
10
|
+
// must follow the one the chain confirms - the address. The former code was computed from the ed25519 key and
|
|
11
|
+
// therefore changed with it, not with the identity (owner's decision: backward compatibility is not needed).
|
|
12
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// Code shape. Strict: "lp-" and exactly 8 hex in lower case.
|
|
15
|
+
export const PROVIDER_CODE_RE = /^lp-[0-9a-f]{8}$/;
|
|
16
|
+
|
|
17
|
+
// A human-readable name shorter than this limit: long names break tables and logs.
|
|
18
|
+
export const DISPLAY_NAME_MAX = 48;
|
|
19
|
+
|
|
20
|
+
// PROVIDER CODE. Computed from the provider's EVM ADDRESS (lower case, with 0x), not from the ed25519 key.
|
|
21
|
+
//
|
|
22
|
+
// Why deterministic and from one address: the code must be THE SAME everywhere the maker is seen (decision 14),
|
|
23
|
+
// and must not change when the node restarts. The address IS the identity, so the code survives a key change and does not
|
|
24
|
+
// depend on which of the accepted keys signed the quote this time.
|
|
25
|
+
export function providerCode(address) {
|
|
26
|
+
const a = String(address == null ? "" : address).trim().toLowerCase();
|
|
27
|
+
if (!/^0x[0-9a-f]{40}$/.test(a)) {
|
|
28
|
+
throw new Error("provider code is derived from an EVM address, got " + JSON.stringify(String(address).slice(0, 24)));
|
|
29
|
+
}
|
|
30
|
+
return "lp-" + createHash("sha256").update(a).digest("hex").slice(0, 8);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A RANDOM NAME - for the case when no name is set in the config. The node must not stay nameless: in market
|
|
34
|
+
// view a provider has to be called something for a human. The alphabet is Latin simply because this is a generated name;
|
|
35
|
+
// a manually set one may be anything - identity is defined by the code, not by the name.
|
|
36
|
+
const WORDS = ["north", "harbor", "lumen", "cedar", "atlas", "quartz", "verde", "nova", "orbit", "delta"];
|
|
37
|
+
|
|
38
|
+
export function randomDisplayName() {
|
|
39
|
+
// randomInt, not a modulo of random bytes: 65536 % 10 leaves six words one draw more likely than the other
|
|
40
|
+
// four. The difference is invisible in a display name, but a biased draw has no reason to exist at all.
|
|
41
|
+
const word = WORDS[randomInt(WORDS.length)];
|
|
42
|
+
return word + "-" + randomBytes(2).toString("hex");
|
|
43
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Signing the quote packet of the RFQ node. Ed25519, canonical packet.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE IS COPIED AND NOT TAKEN FROM OUR SIGNER APPLICATION. The node runs AT THE PROVIDER: he has no
|
|
4
|
+
// signer application of ours, and must not. An application that pulls code from a neighbouring directory is not
|
|
5
|
+
// standalone - and the node must start on its own.
|
|
6
|
+
//
|
|
7
|
+
// THE RISK OF THE THREE IMPLEMENTATIONS DIVERGING (the feeder, rfq, the interface backend) IS CLOSED NOT BY COMMON CODE, BUT BY A CHECK.
|
|
8
|
+
// the check on our side signs the same packet with both senders and confirms it with our
|
|
9
|
+
// backend, and also compares the signatures against each other. They cannot diverge unnoticed.
|
|
10
|
+
//
|
|
11
|
+
// Canonical form: all fields except signature, keys sorted, no spaces. The order of serialisation is part of
|
|
12
|
+
// the contract: if it diverges, the signatures stop matching for everyone at once.
|
|
13
|
+
|
|
14
|
+
import { generateKeyPairSync, sign as nodeSign, verify as nodeVerify, createPrivateKey, createPublicKey } from "node:crypto";
|
|
15
|
+
import { readFileSync, writeFileSync, chmodSync, existsSync, statSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
// The canonical form of the packet: the signature is excluded, the keys are sorted, there are no spaces.
|
|
19
|
+
export function canonical(payload) {
|
|
20
|
+
const { signature, ...rest } = payload || {};
|
|
21
|
+
const sortKeys = (v) => {
|
|
22
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
23
|
+
if (v && typeof v === "object") {
|
|
24
|
+
return Object.keys(v).sort().reduce((acc, k) => {
|
|
25
|
+
acc[k] = sortKeys(v[k]);
|
|
26
|
+
return acc;
|
|
27
|
+
}, {});
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
return JSON.stringify(sortKeys(rest));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function signPayload(payload, privateKeyPem, keyId) {
|
|
35
|
+
// IMPORTANT: the FINAL packet is signed, already including keyId. Otherwise the signature is computed over one set of
|
|
36
|
+
// bytes while the check is over another (the check sees keyId in the packet), and one's own signature does not match.
|
|
37
|
+
// Verified by a round-trip test: before this fix 'own packet' gave false.
|
|
38
|
+
const pkg = { ...payload, keyId };
|
|
39
|
+
const signature = nodeSign(null, Buffer.from(canonical(pkg), "utf8"), createPrivateKey(privateKeyPem)).toString("base64");
|
|
40
|
+
return { ...pkg, signature };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The check is needed by the interface backend when it turns signature checking on. It is not called now,
|
|
44
|
+
// but it lies next to the signing: signing and checking must be computed the same way, otherwise they diverge.
|
|
45
|
+
export function verifyPayload(payload, publicKeyPem) {
|
|
46
|
+
const { signature, ...rest } = payload || {};
|
|
47
|
+
if (!signature) return false;
|
|
48
|
+
try {
|
|
49
|
+
return nodeVerify(null, Buffer.from(canonical(rest), "utf8"), createPublicKey(publicKeyPem), Buffer.from(signature, "base64"));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The public key path: beside the private one, by the same name without ".key" and with ".pub" at the end.
|
|
56
|
+
// <path>.key -> <path>.pub
|
|
57
|
+
// That way the operator does not look for the file by eye and does not copy the key from the terminal output: both files appear together.
|
|
58
|
+
export function publicKeyPath(keyFile) {
|
|
59
|
+
return String(keyFile).replace(/\.key$/i, "") + ".pub";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Key pair generation: the private one goes to a file with permissions 0600, the public one - TO A FILE BESIDE it and to the output
|
|
63
|
+
// (the public one is not a secret, it is handy both to print and to keep as a file; the private one NEVER gets into the log).
|
|
64
|
+
//
|
|
65
|
+
// Overwriting an existing key is possible ONLY explicitly (force). Otherwise - a refusal: to silently wipe someone else's key
|
|
66
|
+
// means breaking the provider, and he learns about it by rejected quotes rather than by a message.
|
|
67
|
+
export function generateKeyFiles({ keyFile, force = false } = {}) {
|
|
68
|
+
if (!keyFile) throw new Error("key file is not specified");
|
|
69
|
+
if (existsSync(keyFile) && !force) {
|
|
70
|
+
const e = new Error("key already exists: " + keyFile + " - it was NOT overwritten");
|
|
71
|
+
e.code = "KEY_EXISTS";
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
// We create the keys directory ourselves: the keys lie apart from the code (keys/), and it may be absent on a fresh install.
|
|
75
|
+
try { mkdirSync(dirname(keyFile), { recursive: true }); } catch { /* it is already there - not a reason to fall */ }
|
|
76
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
77
|
+
const priv = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
78
|
+
const pub = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
79
|
+
writeFileSync(keyFile, priv, { mode: 0o600 });
|
|
80
|
+
try { chmodSync(keyFile, 0o600); } catch { /* on some file systems there are no permissions - not a reason to fall */ }
|
|
81
|
+
const pubFile = publicKeyPath(keyFile);
|
|
82
|
+
writeFileSync(pubFile, pub, { mode: 0o644 });
|
|
83
|
+
return { keyFile, pubFile, publicKeyPem: pub };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Reading a key with a permission check: if the file is available to anyone besides the owner - a refusal, not silent work.
|
|
87
|
+
// THE RAW KEY BYTES - the material for deriving the halves for orders. For an ed25519 key this is exactly
|
|
88
|
+
// 32 bytes of a uniform seed: HMAC and hashing over them give a result indistinguishable from random.
|
|
89
|
+
//
|
|
90
|
+
// AN IMPORTANT CONSEQUENCE ONE MUST KNOW: the derivation requires the RAW key in the process. So a node whose
|
|
91
|
+
// signing key has moved into hardware (HSM, a hardware wallet) will not be able to issue an order quote - and this is
|
|
92
|
+
// an honest limitation, not a breakdown: while the key is there, the node is both identified and serves such quotes.
|
|
93
|
+
export function privateKeySeedBytes(privateKeyPem) {
|
|
94
|
+
const ko = createPrivateKey(privateKeyPem);
|
|
95
|
+
const jwk = ko.export({ format: "jwk" });
|
|
96
|
+
if (!jwk || !jwk.d) throw new Error("could not get raw bytes from the signing key (an ed25519 key is required)");
|
|
97
|
+
const bytes = Buffer.from(jwk.d, "base64url");
|
|
98
|
+
if (bytes.length < 16) throw new Error("signing key is shorter than 16 bytes: unusable for half derivation");
|
|
99
|
+
return new Uint8Array(bytes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function loadPrivateKey(keyFile) {
|
|
103
|
+
const st = statSync(keyFile);
|
|
104
|
+
// The permission check - only where permissions exist. On Windows there are no POSIX mode bits (the file is always seen
|
|
105
|
+
// as 666), and access is defined by ACLs, not by these bits: refusing on Windows would mean not letting the
|
|
106
|
+
// service start locally at all. This is not a weakening - on Linux the check works as it worked.
|
|
107
|
+
if (process.platform !== "win32" && (st.mode & 0o077) !== 0) {
|
|
108
|
+
throw new Error("key file is accessible to others: " + keyFile + " (mode 0600 is required)");
|
|
109
|
+
}
|
|
110
|
+
return readFileSync(keyFile, "utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// THE NODE'S PUBLIC KEY: THE ONE IT SIGNS WITH, AND NOT THE CONTENT OF THE FILE BESIDE IT (partner request to the node).
|
|
114
|
+
//
|
|
115
|
+
// WHY FROM THE LOADED KEY AND NOT FROM THE .pub FILE. The signature is made by the key the node read at start
|
|
116
|
+
// (loadPrivateKey), so ITS public key must go out: the file beside it could be left from a former
|
|
117
|
+
// key, come from a backup or be substituted. A reader who took the key from the file would check the signature
|
|
118
|
+
// with the wrong key and would take a live node for a foreign one.
|
|
119
|
+
//
|
|
120
|
+
// WHY THE FILE IS STILL READ. A divergence of the file and the working key is a STATE, and it is NAMED
|
|
121
|
+
// (nodePublicKeyFileMatches: false), not hidden: other tools and a human read the file, and by it they
|
|
122
|
+
// judge the node. The file is re-read on EVERY read, not remembered at start: a file substituted beside the
|
|
123
|
+
// key must be visible at once, not at the next node start.
|
|
124
|
+
export function publicKeyBase64From(publicKeyPem) {
|
|
125
|
+
return createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }).toString("base64");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The SIGNING public key from a private PEM - with the same SPKI DER export as in the .pub file.
|
|
129
|
+
// createPublicKey accepts the PRIVATE PEM itself and derives the public key from it; a repeated call on a KeyObject
|
|
130
|
+
// (createPublicKey(createPublicKey(pem))) refuses - "expected private", so there is NO nesting here.
|
|
131
|
+
export function signingPublicKeyBase64(privateKeyPem) {
|
|
132
|
+
return publicKeyBase64From(privateKeyPem);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The content of the .pub file in the same form (base64 of SPKI DER), or the reason why it is absent.
|
|
136
|
+
export function publicKeyFileBase64(pubFile) {
|
|
137
|
+
if (!pubFile) return { key: null, why: "the path of the public key file is not known" };
|
|
138
|
+
if (!existsSync(pubFile)) return { key: null, why: "the public key file is missing: " + pubFile };
|
|
139
|
+
try { return { key: publicKeyBase64From(readFileSync(pubFile, "utf8")), why: null }; }
|
|
140
|
+
catch (e) { return { key: null, why: "the public key file could not be read: " + String((e && e.message) || e).slice(0, 140) }; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// THE REPORT FOR THE nodePublicKey FIELD IN GET /v1/info. The shape is assembled by ONE place - here; the listener only
|
|
144
|
+
// returns it (makerApi.mjs), and the node passes its key (index.mjs), so the rule does not multiply.
|
|
145
|
+
// key - the public key the node SIGNS with; file - what lies in the file beside it; matches - whether they
|
|
146
|
+
// match (true/false, and null means 'nothing to compare with'); why - the reasons of the fields left null.
|
|
147
|
+
export function nodePublicKeyReport({ privateKeyPem = null, keyFile = null, pubFile = null } = {}) {
|
|
148
|
+
const pubPath = pubFile || (keyFile ? publicKeyPath(keyFile) : null);
|
|
149
|
+
const file = publicKeyFileBase64(pubPath);
|
|
150
|
+
const why = {};
|
|
151
|
+
if (file.why) why.nodePublicKeyFile = file.why;
|
|
152
|
+
if (!privateKeyPem) {
|
|
153
|
+
why.nodePublicKey = "the node has no signing key it could read" + (keyFile ? " (" + keyFile + ")" : "") + ", so the key it signs with is unknown";
|
|
154
|
+
return { key: null, file: file.key, matches: null, why };
|
|
155
|
+
}
|
|
156
|
+
let key = null;
|
|
157
|
+
try { key = signingPublicKeyBase64(privateKeyPem); }
|
|
158
|
+
catch (e) { why.nodePublicKey = "the signing key could not be read: " + String((e && e.message) || e).slice(0, 140); }
|
|
159
|
+
// A DIVERGENCE IS A NAMED STATE, NOT SILENCE: false is the name of the state, and both keys stand side by side,
|
|
160
|
+
// so that one can sort it out without third-party tools. The node works with ITS OWN key meanwhile - it is loaded and
|
|
161
|
+
// the packets are signed with it; the file does not replace the signature.
|
|
162
|
+
const matches = key === null || file.key === null ? null : key === file.key;
|
|
163
|
+
return { key, file: file.key, matches, why };
|
|
164
|
+
}
|
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;
|