@ninsei-labs/sdk 0.52.0 → 0.54.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 +266 -0
- package/README.md +83 -5
- package/engine/atomic/order-worker.js +29 -2
- package/engine/atomic/order.js +24 -0
- package/engine/core/store.js +60 -12
- package/engine/evm/funding.js +65 -18
- package/engine/monero/arrivalRead.js +3 -22
- package/engine/monero/halvesSweep.js +5 -1
- package/engine/monero/scan.js +144 -33
- package/index.d.ts +154 -26
- package/package.json +35 -3
- package/src/adapters/monero-wallet.mjs +13 -6
- package/src/adapters/monero.mjs +5 -7
- package/src/codes.mjs +4 -0
- package/src/config.mjs +6 -3
- package/src/decline.mjs +6 -1
- package/src/feeEstimate.mjs +20 -13
- package/src/index.mjs +7 -4
- package/src/legs/index.mjs +8 -0
- package/src/legs/profiles.mjs +51 -0
- package/src/lock.mjs +4 -6
- package/src/order.mjs +53 -7
- package/src/public/networks.d.ts +14 -1
- package/src/public/recovery.d.ts +53 -5
- package/src/public/recovery.mjs +6 -0
- package/src/public/xmr.d.ts +26 -8
- package/src/quotes.mjs +410 -3
- package/src/recovery-key.mjs +217 -39
- package/src/recovery.mjs +9 -1
- package/src/relayFeatures.mjs +191 -0
- package/src/relayFirmClient.mjs +86 -0
- package/src/relayQuote.mjs +209 -0
- package/src/relayRequest.mjs +188 -0
- package/src/relayTerms.mjs +145 -0
- package/src/relayView.mjs +110 -0
- package/src/swap-flow.mjs +3 -2
- package/src/swaps.mjs +402 -80
- package/src/sweep-fees.mjs +32 -0
- package/src/sweep-restore.mjs +30 -9
- package/src/sweep.mjs +28 -5
- package/src/version.mjs +1 -1
- package/src/xmr-arrival.mjs +76 -32
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// THE FEE OF A WITHDRAWAL IS THE SUM OF EVERY SIGNED TRANSACTION, not the first one.
|
|
2
|
+
//
|
|
3
|
+
// A sweep normally signs one transaction, but the wallet may split the inputs: every transaction pays its own fee
|
|
4
|
+
// and the relay sends all of them. Showing only the first fee understates what leaves, and subtracting only it
|
|
5
|
+
// overstates what the recipient receives.
|
|
6
|
+
//
|
|
7
|
+
// The answer is null when any signed transaction cannot report a fee: a partial total would be a claim the wallet
|
|
8
|
+
// did not make.
|
|
9
|
+
export function signedFeeTotal(signed) {
|
|
10
|
+
const list = Array.isArray(signed) ? signed : [signed];
|
|
11
|
+
if (!list.length) return null;
|
|
12
|
+
let total = 0n;
|
|
13
|
+
for (const tx of list) {
|
|
14
|
+
if (!tx || typeof tx.getFee !== "function") return null;
|
|
15
|
+
let value;
|
|
16
|
+
try {
|
|
17
|
+
value = tx.getFee();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (value === null || value === undefined) return null;
|
|
22
|
+
let amount;
|
|
23
|
+
try {
|
|
24
|
+
amount = BigInt(value);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (amount < 0n) return null;
|
|
29
|
+
total += amount;
|
|
30
|
+
}
|
|
31
|
+
return total.toString();
|
|
32
|
+
}
|
package/src/sweep-restore.mjs
CHANGED
|
@@ -62,6 +62,23 @@ const heightOf = async (driver, name) => {
|
|
|
62
62
|
}
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
// AN INCOMPLETE SCAN IS A NAMED OUTCOME, NOT A ZERO BALANCE. The wallet library's sync is best-effort: a node
|
|
66
|
+
// that refuses block downloads leaves a wallet at height 0, and a caller that only reads `unlocked` shows "no
|
|
67
|
+
// XMR". The heights and the sync error decide the verdict here, so the page does not have to guess.
|
|
68
|
+
const scanCompleteness = async (driver, { syncError = null, rescanError = null } = {}) => {
|
|
69
|
+
const walletHeight = await heightOf(driver, "getHeight");
|
|
70
|
+
const nodeHeight = await heightOf(driver, "getDaemonHeight");
|
|
71
|
+
let reason = null;
|
|
72
|
+
// A FAILED RESCAN IS NOT A COMPLETE SCAN even when the heights happen to be equal: the wallet was already at the
|
|
73
|
+
// tip, the rescan was the only operation that would have walked the restored range, and its failure leaves the
|
|
74
|
+
// old range unscanned. It outranks the sync error because it names why the sync was a no-op.
|
|
75
|
+
if (rescanError) reason = "rescan-failed";
|
|
76
|
+
else if (syncError) reason = "sync-failed";
|
|
77
|
+
else if (walletHeight === null || nodeHeight === null) reason = "no-heights";
|
|
78
|
+
else if (walletHeight < nodeHeight) reason = "behind-node";
|
|
79
|
+
return { scanComplete: reason === null, ...(reason === null ? {} : { scanIncompleteReason: reason }), walletHeight, nodeHeight };
|
|
80
|
+
};
|
|
81
|
+
|
|
65
82
|
// THE BLOCK WINDOW AROUND THE LOCK. A full rescan of mainnet takes hours; the lock can only be in the blocks from
|
|
66
83
|
// the order's start to just after its t1. The caller may pass the hashes, or a node URL and the SDK asks for them.
|
|
67
84
|
export async function windowTxHashes({ nodeUrl, fromHeight, toHeight, fetchImpl = globalThis.fetch, sleep = defaultSleep, attempts = 3 } = {}) {
|
|
@@ -202,10 +219,11 @@ export async function restoreWallet(request = {}) {
|
|
|
202
219
|
}
|
|
203
220
|
windowReport.txs = hashes.length;
|
|
204
221
|
if (hashes.length && typeof driver.scanTxs === "function") {
|
|
222
|
+
let windowSyncError = null;
|
|
205
223
|
try {
|
|
206
224
|
await step("setRestoreHeight", () => driver.setRestoreHeight(tipHeight));
|
|
207
225
|
await step("scanTxs", () => driver.scanTxs(hashes));
|
|
208
|
-
try { await wallet.sync(); } catch {
|
|
226
|
+
try { await wallet.sync(); } catch (error) { windowSyncError = error; }
|
|
209
227
|
windowTotal = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
|
|
210
228
|
windowUnlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
|
|
211
229
|
windowReport.used = true;
|
|
@@ -257,9 +275,10 @@ export async function restoreWallet(request = {}) {
|
|
|
257
275
|
// A WINDOW WITH COINS IS THE ANSWER. A window with none falls through to the full scan unless the caller
|
|
258
276
|
// explicitly turned the fallback off.
|
|
259
277
|
if (windowReport.used && (windowTotal > 0n || windowUnlocked > 0n || requestWindow.fallback === false)) {
|
|
278
|
+
const completeness = await scanCompleteness(driver, { syncError: windowSyncError });
|
|
260
279
|
return {
|
|
261
|
-
total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight,
|
|
262
|
-
|
|
280
|
+
total: windowTotal.toString(), unlocked: windowUnlocked.toString(), scanHeight,
|
|
281
|
+
...completeness, passes: 1, lockedPass: false, window: windowReport,
|
|
263
282
|
};
|
|
264
283
|
}
|
|
265
284
|
} else if (hashes.length) {
|
|
@@ -280,12 +299,14 @@ export async function restoreWallet(request = {}) {
|
|
|
280
299
|
// A RESCAN IS BEST-EFFORT, LIKE THE SYNC BELOW. A wallet built from keys already carries the height it was opened
|
|
281
300
|
// with, and a library that cannot rescan a chain (monero-ts against a regtest node answers "failed to get hashes")
|
|
282
301
|
// would otherwise stop a withdrawal the sync can still serve. The balance below is the verdict, not the rescan.
|
|
283
|
-
|
|
302
|
+
let rescanError = null;
|
|
303
|
+
try { await step("rescanBlockchain", () => driver.rescanBlockchain()); } catch (error) { rescanError = error; }
|
|
284
304
|
|
|
285
305
|
// A FAILED SYNC IS BEST-EFFORT, exactly as in the signing step: what blocks a withdrawal is the spendable
|
|
286
306
|
// remainder, and it is read below. The library throws low-level while scanning a chain it cannot read; that must
|
|
287
|
-
// not pre-empt the verdict.
|
|
288
|
-
|
|
307
|
+
// not pre-empt the verdict. The error is remembered and named in `scanIncompleteReason`.
|
|
308
|
+
let syncError = null;
|
|
309
|
+
try { await wallet.sync(); } catch (error) { syncError = error; }
|
|
289
310
|
|
|
290
311
|
let total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
|
|
291
312
|
let unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
|
|
@@ -298,18 +319,18 @@ export async function restoreWallet(request = {}) {
|
|
|
298
319
|
if (rescanWhenLocked && total > 0n && unlocked === 0n && typeof driver.rescanSpent === "function" && passes < PASSES) {
|
|
299
320
|
lockedPass = true;
|
|
300
321
|
await step("rescanSpent", () => driver.rescanSpent());
|
|
301
|
-
try { await wallet.sync(); } catch {
|
|
322
|
+
try { await wallet.sync(); } catch (error) { syncError = error; }
|
|
302
323
|
total = asAmount(await step("getBalance", () => driver.getBalance()), "balance");
|
|
303
324
|
unlocked = asAmount(await step("unlockedBalance", () => wallet.unlockedBalance()), "unlockedBalance");
|
|
304
325
|
passes += 1;
|
|
305
326
|
}
|
|
306
327
|
|
|
328
|
+
const completeness = await scanCompleteness(driver, { syncError, rescanError });
|
|
307
329
|
return {
|
|
308
330
|
total: total.toString(),
|
|
309
331
|
unlocked: unlocked.toString(),
|
|
310
332
|
scanHeight,
|
|
311
|
-
|
|
312
|
-
nodeHeight: await heightOf(driver, "getDaemonHeight"),
|
|
333
|
+
...completeness,
|
|
313
334
|
passes,
|
|
314
335
|
// Whether the second pass RAN - not whether it helped. A reader must be able to tell "nothing was locked" from
|
|
315
336
|
// "it was locked and stayed locked".
|
package/src/sweep.mjs
CHANGED
|
@@ -11,8 +11,20 @@ import { restoreWallet } from "./sweep-restore.mjs";
|
|
|
11
11
|
import { moneroWalletAdapter } from "./adapters/monero-wallet.mjs";
|
|
12
12
|
import { checkDestination } from "./destination.mjs";
|
|
13
13
|
import { revealedHalfWord } from "./sweep-half.mjs";
|
|
14
|
-
import { takerHalves } from "./recovery-key.mjs";
|
|
14
|
+
import { takerHalves, takerHalvesV2 } from "./recovery-key.mjs";
|
|
15
15
|
import { jointAddress } from "./order.mjs";
|
|
16
|
+
import { formatXmr } from "../engine/core/xmrAmount.js";
|
|
17
|
+
|
|
18
|
+
// THE XMR AMOUNT IS PART OF THE RECOVERY FILE. A found order carries the amount the provider signed, in atomic
|
|
19
|
+
// units; the file keeps it in whole XMR, the unit every other payload user reads. An order without the amount
|
|
20
|
+
// stays null rather than silently falling back to the ETH side or to zero.
|
|
21
|
+
const xmrAmountOf = (value) => {
|
|
22
|
+
if (value === null || value === undefined || value === "") return null;
|
|
23
|
+
const text = String(value);
|
|
24
|
+
if (/^\d+$/.test(text)) return formatXmr(BigInt(text));
|
|
25
|
+
const numeric = Number(text);
|
|
26
|
+
return Number.isFinite(numeric) && numeric > 0 ? text : null;
|
|
27
|
+
};
|
|
16
28
|
|
|
17
29
|
// The same boundary as when the file was issued: a passphrase shorter than twelve characters will not open the file.
|
|
18
30
|
const MIN_PASSPHRASE = 12;
|
|
@@ -34,8 +46,6 @@ const payloadFromOrder = async (request) => {
|
|
|
34
46
|
if (typeof request.mnemonic !== "string" || !request.mnemonic.trim()) throw new SdkError("bad-input", { field: "mnemonic" });
|
|
35
47
|
const chainId = Number(request.chainId);
|
|
36
48
|
if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new SdkError("bad-input", { field: "chainId" });
|
|
37
|
-
const index = Number(order.index);
|
|
38
|
-
if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "order.index" });
|
|
39
49
|
const network = typeof request.network === "string" ? request.network.trim() : "";
|
|
40
50
|
const moneroNetwork = typeof request.moneroNetwork === "string" ? request.moneroNetwork.trim() : "";
|
|
41
51
|
if (!network) throw new SdkError("bad-input", { field: "network" });
|
|
@@ -47,7 +57,20 @@ const payloadFromOrder = async (request) => {
|
|
|
47
57
|
|
|
48
58
|
const side = order.side === "locker" ? "buy" : order.side === "claimer" ? "sell" : null;
|
|
49
59
|
if (!side) throw new SdkError("bad-input", { field: "order.side" });
|
|
50
|
-
|
|
60
|
+
// TWO DERIVATIONS, BOTH LIVE. A v1 order has an index; a v2 order has the public salt from OrderCreated and
|
|
61
|
+
// the factory in its binding. The page may hand either shape back; reading `index` from a v2 order would invent
|
|
62
|
+
// a number and rebuild the wrong address.
|
|
63
|
+
let halves;
|
|
64
|
+
if (order.derivation === "salt-v2") {
|
|
65
|
+
if (typeof order.factory !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(order.factory)) {
|
|
66
|
+
throw new SdkError("bad-input", { field: "order.factory" });
|
|
67
|
+
}
|
|
68
|
+
halves = takerHalvesV2({ mnemonic: request.mnemonic, chainId, factory: order.factory, salt: order.salt });
|
|
69
|
+
} else {
|
|
70
|
+
const index = Number(order.index);
|
|
71
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new SdkError("bad-input", { field: "order.index" });
|
|
72
|
+
halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
|
|
73
|
+
}
|
|
51
74
|
const buy = side === "buy";
|
|
52
75
|
if (bare(buy ? order.edPointLocker : order.edPointClaimer) !== bare(halves.spendPoint)) {
|
|
53
76
|
throw new SdkError("bad-input", { field: "order", why: "spend-point" });
|
|
@@ -67,7 +90,7 @@ const payloadFromOrder = async (request) => {
|
|
|
67
90
|
swap: {
|
|
68
91
|
id: order.escrow, side, network, moneroNetwork,
|
|
69
92
|
createdAt: order.blockTime ? Number(order.blockTime) * 1000 : null,
|
|
70
|
-
payToken: null, payAmount: null, xmrAmount:
|
|
93
|
+
payToken: null, payAmount: null, xmrAmount: xmrAmountOf(order.xmrAmount), rate: null, maker: null, quoteSignature: null,
|
|
71
94
|
escrow, timeline: null,
|
|
72
95
|
},
|
|
73
96
|
swapWallet: {
|
package/src/version.mjs
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
// tools/sync-version.mjs. Do not edit it by hand - set the version in package.json and run `npm run sync:version`.
|
|
7
7
|
// The `version` check in tools/check.mjs refuses a mismatch, and refuses a runtime read of package.json here.
|
|
8
8
|
|
|
9
|
-
export const SDK_VERSION = "0.
|
|
9
|
+
export const SDK_VERSION = "0.54.0";
|
package/src/xmr-arrival.mjs
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// decision moves into the client, which reads the chain itself with its own view key.
|
|
8
8
|
//
|
|
9
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the
|
|
14
|
-
//
|
|
10
|
+
// least two nodes belonging to DIFFERENT operators see THE WHOLE OBSERVATION THE SAME WAY: the same transaction in
|
|
11
|
+
// the same block, with the same output keys, the same decoded amounts (each backed by its Pedersen commitment) and
|
|
12
|
+
// the same unlock state. A failed or lagging node does not count and blocks nothing; a synced node that does not
|
|
13
|
+
// have the transaction, reports another block, or reports another amount is a DISAGREEMENT - the button stays closed
|
|
14
|
+
// and the screen says the nodes do not agree. When only one operator has confirmed, the core names that state
|
|
15
|
+
// (`one-node`) instead of waiting silently.
|
|
15
16
|
//
|
|
16
17
|
// WHERE THE RULE LIVES. "Locked by unlock_time is not an arrival" is ONE rule (issue #84). It is not copied here:
|
|
17
18
|
// it lives under www/js/monero/unlock.js, is mirrored byte-for-byte into the engine (sdk/engine/monero/unlock.js,
|
|
@@ -108,8 +109,9 @@ async function getBlockTxs(node, hashes, fetchImpl, timeoutMs) {
|
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
// The amount of our own outputs in a matched transaction (a swap can be paid in several outputs).
|
|
112
|
-
|
|
112
|
+
// The amount of our own outputs in a matched transaction (a swap can be paid in several outputs). An output whose
|
|
113
|
+
// amount did not match its Pedersen commitment is NOT money: it contributes zero, and the caller names it.
|
|
114
|
+
const sumOutputs = (hit) => hit.outputs.reduce((n, o) => n + (o.amountVerified ? (o.amountAtomic || 0n) : 0n), 0n);
|
|
113
115
|
|
|
114
116
|
/**
|
|
115
117
|
* FIND THE TRANSACTION, IF IT IS THERE. Blocks are walked from the swap's birth height upwards and the whole block
|
|
@@ -128,7 +130,10 @@ async function locate(node, { txid, address, viewSecret, spendPub, fromHeight, t
|
|
|
128
130
|
for (const tx of txs) {
|
|
129
131
|
if (txid && tx.tx_hash !== txid) continue;
|
|
130
132
|
const hit = engine.xmrScan.matchTransaction(tx, [{ address, viewSecret, spendPub }], { height: h, nowSec });
|
|
131
|
-
|
|
133
|
+
// LOCATE FINDS AN OUTPUT, NOT AN AMOUNT. The amount can be forged or unbacked; the verification step below
|
|
134
|
+
// refuses it. Requiring a positive verified sum here made two nodes that agreed on a forged amount look like
|
|
135
|
+
// "no transaction at all".
|
|
136
|
+
if (hit.length) return { txid: tx.tx_hash, height: h };
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
return null;
|
|
@@ -207,22 +212,51 @@ export async function verifyArrival(request) {
|
|
|
207
212
|
try { body = (await getBlockTxs(node, block.txHashes, fetchImpl, timeoutMs)).find((t) => t.tx_hash === located.txid) || null; }
|
|
208
213
|
catch { record.state = "down"; seen.push(record); continue; }
|
|
209
214
|
if (!body) { record.state = "no-tx"; seen.push(record); continue; }
|
|
210
|
-
|
|
215
|
+
// THE LOCK IS JUDGED AT THE WITNESS'S TIP, NOT AT THE BLOCK THAT MINED THE TRANSFER. `located.height` stays for
|
|
216
|
+
// confirmations; a height lock that has matured is no longer `xmr-locked`.
|
|
217
|
+
const hit = engine.xmrScan.matchTransaction(body, [watched], {
|
|
218
|
+
height: located.height,
|
|
219
|
+
tipHeight: Math.max(0, info.height - 1),
|
|
220
|
+
nowSec,
|
|
221
|
+
});
|
|
211
222
|
if (!hit.length) { record.state = "no-output"; seen.push(record); continue; }
|
|
212
223
|
record.state = "ok";
|
|
213
224
|
record.amountAtomic = sumOutputs(hit[0]);
|
|
225
|
+
record.amountVerified = hit[0].outputs.every((o) => o.amountVerified === true);
|
|
226
|
+
record.outputs = hit[0].outputs.map((o) => ({
|
|
227
|
+
index: o.index,
|
|
228
|
+
key: String(o.key || "").replace(/^0x/, "").toLowerCase(),
|
|
229
|
+
amountAtomic: o.amountVerified ? String(o.amountAtomic ?? null) : null,
|
|
230
|
+
amountVerified: o.amountVerified === true,
|
|
231
|
+
}));
|
|
214
232
|
record.unlock = hit[0].unlock;
|
|
215
233
|
// Confirmations of a block: top - height + 1, and top = info.height - 1 (see above), so this is height - located.
|
|
216
234
|
record.confirmations = Number.isFinite(Number(body.confirmations)) ? Number(body.confirmations) : (info.height - located.height);
|
|
235
|
+
// A NODE THAT CHANGED THE AMOUNT IS A DISAGREEMENT, NOT A WITNESS. The commitment check above is what
|
|
236
|
+
// makes this observable: without it an altered ecdhInfo.amount would still look like a valid output.
|
|
237
|
+
if (!record.amountVerified) { record.state = "amount-unverified"; seen.push(record); continue; }
|
|
238
|
+
// THE WHOLE OBSERVATION, NOT A BLOCK HASH. Two nodes must agree on the transaction id, the block, every
|
|
239
|
+
// output key and amount, and the unlock state. A block hash alone let one node change the amount and still
|
|
240
|
+
// form its own group; that is exactly the finding this closes.
|
|
241
|
+
record.observation = {
|
|
242
|
+
txid: located.txid,
|
|
243
|
+
blockHash: String(block.hash || "").replace(/^0x/, "").toLowerCase(),
|
|
244
|
+
outputs: record.outputs,
|
|
245
|
+
unlock: record.unlock ? {
|
|
246
|
+
locked: record.unlock.locked === true,
|
|
247
|
+
untilHeight: record.unlock.untilHeight ?? null,
|
|
248
|
+
untilTime: record.unlock.untilTime ?? null,
|
|
249
|
+
} : null,
|
|
250
|
+
};
|
|
217
251
|
seen.push(record);
|
|
218
252
|
}
|
|
219
253
|
|
|
220
|
-
// A DISAGREEMENT CLOSES THE BUTTON - it is named, and the nodes and
|
|
221
|
-
const disagreeing = seen.filter((r) => r.state === "no-tx" || r.state === "no-output");
|
|
254
|
+
// A DISAGREEMENT CLOSES THE BUTTON - it is named, and the nodes and observations travel in the params.
|
|
255
|
+
const disagreeing = seen.filter((r) => r.state === "no-tx" || r.state === "no-output" || r.state === "amount-unverified");
|
|
222
256
|
if (disagreeing.length) {
|
|
223
257
|
throw new SdkError("xmr-nodes-disagree", {
|
|
224
258
|
txid: located.txid, height: located.height,
|
|
225
|
-
nodes: disagreeing.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash || null, why: r.state })),
|
|
259
|
+
nodes: disagreeing.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash || null, why: r.state, outputs: r.outputs || null })),
|
|
226
260
|
agreed: seen.filter((r) => r.state === "ok").map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash })),
|
|
227
261
|
});
|
|
228
262
|
}
|
|
@@ -230,43 +264,53 @@ export async function verifyArrival(request) {
|
|
|
230
264
|
const okay = seen.filter((r) => r.state === "ok");
|
|
231
265
|
if (!okay.length) return { seen: false, state: "pending", txid: located.txid, height: located.height, checked: seen };
|
|
232
266
|
|
|
267
|
+
// THE OBSERVATIONS MUST BE IDENTICAL. Grouping by the whole observation (not just the block hash) means a
|
|
268
|
+
// node that reports a different amount, output key or unlock state does not count as agreeing with anyone.
|
|
269
|
+
const byObservation = new Map();
|
|
270
|
+
for (const r of okay) {
|
|
271
|
+
const key = JSON.stringify(r.observation);
|
|
272
|
+
if (!byObservation.has(key)) byObservation.set(key, { observation: r.observation, operators: new Set(), nodes: [] });
|
|
273
|
+
const g = byObservation.get(key);
|
|
274
|
+
g.operators.add(r.operator);
|
|
275
|
+
g.nodes.push(r);
|
|
276
|
+
}
|
|
277
|
+
if (byObservation.size > 1) {
|
|
278
|
+
throw new SdkError("xmr-nodes-disagree", {
|
|
279
|
+
txid: located.txid, height: located.height,
|
|
280
|
+
nodes: okay.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash || null, outputs: r.outputs, why: "observation" })),
|
|
281
|
+
agreed: [],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const best = [...byObservation.values()][0];
|
|
285
|
+
// A node that is further along is not a liar: the minimum is the figure every witness can support. What
|
|
286
|
+
// matters is that the transaction itself is identical; confirmations only decide when the button opens.
|
|
287
|
+
const confirmations = Math.min(...best.nodes.map((r) => r.confirmations));
|
|
288
|
+
const amountAtomic = best.nodes[0].amountAtomic;
|
|
289
|
+
const agreedBy = best.nodes.map((r) => ({ url: r.url, operator: r.operator, blockHash: r.blockHash, confirmations: r.confirmations }));
|
|
290
|
+
|
|
233
291
|
// LOCKED IS NOT AN ARRIVAL, and the rule for that is the shared one (issue #84), mirrored from www/js.
|
|
234
|
-
const locked =
|
|
292
|
+
const locked = best.nodes.find((r) => r.unlock && r.unlock.locked);
|
|
235
293
|
if (locked) {
|
|
236
294
|
const until = locked.unlock;
|
|
237
295
|
throw new SdkError("xmr-locked", { txid: located.txid, height: located.height, address: request.address,
|
|
238
296
|
untilHeight: until.untilHeight ?? null, untilTime: until.untilTime ?? null });
|
|
239
297
|
}
|
|
240
298
|
|
|
241
|
-
// THE AMOUNT IS NOT LESS THAN EXPECTED. A shortfall is named rather than shown as a completed swap.
|
|
242
|
-
|
|
299
|
+
// THE AMOUNT IS NOT LESS THAN EXPECTED. A shortfall is named rather than shown as a completed swap. The figure
|
|
300
|
+
// comes from the agreed observation, never from the largest answer of one node: that maximum was the defect.
|
|
243
301
|
if (amountAtomic < expectedAtomic) {
|
|
244
302
|
throw new SdkError("xmr-underpaid", { txid: located.txid, height: located.height, got: amountAtomic.toString(), want: expectedAtomic.toString() });
|
|
245
303
|
}
|
|
246
304
|
|
|
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
305
|
if (best.operators.size < 2) {
|
|
262
|
-
return { seen: false, state: "one-node", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
306
|
+
return { seen: false, state: "one-node", txid: located.txid, height: located.height, blockHash: best.observation.blockHash,
|
|
263
307
|
amountAtomic: amountAtomic.toString(), confirmations, operators: [...best.operators], agreedBy, checked: seen };
|
|
264
308
|
}
|
|
265
309
|
if (confirmations < minConfirmations) {
|
|
266
|
-
return { seen: false, state: "pending", why: "confirmations", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
310
|
+
return { seen: false, state: "pending", why: "confirmations", txid: located.txid, height: located.height, blockHash: best.observation.blockHash,
|
|
267
311
|
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations, agreedBy, checked: seen };
|
|
268
312
|
}
|
|
269
|
-
return { seen: true, state: "arrived", txid: located.txid, height: located.height, blockHash: best.blockHash,
|
|
313
|
+
return { seen: true, state: "arrived", txid: located.txid, height: located.height, blockHash: best.observation.blockHash,
|
|
270
314
|
amountAtomic: amountAtomic.toString(), confirmations, needConfirmations: minConfirmations,
|
|
271
315
|
operators: [...best.operators], agreedBy, checked: seen };
|
|
272
316
|
}
|