@spirobel/monero-wallet-api 0.6.2 → 0.6.7

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.
@@ -245,8 +245,13 @@ export class ActionLogOpened {
245
245
  }
246
246
  case "worker.changeNodeUrlAndStartHeight": {
247
247
  const p = env.payload;
248
- await this.mco?.changeNodeUrlAndStartHeight(p?.node_url, p?.start_height);
249
- return {};
248
+ try {
249
+ await this.mco?.changeNodeUrlAndStartHeight(p?.node_url, p?.start_height);
250
+ return {};
251
+ }
252
+ catch (err) {
253
+ return { error: err instanceof Error ? err.message : String(err) };
254
+ }
250
255
  }
251
256
  case "worker.buildWallets":
252
257
  await this.mco?.buildWallets();
@@ -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)
@@ -173,14 +171,19 @@ export class ScanCacheOpened {
173
171
  return this._scanSettings.logs_exclude;
174
172
  }
175
173
  async changeNodeUrlAndStartHeight(node_url, start_height) {
176
- await this.stopWorker();
177
- if (node_url !== undefined) {
178
- await this._scanSettings.setNodeUrl(node_url);
179
- this.node_url = node_url;
174
+ // stop first so a live worker cannot write the old file over the new url.
175
+ try {
176
+ await this.stopWorker();
180
177
  }
181
- if (start_height !== undefined) {
182
- await this._scanSettings.setStartHeight(start_height);
183
- this._start_height = start_height;
178
+ catch {
179
+ // stop can fail. still write the new url.
180
+ }
181
+ if (node_url !== undefined || start_height !== undefined) {
182
+ await this._scanSettings.setNodeUrlAndStartHeight(node_url, start_height);
183
+ if (node_url !== undefined)
184
+ this.node_url = node_url;
185
+ if (start_height !== undefined)
186
+ this._start_height = start_height;
184
187
  }
185
188
  await this.unpause();
186
189
  }
@@ -387,15 +390,91 @@ export class ScanCacheOpened {
387
390
  get tx_logs() {
388
391
  return this._cache.tx_logs || [];
389
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
+ }
390
462
  async makeSignSendTransaction(params) {
391
463
  let maybeInputs = [];
392
464
  let maybeFeeEstimate;
393
465
  let maybeSendResult;
394
466
  let maybeSignedTx;
467
+ let txLogIndex;
395
468
  try {
396
469
  const { selectedInputs, feeEstimate } = await this.calculateFeeAndSelectInputs(params);
397
470
  maybeInputs = selectedInputs;
398
471
  maybeFeeEstimate = feeEstimate;
472
+ txLogIndex = await this.beginInFlightSend({
473
+ payments: params.payments,
474
+ inputs: selectedInputs,
475
+ feeEstimate,
476
+ invocationId: params.invocationId,
477
+ });
399
478
  const unsignedTx = await this.makeTransactionFromSelectedInputs(params.payments, selectedInputs, feeEstimate);
400
479
  const signedTx = await this.signTransaction(unsignedTx);
401
480
  maybeSignedTx = signedTx;
@@ -403,52 +482,29 @@ export class ScanCacheOpened {
403
482
  maybeSendResult = sendResult;
404
483
  if (sendResult.status !== "OK")
405
484
  throw new Error("send raw transaction rpc returned error");
406
- // before writing the scan cache, we stop the worker to avoid a race
407
- await this.stopWorker();
408
- // write txlog to cache + update pending_spent_utxos (affects stats + spendability)
409
- await writeCacheFileDefaultLocationThrows({
410
- primary_address: this.primary_address,
411
- pathPrefix: this.pathPrefix,
412
- writeCallback: async (cache) => {
413
- if (!cache.tx_logs)
414
- cache.tx_logs = [];
415
- if (!cache.pending_spent_utxos)
416
- cache.pending_spent_utxos = {};
417
- const inputs_index = selectedInputs.map((input) => String(input.index_on_blockchain));
418
- const txLog = {
419
- sendResult,
420
- feeEstimate,
421
- payments: params.payments,
422
- node_url: this.node_url,
423
- inputs_index,
424
- height: this.current_height,
425
- timestamp: Date.now(),
426
- invocationId: params.invocationId,
427
- signed_tx: signedTx,
428
- };
429
- const newLen = cache.tx_logs.push(txLog);
430
- const txLogIndex = newLen - 1;
431
- for (const inputId of inputs_index) {
432
- cache.pending_spent_utxos[inputId] = txLogIndex;
433
- }
434
- },
435
- });
436
- const newCache = await readCacheFileDefaultLocation(this.primary_address, this.pathPrefix);
437
- if (!newCache)
438
- throw new Error(`cache not found for primary address: ${this.primary_address}, and path prefix: ${this.pathPrefix}`);
439
- const changed_outputs = selectedInputs.map((input) => ({
440
- change_reason: "spent",
441
- output: input,
442
- }));
443
- await this.feed({
444
- newCache,
445
- changed_outputs,
485
+ await this.finishInFlightSend({
486
+ txLogIndex,
487
+ sendResult,
488
+ signedTx,
489
+ invocationId: params.invocationId,
446
490
  });
447
- // restart the worker
448
- await this.unpause();
449
491
  return sendResult;
450
492
  }
451
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
+ }
452
508
  // before writing the scan cache, we stop the worker to avoid a race
453
509
  await this.stopWorker();
454
510
  // write txlog error to cache
@@ -524,31 +580,41 @@ export class ScanCacheOpened {
524
580
  if (!inputs.length)
525
581
  throw new Error("no inputs selected");
526
582
  const feeEstimate = await this.getFeeEstimate();
527
- const unsignedTx = await this.makeSweepTransactionFromSelectedInputs(params.address, inputs, feeEstimate);
528
- const signedTx = await this.signTransaction(unsignedTx);
529
583
  const payments = [{ address: params.address, amount: "0" }];
530
- const sendResult = await this.sendTransaction(signedTx);
531
- if (sendResult.status !== "OK") {
532
- await this.recordSend({
533
- payments,
534
- inputs,
535
- signedTx,
536
- feeEstimate,
537
- sendResult,
538
- error: "send raw transaction rpc returned error",
539
- lockInputs: false,
540
- });
541
- throw new Error("send raw transaction rpc returned error");
542
- }
543
- await this.recordSend({
584
+ const txLogIndex = await this.beginInFlightSend({
544
585
  payments,
545
586
  inputs,
546
- signedTx,
547
587
  feeEstimate,
548
- sendResult,
549
- lockInputs: true,
550
588
  });
551
- 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
+ }
552
618
  }
553
619
  async rebroadcastTx(txLogIndex) {
554
620
  const txlog = this._cache.tx_logs?.[txLogIndex];
@@ -598,6 +664,9 @@ export class ScanCacheOpened {
598
664
  continue;
599
665
  if (txlog.sendResult?.status === "OK")
600
666
  continue;
667
+ // no node answer yet. this row is still in flight.
668
+ if (!txlog.sendResult && !txlog.error)
669
+ continue;
601
670
  const inputs = txlog.inputs_index
602
671
  .map((id) => this._cache.outputs[id])
603
672
  .filter((output) => output);
@@ -608,44 +677,6 @@ export class ScanCacheOpened {
608
677
  }
609
678
  return rows;
610
679
  }
611
- async recordSend(args) {
612
- await this.stopWorker();
613
- await writeCacheFileDefaultLocationThrows({
614
- primary_address: this.primary_address,
615
- pathPrefix: this.pathPrefix,
616
- writeCallback: async (cache) => {
617
- if (!cache.tx_logs)
618
- cache.tx_logs = [];
619
- if (!cache.pending_spent_utxos)
620
- cache.pending_spent_utxos = {};
621
- const inputs_index = args.inputs.map((input) => String(input.index_on_blockchain));
622
- const txLog = {
623
- sendResult: args.sendResult,
624
- error: args.error,
625
- feeEstimate: args.feeEstimate,
626
- payments: args.payments,
627
- node_url: this.node_url,
628
- inputs_index,
629
- height: this.current_height,
630
- timestamp: Date.now(),
631
- signed_tx: args.signedTx,
632
- };
633
- const txLogIndex = cache.tx_logs.push(txLog) - 1;
634
- if (!args.lockInputs)
635
- return;
636
- for (const inputId of inputs_index)
637
- cache.pending_spent_utxos[inputId] = txLogIndex;
638
- },
639
- });
640
- const newCache = await readCacheFileDefaultLocation(this.primary_address, this.pathPrefix);
641
- if (!newCache)
642
- throw new Error("cache not found after send");
643
- const changed_outputs = args.lockInputs
644
- ? args.inputs.map((output) => ({ change_reason: "spent", output }))
645
- : [];
646
- await this.feed({ newCache, changed_outputs });
647
- await this.unpause();
648
- }
649
680
  async dismissSendPlate() {
650
681
  await this.stopWorker();
651
682
  await writeCacheFileDefaultLocationThrows({
@@ -751,7 +782,11 @@ export class ScanCacheOpened {
751
782
  delete this.worker;
752
783
  // drain coordinator first, then hard kill workers
753
784
  try {
754
- await worker.shutdown();
785
+ // shutdown already races 5s. this race still returns if that promise never settles.
786
+ await Promise.race([
787
+ worker.shutdown(),
788
+ new Promise((resolve) => setTimeout(resolve, 5000)),
789
+ ]);
755
790
  }
756
791
  catch {
757
792
  // still terminate below
@@ -925,17 +960,25 @@ export class ManyScanCachesOpened {
925
960
  }
926
961
  async changeNodeUrlAndStartHeight(node_url, start_height) {
927
962
  if (this.actionLogOpened.extensionMessageBus === "ui") {
928
- await sendToBackground("worker.changeNodeUrlAndStartHeight", {
963
+ // the background owns the worker. it stops, then writes. do not write here first.
964
+ const result = await sendToBackground("worker.changeNodeUrlAndStartHeight", {
929
965
  node_url,
930
966
  start_height,
931
- }).catch(() => { });
967
+ });
968
+ if (result &&
969
+ typeof result === "object" &&
970
+ "error" in result &&
971
+ result.error) {
972
+ throw new Error(String(result.error));
973
+ }
974
+ if (result == null)
975
+ throw new Error("background did not save the node url");
932
976
  return;
933
977
  }
934
978
  if (this.wallets.length === 0) {
935
- if (node_url !== undefined)
936
- await this._scanSettings.setNodeUrl(node_url);
937
- if (start_height !== undefined)
938
- await this._scanSettings.setStartHeight(start_height);
979
+ if (node_url !== undefined || start_height !== undefined) {
980
+ await this._scanSettings.setNodeUrlAndStartHeight(node_url, start_height);
981
+ }
939
982
  return;
940
983
  }
941
984
  await this.wallets[0].changeNodeUrlAndStartHeight(node_url, start_height);
@@ -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
  }
@@ -20,6 +20,7 @@ export declare class ScanSettingsOpened {
20
20
  get pathPrefix(): string;
21
21
  setNodeUrl(node_url: string): Promise<void>;
22
22
  setStartHeight(start_height: number | null): Promise<void>;
23
+ setNodeUrlAndStartHeight(node_url?: string, start_height?: number | null): Promise<void>;
23
24
  setMerchantConfirmations(merchant_confirmations: number | undefined | null): Promise<void>;
24
25
  setCpuWorkerCount(cpu_worker_count: number | undefined | null): Promise<void>;
25
26
  setLogSettings(logs?: LogSetting | null, logs_include?: PossibleLogs[] | null, logs_exclude?: PossibleLogs[] | null): Promise<void>;
@@ -54,14 +54,21 @@ export class ScanSettingsOpened {
54
54
  return this._pathPrefix;
55
55
  }
56
56
  async setNodeUrl(node_url) {
57
- await this.reload();
58
- this._settings.node_url = ScanSettingsOpened._validateNodeUrl(node_url);
59
- await this._persist();
57
+ await this.setNodeUrlAndStartHeight(node_url, undefined);
60
58
  }
61
59
  async setStartHeight(start_height) {
60
+ await this.setNodeUrlAndStartHeight(undefined, start_height);
61
+ }
62
+ // one read and one write. a second reload must not put the old url back.
63
+ async setNodeUrlAndStartHeight(node_url, start_height) {
62
64
  await this.reload();
63
- ScanSettingsOpened._validateStartHeight(start_height);
64
- this._settings.start_height = start_height;
65
+ if (node_url !== undefined) {
66
+ this._settings.node_url = ScanSettingsOpened._validateNodeUrl(node_url);
67
+ }
68
+ if (start_height !== undefined) {
69
+ ScanSettingsOpened._validateStartHeight(start_height);
70
+ this._settings.start_height = start_height;
71
+ }
65
72
  await this._persist();
66
73
  }
67
74
  async setMerchantConfirmations(merchant_confirmations) {
@@ -163,7 +170,7 @@ export class ScanSettingsOpened {
163
170
  async addViewWallet(primary_address, view_key, fields) {
164
171
  primary_address = primary_address.trim();
165
172
  view_key = view_key.trim();
166
- ScanSettingsOpened._validateAddress(primary_address);
173
+ await ScanSettingsOpened._validateAddress(primary_address);
167
174
  ScanSettingsOpened._validateViewKey(view_key);
168
175
  ScanSettingsOpened._validateWalletName(fields?.wallet_name);
169
176
  ScanSettingsOpened._validateWalletSlot(fields?.wallet_slot);
@@ -308,8 +315,8 @@ export class ScanSettingsOpened {
308
315
  `valid options are: ${[...valid].join(", ")}`);
309
316
  }
310
317
  }
311
- static _validateAddress(address) {
312
- const parsed = parseAddress(address);
318
+ static async _validateAddress(address) {
319
+ const parsed = await parseAddress(address);
313
320
  if ("error" in parsed) {
314
321
  throw new Error(`invalid monero address: ${address}`);
315
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spirobel/monero-wallet-api",
3
- "version": "0.6.2",
3
+ "version": "0.6.7",
4
4
  "main": "dist/xmr3.js",
5
5
  "module": "dist/xmr3.js",
6
6
  "devDependencies": {