@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
package/engine/evm/funding.js
CHANGED
|
@@ -125,18 +125,45 @@ export const GAS_CREATE_ORDER_AND_FUND = 700_000n;
|
|
|
125
125
|
|
|
126
126
|
// The ACTUAL escrow address from the receipt. Returns null when the event is absent (the transaction is not in a
|
|
127
127
|
// block, or the factory is another) - then the caller must say so aloud rather than substitute a guess.
|
|
128
|
-
export function escrowFromReceipt(receipt) {
|
|
128
|
+
export function escrowFromReceipt(receipt, { factory = null, expectedEscrow = null } = {}) {
|
|
129
129
|
if (!receipt || !Array.isArray(receipt.logs)) return null;
|
|
130
|
+
const wantFactory = factory ? String(factory).toLowerCase() : null;
|
|
131
|
+
const wantEscrow = expectedEscrow ? String(expectedEscrow).toLowerCase() : null;
|
|
130
132
|
for (const log of receipt.logs) {
|
|
133
|
+
// THE EVENT MUST COME FROM THE FACTORY THAT WAS CALLED. Another contract can emit the same topic; accepting
|
|
134
|
+
// it would let a receipt from a different order name the escrow that gets saved and used for the withdrawal.
|
|
135
|
+
if (wantFactory && String(log && log.address || "").toLowerCase() !== wantFactory) continue;
|
|
131
136
|
const topics = log.topics || [];
|
|
132
137
|
if (String(topics[0]).toLowerCase() !== ORDER_CREATED_TOPIC) continue;
|
|
133
138
|
if (topics.length < 2) continue;
|
|
134
139
|
const addr = "0x" + String(topics[1]).slice(26).toLowerCase();
|
|
135
|
-
if (
|
|
140
|
+
if (!/^0x[0-9a-f]{40}$/.test(addr) || /^0x0{40}$/.test(addr)) continue;
|
|
141
|
+
// THE PREDICTED ADDRESS IS THE EXPECTED ONE. A different escrow in the same factory is not this order; the
|
|
142
|
+
// caller must not save it as funded.
|
|
143
|
+
if (wantEscrow && addr !== wantEscrow) continue;
|
|
144
|
+
return addr;
|
|
136
145
|
}
|
|
137
146
|
return null;
|
|
138
147
|
}
|
|
139
148
|
|
|
149
|
+
// A RECEIPT IS A VERDICT, NOT A PRESENCE CHECK. `status: 0` is a reverted transaction; a receipt with no
|
|
150
|
+
// OrderCreated from the expected factory is not a funded escrow; a missing receipt is pending. Only status 1 plus
|
|
151
|
+
// the expected event is funded, and the caller saves the escrow only on that answer.
|
|
152
|
+
export function fundingReceiptVerdict(receipt, { factory = null, expectedEscrow = null } = {}) {
|
|
153
|
+
if (!receipt || typeof receipt !== "object") return { status: "pending", reason: "no-receipt" };
|
|
154
|
+
if (receipt.status === undefined || receipt.status === null || receipt.status === "") {
|
|
155
|
+
return { status: "pending", reason: "no-status" };
|
|
156
|
+
}
|
|
157
|
+
const status = Number(receipt.status);
|
|
158
|
+
if (!Number.isFinite(status)) return { status: "pending", reason: "bad-status" };
|
|
159
|
+
if (status === 0) return { status: "reverted", reason: "status-0" };
|
|
160
|
+
if (status !== 1) return { status: "pending", reason: "bad-status" };
|
|
161
|
+
const escrow = escrowFromReceipt(receipt, { factory, expectedEscrow });
|
|
162
|
+
if (!escrow) return { status: "pending", reason: "no-order-created" };
|
|
163
|
+
const blockNumber = Number(receipt.blockNumber);
|
|
164
|
+
return { status: "funded", escrow, birthBlock: Number.isFinite(blockNumber) ? blockNumber : null };
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
// Wait for a receipt: right after signing there is none, which is normal. Waiting forever is not allowed - we say so honestly.
|
|
141
168
|
async function waitReceipt(receiptReader, hash, { tries = 12, delayMs = 1500 } = {}) {
|
|
142
169
|
for (let i = 0; i < tries; i += 1) {
|
|
@@ -184,8 +211,13 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
184
211
|
// THE DEPOSITOR'S FIELDS GO AS SEPARATE FACTORY ARGUMENTS (they are not in the signed set).
|
|
185
212
|
const quoteEnc = { ...quote, commitHalfLocker: terms.commitHalfLocker, edPointLocker: terms.edPointLocker, edViewPointLocker: terms.edViewPointLocker };
|
|
186
213
|
// QUOTE AND TERMS ARE ABOUT ONE THING: a divergence moves the escrow address and signs the wrong order.
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
// THE COUNTERPARTY'S SPEND HALF IS PART OF THAT ONE THING. The proof is checked against the counterparty side,
|
|
215
|
+
// while the factory encodes the signed quote; without comparing these two fields a quote that commits to one
|
|
216
|
+
// point can be funded beside a proof for another, and the claim reveals a half that does not open the address.
|
|
217
|
+
const sameValue = (a, b) => String(a === undefined || a === null ? "" : a).replace(/^0x/i, "").toLowerCase()
|
|
218
|
+
=== String(b === undefined || b === null ? "" : b).replace(/^0x/i, "").toLowerCase();
|
|
219
|
+
for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt", "commitHalfClaimer", "edPointClaimer"]) {
|
|
220
|
+
if (!sameValue(quote[name], terms[name])) {
|
|
189
221
|
throw new Error("the quote and the order terms diverge on field " + name);
|
|
190
222
|
}
|
|
191
223
|
}
|
|
@@ -260,20 +292,29 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
260
292
|
// independent of the Monero node. Declared outside the branch on purpose: a variable declared inside a block is
|
|
261
293
|
// not visible outside, and it must always be returned.
|
|
262
294
|
let birthBlock = null;
|
|
295
|
+
let verdict = { status: "pending", reason: "no-receipt-reader" };
|
|
263
296
|
if (typeof receipt === "function") {
|
|
264
297
|
const rc = await waitReceipt(receipt, createHash);
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
298
|
+
verdict = fundingReceiptVerdict(rc, { factory, expectedEscrow: escrow });
|
|
299
|
+
if (verdict.status === "reverted") {
|
|
300
|
+
const error = new Error("the funding transaction reverted");
|
|
301
|
+
error.code = "contract-reverted";
|
|
302
|
+
error.receiptStatus = 0;
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
if (verdict.status === "funded") {
|
|
306
|
+
actualEscrow = verdict.escrow;
|
|
307
|
+
birthBlock = verdict.birthBlock;
|
|
308
|
+
if (actualEscrow !== String(escrow).toLowerCase()) {
|
|
309
|
+
console.warn("[escrow] receipt address " + actualEscrow + " != predicted " + escrow + " - using the receipt one");
|
|
310
|
+
onStep?.("receipt address differs from predicted - using the receipt one");
|
|
311
|
+
}
|
|
312
|
+
} else if (!rc) {
|
|
313
|
+
onStep?.("no receipt yet - funding is pending, not funded");
|
|
314
|
+
} else {
|
|
315
|
+
onStep?.("receipt has no matching OrderCreated - funding is pending, not funded");
|
|
274
316
|
}
|
|
275
317
|
}
|
|
276
|
-
const escrowToUse = actualEscrow || escrow;
|
|
277
318
|
|
|
278
319
|
// 3) THE SECOND TRANSACTION IS GONE: the money left together with order creation. The name lockHash is kept on
|
|
279
320
|
// purpose - callers look at it, and renaming would touch them with no benefit. THERE IS AND WILL BE NO TOP-UP:
|
|
@@ -282,10 +323,16 @@ export async function fundOrder({ factory, locker, claimer, commitHalfLocker, co
|
|
|
282
323
|
// created without money.
|
|
283
324
|
const lockHash = createHash;
|
|
284
325
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
326
|
+
// ONLY A STATUS-1 RECEIPT WITH THE EXPECTED ORDER CREATED IS FUNDED. A missing receipt, a receipt without the
|
|
327
|
+
// event, or an event from another factory keeps `escrow` null and is returned as pending; the caller must not
|
|
328
|
+
// save it, retire a recovery number for it, or call it xmr-incoming.
|
|
287
329
|
// THE FEE GOES OUTWARD: it went into the transaction and the deal record must keep it - otherwise the record
|
|
288
330
|
// cannot tell how much the person deposited, and it would diverge from what the chain shows.
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
const common = { feeWei: fee.feeWei, feeTotalWei: totalValueWei, feeBps: fee.bps, feeWasLegacyFactory: Boolean(fee.legacy),
|
|
332
|
+
predictedEscrow: escrow, salt, terms, createHash, lockHash };
|
|
333
|
+
if (verdict.status !== "funded") {
|
|
334
|
+
return { ...common, escrow: null, birthBlock: null, funded: false, pending: true, pendingReason: verdict.reason };
|
|
335
|
+
}
|
|
336
|
+
return { ...common, escrow: actualEscrow, escrowMismatch: actualEscrow !== String(escrow).toLowerCase(),
|
|
337
|
+
birthBlock, funded: true, pending: false };
|
|
291
338
|
}
|
|
@@ -71,25 +71,6 @@ export function viewKeyMatchesAddress(viewScalar, expectedViewPubHex, halves) {
|
|
|
71
71
|
return got === want;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* IS THE COIN SPENDABLE AT THIS MOMENT, AND IF NOT, WHAT IS THE BOUNDARY.
|
|
79
|
-
*
|
|
80
|
-
* `unlocked` / `locked_height` / `locked_time`, with the boundary named in `untilHeight` or `untilTime`. A height
|
|
81
|
-
* that is not known is NOT "unlocked": without a height the answer stays locked, because the caller has no fact
|
|
82
|
-
* that would open it. `nowSec` defaults to the wall clock, and is a parameter so a check can pin it.
|
|
83
|
-
*/
|
|
84
|
-
export function unlockStateOf(unlockTime, { height = null, nowSec = null } = {}) {
|
|
85
|
-
const u = Number(unlockTime) || 0;
|
|
86
|
-
if (u <= 0) return { code: "unlocked", locked: false, untilHeight: null, untilTime: null };
|
|
87
|
-
if (u >= UNLOCK_TIME_BLOCK_MAX) {
|
|
88
|
-
const now = nowSec === null || nowSec === undefined ? Math.floor(Date.now() / 1000) : Number(nowSec);
|
|
89
|
-
const open = Number.isFinite(now) && now >= u;
|
|
90
|
-
return { code: open ? "unlocked" : "locked_time", locked: !open, untilHeight: null, untilTime: u };
|
|
91
|
-
}
|
|
92
|
-
const h = height === null || height === undefined ? null : Number(height);
|
|
93
|
-
const open = h !== null && Number.isFinite(h) && h >= u;
|
|
94
|
-
return { code: open ? "unlocked" : "locked_height", locked: !open, untilHeight: u, untilTime: null };
|
|
95
|
-
}
|
|
74
|
+
// THE UNLOCK RULE HAS ONE HOME: ./unlock.js. This module re-exports it for the public xmr entry instead of keeping
|
|
75
|
+
// a second copy; a second copy is a second truth about whether money can be spent.
|
|
76
|
+
export { UNLOCK_TIME_BLOCK_MAX, unlockStateOf } from "./unlock.js";
|
|
@@ -15,7 +15,11 @@ export function spendKeysFromRevealedHalf({
|
|
|
15
15
|
for (const [name, v] of Object.entries({ ownSpendHalf, ownViewHalf, otherSpendPoint, otherViewHalf, revealedHalf })) {
|
|
16
16
|
if (v === undefined || v === null || v === "") throw new Error("not set: " + name);
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// THE CONTRACT ACCEPTS THE REAL HALF PLUS THE SECP256K1 ORDER. `claim` and `refund` do not reject `h + n` (the
|
|
19
|
+
// escrow only hashes the word), but adding n is not the same point on edwards25519. Reduce the public word by the
|
|
20
|
+
// secp order first: the small real half is unchanged, and the alias lands on the same scalar.
|
|
21
|
+
const revealed = BigInt(revealedHalf) % halves.SECP_ORDER;
|
|
22
|
+
const sum = halves.combineHalves(BigInt(ownSpendHalf), revealed);
|
|
19
23
|
const sumPub = halves.publicHalves(sum).ed;
|
|
20
24
|
// The expected spend key is built FROM OWN HALF AND THE COUNTERPARTY POINT (what was in the order).
|
|
21
25
|
// The half from the file arrives as a string, so it must be converted - forgetting this crashed the XMR
|
package/engine/monero/scan.js
CHANGED
|
@@ -33,6 +33,25 @@ export function keccak(bytes) { return keccak_256(bytes); }
|
|
|
33
33
|
// corrupt the scalar, the derivation and the sum - an "invisible arrival".
|
|
34
34
|
export const ED_ORDER = ed25519.Point.CURVE().n;
|
|
35
35
|
|
|
36
|
+
// THE SECOND PEDERSEN GENERATOR, FROM THE MONERO SOURCE. `rct::H` is a fixed point whose discrete logarithm to
|
|
37
|
+
// the base point is unknown: C = mask*G + amount*H. The bytes are the compressed point from
|
|
38
|
+
// monero/src/ringct/rctTypes.h (`static const key H`), not a value recomputed from a remembered string.
|
|
39
|
+
// It is the same point the DLEQ module calls Galt - one constant, two uses, no second derivation.
|
|
40
|
+
export const PEDERSEN_H = "8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94";
|
|
41
|
+
const PEDERSEN_H_POINT = ed25519.Point.fromHex(PEDERSEN_H);
|
|
42
|
+
|
|
43
|
+
function scalarPoint(point, scalar) {
|
|
44
|
+
const mod = ((BigInt(scalar) % ED_ORDER) + ED_ORDER) % ED_ORDER;
|
|
45
|
+
return mod === 0n ? ed25519.Point.ZERO : point.multiply(mod);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Pedersen commitment C = mask*G + amount*H, compressed. Used to check a decoded amount against `outPk`. */
|
|
49
|
+
export function amountMaskCommitment(amount, mask) {
|
|
50
|
+
const value = BigInt(amount);
|
|
51
|
+
if (value < 0n) throw new Error("the amount must not be negative");
|
|
52
|
+
return scalarPoint(ed25519.Point.BASE, mask).add(scalarPoint(PEDERSEN_H_POINT, value)).toHex();
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
export function hexToBytes(hex) {
|
|
37
56
|
const t = String(hex).trim().replace(/^0x/, "");
|
|
38
57
|
if (t.length % 2) throw new Error("odd hex length");
|
|
@@ -138,32 +157,52 @@ export function txPublicKeyFromExtra(extra) {
|
|
|
138
157
|
return null;
|
|
139
158
|
}
|
|
140
159
|
|
|
141
|
-
// Amount decryption.
|
|
160
|
+
// Amount and mask decryption. THE ENCODING IS CHOSEN BY THE RingCT TYPE, not by a boolean:
|
|
161
|
+
// types 0-3 (old): every ecdhInfo entry carries a 32-byte amount and a 32-byte mask;
|
|
162
|
+
// types 4-6 (Bulletproof2, CLSAG, BulletproofPlus): the entry carries only the 8-byte amount, and the
|
|
163
|
+
// receiver DERIVES the mask as Hs("commitment_mask" || sharedSec). A transaction from a current node has no
|
|
164
|
+
// `ecdhInfo.mask` at all.
|
|
142
165
|
//
|
|
143
166
|
// THE SECRET IS THE DERIVATION SCALAR, NOT THE DERIVATION. Easy to get wrong: both are 32 bytes, but what goes
|
|
144
167
|
// into genAmountEncodingFactor is the scalar (derivation_to_scalar), written LEAST-SIGNIFICANT first. With the
|
|
145
168
|
// derivation the result looks plausible and is WRONG (checked on live numbers).
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
//
|
|
170
|
+
// THE TWO OLD FACTORS ARE DIFFERENT: amount -= Hs(Hs(sharedSec)), mask -= Hs(sharedSec). Using the amount's
|
|
171
|
+
// double hash for the mask (the first version) makes every old-encoding commitment fail.
|
|
172
|
+
function compactEncoding(rctType) { return Number(rctType) >= 4; }
|
|
173
|
+
function oldFactor(scalarLE, label) {
|
|
174
|
+
const base = hashToScalar(scalarLE);
|
|
175
|
+
if (label === "mask") return base;
|
|
176
|
+
return hashToScalar(hexToBytes(base.toString(16).padStart(64, "0")));
|
|
177
|
+
}
|
|
178
|
+
function decodeField(ecdhHex, scalarLE, rctType, label) {
|
|
179
|
+
if (compactEncoding(rctType)) {
|
|
180
|
+
if (label === "mask") return hashToScalar(new TextEncoder().encode("commitment_mask"), scalarLE);
|
|
181
|
+
const enc = hexToBytes(ecdhHex);
|
|
182
|
+
const prefix = new TextEncoder().encode(label);
|
|
183
|
+
const buf = new Uint8Array(prefix.length + scalarLE.length);
|
|
184
|
+
buf.set(prefix, 0); buf.set(scalarLE, prefix.length);
|
|
153
185
|
const factor = keccak(buf);
|
|
154
186
|
const out = enc.slice();
|
|
155
|
-
for (let i = 0; i <
|
|
156
|
-
// And again LEAST-SIGNIFICANT first: Monero amounts are little-endian too.
|
|
187
|
+
for (let i = 0; i < out.length && i < factor.length; i++) out[i] ^= factor[i];
|
|
157
188
|
return scalarFromBytesLE(out);
|
|
158
189
|
}
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
return (
|
|
190
|
+
const enc = hexToBytes(ecdhHex);
|
|
191
|
+
const value = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
|
|
192
|
+
return (value - oldFactor(scalarLE, label) + ED_ORDER) % ED_ORDER;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function decodeAmount(ecdhAmountHex, scalarLE, rctType) {
|
|
196
|
+
return decodeField(ecdhAmountHex, scalarLE, rctType, "amount");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function decodeMask(ecdhMaskHex, scalarLE, rctType) {
|
|
200
|
+
return decodeField(ecdhMaskHex, scalarLE, rctType, "mask");
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
// MAIN: parse a transaction and return OUR outputs (their indices, keys and amounts).
|
|
165
|
-
// txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], rctVersion }
|
|
166
|
-
export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], rctVersion = 2 }) {
|
|
204
|
+
// txView - { txPublicKey, vout: [{key, index}], ecdhAmounts: [...], ecdhMasks: [...], outPk: [...], rctVersion }
|
|
205
|
+
export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, outputs, ecdhAmounts = [], ecdhMasks = [], outPk = [], rctVersion = 2 }) {
|
|
167
206
|
if (!txPublicKey) throw new Error("the transaction has no public key (extra tag 0x01) - nothing to parse");
|
|
168
207
|
// The key form is tried EXPLICITLY; the winner's name goes up so the log shows what was used. If none matched,
|
|
169
208
|
// the first meaningful form is returned, not nothing.
|
|
@@ -178,7 +217,20 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
|
|
|
178
217
|
if (derived.toLowerCase() === String(o.key).toLowerCase()) {
|
|
179
218
|
// The amount decryption uses THIS output's SCALAR, least-significant first.
|
|
180
219
|
const amount = ecdhAmounts[o.index] ? decodeAmount(ecdhAmounts[o.index], scalarToBytesLE(scalar), rctVersion) : null;
|
|
181
|
-
|
|
220
|
+
// THE COMMITMENT CHECK: outPk[i] = mask*G + amount*H. A node can flip a bit of the encrypted amount
|
|
221
|
+
// without any key; the commitment is the only figure it cannot forge. A missing or mismatching
|
|
222
|
+
// commitment is named on the output instead of being accepted as an arrival.
|
|
223
|
+
// COMPACT TYPES HAVE NO mask FIELD: it is derived from the same scalar. Old types carry it.
|
|
224
|
+
const mask = compactEncoding(rctVersion)
|
|
225
|
+
? decodeMask(null, scalarToBytesLE(scalar), rctVersion)
|
|
226
|
+
: (ecdhMasks[o.index] ? decodeMask(ecdhMasks[o.index], scalarToBytesLE(scalar), rctVersion) : null);
|
|
227
|
+
let amountVerified = false;
|
|
228
|
+
if (amount !== null && mask !== null && outPk[o.index]) {
|
|
229
|
+
try {
|
|
230
|
+
amountVerified = amountMaskCommitment(amount, mask).toLowerCase() === String(outPk[o.index]).replace(/^0x/, "").toLowerCase();
|
|
231
|
+
} catch { amountVerified = false; }
|
|
232
|
+
}
|
|
233
|
+
found.push({ index: o.index, key: o.key, amountAtomic: amount, maskAtomic: mask, amountVerified });
|
|
182
234
|
}
|
|
183
235
|
}
|
|
184
236
|
if (found.length) return { derivation: bytesToHex(derivation), outputs: found, viewKeyForm: form };
|
|
@@ -200,31 +252,69 @@ export function findOurOutputs({ txPublicKey, viewSecretHex, spendPubHex, output
|
|
|
200
252
|
// ============================================================================================
|
|
201
253
|
|
|
202
254
|
|
|
203
|
-
|
|
204
|
-
|
|
255
|
+
// A trailing-slash trim without a backtracking regular expression: the daemon URL is caller input, and CodeQL
|
|
256
|
+
// flags `/+$` on a long run of slashes as a possible denial of service.
|
|
257
|
+
const baseUrl = (value) => {
|
|
258
|
+
let out = String(value);
|
|
259
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
260
|
+
return out;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export async function fetchBlock(daemonUrl, height, fetchImpl = globalThis.fetch) {
|
|
264
|
+
const res = await fetchImpl(`${baseUrl(daemonUrl)}/json_rpc`, {
|
|
205
265
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
206
266
|
body: JSON.stringify({ jsonrpc: "2.0", id: "block", method: "get_block", params: { height } }),
|
|
207
267
|
});
|
|
208
268
|
const j = await res.json();
|
|
269
|
+
// A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon answers BUSY while it is still indexing; a reader that treats
|
|
270
|
+
// the missing `json` as "no transactions" reports a complete range with a smaller balance.
|
|
271
|
+
if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
|
|
209
272
|
if (!j.result) throw new Error(`block ${height} not received: ${j.error ? j.error.message : "empty answer"}`);
|
|
273
|
+
if (String(j.result.status || "").toUpperCase() === "BUSY") throw new Error(`block ${height}: daemon is BUSY`);
|
|
274
|
+
// THE BLOCK MUST CARRY ITS DECODED BODY. `json` is what scanRange walks; without it the block is unreadable, not
|
|
275
|
+
// empty. A result with only a blob or a header is refused by the same rule.
|
|
276
|
+
if (j.result.json === undefined || j.result.json === null || j.result.json === "") {
|
|
277
|
+
throw new Error(`block ${height}: no decoded body`);
|
|
278
|
+
}
|
|
210
279
|
return j.result;
|
|
211
280
|
}
|
|
212
281
|
|
|
213
282
|
// Transactions by hash list. We hit the PATH /get_transactions, not /json_rpc: over JSON-RPC this method
|
|
214
283
|
// silently returns empty, which looks like "no transactions".
|
|
215
|
-
export async function fetchTransactions(daemonUrl, hashes) {
|
|
284
|
+
export async function fetchTransactions(daemonUrl, hashes, fetchImpl = globalThis.fetch) {
|
|
216
285
|
if (!hashes.length) return [];
|
|
217
|
-
const res = await
|
|
286
|
+
const res = await fetchImpl(`${baseUrl(daemonUrl)}/get_transactions`, {
|
|
218
287
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
219
288
|
body: JSON.stringify({ txs_hashes: hashes, decode_as_json: true }),
|
|
220
289
|
});
|
|
221
290
|
const j = await res.json();
|
|
222
|
-
|
|
291
|
+
// A BUSY ANSWER IS NOT AN EMPTY BLOCK. The daemon says BUSY while it is still indexing; returning [] would turn
|
|
292
|
+
// money that is there into a complete scan with a zero balance.
|
|
293
|
+
if (j && String(j.status || "").toUpperCase() === "BUSY") throw new Error("transactions-busy");
|
|
294
|
+
const wrappers = Array.isArray(j && j.txs) ? j.txs : [];
|
|
295
|
+
const encoded = Array.isArray(j && j.txs_as_json) ? j.txs_as_json : [];
|
|
296
|
+
// EVERY HASH WE ASKED FOR MUST COME BACK. A partial answer is a missing block, not a quiet "no transactions".
|
|
297
|
+
const requested = [...new Set(hashes.map((h) => String(h).replace(/^0x/, "").toLowerCase()))];
|
|
298
|
+
const byHash = new Map();
|
|
299
|
+
for (const wrapper of wrappers) {
|
|
300
|
+
const hash = String((wrapper && wrapper.tx_hash) || "").replace(/^0x/, "").toLowerCase();
|
|
301
|
+
if (!hash) continue;
|
|
302
|
+
if (byHash.has(hash)) throw new Error("transactions-duplicate: " + hash);
|
|
303
|
+
byHash.set(hash, wrapper);
|
|
304
|
+
}
|
|
305
|
+
const missing = requested.filter((h) => !byHash.has(h));
|
|
306
|
+
if (missing.length) throw new Error("transactions-missing: " + missing.length);
|
|
307
|
+
// EXACTLY THE REQUESTED SET. An extra transaction is not money this range asked for, and it may belong to another
|
|
308
|
+
// block; counting it silently inflates the sum. A decoded body is required for every wrapper, one for one.
|
|
309
|
+
if (byHash.size !== requested.length) throw new Error("transactions-extra: " + (byHash.size - requested.length));
|
|
310
|
+
if (encoded.length !== wrappers.length) throw new Error("transactions-undecoded");
|
|
223
311
|
// HASH AND HEIGHT LIVE IN THE WRAPPER, not in the parsed body: as_json omits them. Without this stitching a
|
|
224
312
|
// "found transfer" would have neither hash nor height - useless to the caller, yet everything would look successful.
|
|
225
|
-
|
|
313
|
+
const indexOf = new Map(wrappers.map((w, i) => [String((w && w.tx_hash) || "").replace(/^0x/, "").toLowerCase(), i]));
|
|
314
|
+
return requested.map((hash) => {
|
|
315
|
+
const w = byHash.get(hash) || {};
|
|
316
|
+
const s = encoded[indexOf.get(hash)];
|
|
226
317
|
const body = typeof s === "string" ? JSON.parse(s) : s;
|
|
227
|
-
const w = wrappers[i] || {};
|
|
228
318
|
body.tx_hash = w.tx_hash || body.tx_hash || null;
|
|
229
319
|
body.block_height = w.block_height ?? null;
|
|
230
320
|
body.confirmations = w.confirmations ?? null;
|
|
@@ -235,22 +325,29 @@ export async function fetchTransactions(daemonUrl, hashes) {
|
|
|
235
325
|
|
|
236
326
|
// Parsing one transaction against the watched addresses. watched: [{ address, viewSecret, spendPub }] - viewSecret
|
|
237
327
|
// is taken as a number, LE-hex or BE-hex. The result carries viewKeyForm: the form name that matched.
|
|
238
|
-
export function matchTransaction(tx, watched, { height = null, nowSec = null } = {}) {
|
|
328
|
+
export function matchTransaction(tx, watched, { height = null, tipHeight = null, nowSec = null } = {}) {
|
|
239
329
|
const out = [];
|
|
240
330
|
const txPublicKey = txPublicKeyFromExtra(tx.extra);
|
|
241
331
|
if (!txPublicKey) return out; // no key in extra - nothing to look at
|
|
242
332
|
// WHETHER THE OUTPUT IS LOCKED IS DECIDED HERE, ONCE PER TRANSACTION: all its outputs carry one unlock_time.
|
|
243
333
|
// A normal transaction (unlock_time = 0) goes as before; the height comes from the block holding the transfer.
|
|
244
334
|
const blockHeight = height === null || height === undefined ? (tx.block_height ?? null) : height;
|
|
245
|
-
|
|
335
|
+
// THE INCLUSION HEIGHT IS FOR CONFIRMATIONS, THE TIP FOR THE LOCK. A height lock at 101 is open once the chain
|
|
336
|
+
// has reached 101, not once the transaction was mined at 100; using the inclusion height kept a matured lock
|
|
337
|
+
// reporting `xmr-locked` forever.
|
|
338
|
+
const unlockHeight = tipHeight === null || tipHeight === undefined ? blockHeight : Number(tipHeight);
|
|
339
|
+
const unlock = unlockStateOf(tx.unlock_time, { height: unlockHeight, nowSec });
|
|
246
340
|
const keyOf = (o) => ((o.target || {}).tagged_key || o.target || {}).key;
|
|
247
341
|
const outputs = (tx.vout || []).map((o, i) => ({ index: i, key: keyOf(o) }));
|
|
248
|
-
const
|
|
342
|
+
const ecdh = ((tx.rct_signatures || {}).ecdhInfo || []);
|
|
343
|
+
const ecdhAmounts = ecdh.map((e) => e.amount);
|
|
344
|
+
const ecdhMasks = ecdh.map((e) => e.mask);
|
|
345
|
+
const outPk = ((tx.rct_signatures || {}).outPk || []);
|
|
249
346
|
const rctVersion = (tx.rct_signatures || {}).type || 2;
|
|
250
347
|
for (const w of watched) {
|
|
251
348
|
const r = findOurOutputs({
|
|
252
349
|
txPublicKey, viewSecretHex: w.viewSecret, spendPubHex: w.spendPub,
|
|
253
|
-
outputs, ecdhAmounts, rctVersion: Number(rctVersion)
|
|
350
|
+
outputs, ecdhAmounts, ecdhMasks, outPk, rctVersion: Number(rctVersion) || 1,
|
|
254
351
|
});
|
|
255
352
|
if (r.outputs.length) out.push({ address: w.address, index: w.index ?? null, outputs: r.outputs, derivation: r.derivation, viewKeyForm: r.viewKeyForm, unlockTime: Number(tx.unlock_time) || 0, unlock });
|
|
256
353
|
}
|
|
@@ -259,13 +356,14 @@ export function matchTransaction(tx, watched, { height = null, nowSec = null } =
|
|
|
259
356
|
|
|
260
357
|
// Range scan. Returns per address the sum (receivedAtomic - only the UNLOCKED), hashes, the locked sum
|
|
261
358
|
// (lockedAtomic) with its boundary (lockedUntil) and the best (highest) height of a found output.
|
|
262
|
-
export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null }) {
|
|
359
|
+
export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log = () => {}, concurrency = 4, nowSec = null, tipHeight = null, fetchImpl = globalThis.fetch }) {
|
|
263
360
|
const from = Number(fromHeight), to = Number(toHeight);
|
|
264
361
|
if (!(from >= 0) || !(to >= from)) throw new Error("bad height range: " + fromHeight + ".." + toHeight);
|
|
265
362
|
const acc = new Map();
|
|
266
363
|
for (const w of watched) acc.set(w.address, { address: w.address, receivedAtomic: 0n, lockedAtomic: 0n, lockedUntil: null, lockedTxids: [], txids: [], bestHeight: null, outputs: 0, viewKeyForm: null });
|
|
267
364
|
const heights = [];
|
|
268
365
|
for (let h = from; h <= to; h++) heights.push(h);
|
|
366
|
+
const missingHeights = [];
|
|
269
367
|
|
|
270
368
|
// Bounded parallelism: the node is a shared resource and blocks are independent.
|
|
271
369
|
let cursor = 0;
|
|
@@ -273,19 +371,21 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
|
|
|
273
371
|
while (cursor < heights.length) {
|
|
274
372
|
const h = heights[cursor++];
|
|
275
373
|
try {
|
|
276
|
-
const block = await fetchBlock(daemonUrl, h);
|
|
374
|
+
const block = await fetchBlock(daemonUrl, h, fetchImpl);
|
|
277
375
|
const hashes = block.json ? (typeof block.json === "string" ? JSON.parse(block.json) : block.json).tx_hashes || [] : [];
|
|
278
376
|
if (!hashes.length) continue;
|
|
279
|
-
const txs = await fetchTransactions(daemonUrl, hashes);
|
|
377
|
+
const txs = await fetchTransactions(daemonUrl, hashes, fetchImpl);
|
|
280
378
|
for (const tx of txs) {
|
|
281
|
-
for (const hit of matchTransaction(tx, watched, { height: h, nowSec })) {
|
|
379
|
+
for (const hit of matchTransaction(tx, watched, { height: h, tipHeight: tipHeight === null || tipHeight === undefined ? to : tipHeight, nowSec })) {
|
|
282
380
|
const a = acc.get(hit.address);
|
|
283
381
|
if (!a) continue;
|
|
284
382
|
if (hit.viewKeyForm) a.viewKeyForm = hit.viewKeyForm; // what matched - this goes to the log
|
|
285
383
|
const lockedHere = !!(hit.unlock && hit.unlock.locked);
|
|
286
384
|
const hash = String(tx.tx_hash || "").replace(/^0x/, "");
|
|
287
385
|
for (const o of hit.outputs) {
|
|
288
|
-
|
|
386
|
+
// AN UNVERIFIED AMOUNT IS NOT MONEY. A node can alter ecdhInfo.amount; until the Pedersen
|
|
387
|
+
// commitment confirms it, the figure is not added to any sum.
|
|
388
|
+
const amount = o.amountVerified ? (o.amountAtomic || 0n) : 0n;
|
|
289
389
|
// A LOCKED OUTPUT IS NOT AN ARRIVAL. Its transfer shows in txids (money is on the address) but the
|
|
290
390
|
// sum does NOT go into receivedAtomic: else 'ready' would allow claiming ETH against locked XMR.
|
|
291
391
|
if (lockedHere) {
|
|
@@ -302,11 +402,22 @@ export async function scanRange({ daemonUrl, fromHeight, toHeight, watched, log
|
|
|
302
402
|
}
|
|
303
403
|
}
|
|
304
404
|
} catch (e) {
|
|
405
|
+
// A BLOCK THAT COULD NOT BE READ IS NOT A BLOCK WITH NO MONEY. Keep its height so the caller can say the
|
|
406
|
+
// range is partial instead of treating a smaller sum as complete.
|
|
407
|
+
missingHeights.push(h);
|
|
305
408
|
log({ event: "indexer_block_error", height: h, error: String(e.message).slice(0, 160) });
|
|
306
409
|
}
|
|
307
410
|
}
|
|
308
411
|
};
|
|
309
412
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, heights.length)) }, worker));
|
|
310
|
-
|
|
413
|
+
const missing = [...new Set(missingHeights)].sort((a, b) => a - b);
|
|
414
|
+
return {
|
|
415
|
+
fromHeight: from,
|
|
416
|
+
toHeight: to,
|
|
417
|
+
results: Array.from(acc.values()),
|
|
418
|
+
complete: missing.length === 0,
|
|
419
|
+
missingHeights: missing,
|
|
420
|
+
...(missing.length ? { incompleteReason: "blocks-unreadable" } : {}),
|
|
421
|
+
};
|
|
311
422
|
}
|
|
312
423
|
|