Skip to content
Bitcoin Roots

v29.4-roots.4 · Commit 26: [PATCH 26/37] feat(qt): enhance privacy-aware coin control

src/qt/coincontroldialog.cpp

Before: src/qt/coincontroldialog.cpp · After: src/qt/coincontroldialog.cpp

File metadata
index febf1ee82fa01e85937561e2100b44e3e8cf3627..d9ade71911933090d65ee010561f6dc37c6bca08 100644

Download original patch · Release notes

Diff rows 1–223 of 223. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. The two line-number columns refer to before and after.

src/qt/coincontroldialog.cpp CHANGED
@@ -25,9 +25,14 @@
25
25
  #include <QDialogButtonBox>
26
26
  #include <QFlags>
27
27
  #include <QIcon>
28
+ #include <QLineEdit>
28
29
  #include <QSettings>
30
+ #include <QStringList>
29
31
  #include <QTreeWidget>
30
32
 
33
+ #include <limits>
34
+ #include <map>
35
+
31
36
  using wallet::CCoinControl;
32
37
 
33
38
  QList<CAmount> CoinControlDialog::payAmounts;
@@ -35,7 +40,9 @@ bool CoinControlDialog::fSubtractFeeFromAmount = false;
35
40
 
36
41
  bool CCoinControlWidgetItem::operator<(const QTreeWidgetItem &other) const {
37
42
  int column = treeWidget()->sortColumn();
38
- if (column == CoinControlDialog::COLUMN_AMOUNT || column == CoinControlDialog::COLUMN_DATE || column == CoinControlDialog::COLUMN_CONFIRMATIONS)
43
+ if (column == CoinControlDialog::COLUMN_AMOUNT || column == CoinControlDialog::COLUMN_EFFECTIVE_VALUE ||
44
+ column == CoinControlDialog::COLUMN_INPUT_BYTES || column == CoinControlDialog::COLUMN_DATE ||
45
+ column == CoinControlDialog::COLUMN_CONFIRMATIONS)
39
46
  return data(column, Qt::UserRole).toLongLong() < other.data(column, Qt::UserRole).toLongLong();
40
47
  return QTreeWidgetItem::operator<(other);
41
48
  }
@@ -98,6 +105,9 @@ CoinControlDialog::CoinControlDialog(CCoinControl& coin_control, WalletModel* _m
98
105
 
99
106
  // (un)select all
100
107
  connect(ui->pushButtonSelectAll, &QPushButton::clicked, this, &CoinControlDialog::buttonSelectAllClicked);
108
+ connect(ui->lineEditFilter, &QLineEdit::textChanged, this, &CoinControlDialog::filterChanged);
109
+ connect(model, &WalletModel::balanceChanged, this, &CoinControlDialog::walletChanged);
110
+ connect(model, &WalletModel::unload, this, &QDialog::reject);
101
111
 
102
112
  ui->treeWidget->setColumnWidth(COLUMN_CHECKBOX, 84);
103
113
  ui->treeWidget->setColumnWidth(COLUMN_AMOUNT, 110);
@@ -105,6 +115,14 @@ CoinControlDialog::CoinControlDialog(CCoinControl& coin_control, WalletModel* _m
105
115
  ui->treeWidget->setColumnWidth(COLUMN_ADDRESS, 320);
106
116
  ui->treeWidget->setColumnWidth(COLUMN_DATE, 130);
107
117
  ui->treeWidget->setColumnWidth(COLUMN_CONFIRMATIONS, 110);
118
+ ui->treeWidget->setColumnWidth(COLUMN_STATUS, 180);
119
+ ui->treeWidget->setColumnWidth(COLUMN_EFFECTIVE_VALUE, 125);
120
+ ui->treeWidget->setColumnWidth(COLUMN_INPUT_BYTES, 95);
121
+
122
+ ui->lineEditFilter->setAccessibleName(tr("Filter coin selection"));
123
+ ui->lineEditFilter->setAccessibleDescription(tr("Filters outpoints, addresses, labels, amounts, and wallet state without changing selected inputs."));
124
+ ui->treeWidget->setAccessibleName(tr("Coin selection list"));
125
+ ui->treeWidget->setAccessibleDescription(tr("Use Space to select an eligible input. Locked, unsafe, watch-only, and reused states are shown in the Status column."));
108
126
 
109
127
  // default view is sorted by amount desc
110
128
  sortView(COLUMN_AMOUNT, Qt::DescendingOrder);
@@ -318,6 +336,30 @@ void CoinControlDialog::headerSectionClicked(int logicalIndex)
318
336
  }
319
337
  }
