Skip to content

Commit 5fc0e88

Browse files
committed
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
1 parent fbec383 commit 5fc0e88

9 files changed

Lines changed: 290 additions & 64 deletions

File tree

lib/db/isar/main_db.dart

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,9 @@ class MainDB {
324324

325325
await isar.writeTxn(() async {
326326
final set = utxos.toSet();
327+
final noteValues = <String, String?>{};
327328
for (final utxo in utxos) {
329+
UTXO persistedUtxo = utxo;
328330
// check if utxo exists in db and update accordingly
329331
final storedUtxo = await isar.utxos
330332
.where()
@@ -342,24 +344,36 @@ class MainDB {
342344
!storedUtxo.isBlocked &&
343345
!storedUtxo.userUnfroze;
344346
set.remove(utxo);
345-
set.add(
346-
storedUtxo.copyWith(
347-
value: utxo.value,
348-
address: utxo.address,
349-
blockTime: utxo.blockTime,
350-
blockHeight: utxo.blockHeight,
351-
blockHash: utxo.blockHash,
352-
// passing null keeps the stored value
353-
isBlocked: applyAutoBlock ? true : null,
354-
blockedReason: applyAutoBlock ? utxo.blockedReason : null,
355-
name: applyAutoBlock && storedUtxo.name.isEmpty
356-
? utxo.name
357-
: null,
358-
),
347+
persistedUtxo = storedUtxo.copyWith(
348+
value: utxo.value,
349+
address: utxo.address,
350+
blockTime: utxo.blockTime,
351+
blockHeight: utxo.blockHeight,
352+
blockHash: utxo.blockHash,
353+
// passing null keeps the stored value
354+
isBlocked: applyAutoBlock ? true : null,
355+
blockedReason: applyAutoBlock ? utxo.blockedReason : null,
356+
name: applyAutoBlock && storedUtxo.name.isEmpty ? utxo.name : null,
359357
);
358+
set.add(persistedUtxo);
360359
} else {
361360
newUTXO = true;
362361
}
362+
363+
if (persistedUtxo.name.isEmpty) {
364+
final noteValue = noteValues.containsKey(utxo.txid)
365+
? noteValues[utxo.txid]
366+
: (await isar.transactionNotes.getByTxidWalletId(
367+
utxo.txid,
368+
walletId,
369+
))?.value;
370+
noteValues[utxo.txid] = noteValue;
371+
if (noteValue?.isNotEmpty == true) {
372+
set
373+
..remove(persistedUtxo)
374+
..add(persistedUtxo.copyWith(name: noteValue));
375+
}
376+
}
363377
}
364378

365379
await isar.utxos.where().walletIdEqualTo(walletId).deleteAll();
@@ -381,14 +395,37 @@ class MainDB {
381395
isar.transactionNotes.where().walletIdEqualTo(walletId);
382396

383397
Future<void> putTransactionNote(TransactionNote transactionNote) =>
384-
isar.writeTxn(() async {
385-
await isar.transactionNotes.put(transactionNote);
386-
});
398+
putTransactionNotes([transactionNote]);
387399

400+
/// Copies a note only to blank UTXO labels. The label is independent after
401+
/// that first assignment, so later note edits cannot overwrite it.
388402
Future<void> putTransactionNotes(List<TransactionNote> transactionNotes) =>
389-
isar.writeTxn(() async {
390-
await isar.transactionNotes.putAll(transactionNotes);
391-
});
403+
transactionNotes.isEmpty
404+
? Future.value()
405+
: isar.writeTxn(() async {
406+
await isar.transactionNotes.putAll(transactionNotes);
407+
408+
final toUpdate = <UTXO>[];
409+
for (final note in transactionNotes) {
410+
if (note.value.isEmpty) {
411+
continue;
412+
}
413+
final utxos = await isar.utxos
414+
.where()
415+
.walletIdEqualTo(note.walletId)
416+
.filter()
417+
.txidEqualTo(note.txid)
418+
.findAll();
419+
toUpdate.addAll(
420+
utxos
421+
.where((utxo) => utxo.name.isEmpty)
422+
.map((utxo) => utxo.copyWith(name: note.value)),
423+
);
424+
}
425+
if (toUpdate.isNotEmpty) {
426+
await isar.utxos.putAll(toUpdate);
427+
}
428+
});
392429

393430
Future<TransactionNote?> getTransactionNote(
394431
String walletId,

lib/pages/cakepay/cakepay_confirm_send_view.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../../notifications/show_flush_bar.dart';
88
import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart';
99
import '../../providers/providers.dart';
1010
import '../../route_generator.dart';
11+
import '../../services/transaction_note_service.dart';
1112
import '../../themes/stack_colors.dart';
1213
import '../../utilities/amount/amount_formatter.dart';
1314
import '../../utilities/constants.dart';
@@ -95,11 +96,10 @@ class _CakePayConfirmSendViewState
9596

9697
txid = (results.first as TxData).txid!;
9798

98-
await ref
99-
.read(mainDBProvider)
100-
.putTransactionNote(
101-
TransactionNote(walletId: walletId, txid: txid, value: note),
102-
);
99+
await saveTransactionNotesAfterSend(
100+
notes: [TransactionNote(walletId: walletId, txid: txid, value: note)],
101+
persist: ref.read(mainDBProvider).putTransactionNotes,
102+
);
103103

104104
if (context.mounted) {
105105
// pop sending dialog (pushed via showDialog which uses root navigator)

lib/pages/exchange_view/confirm_change_now_send.dart

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import '../../notifications/show_flush_bar.dart';
2121
import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart';
2222
import '../../providers/providers.dart';
2323
import '../../route_generator.dart';
24+
import '../../services/transaction_note_service.dart';
2425
import '../../themes/stack_colors.dart';
2526
import '../../utilities/amount/amount.dart';
2627
import '../../utilities/amount/amount_formatter.dart';
@@ -135,12 +136,10 @@ class _ConfirmChangeNowSendViewState
135136

136137
txid = (results.first as TxData).txid!;
137138

138-
// save note
139-
await ref
140-
.read(mainDBProvider)
141-
.putTransactionNote(
142-
TransactionNote(walletId: walletId, txid: txid, value: note),
143-
);
139+
await saveTransactionNotesAfterSend(
140+
notes: [TransactionNote(walletId: walletId, txid: txid, value: note)],
141+
persist: ref.read(mainDBProvider).putTransactionNotes,
142+
);
144143

145144
await ref
146145
.read(tradeSentFromStackLookupProvider)

lib/pages/namecoin_names/confirm_name_transaction_view.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt
2424
import '../../providers/global/secure_store_provider.dart';
2525
import '../../providers/providers.dart';
2626
import '../../route_generator.dart';
27+
import '../../services/transaction_note_service.dart';
2728
import '../../themes/stack_colors.dart';
2829
import '../../themes/theme_providers.dart';
2930
import '../../utilities/amount/amount.dart';
@@ -140,14 +141,15 @@ class _ConfirmNameTransactionViewState
140141
ref.refresh(desktopUseUTXOs);
141142
}
142143

143-
// save note
144-
for (final txid in txids) {
145-
await ref
146-
.read(mainDBProvider)
147-
.putTransactionNote(
148-
TransactionNote(walletId: walletId, txid: txid, value: note),
149-
);
150-
}
144+
await saveTransactionNotesAfterSend(
145+
notes: txids
146+
.map(
147+
(txid) =>
148+
TransactionNote(walletId: walletId, txid: txid, value: note),
149+
)
150+
.toList(),
151+
persist: ref.read(mainDBProvider).putTransactionNotes,
152+
);
151153

152154
unawaited(wallet.refresh());
153155

lib/pages/send_view/confirm_transaction_view.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt
2929
import '../../providers/providers.dart';
3030
import '../../providers/wallet/public_private_balance_state_provider.dart';
3131
import '../../route_generator.dart';
32+
import '../../services/transaction_note_service.dart';
3233
import '../../themes/stack_colors.dart';
3334
import '../../themes/theme_providers.dart';
3435
import '../../utilities/amount/amount.dart';
@@ -458,14 +459,15 @@ class _ConfirmTransactionViewState
458459
ref.refresh(desktopUseUTXOs);
459460
}
460461

461-
// save note
462-
for (final txid in txids) {
463-
await ref
464-
.read(mainDBProvider)
465-
.putTransactionNote(
466-
TransactionNote(walletId: walletId, txid: txid, value: note),
467-
);
468-
}
462+
await saveTransactionNotesAfterSend(
463+
notes: txids
464+
.map(
465+
(txid) =>
466+
TransactionNote(walletId: walletId, txid: txid, value: note),
467+
)
468+
.toList(),
469+
persist: ref.read(mainDBProvider).putTransactionNotes,
470+
);
469471

470472
if (widget.isTokenTx) {
471473
if (wallet is SolanaWallet) {

lib/pages/shopinbit/shopinbit_confirm_send_view.dart

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/deskt
99
import '../../providers/global/shopin_bit_service_provider.dart';
1010
import '../../providers/providers.dart';
1111
import '../../route_generator.dart';
12+
import '../../services/transaction_note_service.dart';
1213
import '../../themes/stack_colors.dart';
1314
import '../../utilities/amount/amount.dart';
1415
import '../../utilities/amount/amount_formatter.dart';
@@ -113,12 +114,10 @@ class _ShopInBitConfirmSendViewState
113114

114115
txid = (results.first as TxData).txid!;
115116

116-
// save note
117-
await ref
118-
.read(mainDBProvider)
119-
.putTransactionNote(
120-
TransactionNote(walletId: walletId, txid: txid, value: note),
121-
);
117+
await saveTransactionNotesAfterSend(
118+
notes: [TransactionNote(walletId: walletId, txid: txid, value: note)],
119+
persist: ref.read(mainDBProvider).putTransactionNotes,
120+
);
122121

123122
// The server (and the BTCPay webhook) own ticket + payment state from
124123
// here, so there's nothing to persist locally; just nudge a refresh so
@@ -132,9 +131,7 @@ class _ShopInBitConfirmSendViewState
132131
final popThroughRouteName = widget.popThroughRouteName;
133132
if (popThroughRouteName != null) {
134133
final navigator = Navigator.of(context, rootNavigator: true);
135-
navigator.popUntil(
136-
ModalRoute.withName(popThroughRouteName),
137-
);
134+
navigator.popUntil(ModalRoute.withName(popThroughRouteName));
138135
navigator.pop();
139136
} else {
140137
// pop sending dialog (pushed via showDialog which uses root navigator)

lib/pages/spark_names/confirm_spark_name_transaction_view.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialo
2222
import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart';
2323
import '../../providers/providers.dart';
2424
import '../../route_generator.dart';
25+
import '../../services/transaction_note_service.dart';
2526
import '../../themes/stack_colors.dart';
2627
import '../../themes/theme_providers.dart';
2728
import '../../utilities/amount/amount.dart';
@@ -119,14 +120,15 @@ class _ConfirmSparkNameTransactionViewState
119120
txids.addAll(txData.sparkSpends?.map((e) => e.txid!) ?? [txData.txid!]);
120121
ref.refresh(desktopUseUTXOs);
121122

122-
// save note
123-
for (final txid in txids) {
124-
await ref
125-
.read(mainDBProvider)
126-
.putTransactionNote(
127-
TransactionNote(walletId: walletId, txid: txid, value: note),
128-
);
129-
}
123+
await saveTransactionNotesAfterSend(
124+
notes: txids
125+
.map(
126+
(txid) =>
127+
TransactionNote(walletId: walletId, txid: txid, value: note),
128+
)
129+
.toList(),
130+
persist: ref.read(mainDBProvider).putTransactionNotes,
131+
);
130132

131133
final address = txData.sparkNameInfo?.sparkAddress;
132134
final currentReceiving = await wallet.getCurrentReceivingSparkAddress();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* This file is part of Stack Wallet.
3+
*
4+
* Copyright (c) 2023 Cypher Stack
5+
* All Rights Reserved.
6+
* The code is distributed under GPLv3 license, see LICENSE file for details.
7+
*/
8+
9+
import '../models/isar/models/transaction_note.dart';
10+
import '../utilities/logger.dart';
11+
12+
Future<bool> saveTransactionNotesAfterSend({
13+
required List<TransactionNote> notes,
14+
required Future<void> Function(List<TransactionNote>) persist,
15+
}) async {
16+
try {
17+
await persist(notes);
18+
return true;
19+
} catch (e, s) {
20+
Logging.instance.w(
21+
"Transaction sent, but its note could not be saved",
22+
error: e,
23+
stackTrace: s,
24+
);
25+
return false;
26+
}
27+
}

0 commit comments

Comments
 (0)