@spirobel/monero-wallet-api 0.6.4 → 0.6.5

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.
@@ -102,6 +102,9 @@ export declare class ScanCacheOpened {
102
102
  get pending_amount(): bigint;
103
103
  get subaddresses(): Subaddress[];
104
104
  get tx_logs(): TxLog[];
105
+ private writeTxCache;
106
+ private beginInFlightSend;
107
+ private finishInFlightSend;
105
108
  makeSignSendTransaction(params: CreateTransactionParams): Promise<SendRawTransactionResult>;
106
109
  private outputsForIndexes;
107
110
  makeSignSend(params: {
@@ -118,7 +121,6 @@ export declare class ScanCacheOpened {
118
121
  txlog: TxLog;
119
122
  inputs: Output[];
120
123
  }[];
121
- private recordSend;
122
124
  dismissSendPlate(): Promise<void>;
123
125
  /**
124
126
  * makeStandardTransaction
@@ -8,7 +8,7 @@ import { ConnectionStatusOpened } from "../connectionStatusOpened";
8
8
  import { ActionLogOpened, } from "../../actionlog/actionLogOpened";
9
9
  import { sendToBackground } from "../../tools/globals";
10
10
  import { findRange, lastRange, readCacheFileDefaultLocation, writeCacheFileDefaultLocationThrows, } from "./scanCache";
11
- import { alignScanStatsWithCache, isSelfSpent, processTxlogInputs, processTxlogPayments, writeStatsFileDefaultLocation, } from "./scanStats";
11
+ import { alignScanStatsWithCache, isSelfSpent, processTxlogInputs, processTxlogPayments, txLogHoldsFunds, writeStatsFileDefaultLocation, } from "./scanStats";
12
12
  import { readWriteConnectionStatusFile, } from "../connectionStatus";
13
13
  export class ScanCacheOpened {
14
14
  _scanSettings;
@@ -109,9 +109,7 @@ export class ScanCacheOpened {
109
109
  get prepending_txs() {
110
110
  const txs = [];
111
111
  for (const txlog of this._cache.tx_logs || []) {
112
- if (!txlog ||
113
- !txlog.sendResult ||
114
- (txlog.sendResult && txlog.sendResult.status !== "OK"))
112
+ if (!txlog || !txLogHoldsFunds(txlog))
115
113
  continue;
116
114
  const { inputSum, alreadyRecognizedAsSpend } = processTxlogInputs(txlog, this._cache);
117
115
  if (alreadyRecognizedAsSpend)
@@ -392,15 +390,91 @@ export class ScanCacheOpened {
392
390
  get tx_logs() {
393
391
  return this._cache.tx_logs || [];
394
392
  }
393
+ async writeTxCache(writeCallback, changed_outputs) {
394
+ await writeCacheFileDefaultLocationThrows({
395
+ primary_address: this.primary_address,
396
+ pathPrefix: this.pathPrefix,
397
+ writeCallback,
398
+ });
399
+ const newCache = await readCacheFileDefaultLocation(this.primary_address, this.pathPrefix);
400
+ if (!newCache)
401
+ throw new Error(`cache not found for primary address: ${this.primary_address}, and path prefix: ${this.pathPrefix}`);
402
+ await this.feed({ newCache, changed_outputs });
403
+ }
404
+ // stop the worker, lock the inputs, then let stats show pending.
405
+ async beginInFlightSend(args) {
406
+ await this.stopWorker();
407
+ const inputs_index = args.inputs.map((input) => String(input.index_on_blockchain));
408
+ let txLogIndex = 0;
409
+ try {
410
+ await this.writeTxCache(async (cache) => {
411
+ if (!cache.tx_logs)
412
+ cache.tx_logs = [];
413
+ if (!cache.pending_spent_utxos)
414
+ cache.pending_spent_utxos = {};
415
+ const txLog = {
416
+ feeEstimate: args.feeEstimate,
417
+ payments: args.payments,
418
+ node_url: this.node_url,
419
+ inputs_index,
420
+ height: this.current_height,
421
+ timestamp: Date.now(),
422
+ invocationId: args.invocationId,
423
+ };
424
+ txLogIndex = cache.tx_logs.push(txLog) - 1;
425
+ for (const inputId of inputs_index)
426
+ cache.pending_spent_utxos[inputId] = txLogIndex;
427
+ }, args.inputs.map((output) => ({ change_reason: "spent", output })));
428
+ }
429
+ catch (e) {
430
+ await this.unpause();
431
+ throw e;
432
+ }
433
+ return txLogIndex;
434
+ }
435
+ // update the same row with the node answer. unlock the inputs on failure.
436
+ async finishInFlightSend(args) {
437
+ const ok = args.sendResult?.status === "OK" && args.error === undefined;
438
+ try {
439
+ await this.writeTxCache(async (cache) => {
440
+ const row = cache.tx_logs?.[args.txLogIndex];
441
+ if (!row)
442
+ throw new Error("tx log missing");
443
+ if (args.invocationId)
444
+ row.invocationId = args.invocationId;
445
+ if (args.signedTx)
446
+ row.signed_tx = args.signedTx;
447
+ if (args.sendResult)
448
+ row.sendResult = args.sendResult;
449
+ if (!ok) {
450
+ row.error = args.error || args.sendResult?.reason || "send failed";
451
+ for (const inputId of row.inputs_index) {
452
+ if (cache.pending_spent_utxos?.[inputId] === args.txLogIndex)
453
+ delete cache.pending_spent_utxos[inputId];
454
+ }
455
+ }
456
+ }, []);
457
+ }
458
+ finally {
459
+ await this.unpause();
460
+ }
461
+ }
395
462
  async makeSignSendTransaction(params) {
396
463
  let maybeInputs = [];
397
464
  let maybeFeeEstimate;
398
465
  let maybeSendResult;
399
466
  let maybeSignedTx;
467
+ let txLogIndex;
400
468
  try {
401
469
  const { selectedInputs, feeEstimate } = await this.calculateFeeAndSelectInputs(params);
402
470
  maybeInputs = selectedInputs;
403
471
  maybeFeeEstimate = feeEstimate;
472
+ txLogIndex = await this.beginInFlightSend({
473
+ payments: params.payments,
474
+ inputs: selectedInputs,
475
+ feeEstimate,
476
+ invocationId: params.invocationId,
477
+ });
404
478
  const unsignedTx = await this.makeTransactionFromSelectedInputs(params.payments, selectedInputs, feeEstimate);
405
479
  const signedTx = await this.signTransaction(unsignedTx);
406
480
  maybeSignedTx = signedTx;
@@ -408,52 +482,29 @@ export class ScanCacheOpened {
408
482
  maybeSendResult = sendResult;
409
483
  if (sendResult.status !== "OK")
410
484
  throw new Error("send raw transaction rpc returned error");
411
- // before writing the scan cache, we stop the worker to avoid a race
412
- await this.stopWorker();
413
- // write txlog to cache + update pending_spent_utxos (affects stats + spendability)
414
- await writeCacheFileDefaultLocationThrows({
415
- primary_address: this.primary_address,
416
- pathPrefix: this.pathPrefix,
417
- writeCallback: async (cache) => {
418
- if (!cache.tx_logs)
419
- cache.tx_logs = [];
420
- if (!cache.pending_spent_utxos)
421
- cache.pending_spent_utxos = {};
422
- const inputs_index = selectedInputs.map((input) => String(input.index_on_blockchain));
423
- const txLog = {
424
- sendResult,
425
- feeEstimate,
426
- payments: params.payments,
427
- node_url: this.node_url,
428
- inputs_index,
429
- height: this.current_height,
430
- timestamp: Date.now(),
431
- invocationId: params.invocationId,
432
- signed_tx: signedTx,
433
- };
434
- const newLen = cache.tx_logs.push(txLog);
435
- const txLogIndex = newLen - 1;
436
- for (const inputId of inputs_index) {
437
- cache.pending_spent_utxos[inputId] = txLogIndex;
438
- }
439
- },
440
- });
441
- const newCache = await readCacheFileDefaultLocation(this.primary_address, this.pathPrefix);
442
- if (!newCache)
443
- throw new Error(`cache not found for primary address: ${this.primary_address}, and path prefix: ${this.pathPrefix}`);
444
- const changed_outputs = selectedInputs.map((input) => ({
445
- change_reason: "spent",
446
- output: input,
447
- }));
448
- await this.feed({
449
- newCache,
450
- changed_outputs,
485
+ await this.finishInFlightSend({
486
+ txLogIndex,
487
+ sendResult,
488
+ signedTx,
489
+ invocationId: params.invocationId,
451
490
  });
452
- // restart the worker
453
- await this.unpause();
454
491
  return sendResult;
455
492
  }
456
493
  catch (e) {
494
+ if (txLogIndex !== undefined && maybeSendResult?.status !== "OK") {
495
+ await this.finishInFlightSend({
496
+ txLogIndex,
497
+ sendResult: maybeSendResult,
498
+ signedTx: maybeSignedTx,
499
+ error: String(e || "unknown error"),
500
+ invocationId: params.invocationId,
501
+ });
502
+ throw e;
503
+ }
504
+ if (txLogIndex !== undefined) {
505
+ await this.unpause();
506
+ throw e;
507
+ }
457
508
  // before writing the scan cache, we stop the worker to avoid a race
458
509
  await this.stopWorker();
459
510
  // write txlog error to cache
@@ -529,31 +580,41 @@ export class ScanCacheOpened {
529
580
  if (!inputs.length)
530
581
  throw new Error("no inputs selected");
531
582
  const feeEstimate = await this.getFeeEstimate();
532
- const unsignedTx = await this.makeSweepTransactionFromSelectedInputs(params.address, inputs, feeEstimate);
533
- const signedTx = await this.signTransaction(unsignedTx);
534
583
  const payments = [{ address: params.address, amount: "0" }];
535
- const sendResult = await this.sendTransaction(signedTx);
536
- if (sendResult.status !== "OK") {
537
- await this.recordSend({
538
- payments,
539
- inputs,
540
- signedTx,
541
- feeEstimate,
542
- sendResult,
543
- error: "send raw transaction rpc returned error",
544
- lockInputs: false,
545
- });
546
- throw new Error("send raw transaction rpc returned error");
547
- }
548
- await this.recordSend({
584
+ const txLogIndex = await this.beginInFlightSend({
549
585
  payments,
550
586
  inputs,
551
- signedTx,
552
587
  feeEstimate,
553
- sendResult,
554
- lockInputs: true,
555
588
  });
556
- return sendResult;
589
+ let sendResult;
590
+ let signedTx;
591
+ try {
592
+ const unsignedTx = await this.makeSweepTransactionFromSelectedInputs(params.address, inputs, feeEstimate);
593
+ signedTx = await this.signTransaction(unsignedTx);
594
+ sendResult = await this.sendTransaction(signedTx);
595
+ if (sendResult.status !== "OK")
596
+ throw new Error("send raw transaction rpc returned error");
597
+ await this.finishInFlightSend({
598
+ txLogIndex,
599
+ sendResult,
600
+ signedTx,
601
+ });
602
+ return sendResult;
603
+ }
604
+ catch (e) {
605
+ if (sendResult?.status !== "OK") {
606
+ await this.finishInFlightSend({
607
+ txLogIndex,
608
+ sendResult,
609
+ signedTx,
610
+ error: e instanceof Error ? e.message : String(e),
611
+ });
612
+ }
613
+ else {
614
+ await this.unpause();
615
+ }
616
+ throw e;
617
+ }
557
618
  }
558
619
  async rebroadcastTx(txLogIndex) {
559
620
  const txlog = this._cache.tx_logs?.[txLogIndex];
@@ -603,6 +664,9 @@ export class ScanCacheOpened {
603
664
  continue;
604
665
  if (txlog.sendResult?.status === "OK")
605
666
  continue;
667
+ // no node answer yet. this row is still in flight.
668
+ if (!txlog.sendResult && !txlog.error)
669
+ continue;
606
670
  const inputs = txlog.inputs_index
607
671
  .map((id) => this._cache.outputs[id])
608
672
  .filter((output) => output);
@@ -613,44 +677,6 @@ export class ScanCacheOpened {
613
677
  }
614
678
  return rows;
615
679
  }
616
- async recordSend(args) {
617
- await this.stopWorker();
618
- await writeCacheFileDefaultLocationThrows({
619
- primary_address: this.primary_address,
620
- pathPrefix: this.pathPrefix,
621
- writeCallback: async (cache) => {
622
- if (!cache.tx_logs)
623
- cache.tx_logs = [];
624
- if (!cache.pending_spent_utxos)
625
- cache.pending_spent_utxos = {};
626
- const inputs_index = args.inputs.map((input) => String(input.index_on_blockchain));
627
- const txLog = {
628
- sendResult: args.sendResult,
629
- error: args.error,
630
- feeEstimate: args.feeEstimate,
631
- payments: args.payments,
632
- node_url: this.node_url,
633
- inputs_index,
634
- height: this.current_height,
635
- timestamp: Date.now(),
636
- signed_tx: args.signedTx,
637
- };
638
- const txLogIndex = cache.tx_logs.push(txLog) - 1;
639
- if (!args.lockInputs)
640
- return;
641
- for (const inputId of inputs_index)
642
- cache.pending_spent_utxos[inputId] = txLogIndex;
643
- },
644
- });
645
- const newCache = await readCacheFileDefaultLocation(this.primary_address, this.pathPrefix);
646
- if (!newCache)
647
- throw new Error("cache not found after send");
648
- const changed_outputs = args.lockInputs
649
- ? args.inputs.map((output) => ({ change_reason: "spent", output }))
650
- : [];
651
- await this.feed({ newCache, changed_outputs });
652
- await this.unpause();
653
- }
654
680
  async dismissSendPlate() {
655
681
  await this.stopWorker();
656
682
  await writeCacheFileDefaultLocationThrows({
@@ -218,8 +218,10 @@ export function unlockedAtHeight(output) {
218
218
  export function outputStatus(output, cache, current_height) {
219
219
  if (
220
220
  // order matters, check this before "spent" check + "pending" check
221
+ // tx log index 0 is a real lock. 0 must not count as missing.
221
222
  cache.pending_spent_utxos &&
222
- cache.pending_spent_utxos[output.index_on_blockchain.toString()]) {
223
+ cache.pending_spent_utxos[output.index_on_blockchain.toString()] !==
224
+ undefined) {
223
225
  return { status: "prepending" };
224
226
  }
225
227
  if (typeof output.burned === "number") {
@@ -58,6 +58,7 @@ export declare function processTxlogInputs(txlog: TxLog, cache: ScanCache): {
58
58
  inputSum: bigint;
59
59
  alreadyRecognizedAsSpend: boolean;
60
60
  };
61
+ export declare function txLogHoldsFunds(txlog: TxLog): boolean;
61
62
  export declare function processTxlogs(cache: ScanCache, stats: ScanStats): void;
62
63
  export declare function alignScanStatsWithCache(cache: ScanCache, view_pair: ViewPair, primary_address: string, pathPrefix?: string, highestSubaddressMinor?: number, current_scan_tip_height?: number): Promise<ScanStats>;
63
64
  export declare function resetStats(stats: ScanStats): void;
@@ -241,17 +241,33 @@ export function processTxlogInputs(txlog, cache) {
241
241
  }
242
242
  return { inputSum, alreadyRecognizedAsSpend };
243
243
  }
244
+ // an in-flight row has no node answer yet. it still locks the inputs.
245
+ export function txLogHoldsFunds(txlog) {
246
+ if (txlog.error)
247
+ return false;
248
+ if (!txlog.sendResult)
249
+ return true;
250
+ return txlog.sendResult.status === "OK";
251
+ }
244
252
  export function processTxlogs(cache, stats) {
245
253
  for (const txlog of cache.tx_logs || []) {
246
- if (!txlog ||
247
- !txlog.sendResult ||
248
- (txlog.sendResult && txlog.sendResult.status !== "OK"))
254
+ if (!txlog || !txLogHoldsFunds(txlog))
249
255
  continue;
250
256
  const { inputSum, alreadyRecognizedAsSpend } = processTxlogInputs(txlog, cache);
251
257
  if (alreadyRecognizedAsSpend)
252
258
  continue;
253
259
  const outWardPaymentSum = processTxlogPayments(txlog, cache);
254
- stats.total_spendable_amount -= inputSum;
260
+ // locked inputs are already out of the spendable sum. do not subtract them again.
261
+ let alreadyExcluded = 0n;
262
+ for (const inputId of txlog.inputs_index) {
263
+ if (cache.pending_spent_utxos?.[inputId] === undefined)
264
+ continue;
265
+ const input = cache.outputs[inputId];
266
+ if (!input || typeof input.spent_in_tx_hash === "string")
267
+ continue;
268
+ alreadyExcluded += input.amount;
269
+ }
270
+ stats.total_spendable_amount -= inputSum - alreadyExcluded;
255
271
  const newPending = inputSum - outWardPaymentSum;
256
272
  stats.total_pending_amount += newPending;
257
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spirobel/monero-wallet-api",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "main": "dist/xmr3.js",
5
5
  "module": "dist/xmr3.js",
6
6
  "devDependencies": {