320
338
 
339
+ void CoinControlDialog::filterChanged(const QString& filter)
340
+ {
341
+ const QString needle{filter.trimmed()};
342
+ const auto matches = [&needle](QTreeWidgetItem* item) {
343
+ if (needle.isEmpty()) return true;
344
+ for (int column = COLUMN_AMOUNT; column <= COLUMN_STATUS; ++column) {
345
+ if (item->text(column).contains(needle, Qt::CaseInsensitive)) return true;
346
+ }
347
+ return item->data(COLUMN_ADDRESS, TxHashRole).toString().contains(needle, Qt::CaseInsensitive);
348
+ };
349
+ for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
350
+ QTreeWidgetItem* parent{ui->treeWidget->topLevelItem(i)};
351
+ const bool group_matches{matches(parent)};
352
+ bool has_visible_child{false};
353
+ for (int child = 0; child < parent->childCount(); ++child) {
354
+ const bool visible{group_matches || matches(parent->child(child))};
355
+ parent->child(child)->setHidden(!visible);
356
+ has_visible_child |= visible;
357
+ }
358
+ parent->setHidden(parent->childCount() ? !has_visible_child && !group_matches : !group_matches);
359
+ if (!needle.isEmpty() && parent->childCount()) parent->setExpanded(has_visible_child);
360
+ }
361
+ }
362
+
321
363
  // toggle tree mode
322
364
  void CoinControlDialog::radioTreeMode(bool checked)
323
365
  {
@@ -366,6 +408,26 @@ void CoinControlDialog::updateLabelLocked()
366
408
  else ui->labelLocked->setVisible(false);
367
409
  }
368
410
 
411
+ void CoinControlDialog::updateAvailableLabel()
412
+ {
413
+ ui->labelAvailable->setText(tr("Available: %1 (%2 eligible)")
414
+ .arg(BitcoinUnits::formatWithUnit(model->getOptionsModel()->getDisplayUnit(), m_available_amount))
415
+ .arg(m_available_count));
416
+ }
417
+
418
+ void CoinControlDialog::walletChanged()
419
+ {
420
+ // A balance update can represent a competing spend, a reorg, or a lock
421
+ // change. Keep intent only for harmless presentation refreshes; wallet
422
+ // changes require the operator to deliberately select inputs again.
423
+ m_coin_control.UnSelectAll();
424
+ updateView();
425
+ updateLabelLocked();
426
+ CoinControlDialog::updateLabels(m_coin_control, model, this);
427
+ ui->labelSelectionNotice->setText(tr("Input selection cleared after a wallet change. Review the current inputs."));
428
+ ui->labelSelectionNotice->setVisible(true);
429
+ }
430
+
369
431
  void CoinControlDialog::updateLabels(CCoinControl& m_coin_control, WalletModel *model, QDialog* dialog)
