@ninsei-labs/sdk 0.39.0 → 0.39.2
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 +115 -0
- package/README.md +21 -0
- package/engine/core/chainSource.js +306 -306
- package/engine/core/config.js +29 -2
- package/engine/core/sellWindow.js +43 -43
- package/engine/core/swap.js +1153 -1153
- package/engine/evm/claimGas.js +88 -87
- package/engine/evm/dex.js +836 -836
- package/engine/evm/factory.js +106 -0
- package/engine/evm/funding.js +11 -11
- package/engine/evm/gasReserve.js +88 -88
- package/engine/evm/prices.js +179 -179
- package/engine/recovery/recoveryFile.js +4 -1
- package/engine/sdk/bridge.js +7 -2
- package/index.d.ts +18 -2
- package/package.json +4 -4
- package/src/actions.mjs +72 -72
- package/src/adapters/node-test-evm-wallet.mjs +345 -345
- package/src/config.mjs +9 -6
- package/src/engine.mjs +53 -53
- package/src/index.mjs +10 -1
- package/src/levelsWalkSpec.mjs +21 -4
- package/src/lock.mjs +140 -140
- package/src/order.mjs +212 -212
- package/src/quotes.mjs +495 -465
- package/src/swaps.mjs +935 -935
- package/src/sweep.mjs +29 -4
- package/src/xmr-arrival.mjs +289 -289
package/src/xmr-arrival.mjs
CHANGED
|
@@ -1,289 +1,289 @@
|
|
|
1
|
-
// THE XMR ARRIVAL CHECK - IN THE SDK CORE, ON TWO INDEPENDENT NODES.
|
|
2
|
-
//
|
|
3
|
-
// WHY HERE AND NOT IN OUR SERVICE. `ready` is the point of no return: after it the maker may take the ETH. Today
|
|
4
|
-
// the decision "the XMR arrived" is made by our backend (it holds the view key, scans our node, and the interface
|
|
5
|
-
// only shows that answer). The user cannot check it: if the answer is wrong - an indexer bug, a failure, a
|
|
6
|
-
// compromised server, a swapped node - the user marks ready under XMR that are not there and loses the ETH. So the
|
|
7
|
-
// decision moves into the client, which reads the chain itself with its own view key.
|
|
8
|
-
//
|
|
9
|
-
// TWO NODES OF DIFFERENT OPERATORS. A single node is a single truth that can lie. So `ready` opens only when at
|
|
10
|
-
// least two nodes belonging to DIFFERENT operators see the transaction IN THE SAME BLOCK (the block hash matches)
|
|
11
|
-
// with the required number of confirmations. A failed or lagging node does not count and blocks nothing; a synced
|
|
12
|
-
// node that does not have the transaction in that block, or has a different block hash there, is a DISAGREEMENT -
|
|
13
|
-
// the button stays closed and the screen says the nodes do not agree. When only one operator has confirmed, the
|
|
14
|
-
// core names that state (`one-node`) instead of waiting silently.
|
|
15
|
-
//
|
|
16
|
-
// WHERE THE RULE LIVES. "Locked by unlock_time is not an arrival" is ONE rule (issue #84). It is not copied here:
|
|
17
|
-
// it lives under www/js/monero/unlock.js, is mirrored byte-for-byte into the engine (sdk/engine/monero/unlock.js,
|
|
18
|
-
// guarded by tools/check-sdk-engine.mjs) and is read through the engine seam. The block parser is mirrored the
|
|
19
|
-
// same way (www/js/monero/scan.js) - a second copy would be a second truth about money.
|
|
20
|
-
//
|
|
21
|
-
// PRIVACY. Public nodes are asked for the WHOLE BLOCK (get_block plus /get_transactions over all of the block's
|
|
22
|
-
// hashes), never for a single transaction: otherwise a node links the user's IP to the trade. Requests carry
|
|
23
|
-
// `Content-Type: text/plain` (verified on every node in the default list - it avoids the CORS preflight).
|
|
24
|
-
//
|
|
25
|
-
// WHAT THE MAKER GIVES IS ONLY A HINT. The maker's node may name the payout txid; the backend may relay it. It
|
|
26
|
-
// decides nothing - a fabricated txid simply fails the check. Without a txid the client scans blocks from the
|
|
27
|
-
// swap's birth height (a few dozen blocks).
|
|
28
|
-
import { SdkError } from "./errors.mjs";
|
|
29
|
-
import * as engine from "./engine.mjs";
|
|
30
|
-
|
|
31
|
-
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
32
|
-
const intOf = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; };
|
|
33
|
-
const txidOf = (v) => (isStr(v) ? v.trim().replace(/^0x/, "").toLowerCase() : null);
|
|
34
|
-
const hostOf = (url) => { try { return new URL(url).host; } catch { throw new SdkError("bad-input", { field: "nodes.url", unknown: url }); } };
|
|
35
|
-
|
|
36
|
-
// A NODE LIST IS A LIST OF {url, operator}. The operator is what makes two nodes two witnesses: two nodes of one
|
|
37
|
-
// operator are one opinion. A plain string is accepted and its operator is the host - a user who adds their own
|
|
38
|
-
// node is the operator of it.
|
|
39
|
-
export function normalizeNodes(nodes) {
|
|
40
|
-
if (!Array.isArray(nodes)) throw new SdkError("bad-input", { field: "nodes" });
|
|
41
|
-
const out = [];
|
|
42
|
-
for (const raw of nodes) {
|
|
43
|
-
if (isStr(raw)) {
|
|
44
|
-
const url = raw.replace(/\/+$/, "");
|
|
45
|
-
out.push({ url, operator: hostOf(url) });
|
|
46
|
-
} else if (raw && typeof raw === "object" && isStr(raw.url)) {
|
|
47
|
-
const url = raw.url.replace(/\/+$/, "");
|
|
48
|
-
out.push({ url, operator: isStr(raw.operator) ? raw.operator : hostOf(url) });
|
|
49
|
-
} else {
|
|
50
|
-
throw new SdkError("bad-input", { field: "nodes", why: "each-node-is-url-or-url+operator" });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (!out.length) throw new SdkError("bad-input", { field: "nodes", why: "empty-list" });
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ONE REQUEST. `text/plain` on purpose: the public nodes answer it without a preflight (verified on the whole
|
|
58
|
-
// default list). The response body is JSON even though the content type is not.
|
|
59
|
-
async function call(node, path, body, fetchImpl, timeoutMs) {
|
|
60
|
-
let res;
|
|
61
|
-
try {
|
|
62
|
-
res = await fetchImpl(node.url + path, {
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: { "Content-Type": "text/plain" },
|
|
65
|
-
body: JSON.stringify(body),
|
|
66
|
-
signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
67
|
-
});
|
|
68
|
-
} catch (error) {
|
|
69
|
-
throw new SdkError("xmr-no-nodes", { node: node.url, why: String((error && error.message) || error).slice(0, 120) });
|
|
70
|
-
}
|
|
71
|
-
if (!res || res.ok !== true) throw new SdkError("xmr-no-nodes", { node: node.url, status: res ? res.status : null });
|
|
72
|
-
try { return await res.json(); } catch { throw new SdkError("xmr-no-nodes", { node: node.url, why: "bad-json" }); }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function getInfo(node, fetchImpl, timeoutMs) {
|
|
76
|
-
const j = await call(node, "/json_rpc", { jsonrpc: "2.0", id: "info", method: "get_info" }, fetchImpl, timeoutMs);
|
|
77
|
-
const r = j && j.result;
|
|
78
|
-
if (!r || !Number.isFinite(Number(r.height))) throw new SdkError("xmr-no-nodes", { node: node.url, why: "no-height" });
|
|
79
|
-
return { height: Number(r.height), synced: r.synchronized !== false, nettype: r.nettype || null };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// THE WHOLE BLOCK: its hash and ALL of its transaction hashes. This is the only form a public node is asked in.
|
|
83
|
-
async function getBlock(node, height, fetchImpl, timeoutMs) {
|
|
84
|
-
const j = await call(node, "/json_rpc", { jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }, fetchImpl, timeoutMs);
|
|
85
|
-
const r = j && j.result;
|
|
86
|
-
if (!r) throw new SdkError("xmr-no-nodes", { node: node.url, why: "no-block" });
|
|
87
|
-
const hash = (r.block_header && r.block_header.hash) || null;
|
|
88
|
-
let txHashes = Array.isArray(r.tx_hashes) ? r.tx_hashes : null;
|
|
89
|
-
if (!txHashes) {
|
|
90
|
-
const json = typeof r.json === "string" ? JSON.parse(r.json) : r.json;
|
|
91
|
-
txHashes = (json && json.tx_hashes) || [];
|
|
92
|
-
}
|
|
93
|
-
return { hash, txHashes };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ALL THE BLOCK'S TRANSACTIONS in one request (never a single-hash request to a public node).
|
|
97
|
-
async function getBlockTxs(node, hashes, fetchImpl, timeoutMs) {
|
|
98
|
-
if (!hashes.length) return [];
|
|
99
|
-
const j = await call(node, "/get_transactions", { txs_hashes: hashes, decode_as_json: true }, fetchImpl, timeoutMs);
|
|
100
|
-
const wrappers = j.txs || [];
|
|
101
|
-
return (j.txs_as_json || []).map((s, i) => {
|
|
102
|
-
const body = typeof s === "string" ? JSON.parse(s) : s;
|
|
103
|
-
const w = wrappers[i] || {};
|
|
104
|
-
body.tx_hash = String(w.tx_hash || body.tx_hash || "").replace(/^0x/, "").toLowerCase();
|
|
105
|
-
body.block_height = w.block_height ?? null;
|
|
106
|
-
body.confirmations = w.confirmations ?? null;
|
|
107
|
-
return body;
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// The amount of our own outputs in a matched transaction (a swap can be paid in several outputs).
|
|
112
|
-
const sumOutputs = (hit) => hit.outputs.reduce((n, o) => n + (o.amountAtomic || 0n), 0n);
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* FIND THE TRANSACTION, IF IT IS THERE. Blocks are walked from the swap's birth height upwards and the whole block
|
|
116
|
-
* is asked for each height, so a public node never learns which transaction is ours. A txid hint only shortens the
|
|
117
|
-
* work: a block without that hash is still fetched whole, but its transactions are not parsed.
|
|
118
|
-
*/
|
|
119
|
-
async function locate(node, { txid, address, viewSecret, spendPub, fromHeight, toHeight, nowSec, fetchImpl, timeoutMs }) {
|
|
120
|
-
const from = Math.max(0, intOf(fromHeight) ?? 0);
|
|
121
|
-
const to = intOf(toHeight);
|
|
122
|
-
if (!Number.isFinite(to) || to < from) throw new SdkError("bad-input", { field: "fromHeight", got: fromHeight });
|
|
123
|
-
for (let h = from; h <= to; h++) {
|
|
124
|
-
const { txHashes } = await getBlock(node, h, fetchImpl, timeoutMs);
|
|
125
|
-
if (!txHashes.length) continue;
|
|
126
|
-
if (txid && !txHashes.includes(txid)) continue; // the hint says it is not in this block
|
|
127
|
-
const txs = await getBlockTxs(node, txHashes, fetchImpl, timeoutMs);
|
|
128
|
-
for (const tx of txs) {
|
|
129
|
-
if (txid && tx.tx_hash !== txid) continue;
|
|
130
|
-
const hit = engine.xmrScan.matchTransaction(tx, [{ address, viewSecret, spendPub }], { height: h, nowSec });
|
|
131
|
-
if (hit.length && sumOutputs(hit[0]) > 0n) return { txid: tx.tx_hash, height: h };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* VERIFY THE ARRIVAL. Returns a verdict, and refuses WITH A CODE where the money cannot be declared an arrival:
|
|
139
|
-
* xmr-locked - the outputs are barred by the transaction's unlock_time (until a height or a date);
|
|
140
|
-
* xmr-underpaid - the amount is less than expected;
|
|
141
|
-
* xmr-nodes-disagree - a synced node has no such transaction in that block or a different block hash there;
|
|
142
|
-
* xmr-no-nodes - every node in the list is unreachable.
|
|
143
|
-
* While the money is simply not there yet, or only one operator has confirmed, it returns a WAITING state
|
|
144
|
-
* (`pending` / `one-node`) - never a silent success.
|
|
145
|
-
*/
|
|
146
|
-
export async function verifyArrival(request) {
|
|
147
|
-
if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
|
|
148
|
-
const nodes = normalizeNodes(request.nodes);
|
|
149
|
-
if (!isStr(request.address)) throw new SdkError("bad-input", { field: "address" });
|
|
150
|
-
if (request.viewSecret === undefined || request.viewSecret === null) throw new SdkError("bad-input", { field: "viewSecret" });
|
|
151
|
-
if (!isStr(request.spendPub)) throw new SdkError("bad-input", { field: "spendPub" });
|
|
152
|
-
let expectedAtomic;
|
|
153
|
-
try { expectedAtomic = BigInt(request.expectedAtomic); } catch { throw new SdkError("bad-input", { field: "expectedAtomic" }); }
|
|
154
|
-
if (expectedAtomic <= 0n) throw new SdkError("bad-input", { field: "expectedAtomic" });
|
|
155
|
-
const minConfirmations = Math.max(1, intOf(request.minConfirmations) ?? 1);
|
|
156
|
-
const fetchImpl = typeof request.fetch === "function" ? request.fetch : globalThis.fetch;
|
|
157
|
-
if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetch" });
|
|
158
|
-
const nowSec = Number.isFinite(Number(request.nowSec)) ? Number(request.nowSec) : Math.floor(Date.now() / 1000);
|
|
159
|
-
const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0 ? Number(request.timeoutMs) : 20000;
|
|
160
|
-
const txidHint = txidOf(request.txid);
|
|
161
|
-
const watched = { address: request.address, viewSecret: request.viewSecret, spendPub: request.spendPub };
|
|
162
|
-
|
|
163
|
-
// 1. THE TIP, and with it the first proof that ANY node answers. All nodes down is a named refusal, not silence.
|
|
164
|
-
const live = [];
|
|
165
|
-
for (const node of nodes) {
|
|
166
|
-
try { const info = await getInfo(node, fetchImpl, timeoutMs); live.push({ node, info }); }
|
|
167
|
-
catch { /* a failed node does not count and blocks nothing */ }
|
|
168
|
-
}
|
|
169
|
-
if (!live.length) throw new SdkError("xmr-no-nodes", { tried: nodes.map((n) => n.url) });
|
|
170
|
-
const tip = Math.max(...live.map((l) => l.info.height));
|
|
171
|
-
// THE REPORTED HEIGHT IS THE NEXT BLOCK, NOT A REACHABLE ONE. monerod's get_info returns height = "the block that
|
|
172
|
-
// will be mined next", so get_block(height) refuses with "greater than current top block". The backend already
|
|
173
|
-
// knows this (app/watcher.mjs: "take top = height - 1"), and a live run against the contour's own node found the
|
|
174
|
-
// same off-by-one here: walking to `tip` inclusive threw xmr-no-nodes on the very last block instead of returning
|
|
175
|
-
// `pending`. The reachable top is one below.
|
|
176
|
-
const top = Math.max(0, tip - 1);
|
|
177
|
-
|
|
178
|
-
// 2. LOCATE: from the birth height, by whole blocks. The first reachable node does the walk.
|
|
179
|
-
let located = null;
|
|
180
|
-
let walkError = null;
|
|
181
|
-
for (const { node } of live) {
|
|
182
|
-
try { located = await locate(node, { txid: txidHint, address: request.address, viewSecret: request.viewSecret, spendPub: request.spendPub, fromHeight: request.fromHeight, toHeight: top, nowSec, fetchImpl, timeoutMs }); }
|
|
183
|
-
catch (error) { walkError = error; located = null; }
|
|
184
|
-
if (located) break;
|
|
185
|
-
}
|
|
186
|
-
if (!located) {
|
|
187
|
-
if (walkError && walkError.code === "xmr-no-nodes") throw walkError;
|
|
188
|
-
return { seen: false, state: "pending", txid: txidHint, tip, checked: live.map((l) => ({ url: l.node.url, operator: l.node.operator, height: l.info.height })) };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 3. VERIFY ON EVERY NODE: the SAME block hash, the SAME transaction inside it, the confirmations and the money.
|
|
192
|
-
const seen = [];
|
|
193
|
-
for (const node of nodes) {
|
|
194
|
-
const record = { url: node.url, operator: node.operator };
|
|
195
|
-
let info;
|
|
196
|
-
try { info = await getInfo(node, fetchImpl, timeoutMs); }
|
|
197
|
-
catch { record.state = "down"; seen.push(record); continue; }
|
|
198
|
-
// A LAGGING NODE IS NOT A WITNESS and is not a disagreement: it simply cannot see the block yet.
|
|
199
|
-
if (!info.synced || info.height < located.height) { record.state = "stale"; record.height = info.height; seen.push(record); continue; }
|
|
200
|
-
let block;
|
|
201
|
-
try { block = await getBlock(node, located.height, fetchImpl, timeoutMs); }
|
|
202
|
-
catch { record.state = "down"; seen.push(record); continue; }
|
|
203
|
-
record.blockHash = block.hash;
|
|
204
|
-
// A SYNCED NODE WITHOUT THIS TRANSACTION IN THAT BLOCK IS A DISAGREEMENT (it may hold a different block hash).
|
|
205
|
-
if (!block.hash || !block.txHashes.includes(located.txid)) { record.state = "no-tx"; seen.push(record); continue; }
|
|
206
|
-
let body = null;
|
|
207
|
-
try { body = (await getBlockTxs(node, block.txHashes, fetchImpl, timeoutMs)).find((t) => t.tx_hash === located.txid) || null; }
|
|
208
|
-
catch { record.state = "down"; seen.push(record); continue; }
|
|
209
|
-
if (!body) { record.state = "no-tx"; seen.push(record); continue; }
|
|
210
|
-
const hit = engine.xmrScan.matchTransaction(body, [watched], { height: located.height, nowSec });
|
|
211
|
-
if (!hit.length) { record.state = "no-output"; seen.push(record); continue; }
|
|
212
|
-
record.state = "ok";
|
|
213
|
-
record.amountAtomic = sumOutputs(hit[0]);
|
|
214
|
-
record.unlock = hit[0].unlock;
|
|
215
|
-
// Confirmations of a block: top - height + 1, and top = info.height - 1 (see above), so this is height - located.
|
|
216
|
-
record.confirmations = Number.isFinite(Number(body.confirmations)) ? Number(body.confirmations) : (info.height - located.height);
|
|
217
|
-
seen.push(record);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// A DISAGREEMENT CLOSES THE BUTTON - it is named, and the nodes and hashes travel in the params.
|
|
221
|
-
const disagreeing = seen.filter((r) => r.state === "no-tx" || r.state === "no-output");
|
|
222
|
-
if (disagreeing.length) {
|
|
223
|
-
throw new SdkError("xmr-nodes-disagree", {
|
|
224
|
-
txid: located.txid, height: located.height,
|
|
225
|
-
nodes: disagreeing.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash || null, why: r.state })),
|
|
226
|
-
agreed: seen.filter((r) => r.state === "ok").map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash })),
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const okay = seen.filter((r) => r.state === "ok");
|
|
231
|
-
if (!okay.length) return { seen: false, state: "pending", txid: located.txid, height: located.height, checked: seen };
|
|
232
|
-
|
|
233
|
-
// LOCKED IS NOT AN ARRIVAL, and the rule for that is the shared one (issue #84), mirrored from www/js.
|
|
234
|
-
const locked = okay.find((r) => r.unlock && r.unlock.locked);
|
|
235
|
-
if (locked) {
|
|
236
|
-
const until = locked.unlock;
|
|
237
|
-
throw new SdkError("xmr-locked", { txid: located.txid, height: located.height, address: request.address,
|
|
238
|
-
untilHeight: until.untilHeight ?? null, untilTime: until.untilTime ?? null });
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// THE AMOUNT IS NOT LESS THAN EXPECTED. A shortfall is named rather than shown as a completed swap.
|
|
242
|
-
const amountAtomic = okay.reduce((best, r) => (r.amountAtomic > best ? r.amountAtomic : best), 0n);
|
|
243
|
-
if (amountAtomic < expectedAtomic) {
|
|
244
|
-
throw new SdkError("xmr-underpaid", { txid: located.txid, height: located.height, got: amountAtomic.toString(), want: expectedAtomic.toString() });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// AGREEMENT: the same block hash, held by at least two DIFFERENT operators, with the required confirmations.
|
|
248
|
-
const byHash = new Map();
|
|
249
|
-
for (const r of okay) {
|
|
250
|
-
const key = r.blockHash;
|
|
251
|
-
if (!byHash.has(key)) byHash.set(key, { blockHash: key, operators: new Set(), nodes: [] });
|
|
252
|
-
const g = byHash.get(key);
|
|
253
|
-
g.operators.add(r.operator);
|
|
254
|
-
g.nodes.push(r);
|
|
255
|
-
}
|
|
256
|
-
let best = null;
|
|
257
|
-
for (const g of byHash.values()) if (!best || g.operators.size > best.operators.size) best = g;
|
|
258
|
-
const confirmations = Math.min(...best.nodes.map((r) => r.confirmations));
|
|
259
|
-
const agreedBy = best.nodes.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash, confirmations: r.confirmations }));
|
|
260
|
-
|
|
261
|
-
if (best.operators.size < 2) {
|
|
262
|
-
return { seen: false, state: "one-node", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
263
|
-
amountAtomic: amountAtomic.toString(), confirmations, operators: [...best.operators], agreedBy, checked: seen };
|
|
264
|
-
}
|
|
265
|
-
if (confirmations < minConfirmations) {
|
|
266
|
-
return { seen: false, state: "pending", why: "confirmations", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
267
|
-
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations, agreedBy, checked: seen };
|
|
268
|
-
}
|
|
269
|
-
return { seen: true, state: "arrived", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
270
|
-
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations,
|
|
271
|
-
operators: [...best.operators], agreedBy, checked: seen };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* IS THERE STILL TIME FOR THE CONFIRMATIONS? Ten confirmations of Monero are about twenty minutes, and the ready
|
|
276
|
-
* deadline does not move. If less than that is left, the client must NAME it and not start - waiting silently would
|
|
277
|
-
* start a trade that cannot be finished in time. Returns seconds left; refuses with `xmr-too-late` otherwise.
|
|
278
|
-
*/
|
|
279
|
-
export function assertTimeForConfirmations({ readyBySec, nowSec, minConfirmations, blockTimeSec } = {}) {
|
|
280
|
-
const readyBy = Number(readyBySec);
|
|
281
|
-
const now = Number.isFinite(Number(nowSec)) ? Number(nowSec) : Math.floor(Date.now() / 1000);
|
|
282
|
-
const count = Math.max(1, intOf(minConfirmations) ?? 1);
|
|
283
|
-
const block = Number.isFinite(Number(blockTimeSec)) && Number(blockTimeSec) > 0 ? Number(blockTimeSec) : 120;
|
|
284
|
-
if (!Number.isFinite(readyBy) || readyBy <= 0) return null; // no deadline known - nothing to compare against
|
|
285
|
-
const needSec = count * block;
|
|
286
|
-
const leftSec = Math.floor(readyBy - now);
|
|
287
|
-
if (leftSec < needSec) throw new SdkError("xmr-too-late", { needSec, leftSec, minConfirmations: count, readyBy: Math.floor(readyBy) });
|
|
288
|
-
return leftSec;
|
|
289
|
-
}
|
|
1
|
+
// THE XMR ARRIVAL CHECK - IN THE SDK CORE, ON TWO INDEPENDENT NODES.
|
|
2
|
+
//
|
|
3
|
+
// WHY HERE AND NOT IN OUR SERVICE. `ready` is the point of no return: after it the maker may take the ETH. Today
|
|
4
|
+
// the decision "the XMR arrived" is made by our backend (it holds the view key, scans our node, and the interface
|
|
5
|
+
// only shows that answer). The user cannot check it: if the answer is wrong - an indexer bug, a failure, a
|
|
6
|
+
// compromised server, a swapped node - the user marks ready under XMR that are not there and loses the ETH. So the
|
|
7
|
+
// decision moves into the client, which reads the chain itself with its own view key.
|
|
8
|
+
//
|
|
9
|
+
// TWO NODES OF DIFFERENT OPERATORS. A single node is a single truth that can lie. So `ready` opens only when at
|
|
10
|
+
// least two nodes belonging to DIFFERENT operators see the transaction IN THE SAME BLOCK (the block hash matches)
|
|
11
|
+
// with the required number of confirmations. A failed or lagging node does not count and blocks nothing; a synced
|
|
12
|
+
// node that does not have the transaction in that block, or has a different block hash there, is a DISAGREEMENT -
|
|
13
|
+
// the button stays closed and the screen says the nodes do not agree. When only one operator has confirmed, the
|
|
14
|
+
// core names that state (`one-node`) instead of waiting silently.
|
|
15
|
+
//
|
|
16
|
+
// WHERE THE RULE LIVES. "Locked by unlock_time is not an arrival" is ONE rule (issue #84). It is not copied here:
|
|
17
|
+
// it lives under www/js/monero/unlock.js, is mirrored byte-for-byte into the engine (sdk/engine/monero/unlock.js,
|
|
18
|
+
// guarded by tools/check-sdk-engine.mjs) and is read through the engine seam. The block parser is mirrored the
|
|
19
|
+
// same way (www/js/monero/scan.js) - a second copy would be a second truth about money.
|
|
20
|
+
//
|
|
21
|
+
// PRIVACY. Public nodes are asked for the WHOLE BLOCK (get_block plus /get_transactions over all of the block's
|
|
22
|
+
// hashes), never for a single transaction: otherwise a node links the user's IP to the trade. Requests carry
|
|
23
|
+
// `Content-Type: text/plain` (verified on every node in the default list - it avoids the CORS preflight).
|
|
24
|
+
//
|
|
25
|
+
// WHAT THE MAKER GIVES IS ONLY A HINT. The maker's node may name the payout txid; the backend may relay it. It
|
|
26
|
+
// decides nothing - a fabricated txid simply fails the check. Without a txid the client scans blocks from the
|
|
27
|
+
// swap's birth height (a few dozen blocks).
|
|
28
|
+
import { SdkError } from "./errors.mjs";
|
|
29
|
+
import * as engine from "./engine.mjs";
|
|
30
|
+
|
|
31
|
+
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
32
|
+
const intOf = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; };
|
|
33
|
+
const txidOf = (v) => (isStr(v) ? v.trim().replace(/^0x/, "").toLowerCase() : null);
|
|
34
|
+
const hostOf = (url) => { try { return new URL(url).host; } catch { throw new SdkError("bad-input", { field: "nodes.url", unknown: url }); } };
|
|
35
|
+
|
|
36
|
+
// A NODE LIST IS A LIST OF {url, operator}. The operator is what makes two nodes two witnesses: two nodes of one
|
|
37
|
+
// operator are one opinion. A plain string is accepted and its operator is the host - a user who adds their own
|
|
38
|
+
// node is the operator of it.
|
|
39
|
+
export function normalizeNodes(nodes) {
|
|
40
|
+
if (!Array.isArray(nodes)) throw new SdkError("bad-input", { field: "nodes" });
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const raw of nodes) {
|
|
43
|
+
if (isStr(raw)) {
|
|
44
|
+
const url = raw.replace(/\/+$/, "");
|
|
45
|
+
out.push({ url, operator: hostOf(url) });
|
|
46
|
+
} else if (raw && typeof raw === "object" && isStr(raw.url)) {
|
|
47
|
+
const url = raw.url.replace(/\/+$/, "");
|
|
48
|
+
out.push({ url, operator: isStr(raw.operator) ? raw.operator : hostOf(url) });
|
|
49
|
+
} else {
|
|
50
|
+
throw new SdkError("bad-input", { field: "nodes", why: "each-node-is-url-or-url+operator" });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!out.length) throw new SdkError("bad-input", { field: "nodes", why: "empty-list" });
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ONE REQUEST. `text/plain` on purpose: the public nodes answer it without a preflight (verified on the whole
|
|
58
|
+
// default list). The response body is JSON even though the content type is not.
|
|
59
|
+
async function call(node, path, body, fetchImpl, timeoutMs) {
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await fetchImpl(node.url + path, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "text/plain" },
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new SdkError("xmr-no-nodes", { node: node.url, why: String((error && error.message) || error).slice(0, 120) });
|
|
70
|
+
}
|
|
71
|
+
if (!res || res.ok !== true) throw new SdkError("xmr-no-nodes", { node: node.url, status: res ? res.status : null });
|
|
72
|
+
try { return await res.json(); } catch { throw new SdkError("xmr-no-nodes", { node: node.url, why: "bad-json" }); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getInfo(node, fetchImpl, timeoutMs) {
|
|
76
|
+
const j = await call(node, "/json_rpc", { jsonrpc: "2.0", id: "info", method: "get_info" }, fetchImpl, timeoutMs);
|
|
77
|
+
const r = j && j.result;
|
|
78
|
+
if (!r || !Number.isFinite(Number(r.height))) throw new SdkError("xmr-no-nodes", { node: node.url, why: "no-height" });
|
|
79
|
+
return { height: Number(r.height), synced: r.synchronized !== false, nettype: r.nettype || null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// THE WHOLE BLOCK: its hash and ALL of its transaction hashes. This is the only form a public node is asked in.
|
|
83
|
+
async function getBlock(node, height, fetchImpl, timeoutMs) {
|
|
84
|
+
const j = await call(node, "/json_rpc", { jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }, fetchImpl, timeoutMs);
|
|
85
|
+
const r = j && j.result;
|
|
86
|
+
if (!r) throw new SdkError("xmr-no-nodes", { node: node.url, why: "no-block" });
|
|
87
|
+
const hash = (r.block_header && r.block_header.hash) || null;
|
|
88
|
+
let txHashes = Array.isArray(r.tx_hashes) ? r.tx_hashes : null;
|
|
89
|
+
if (!txHashes) {
|
|
90
|
+
const json = typeof r.json === "string" ? JSON.parse(r.json) : r.json;
|
|
91
|
+
txHashes = (json && json.tx_hashes) || [];
|
|
92
|
+
}
|
|
93
|
+
return { hash, txHashes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ALL THE BLOCK'S TRANSACTIONS in one request (never a single-hash request to a public node).
|
|
97
|
+
async function getBlockTxs(node, hashes, fetchImpl, timeoutMs) {
|
|
98
|
+
if (!hashes.length) return [];
|
|
99
|
+
const j = await call(node, "/get_transactions", { txs_hashes: hashes, decode_as_json: true }, fetchImpl, timeoutMs);
|
|
100
|
+
const wrappers = j.txs || [];
|
|
101
|
+
return (j.txs_as_json || []).map((s, i) => {
|
|
102
|
+
const body = typeof s === "string" ? JSON.parse(s) : s;
|
|
103
|
+
const w = wrappers[i] || {};
|
|
104
|
+
body.tx_hash = String(w.tx_hash || body.tx_hash || "").replace(/^0x/, "").toLowerCase();
|
|
105
|
+
body.block_height = w.block_height ?? null;
|
|
106
|
+
body.confirmations = w.confirmations ?? null;
|
|
107
|
+
return body;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The amount of our own outputs in a matched transaction (a swap can be paid in several outputs).
|
|
112
|
+
const sumOutputs = (hit) => hit.outputs.reduce((n, o) => n + (o.amountAtomic || 0n), 0n);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* FIND THE TRANSACTION, IF IT IS THERE. Blocks are walked from the swap's birth height upwards and the whole block
|
|
116
|
+
* is asked for each height, so a public node never learns which transaction is ours. A txid hint only shortens the
|
|
117
|
+
* work: a block without that hash is still fetched whole, but its transactions are not parsed.
|
|
118
|
+
*/
|
|
119
|
+
async function locate(node, { txid, address, viewSecret, spendPub, fromHeight, toHeight, nowSec, fetchImpl, timeoutMs }) {
|
|
120
|
+
const from = Math.max(0, intOf(fromHeight) ?? 0);
|
|
121
|
+
const to = intOf(toHeight);
|
|
122
|
+
if (!Number.isFinite(to) || to < from) throw new SdkError("bad-input", { field: "fromHeight", got: fromHeight });
|
|
123
|
+
for (let h = from; h <= to; h++) {
|
|
124
|
+
const { txHashes } = await getBlock(node, h, fetchImpl, timeoutMs);
|
|
125
|
+
if (!txHashes.length) continue;
|
|
126
|
+
if (txid && !txHashes.includes(txid)) continue; // the hint says it is not in this block
|
|
127
|
+
const txs = await getBlockTxs(node, txHashes, fetchImpl, timeoutMs);
|
|
128
|
+
for (const tx of txs) {
|
|
129
|
+
if (txid && tx.tx_hash !== txid) continue;
|
|
130
|
+
const hit = engine.xmrScan.matchTransaction(tx, [{ address, viewSecret, spendPub }], { height: h, nowSec });
|
|
131
|
+
if (hit.length && sumOutputs(hit[0]) > 0n) return { txid: tx.tx_hash, height: h };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* VERIFY THE ARRIVAL. Returns a verdict, and refuses WITH A CODE where the money cannot be declared an arrival:
|
|
139
|
+
* xmr-locked - the outputs are barred by the transaction's unlock_time (until a height or a date);
|
|
140
|
+
* xmr-underpaid - the amount is less than expected;
|
|
141
|
+
* xmr-nodes-disagree - a synced node has no such transaction in that block or a different block hash there;
|
|
142
|
+
* xmr-no-nodes - every node in the list is unreachable.
|
|
143
|
+
* While the money is simply not there yet, or only one operator has confirmed, it returns a WAITING state
|
|
144
|
+
* (`pending` / `one-node`) - never a silent success.
|
|
145
|
+
*/
|
|
146
|
+
export async function verifyArrival(request) {
|
|
147
|
+
if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
|
|
148
|
+
const nodes = normalizeNodes(request.nodes);
|
|
149
|
+
if (!isStr(request.address)) throw new SdkError("bad-input", { field: "address" });
|
|
150
|
+
if (request.viewSecret === undefined || request.viewSecret === null) throw new SdkError("bad-input", { field: "viewSecret" });
|
|
151
|
+
if (!isStr(request.spendPub)) throw new SdkError("bad-input", { field: "spendPub" });
|
|
152
|
+
let expectedAtomic;
|
|
153
|
+
try { expectedAtomic = BigInt(request.expectedAtomic); } catch { throw new SdkError("bad-input", { field: "expectedAtomic" }); }
|
|
154
|
+
if (expectedAtomic <= 0n) throw new SdkError("bad-input", { field: "expectedAtomic" });
|
|
155
|
+
const minConfirmations = Math.max(1, intOf(request.minConfirmations) ?? 1);
|
|
156
|
+
const fetchImpl = typeof request.fetch === "function" ? request.fetch : globalThis.fetch;
|
|
157
|
+
if (typeof fetchImpl !== "function") throw new SdkError("bad-input", { field: "fetch" });
|
|
158
|
+
const nowSec = Number.isFinite(Number(request.nowSec)) ? Number(request.nowSec) : Math.floor(Date.now() / 1000);
|
|
159
|
+
const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0 ? Number(request.timeoutMs) : 20000;
|
|
160
|
+
const txidHint = txidOf(request.txid);
|
|
161
|
+
const watched = { address: request.address, viewSecret: request.viewSecret, spendPub: request.spendPub };
|
|
162
|
+
|
|
163
|
+
// 1. THE TIP, and with it the first proof that ANY node answers. All nodes down is a named refusal, not silence.
|
|
164
|
+
const live = [];
|
|
165
|
+
for (const node of nodes) {
|
|
166
|
+
try { const info = await getInfo(node, fetchImpl, timeoutMs); live.push({ node, info }); }
|
|
167
|
+
catch { /* a failed node does not count and blocks nothing */ }
|
|
168
|
+
}
|
|
169
|
+
if (!live.length) throw new SdkError("xmr-no-nodes", { tried: nodes.map((n) => n.url) });
|
|
170
|
+
const tip = Math.max(...live.map((l) => l.info.height));
|
|
171
|
+
// THE REPORTED HEIGHT IS THE NEXT BLOCK, NOT A REACHABLE ONE. monerod's get_info returns height = "the block that
|
|
172
|
+
// will be mined next", so get_block(height) refuses with "greater than current top block". The backend already
|
|
173
|
+
// knows this (app/watcher.mjs: "take top = height - 1"), and a live run against the contour's own node found the
|
|
174
|
+
// same off-by-one here: walking to `tip` inclusive threw xmr-no-nodes on the very last block instead of returning
|
|
175
|
+
// `pending`. The reachable top is one below.
|
|
176
|
+
const top = Math.max(0, tip - 1);
|
|
177
|
+
|
|
178
|
+
// 2. LOCATE: from the birth height, by whole blocks. The first reachable node does the walk.
|
|
179
|
+
let located = null;
|
|
180
|
+
let walkError = null;
|
|
181
|
+
for (const { node } of live) {
|
|
182
|
+
try { located = await locate(node, { txid: txidHint, address: request.address, viewSecret: request.viewSecret, spendPub: request.spendPub, fromHeight: request.fromHeight, toHeight: top, nowSec, fetchImpl, timeoutMs }); }
|
|
183
|
+
catch (error) { walkError = error; located = null; }
|
|
184
|
+
if (located) break;
|
|
185
|
+
}
|
|
186
|
+
if (!located) {
|
|
187
|
+
if (walkError && walkError.code === "xmr-no-nodes") throw walkError;
|
|
188
|
+
return { seen: false, state: "pending", txid: txidHint, tip, checked: live.map((l) => ({ url: l.node.url, operator: l.node.operator, height: l.info.height })) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. VERIFY ON EVERY NODE: the SAME block hash, the SAME transaction inside it, the confirmations and the money.
|
|
192
|
+
const seen = [];
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
const record = { url: node.url, operator: node.operator };
|
|
195
|
+
let info;
|
|
196
|
+
try { info = await getInfo(node, fetchImpl, timeoutMs); }
|
|
197
|
+
catch { record.state = "down"; seen.push(record); continue; }
|
|
198
|
+
// A LAGGING NODE IS NOT A WITNESS and is not a disagreement: it simply cannot see the block yet.
|
|
199
|
+
if (!info.synced || info.height < located.height) { record.state = "stale"; record.height = info.height; seen.push(record); continue; }
|
|
200
|
+
let block;
|
|
201
|
+
try { block = await getBlock(node, located.height, fetchImpl, timeoutMs); }
|
|
202
|
+
catch { record.state = "down"; seen.push(record); continue; }
|
|
203
|
+
record.blockHash = block.hash;
|
|
204
|
+
// A SYNCED NODE WITHOUT THIS TRANSACTION IN THAT BLOCK IS A DISAGREEMENT (it may hold a different block hash).
|
|
205
|
+
if (!block.hash || !block.txHashes.includes(located.txid)) { record.state = "no-tx"; seen.push(record); continue; }
|
|
206
|
+
let body = null;
|
|
207
|
+
try { body = (await getBlockTxs(node, block.txHashes, fetchImpl, timeoutMs)).find((t) => t.tx_hash === located.txid) || null; }
|
|
208
|
+
catch { record.state = "down"; seen.push(record); continue; }
|
|
209
|
+
if (!body) { record.state = "no-tx"; seen.push(record); continue; }
|
|
210
|
+
const hit = engine.xmrScan.matchTransaction(body, [watched], { height: located.height, nowSec });
|
|
211
|
+
if (!hit.length) { record.state = "no-output"; seen.push(record); continue; }
|
|
212
|
+
record.state = "ok";
|
|
213
|
+
record.amountAtomic = sumOutputs(hit[0]);
|
|
214
|
+
record.unlock = hit[0].unlock;
|
|
215
|
+
// Confirmations of a block: top - height + 1, and top = info.height - 1 (see above), so this is height - located.
|
|
216
|
+
record.confirmations = Number.isFinite(Number(body.confirmations)) ? Number(body.confirmations) : (info.height - located.height);
|
|
217
|
+
seen.push(record);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A DISAGREEMENT CLOSES THE BUTTON - it is named, and the nodes and hashes travel in the params.
|
|
221
|
+
const disagreeing = seen.filter((r) => r.state === "no-tx" || r.state === "no-output");
|
|
222
|
+
if (disagreeing.length) {
|
|
223
|
+
throw new SdkError("xmr-nodes-disagree", {
|
|
224
|
+
txid: located.txid, height: located.height,
|
|
225
|
+
nodes: disagreeing.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash || null, why: r.state })),
|
|
226
|
+
agreed: seen.filter((r) => r.state === "ok").map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash })),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const okay = seen.filter((r) => r.state === "ok");
|
|
231
|
+
if (!okay.length) return { seen: false, state: "pending", txid: located.txid, height: located.height, checked: seen };
|
|
232
|
+
|
|
233
|
+
// LOCKED IS NOT AN ARRIVAL, and the rule for that is the shared one (issue #84), mirrored from www/js.
|
|
234
|
+
const locked = okay.find((r) => r.unlock && r.unlock.locked);
|
|
235
|
+
if (locked) {
|
|
236
|
+
const until = locked.unlock;
|
|
237
|
+
throw new SdkError("xmr-locked", { txid: located.txid, height: located.height, address: request.address,
|
|
238
|
+
untilHeight: until.untilHeight ?? null, untilTime: until.untilTime ?? null });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// THE AMOUNT IS NOT LESS THAN EXPECTED. A shortfall is named rather than shown as a completed swap.
|
|
242
|
+
const amountAtomic = okay.reduce((best, r) => (r.amountAtomic > best ? r.amountAtomic : best), 0n);
|
|
243
|
+
if (amountAtomic < expectedAtomic) {
|
|
244
|
+
throw new SdkError("xmr-underpaid", { txid: located.txid, height: located.height, got: amountAtomic.toString(), want: expectedAtomic.toString() });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// AGREEMENT: the same block hash, held by at least two DIFFERENT operators, with the required confirmations.
|
|
248
|
+
const byHash = new Map();
|
|
249
|
+
for (const r of okay) {
|
|
250
|
+
const key = r.blockHash;
|
|
251
|
+
if (!byHash.has(key)) byHash.set(key, { blockHash: key, operators: new Set(), nodes: [] });
|
|
252
|
+
const g = byHash.get(key);
|
|
253
|
+
g.operators.add(r.operator);
|
|
254
|
+
g.nodes.push(r);
|
|
255
|
+
}
|
|
256
|
+
let best = null;
|
|
257
|
+
for (const g of byHash.values()) if (!best || g.operators.size > best.operators.size) best = g;
|
|
258
|
+
const confirmations = Math.min(...best.nodes.map((r) => r.confirmations));
|
|
259
|
+
const agreedBy = best.nodes.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash, confirmations: r.confirmations }));
|
|
260
|
+
|
|
261
|
+
if (best.operators.size < 2) {
|
|
262
|
+
return { seen: false, state: "one-node", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
263
|
+
amountAtomic: amountAtomic.toString(), confirmations, operators: [...best.operators], agreedBy, checked: seen };
|
|
264
|
+
}
|
|
265
|
+
if (confirmations < minConfirmations) {
|
|
266
|
+
return { seen: false, state: "pending", why: "confirmations", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
267
|
+
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations, agreedBy, checked: seen };
|
|
268
|
+
}
|
|
269
|
+
return { seen: true, state: "arrived", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
270
|
+
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations,
|
|
271
|
+
operators: [...best.operators], agreedBy, checked: seen };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* IS THERE STILL TIME FOR THE CONFIRMATIONS? Ten confirmations of Monero are about twenty minutes, and the ready
|
|
276
|
+
* deadline does not move. If less than that is left, the client must NAME it and not start - waiting silently would
|
|
277
|
+
* start a trade that cannot be finished in time. Returns seconds left; refuses with `xmr-too-late` otherwise.
|
|
278
|
+
*/
|
|
279
|
+
export function assertTimeForConfirmations({ readyBySec, nowSec, minConfirmations, blockTimeSec } = {}) {
|
|
280
|
+
const readyBy = Number(readyBySec);
|
|
281
|
+
const now = Number.isFinite(Number(nowSec)) ? Number(nowSec) : Math.floor(Date.now() / 1000);
|
|
282
|
+
const count = Math.max(1, intOf(minConfirmations) ?? 1);
|
|
283
|
+
const block = Number.isFinite(Number(blockTimeSec)) && Number(blockTimeSec) > 0 ? Number(blockTimeSec) : 120;
|
|
284
|
+
if (!Number.isFinite(readyBy) || readyBy <= 0) return null; // no deadline known - nothing to compare against
|
|
285
|
+
const needSec = count * block;
|
|
286
|
+
const leftSec = Math.floor(readyBy - now);
|
|
287
|
+
if (leftSec < needSec) throw new SdkError("xmr-too-late", { needSec, leftSec, minConfirmations: count, readyBy: Math.floor(readyBy) });
|
|
288
|
+
return leftSec;
|
|
289
|
+
}
|