@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.
@@ -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
+ }
@@ -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 { /* the balance below decides */ }
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, walletHeight: await heightOf(driver, "getHeight"),
262
- nodeHeight: await heightOf(driver, "getDaemonHeight"), passes: 1, lockedPass: false, window: windowReport,
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
- try { await step("rescanBlockchain", () => driver.rescanBlockchain()); } catch { /* the balance decides */ }
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
- try { await wallet.sync(); } catch { /* the remainder decides */ }
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 { /* as above */ }
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
- walletHeight: await heightOf(driver, "getHeight"),
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
- const halves = takerHalves({ mnemonic: request.mnemonic, chainId, index });
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: null, rate: null, maker: null, quoteSignature: null,
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.52.0";
9
+ export const SDK_VERSION = "0.54.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
  }