@ninsei-labs/sdk 0.52.0 → 0.53.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/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.52.0";
9
+ export const SDK_VERSION = "0.53.0";
@@ -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 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.
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
- const sumOutputs = (hit) => hit.outputs.reduce((n, o) => n + (o.amountAtomic || 0n), 0n);
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
- if (hit.length && sumOutputs(hit[0]) > 0n) return { txid: tx.tx_hash, height: h };
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
- const hit = engine.xmrScan.matchTransaction(body, [watched], { height: located.height, nowSec });
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 hashes travel in the params.
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 = okay.find((r) => r.unlock && r.unlock.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
- const amountAtomic = okay.reduce((best, r) => (r.amountAtomic > best ? r.amountAtomic : best), 0n);
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
  }