@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.
@@ -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 (/^0x[0-9a-f]{40}$/.test(addr) && !/^0x0{40}$/.test(addr)) return addr;
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
- for (const name of ["locker", "claimer", "amount", "readyBy", "t1", "salt"]) {
188
- if (String(quote[name]).toLowerCase() !== String(terms[name]).toLowerCase()) {
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
- actualEscrow = escrowFromReceipt(rc);
266
- if (rc && Number.isFinite(Number(rc.blockNumber))) birthBlock = Number(rc.blockNumber);
267
- if (!rc) {
268
- onStep?.("no receipt yet - using the predicted address");
269
- } else if (!actualEscrow) {
270
- onStep?.("no OrderCreated in the receipt - using the predicted address");
271
- } else if (actualEscrow !== String(escrow).toLowerCase()) {
272
- console.warn("[escrow] receipt address " + actualEscrow + " != predicted " + escrow + " - using the receipt one");
273
- onStep?.("receipt address differs from predicted - using the receipt one");
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
- // escrow is the ACTUAL address (from the receipt, if there was one); predictedEscrow is kept so a divergence is
286
- // visible outside, not only in the log.
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
- return { feeWei: fee.feeWei, feeTotalWei: totalValueWei, feeBps: fee.bps, feeWasLegacyFactory: Boolean(fee.legacy),
290
- escrow: escrowToUse, predictedEscrow: escrow, escrowMismatch: Boolean(actualEscrow && actualEscrow !== String(escrow).toLowerCase()), salt, birthBlock, terms, createHash, lockHash };
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
- /** A boundary up to this value is a BLOCK HEIGHT; from it on, a unix TIME. Monero's own convention. */
75
- export const UNLOCK_TIME_BLOCK_MAX = 500_000_000;
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
- const sum = halves.combineHalves(BigInt(ownSpendHalf), BigInt(revealedHalf));
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
@@ -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. version: 2 = current scheme (xor of the first 8 bytes), 1 = old (scalar subtraction).
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
- export function decodeAmount(ecdhAmountHex, scalarLE, rctVersion) {
147
- const enc = hexToBytes(ecdhAmountHex);
148
- if (Number(rctVersion) >= 2) {
149
- // Multiplier: keccak("amount" || scalar_LE), 38 bytes - order checked against the source.
150
- const label = new TextEncoder().encode("amount");
151
- const buf = new Uint8Array(label.length + scalarLE.length);
152
- buf.set(label, 0); buf.set(scalarLE, label.length);
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 < 8; i++) out[i] ^= factor[i]; // xor of the FIRST 8 bytes only
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 s2 = hashToScalar(hexToBytes(hashToScalar(hexToBytes(bytesToHex(rctVersion === 1 ? scalarLE : scalarLE))).toString(16).padStart(64, "0")));
160
- const amt = BigInt("0x" + bytesToHex(enc)) % ED_ORDER;
161
- return (amt - s2 + ED_ORDER) % ED_ORDER;
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
- found.push({ index: o.index, key: o.key, amountAtomic: amount });
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
- export async function fetchBlock(daemonUrl, height) {
204
- const res = await fetch(`${String(daemonUrl).replace(/\/+$/, "")}/json_rpc`, {
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 fetch(`${String(daemonUrl).replace(/\/+$/, "")}/get_transactions`, {
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
- const wrappers = j.txs || [];
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
- return (j.txs_as_json || []).map((s, i) => {
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
- const unlock = unlockStateOf(tx.unlock_time, { height: blockHeight, nowSec });
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 ecdhAmounts = ((tx.rct_signatures || {}).ecdhInfo || []).map((e) => e.amount);
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) >= 2 ? 2 : 1,
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
- const amount = o.amountAtomic || 0n;
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
- return { fromHeight: from, toHeight: to, results: Array.from(acc.values()) };
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