370
432
  {
371
433
  if (!model)
@@ -502,6 +564,9 @@ void CoinControlDialog::updateLabels(CCoinControl& m_coin_control, WalletModel *
502
564
  QLabel *l4 = dialog->findChild<QLabel *>("labelCoinControlAfterFee");
503
565
  QLabel *l5 = dialog->findChild<QLabel *>("labelCoinControlBytes");
504
566
  QLabel *l8 = dialog->findChild<QLabel *>("labelCoinControlChange");
567
+ QLabel *fee_rate = dialog->findChild<QLabel *>("labelCoinControlFeeRate");
568
+ QLabel *rbf = dialog->findChild<QLabel *>("labelCoinControlRbf");
569
+ QLabel *dust = dialog->findChild<QLabel *>("labelCoinControlDust");
505
570
 
506
571
  // enable/disable "change"
507
572
  dialog->findChild<QLabel *>("labelCoinControlChangeText") ->setEnabled(nPayAmount > 0);
@@ -514,6 +579,10 @@ void CoinControlDialog::updateLabels(CCoinControl& m_coin_control, WalletModel *
514
579
  l4->setText(BitcoinUnits::formatWithUnit(nDisplayUnit, nAfterFee)); // After Fee
515
580
  l5->setText(((nBytes > 0) ? ASYMP_UTF8 : "") + QString::number(nBytes)); // Bytes
516
581
  l8->setText(BitcoinUnits::formatWithUnit(nDisplayUnit, nChange)); // Change
582
+ if (fee_rate) fee_rate->setText(nBytes > 0 ? tr("%1 sat/vB").arg(QString::number(static_cast<double>(nPayFee) / nBytes, 'f', 1)) : tr("—"));
583
+ if (rbf) rbf->setText(m_coin_control.m_signal_bip125_rbf ? tr("Signals Replace-By-Fee") : tr("Does not signal Replace-By-Fee"));
584
+ if (dust) dust->setText(nQuantity > 0 && nChange == 0 && nPayAmount > 0 && !CoinControlDialog::fSubtractFeeFromAmount
585
+ ? tr("No change output (change may have been added to the fee)") : tr("No dust change detected"));
517
586
  if (nPayFee > 0)
518
587
  {
519
588
  l3->setText(ASYMP_UTF8 + l3->text());
@@ -555,7 +624,9 @@ void CoinControlDialog::updateView()
555
624
  if (!model || !model->getOptionsModel() || !model->getAddressTableModel())
556
625
  return;
557
626
 
558
- bool treeMode = ui->radioTreeMode->isChecked();
627
+ const bool treeMode = ui->radioTreeMode->isChecked();
628
+ m_available_amount = 0;
629
+ m_available_count = 0;
559
630
 
560
631
  ui->treeWidget->clear();
561
632
  ui->treeWidget->setEnabled(false); // performance, otherwise updateLabels would be called for every checked checkbox
@@ -565,7 +636,21 @@ void CoinControlDialog::updateView()
565
636
 
566
637
  BitcoinUnit nDisplayUnit = model->getOptionsModel()->getDisplayUnit();
567
638
 
568
- for (const auto& coins : model->wallet().listCoins()) {
639
+ const auto coin_groups = model->wallet().listCoins();
640
+ std::vector<COutPoint> outpoints;
641
+ for (const auto& coins : coin_groups) {
642
+ for (const auto& outpair : coins.second) outpoints.push_back(std::get<0>(outpair));
643
+ }
644
+ // Use the same fee choice that the wallet will use for the current coin
645
+ // control. This makes effective value a wallet calculation, rather than
646
+ // a GUI approximation. The 1000-vbyte query converts the selected wallet
647
+ // fee into CFeeRate's sat/kvB representation.
648
+ const CFeeRate fee_rate{m_coin_control.m_feerate.value_or(
649
+ CFeeRate(model->wallet().getMinimumFee(1000, m_coin_control, /*returned_target=*/nullptr, /*reason=*/nullptr)))};
650
+ std::map<COutPoint, interfaces::WalletTxOut> coin_facts;
651
+ for (const auto& coin : model->wallet().getCoins(outpoints, fee_rate)) coin_facts.emplace(coin.outpoint, coin);
652
+
653
+ for (const auto& coins : coin_groups) {
569
654
  CCoinControlWidgetItem* itemWalletAddress{nullptr};
570
655
  QString sWalletAddress = QString::fromStdString(EncodeDestination(coins.first));
571
656
  QString sWalletLabel = model->getAddressTableModel()->labelForAddress(sWalletAddress);
@@ -585,13 +670,15 @@ void CoinControlDialog::updateView()
585
670
 
586
671
  // address
587
672
  itemWalletAddress->setText(COLUMN_ADDRESS, sWalletAddress);
673
+ itemWalletAddress->setText(COLUMN_STATUS, tr("Grouped by wallet address"));
674
+ itemWalletAddress->setToolTip(COLUMN_STATUS, tr("Grouping reflects wallet address relationships. Selecting this group combines its inputs; it does not measure privacy."));
588
675
  }
589
676
 
590
677
  CAmount nSum = 0;
591
678
  int nChildren = 0;
592
679
  for (const auto& outpair : coins.second) {
593
680
  const COutPoint& output = std::get<0>(outpair);
594
- const interfaces::WalletTxOut& out = std::get<1>(outpair);
681
+ const interfaces::WalletTxOut& out = coin_facts.at(output);
595
682
  nSum += out.txout.nValue;
596
683
  nChildren++;
597
684
 
@@ -640,14 +727,35 @@ void CoinControlDialog::updateView()
640
727
  itemOutput->setText(COLUMN_CONFIRMATIONS, QString::number(out.depth_in_main_chain));
641
728
  itemOutput->setData(COLUMN_CONFIRMATIONS, Qt::UserRole, QVariant((qlonglong)out.depth_in_main_chain));
642
729
 
730
+ QStringList states;
731
+ if (out.is_locked) states << tr("Locked");
732
+ if (!out.is_safe) states << tr("Unsafe");
733
+ if (!out.is_spendable && out.is_solvable) states << tr("Watch-only");
734
+ if (!out.is_spendable && !out.is_solvable) states << tr("Not spendable");
735
+ if (out.is_reused) states << tr("Reused address");
736
+ if (out.is_immature) states << tr("Immature");
737
+ if (out.is_change) states << tr("Change");
738
+ if (states.isEmpty()) states << tr("Eligible");
739
+ itemOutput->setText(COLUMN_STATUS, states.join(", "));
740
+ itemOutput->setToolTip(COLUMN_STATUS, tr("Wallet-reported state. Reuse and grouping describe observable wallet facts; they are not a privacy score."));
741
+ if (out.effective_value) {
742
+ itemOutput->setText(COLUMN_EFFECTIVE_VALUE, BitcoinUnits::format(nDisplayUnit, *out.effective_value));
743
+ itemOutput->setData(COLUMN_EFFECTIVE_VALUE, Qt::UserRole, QVariant((qlonglong)*out.effective_value));
744
+ } else {
745
+ itemOutput->setText(COLUMN_EFFECTIVE_VALUE, tr("Not estimated"));
746
+ itemOutput->setData(COLUMN_EFFECTIVE_VALUE, Qt::UserRole, QVariant(std::numeric_limits<qlonglong>::min()));
747
+ }
748
+ itemOutput->setText(COLUMN_INPUT_BYTES, out.input_bytes >= 0 ? QString::number(out.input_bytes) : tr("Unknown"));
749
+ itemOutput->setData(COLUMN_INPUT_BYTES, Qt::UserRole, QVariant((qlonglong)out.input_bytes));
750
+
643
751
  // transaction hash
644
752
  itemOutput->setData(COLUMN_ADDRESS, TxHashRole, QString::fromStdString(output.hash.GetHex()));
645
753
 
646
754
  // vout index
647
755
  itemOutput->setData(COLUMN_ADDRESS, VOutRole, output.n);
648
756
 
649
- // disable locked coins
650
- if (model->wallet().isLockedCoin(output))
757
+ // Wallet-owned locked state prevents selection, but remains visible.
758
+ if (out.is_locked)
651
759
  {
652
760
  m_coin_control.UnSelect(output); // just to be sure
653
761
  itemOutput->setDisabled(true);
@@ -657,6 +765,10 @@ void CoinControlDialog::updateView()
657
765
  // set checkbox
658
766
  if (m_coin_control.IsSelected(output))
659
767
  itemOutput->setCheckState(COLUMN_CHECKBOX, Qt::Checked);
768
+ if (out.is_spendable && out.is_safe && !out.is_locked && !out.is_immature) {
769
+ m_available_amount += out.txout.nValue;
770
+ ++m_available_count;
771
+ }
660
772
  }
661
773
 
662
774
  // amount
@@ -679,4 +791,6 @@ void CoinControlDialog::updateView()
679
791
  // sort view
680
792
  sortView(sortColumn, sortOrder);
681
793
  ui->treeWidget->setEnabled(true);
794
+ updateAvailableLabel();
795
+ filterChanged(ui->lineEditFilter->text());
682
796
  }