From c8d2364ff1a796afd1aada4609bcb2b9ae379088 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:12:53 -0500 Subject: [PATCH 1/3] chore: format confirm_name_transaction_view.dart The file predates the current formatter, and CI checks the formatting of every changed file, so it has to be formatted before the next commit can touch it. No behaviour change. --- .../confirm_name_transaction_view.dart | 461 ++++++++---------- 1 file changed, 213 insertions(+), 248 deletions(-) diff --git a/lib/pages/namecoin_names/confirm_name_transaction_view.dart b/lib/pages/namecoin_names/confirm_name_transaction_view.dart index ff2f0b74a4..f30baac8f8 100644 --- a/lib/pages/namecoin_names/confirm_name_transaction_view.dart +++ b/lib/pages/namecoin_names/confirm_name_transaction_view.dart @@ -225,10 +225,9 @@ class _ConfirmNameTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -273,81 +272,76 @@ class _ConfirmNameTransactionViewState return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - // if (FocusScope.of(context).hasFocus) { - // FocusScope.of(context).unfocus(); - // await Future.delayed(Duration(milliseconds: 50)); - // } - Navigator.of(context).pop(); - }, - ), - title: Text( - "Confirm transaction", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + // if (FocusScope.of(context).hasFocus) { + // FocusScope.of(context).unfocus(); + // await Future.delayed(Duration(milliseconds: 50)); + // } + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, ), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: isDesktop, - builder: - (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( children: [ - Row( - children: [ - AppBarBackButton( - size: 40, - iconSize: 24, - onPressed: - () => - Navigator.of(context, rootNavigator: true).pop(), - ), - Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - ], + AppBarBackButton( + size: 40, + iconSize: 24, + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(), + ), + Text( + "Confirm transaction", + style: STextStyles.desktopH3(context), ), - Flexible(child: SingleChildScrollView(child: child)), ], ), + Flexible(child: SingleChildScrollView(child: child)), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -487,18 +481,18 @@ class _ConfirmNameTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: - Theme.of(context).extension()!.background, + borderColor: Theme.of( + context, + ).extension()!.background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -550,24 +544,23 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.name, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), ), Container( height: 1, - color: - Theme.of( - context, - ).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, ), Padding( padding: const EdgeInsets.all(12), @@ -584,14 +577,14 @@ class _ConfirmNameTransactionViewState const SizedBox(height: 2), SelectableText( widget.txData.opNameState!.value, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( context, ).extension()!.textDark, - ), + ), ), ], ), @@ -609,14 +602,12 @@ class _ConfirmNameTransactionViewState children: [ SelectableText( "Note (optional)", - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) .extension()! .textFieldActiveSearchIconRight, - ), + ), textAlign: TextAlign.left, ), const SizedBox(height: 10), @@ -631,49 +622,48 @@ class _ConfirmNameTransactionViewState enableSuggestions: isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), + height: 1.8, + ), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ), - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState( - () => noteController.text = "", - ); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState( + () => + noteController.text = "", + ); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -697,10 +687,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Builder( builder: (context) { final externalCalls = ref.watch( @@ -711,21 +700,17 @@ class _ConfirmNameTransactionViewState String fiatAmount = "N/A"; if (externalCalls) { - final price = - ref - .read(priceAnd24hChangeNotifierProvider) - .getPrice(coin) - ?.value; + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getPrice(coin) + ?.value; if (price != null && price > Decimal.zero) { fiatAmount = (amountWithoutChange.decimal * price) .toAmount(fractionDigits: 2) .fiatString( - locale: - ref - .read( - localeServiceChangeNotifierProvider, - ) - .locale, + locale: ref + .read(localeServiceChangeNotifierProvider) + .locale, ); } } @@ -770,10 +755,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( widget.txData.recipients!.first.address, style: STextStyles.itemSubtitle(context), @@ -797,10 +781,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -827,10 +810,9 @@ class _ConfirmNameTransactionViewState horizontal: 16, vertical: 18, ), - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -840,64 +822,52 @@ class _ConfirmNameTransactionViewState if (!isDesktop) const Spacer(), SizedBox(height: isDesktop ? 23 : 12), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: RoundedContainer( - padding: - isDesktop - ? const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ) - : const EdgeInsets.all(12), - color: - Theme.of( - context, - ).extension()!.snackBarBackSuccess, + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) + : const EdgeInsets.all(12), + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.titleBold12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), ), SelectableText( ref .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: - Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), textAlign: TextAlign.right, ), ], @@ -906,10 +876,9 @@ class _ConfirmNameTransactionViewState ), SizedBox(height: isDesktop ? 28 : 16), Padding( - padding: - isDesktop - ? const EdgeInsets.symmetric(horizontal: 32) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32) + : const EdgeInsets.all(0), child: PrimaryButton( label: "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, @@ -919,28 +888,27 @@ class _ConfirmNameTransactionViewState if (isDesktop) { unlocked = await showDialog( context: context, - builder: - (context) => DesktopDialog( - maxWidth: 580, - maxHeight: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [DesktopDialogCloseButton()], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: DesktopAuthSend(coin: coin), - ), - ], + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], ), - ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), ); } else { unlocked = await Navigator.push( @@ -948,18 +916,16 @@ class _ConfirmNameTransactionViewState RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - popOnSuccess: true, - routeOnSuccessArguments: true, - routeOnSuccess: "", - biometricsCancelButtonString: "CANCEL", - biometricsLocalizedReason: - "Authenticate to send transaction", - biometricsAuthenticationTitle: - "Confirm Transaction", - ), + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: + "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), settings: const RouteSettings( name: "/confirmsendlockscreen", ), @@ -975,10 +941,9 @@ class _ConfirmNameTransactionViewState unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: - Util.isDesktop - ? "Invalid passphrase" - : "Invalid PIN", + message: Util.isDesktop + ? "Invalid passphrase" + : "Invalid PIN", context: context, ), ); From fbec383be54395aa21ddc3ddde3e4b1f1f367e85 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:12:53 -0500 Subject: [PATCH 2/3] test: bootstrap the bundled Isar core for real-database tests Isar.open only looks for libisar.so on the system loader path and in the working directory, and neither holds it on a fresh checkout or in CI. Resolve the binary shipped with isar_community_flutter_libs instead, so a test can open a real Isar with no manual step and no network download. --- test/utilities/isar_test_core.dart | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test/utilities/isar_test_core.dart diff --git a/test/utilities/isar_test_core.dart b/test/utilities/isar_test_core.dart new file mode 100644 index 0000000000..2ae406546b --- /dev/null +++ b/test/utilities/isar_test_core.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:isar_community/isar.dart'; + +/// Point Isar at the core binary bundled in `isar_community_flutter_libs` so +/// tests can open a real Isar instance anywhere, including CI. +/// +/// Without this, `Isar.open` only looks for `libisar.so` on the system loader +/// path or in the working directory; neither exists on a fresh checkout, and +/// the alternative (`download: true`) would make the test suite hit the +/// network. Call once from `setUpAll` before opening Isar. Repeat calls and +/// calls from other test files are no-ops. +Future initializeIsarCoreForTests() async { + final String? relativeLibPath = switch (Abi.current()) { + Abi.linuxX64 || Abi.linuxArm64 => "linux/libisar.so", + Abi.macosX64 || Abi.macosArm64 => "macos/libisar.dylib", + Abi.windowsX64 || Abi.windowsArm64 => "windows/libisar.dll", + _ => null, + }; + if (relativeLibPath == null) { + throw UnsupportedError( + "isar_community_flutter_libs bundles no Isar core for ${Abi.current()}", + ); + } + + await Isar.initializeIsarCore( + libraries: { + Abi.current(): _packageRoot( + "isar_community_flutter_libs", + ).resolve(relativeLibPath).toFilePath(), + }, + ); +} + +Uri _packageRoot(String packageName) { + final configFile = File(".dart_tool/package_config.json"); + if (!configFile.existsSync()) { + throw StateError( + "${configFile.path} not found; run `flutter pub get` before the tests", + ); + } + + final config = + jsonDecode(configFile.readAsStringSync()) as Map; + final package = (config["packages"] as List) + .cast>() + .firstWhere( + (p) => p["name"] == packageName, + orElse: () => throw StateError("package $packageName not resolved"), + ); + + // rootUri may be relative to .dart_tool/ and carries no trailing slash; + // without one, resolve() would replace its last segment. + final rootUri = package["rootUri"] as String; + return configFile.absolute.parent.uri.resolve( + rootUri.endsWith("/") ? rootUri : "$rootUri/", + ); +} From 5fc0e88112bb80796b2af50ff67f7a11ebf067ce Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:12:53 -0500 Subject: [PATCH 3/3] feat: auto-label UTXOs from transaction notes Copy a transaction note into the labels of that transaction's outputs at the MainDB boundary, in either arrival order: updateUTXOs picks up a note stored before its outputs, putTransactionNotes labels outputs stored before their note. Only a blank label is ever written, so a label the user set by hand is never overwritten. Batch the six confirm/send screens' per-txid note writes into a single call wrapped by saveTransactionNotesAfterSend, so a note that fails to persist after a successful broadcast is no longer reported to the user as a failed send. Closes #411 --- lib/db/isar/main_db.dart | 77 ++++++--- .../cakepay/cakepay_confirm_send_view.dart | 10 +- .../confirm_change_now_send.dart | 11 +- .../confirm_name_transaction_view.dart | 18 +- .../send_view/confirm_transaction_view.dart | 18 +- .../shopinbit_confirm_send_view.dart | 15 +- .../confirm_spark_name_transaction_view.dart | 18 +- lib/services/transaction_note_service.dart | 27 +++ .../transaction_note_service_test.dart | 160 ++++++++++++++++++ 9 files changed, 290 insertions(+), 64 deletions(-) create mode 100644 lib/services/transaction_note_service.dart create mode 100644 test/services/transaction_note_service_test.dart 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 f30baac8f8..34516c81ab 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,15 @@ 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..1f802e7f1f --- /dev/null +++ b/test/services/transaction_note_service_test.dart @@ -0,0 +1,160 @@ +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'; + +import '../utilities/isar_test_core.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 { + await initializeIsarCoreForTests(); + 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)); + }); +}