diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 8958114736..6bc5f4b89e 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -324,7 +324,9 @@ class MainDB { await isar.writeTxn(() async { final set = utxos.toSet(); + final noteValues = {}; for (final utxo in utxos) { + UTXO persistedUtxo = utxo; // check if utxo exists in db and update accordingly final storedUtxo = await isar.utxos .where() @@ -342,24 +344,36 @@ class MainDB { !storedUtxo.isBlocked && !storedUtxo.userUnfroze; set.remove(utxo); - set.add( - storedUtxo.copyWith( - value: utxo.value, - address: utxo.address, - blockTime: utxo.blockTime, - blockHeight: utxo.blockHeight, - blockHash: utxo.blockHash, - // passing null keeps the stored value - isBlocked: applyAutoBlock ? true : null, - blockedReason: applyAutoBlock ? utxo.blockedReason : null, - name: applyAutoBlock && storedUtxo.name.isEmpty - ? utxo.name - : null, - ), + persistedUtxo = storedUtxo.copyWith( + value: utxo.value, + address: utxo.address, + blockTime: utxo.blockTime, + blockHeight: utxo.blockHeight, + blockHash: utxo.blockHash, + // passing null keeps the stored value + isBlocked: applyAutoBlock ? true : null, + blockedReason: applyAutoBlock ? utxo.blockedReason : null, + name: applyAutoBlock && storedUtxo.name.isEmpty ? utxo.name : null, ); + set.add(persistedUtxo); } else { newUTXO = true; } + + if (persistedUtxo.name.isEmpty) { + final noteValue = noteValues.containsKey(utxo.txid) + ? noteValues[utxo.txid] + : (await isar.transactionNotes.getByTxidWalletId( + utxo.txid, + walletId, + ))?.value; + noteValues[utxo.txid] = noteValue; + if (noteValue?.isNotEmpty == true) { + set + ..remove(persistedUtxo) + ..add(persistedUtxo.copyWith(name: noteValue)); + } + } } await isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); @@ -381,14 +395,37 @@ class MainDB { isar.transactionNotes.where().walletIdEqualTo(walletId); Future putTransactionNote(TransactionNote transactionNote) => - isar.writeTxn(() async { - await isar.transactionNotes.put(transactionNote); - }); + putTransactionNotes([transactionNote]); + /// Copies a note only to blank UTXO labels. The label is independent after + /// that first assignment, so later note edits cannot overwrite it. Future putTransactionNotes(List transactionNotes) => - isar.writeTxn(() async { - await isar.transactionNotes.putAll(transactionNotes); - }); + transactionNotes.isEmpty + ? Future.value() + : isar.writeTxn(() async { + await isar.transactionNotes.putAll(transactionNotes); + + final toUpdate = []; + for (final note in transactionNotes) { + if (note.value.isEmpty) { + continue; + } + final utxos = await isar.utxos + .where() + .walletIdEqualTo(note.walletId) + .filter() + .txidEqualTo(note.txid) + .findAll(); + toUpdate.addAll( + utxos + .where((utxo) => utxo.name.isEmpty) + .map((utxo) => utxo.copyWith(name: note.value)), + ); + } + if (toUpdate.isNotEmpty) { + await isar.utxos.putAll(toUpdate); + } + }); Future getTransactionNote( String walletId, diff --git a/lib/pages/cakepay/cakepay_confirm_send_view.dart b/lib/pages/cakepay/cakepay_confirm_send_view.dart index 41ea7a14bf..e8ddb01c75 100644 --- a/lib/pages/cakepay/cakepay_confirm_send_view.dart +++ b/lib/pages/cakepay/cakepay_confirm_send_view.dart @@ -8,6 +8,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/constants.dart'; @@ -95,11 +96,10 @@ class _CakePayConfirmSendViewState txid = (results.first as TxData).txid!; - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, + ); if (context.mounted) { // pop sending dialog (pushed via showDialog which uses root navigator) diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index 3a5c759262..dc1b76a41f 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -21,6 +21,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -135,12 +136,10 @@ class _ConfirmChangeNowSendViewState txid = (results.first as TxData).txid!; - // save note - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, + ); await ref .read(tradeSentFromStackLookupProvider) diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index ff2f0b74a4..5d46f77512 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -24,6 +24,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/global/secure_store_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -140,14 +141,18 @@ class _ConfirmNameTransactionViewState ref.refresh(desktopUseUTXOs); } - // save note - for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); unawaited(wallet.refresh()); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index b54b3b071e..e7973a7fea 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -29,6 +29,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -458,14 +459,15 @@ class _ConfirmTransactionViewState ref.refresh(desktopUseUTXOs); } - // save note - for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => + TransactionNote(walletId: walletId, txid: txid, value: note), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); if (widget.isTokenTx) { if (wallet is SolanaWallet) { diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart index 83781fdf24..33f6066205 100644 --- a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart +++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart @@ -9,6 +9,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -113,12 +114,10 @@ class _ShopInBitConfirmSendViewState txid = (results.first as TxData).txid!; - // save note - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); + await saveTransactionNotesAfterSend( + notes: [TransactionNote(walletId: walletId, txid: txid, value: note)], + persist: ref.read(mainDBProvider).putTransactionNotes, + ); // The server (and the BTCPay webhook) own ticket + payment state from // here, so there's nothing to persist locally; just nudge a refresh so @@ -132,9 +131,7 @@ class _ShopInBitConfirmSendViewState final popThroughRouteName = widget.popThroughRouteName; if (popThroughRouteName != null) { final navigator = Navigator.of(context, rootNavigator: true); - navigator.popUntil( - ModalRoute.withName(popThroughRouteName), - ); + navigator.popUntil(ModalRoute.withName(popThroughRouteName)); navigator.pop(); } else { // pop sending dialog (pushed via showDialog which uses root navigator) diff --git a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart index 1c466c7ea3..a9c5b2f83d 100644 --- a/lib/pages/spark_names/confirm_spark_name_transaction_view.dart +++ b/lib/pages/spark_names/confirm_spark_name_transaction_view.dart @@ -22,6 +22,7 @@ import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialo import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/transaction_note_service.dart'; import '../../themes/stack_colors.dart'; import '../../themes/theme_providers.dart'; import '../../utilities/amount/amount.dart'; @@ -119,14 +120,15 @@ class _ConfirmSparkNameTransactionViewState txids.addAll(txData.sparkSpends?.map((e) => e.txid!) ?? [txData.txid!]); ref.refresh(desktopUseUTXOs); - // save note - for (final txid in txids) { - await ref - .read(mainDBProvider) - .putTransactionNote( - TransactionNote(walletId: walletId, txid: txid, value: note), - ); - } + await saveTransactionNotesAfterSend( + notes: txids + .map( + (txid) => + TransactionNote(walletId: walletId, txid: txid, value: note), + ) + .toList(), + persist: ref.read(mainDBProvider).putTransactionNotes, + ); final address = txData.sparkNameInfo?.sparkAddress; final currentReceiving = await wallet.getCurrentReceivingSparkAddress(); diff --git a/lib/services/transaction_note_service.dart b/lib/services/transaction_note_service.dart new file mode 100644 index 0000000000..b283d692dc --- /dev/null +++ b/lib/services/transaction_note_service.dart @@ -0,0 +1,27 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import '../models/isar/models/transaction_note.dart'; +import '../utilities/logger.dart'; + +Future saveTransactionNotesAfterSend({ + required List notes, + required Future Function(List) persist, +}) async { + try { + await persist(notes); + return true; + } catch (e, s) { + Logging.instance.w( + "Transaction sent, but its note could not be saved", + error: e, + stackTrace: s, + ); + return false; + } +} diff --git a/test/services/transaction_note_service_test.dart b/test/services/transaction_note_service_test.dart new file mode 100644 index 0000000000..8fda9682b3 --- /dev/null +++ b/test/services/transaction_note_service_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/services/transaction_note_service.dart'; + +void main() { + const walletId = "wallet-1"; + late Directory tempDir; + late Isar isar; + final db = MainDB.instance; + + UTXO utxo({ + required String txid, + String wallet = walletId, + int vout = 0, + int value = 1000, + String name = "", + }) => UTXO( + walletId: wallet, + txid: txid, + vout: vout, + value: value, + name: name, + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: "block", + blockHeight: 1, + blockTime: 1, + ); + + TransactionNote note(String txid, String value) => + TransactionNote(walletId: walletId, txid: txid, value: value); + + UTXO stored(String txid, int vout, {String wallet = walletId}) => isar.utxos + .where() + .txidWalletIdVoutEqualTo(txid, wallet, vout) + .findFirstSync()!; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp("stack-note-test-"); + isar = await Isar.open( + [TransactionNoteSchema, UTXOSchema], + directory: tempDir.path, + name: "transaction_note_test", + ); + await db.initMainDB(mock: isar); + }); + + setUp(() async { + await isar.writeTxn(() async { + await isar.transactionNotes.clear(); + await isar.utxos.clear(); + }); + }); + + tearDownAll(() async { + await isar.close(deleteFromDisk: true); + await tempDir.delete(recursive: true); + }); + + test("labels outputs that arrive after their note", () async { + await db.putTransactionNote(note("tx-1", "exchange")); + + await db.updateUTXOs(walletId, [ + utxo(txid: "tx-1"), + utxo(txid: "tx-1", vout: 1, name: "manual"), + ]); + + expect(stored("tx-1", 0).name, "exchange"); + expect(stored("tx-1", 1).name, "manual"); + }); + + test("labels existing blank outputs when a note is saved", () async { + await db.updateUTXOs(walletId, [utxo(txid: "tx-2")]); + + await db.putTransactionNote(note("tx-2", "salary")); + + expect(stored("tx-2", 0).name, "salary"); + }); + + test("later note edits preserve existing output labels", () async { + await db.updateUTXOs(walletId, [ + utxo(txid: "tx-3"), + utxo(txid: "tx-3", vout: 1, name: "manual"), + ]); + await db.putTransactionNote(note("tx-3", "first")); + + await db.putTransactionNote(note("tx-3", "second")); + + expect(stored("tx-3", 0).name, "first"); + expect(stored("tx-3", 1).name, "manual"); + }); + + test("wallet refreshes preserve an inherited label", () async { + await db.putTransactionNote(note("tx-refresh", "savings")); + await db.updateUTXOs(walletId, [utxo(txid: "tx-refresh")]); + + await db.updateUTXOs(walletId, [utxo(txid: "tx-refresh", value: 1200)]); + + expect(stored("tx-refresh", 0).name, "savings"); + expect(stored("tx-refresh", 0).value, 1200); + }); + + test("refresh labels legacy blank outputs with an existing note", () async { + await isar.writeTxn(() async { + await isar.transactionNotes.put(note("tx-legacy", "legacy")); + await isar.utxos.put(utxo(txid: "tx-legacy")); + }); + + await db.updateUTXOs(walletId, [utxo(txid: "tx-legacy")]); + + expect(stored("tx-legacy", 0).name, "legacy"); + }); + + test("blank notes do not label outputs", () async { + await db.putTransactionNote(note("tx-4", "")); + await db.updateUTXOs(walletId, [utxo(txid: "tx-4")]); + + expect(stored("tx-4", 0).name, isEmpty); + }); + + test("notes never cross wallet boundaries", () async { + await db.putTransactionNote(note("shared-txid", "private")); + + await db.updateUTXOs("wallet-2", [ + utxo(txid: "shared-txid", wallet: "wallet-2"), + ]); + + expect(stored("shared-txid", 0, wallet: "wallet-2").name, isEmpty); + }); + + test("post-send note failures do not report a send failure", () async { + final saved = await saveTransactionNotesAfterSend( + notes: [note("tx-5", "gift")], + persist: (_) async => throw StateError("disk full"), + ); + + expect(saved, isFalse); + }); + + test("post-send note persistence receives the complete batch", () async { + List? persisted; + final notes = [note("tx-6", "one"), note("tx-7", "two")]; + + final saved = await saveTransactionNotesAfterSend( + notes: notes, + persist: (value) async => persisted = value, + ); + + expect(saved, isTrue); + expect(persisted, same(notes)); + }); +}