From a773635a3a434a3988840d70fc04a6068fd628fe Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:16:59 +0530 Subject: [PATCH 01/30] qt: move masternode list fetch logic to thread and use debounce timer --- src/qt/masternodelist.cpp | 179 +++++++++++++++++++++----------------- src/qt/masternodelist.h | 39 +++++---- 2 files changed, 119 insertions(+), 99 deletions(-) diff --git a/src/qt/masternodelist.cpp b/src/qt/masternodelist.cpp index 700fdabc6782..d6368fa0ce58 100644 --- a/src/qt/masternodelist.cpp +++ b/src/qt/masternodelist.cpp @@ -8,17 +8,19 @@ #include #include #include +#include #include #include #include #include -#include #include #include #include #include +#include +#include #include @@ -62,7 +64,7 @@ bool MasternodeListSortFilterProxyModel::filterAcceptsRow(int source_row, const if (m_show_owned_only) { QModelIndex idx = sourceModel()->index(source_row, MasternodeModel::PROTX_HASH, source_parent); QString proTxHash = sourceModel()->data(idx, Qt::DisplayRole).toString(); - if (!m_my_mn_hashes.contains(proTxHash)) { + if (!m_owned_mns.contains(proTxHash)) { return false; } } @@ -73,8 +75,11 @@ bool MasternodeListSortFilterProxyModel::filterAcceptsRow(int source_row, const MasternodeList::MasternodeList(QWidget* parent) : QWidget(parent), ui(new Ui::MasternodeList), + m_proxy_model(new MasternodeListSortFilterProxyModel(this)), m_model(new MasternodeModel(this)), - m_proxy_model(new MasternodeListSortFilterProxyModel(this)) + m_worker(new QObject), + m_thread{new QThread(this)}, + m_timer{new QTimer(this)} { ui->setupUi(this); @@ -126,15 +131,24 @@ MasternodeList::MasternodeList(QWidget* parent) : connect(m_proxy_model, &QSortFilterProxyModel::modelReset, this, &MasternodeList::updateFilteredCount); connect(m_proxy_model, &QSortFilterProxyModel::layoutChanged, this, &MasternodeList::updateFilteredCount); - timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &MasternodeList::updateDIP3ListScheduled); - GUIUtil::updateFonts(); + + // Background thread for calculating masternode list + m_worker->moveToThread(m_thread); + // Make sure executor object is deleted in its own thread + connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); + m_thread->start(); + + // Debounce timer to apply masternode list changes + m_timer->setSingleShot(true); + connect(m_timer, &QTimer::timeout, this, &MasternodeList::updateDIP3ListScheduled); } MasternodeList::~MasternodeList() { - timer->stop(); + m_timer->stop(); + m_thread->quit(); + m_thread->wait(); delete ui; } @@ -150,11 +164,10 @@ void MasternodeList::setClientModel(ClientModel* model) { this->clientModel = model; if (model) { - // try to update list when masternode count changes connect(clientModel, &ClientModel::masternodeListChanged, this, &MasternodeList::handleMasternodeListChanged); - timer->start(1000); + m_timer->start(0); } else { - timer->stop(); + m_timer->stop(); } } @@ -174,7 +187,17 @@ void MasternodeList::showContextMenuDIP3(const QPoint& point) void MasternodeList::handleMasternodeListChanged() { - m_mn_list_changed.store(true, std::memory_order_relaxed); + if (!clientModel || m_timer->isActive()) { + // Too early or already processing, nothing to do + return; + } + + int delay{MASTERNODELIST_UPDATE_SECONDS * 1000}; + if (!clientModel->masternodeSync().isBlockchainSynced()) { + // Currently syncing, reduce refreshes + delay *= 10; + } + m_timer->start(delay); } void MasternodeList::updateDIP3ListScheduled() @@ -183,62 +206,63 @@ void MasternodeList::updateDIP3ListScheduled() return; } - if (m_mn_list_changed.load(std::memory_order_relaxed)) { - int64_t nMnListUpdateSecods = clientModel->masternodeSync().isBlockchainSynced() ? MASTERNODELIST_UPDATE_SECONDS : MASTERNODELIST_UPDATE_SECONDS * 10; - int64_t nSecondsToWait = nTimeUpdatedDIP3 - GetTime() + nMnListUpdateSecods; + if (m_in_progress.exchange(true)) { + // Already applying, re-arm for next attempt + handleMasternodeListChanged(); + return; + } - if (nSecondsToWait <= 0) { - if (updateDIP3List()) { - m_mn_list_changed.store(false, std::memory_order_relaxed); + QMetaObject::invokeMethod(m_worker, [this] { + auto result = std::make_shared(calcMasternodeList()); + m_in_progress.store(false); + QTimer::singleShot(0, this, [this, result] { + if (result->m_valid) { + setMasternodeList(std::move(*result)); + } else { + // Something went wrong, try again + handleMasternodeListChanged(); } - } - } + }); + }); } -bool MasternodeList::updateDIP3List() +MasternodeList::CalcMnList MasternodeList::calcMasternodeList() const { + CalcMnList ret; if (!clientModel || clientModel->node().shutdownRequested()) { - return false; + return ret; } auto [mnList, pindex] = clientModel->getMasternodeList(); - if (!pindex) return false; + if (!pindex) return ret; auto projectedPayees = mnList->getProjectedMNPayees(pindex); if (projectedPayees.empty() && mnList->getValidMNsCount() > 0) { // GetProjectedMNPayees failed to provide results for a list with valid mns. // Keep current list and let it try again later. - return false; - } - - std::map mapCollateralDests; - - { - // Get all UTXOs for each MN collateral in one go so that we can reduce locking overhead for cs_main - // We also do this outside of the below Qt list update loop to reduce cs_main locking time to a minimum - mnList->forEachMN(/*only_valid=*/false, [&](const auto& dmn) { - CTxDestination collateralDest; - Coin coin; - if (clientModel->node().getUnspentOutput(dmn.getCollateralOutpoint(), coin) && - ExtractDestination(coin.out.scriptPubKey, collateralDest)) { - mapCollateralDests.emplace(dmn.getProTxHash(), collateralDest); - } - }); + return ret; } - ui->countLabel->setText(tr("Updating…")); + ret.m_list_height = mnList->getHeight(); - nTimeUpdatedDIP3 = GetTime(); + Uint256HashMap mapCollateralDests; + mnList->forEachMN(/*only_valid=*/false, [&](const auto& dmn) { + CTxDestination collateralDest; + Coin coin; + if (clientModel->node().getUnspentOutput(dmn.getCollateralOutpoint(), coin) && + ExtractDestination(coin.out.scriptPubKey, collateralDest)) { + mapCollateralDests.emplace(dmn.getProTxHash(), collateralDest); + } + }); - std::map nextPayments; + Uint256HashMap nextPayments; for (size_t i = 0; i < projectedPayees.size(); i++) { const auto& dmn = projectedPayees[i]; - nextPayments.emplace(dmn->getProTxHash(), mnList->getHeight() + (int)i + 1); + nextPayments.emplace(dmn->getProTxHash(), ret.m_list_height + (int)i + 1); } - MasternodeEntryList entries; mnList->forEachMN(/*only_valid=*/false, [&](const auto& dmn) { - QString collateralStr = tr("UNKNOWN"); + QString collateralStr = QObject::tr("UNKNOWN"); auto collateralDestIt = mapCollateralDests.find(dmn.getProTxHash()); if (collateralDestIt != mapCollateralDests.end()) { collateralStr = QString::fromStdString(EncodeDestination(collateralDestIt->second)); @@ -250,47 +274,45 @@ bool MasternodeList::updateDIP3List() nNextPayment = nextPaymentIt->second; } - entries.push_back(std::make_unique(dmn, collateralStr, nNextPayment)); + ret.m_entries.push_back(std::make_unique(dmn, collateralStr, nNextPayment)); }); - // Update model - m_model->setCurrentHeight(mnList->getHeight()); - m_model->reconcile(std::move(entries)); + // Compute "owned" masternode hashes for the filter + if (walletModel) { + std::set setOutpts; + for (const auto& outpt : walletModel->wallet().listProTxCoins()) { + setOutpts.emplace(outpt); + } - // Update filters - if (walletModel && ui->checkBoxOwned->isChecked()) { - updateMyMasternodeHashes(mnList); + mnList->forEachMN(/*only_valid=*/false, [&](const auto& dmn) { + bool fMyMasternode = setOutpts.count(dmn.getCollateralOutpoint()) || + walletModel->wallet().isSpendable(PKHash(dmn.getKeyIdOwner())) || + walletModel->wallet().isSpendable(PKHash(dmn.getKeyIdVoting())) || + walletModel->wallet().isSpendable(dmn.getScriptPayout()) || + walletModel->wallet().isSpendable(dmn.getScriptOperatorPayout()); + if (fMyMasternode) { + ret.m_owned_mns.insert(QString::fromStdString(dmn.getProTxHash().ToString())); + } + }); } - updateFilteredCount(); - return true; + ret.m_valid = true; + return ret; } -void MasternodeList::updateMyMasternodeHashes(const interfaces::MnListPtr& mnList) +void MasternodeList::setMasternodeList(CalcMnList&& list) { - if (!walletModel || !mnList) { - return; - } - - std::set setOutpts; - for (const auto& outpt : walletModel->wallet().listProTxCoins()) { - setOutpts.emplace(outpt); - } + m_model->setCurrentHeight(list.m_list_height); + m_model->reconcile(std::move(list.m_entries)); - QSet myHashes; - mnList->forEachMN(/*only_valid=*/false, [&](const auto& dmn) { - bool fMyMasternode = setOutpts.count(dmn.getCollateralOutpoint()) || - walletModel->wallet().isSpendable(PKHash(dmn.getKeyIdOwner())) || - walletModel->wallet().isSpendable(PKHash(dmn.getKeyIdVoting())) || - walletModel->wallet().isSpendable(dmn.getScriptPayout()) || - walletModel->wallet().isSpendable(dmn.getScriptOperatorPayout()); - if (fMyMasternode) { - myHashes.insert(QString::fromStdString(dmn.getProTxHash().ToString())); + if (walletModel) { + m_proxy_model->setMyMasternodeHashes(std::move(list.m_owned_mns)); + if (ui->checkBoxOwned->isChecked()) { + m_proxy_model->forceInvalidateFilter(); } - }); + } - m_proxy_model->setMyMasternodeHashes(std::move(myHashes)); - m_proxy_model->forceInvalidateFilter(); + updateFilteredCount(); } void MasternodeList::updateFilteredCount() @@ -320,14 +342,7 @@ void MasternodeList::on_comboBoxType_currentIndexChanged(int index) void MasternodeList::on_checkBoxOwned_stateChanged(int state) { m_proxy_model->setShowOwnedOnly(state == Qt::Checked); - if (clientModel && state == Qt::Checked) { - auto [mnList, pindex] = clientModel->getMasternodeList(); - if (mnList) { - updateMyMasternodeHashes(mnList); - } - } else { - m_proxy_model->forceInvalidateFilter(); - } + m_proxy_model->forceInvalidateFilter(); updateFilteredCount(); } diff --git a/src/qt/masternodelist.h b/src/qt/masternodelist.h index 2a940c14538a..ce07082cd9f0 100644 --- a/src/qt/masternodelist.h +++ b/src/qt/masternodelist.h @@ -5,7 +5,7 @@ #ifndef BITCOIN_QT_MASTERNODELIST_H #define BITCOIN_QT_MASTERNODELIST_H -#include +#include #include #include @@ -26,8 +26,7 @@ class MasternodeList; } // namespace Ui class ClientModel; -class MasternodeEntry; -class MasternodeModel; +class QThread; class WalletModel; QT_BEGIN_NAMESPACE @@ -51,7 +50,7 @@ class MasternodeListSortFilterProxyModel : public QSortFilterProxyModel void forceInvalidateFilter() { invalidateFilter(); } void setHideBanned(bool hide) { m_hide_banned = hide; } - void setMyMasternodeHashes(QSet hashes) { m_my_mn_hashes = std::move(hashes); } + void setMyMasternodeHashes(QSet&& hashes) { m_owned_mns = std::move(hashes); } void setShowOwnedOnly(bool show) { m_show_owned_only = show; } void setTypeFilter(TypeFilter type) { m_type_filter = type; } @@ -61,7 +60,7 @@ class MasternodeListSortFilterProxyModel : public QSortFilterProxyModel private: bool m_hide_banned{true}; bool m_show_owned_only{false}; - QSet m_my_mn_hashes; + QSet m_owned_mns; TypeFilter m_type_filter{TypeFilter::All}; }; @@ -70,9 +69,11 @@ class MasternodeList : public QWidget { Q_OBJECT + Ui::MasternodeList* ui; + public: explicit MasternodeList(QWidget* parent = nullptr); - ~MasternodeList(); + ~MasternodeList() override; void setClientModel(ClientModel* clientModel); void setWalletModel(WalletModel* walletModel); @@ -81,24 +82,28 @@ class MasternodeList : public QWidget void changeEvent(QEvent* event) override; private: - QMenu* contextMenuDIP3; - int64_t nTimeUpdatedDIP3{0}; + struct CalcMnList { + int m_list_height{0}; + MasternodeEntryList m_entries; + QSet m_owned_mns; + bool m_valid{false}; + }; - QTimer* timer; - Ui::MasternodeList* ui; ClientModel* clientModel{nullptr}; - WalletModel* walletModel{nullptr}; - - MasternodeModel* m_model{nullptr}; MasternodeListSortFilterProxyModel* m_proxy_model{nullptr}; + MasternodeModel* m_model{nullptr}; + QMenu* contextMenuDIP3{nullptr}; + QObject* m_worker{nullptr}; + QThread* m_thread{nullptr}; + QTimer* m_timer{nullptr}; + std::atomic m_in_progress{false}; + WalletModel* walletModel{nullptr}; - std::atomic m_mn_list_changed{true}; + CalcMnList calcMasternodeList() const; + void setMasternodeList(CalcMnList&& data); const MasternodeEntry* GetSelectedEntry(); - bool updateDIP3List(); - void updateMyMasternodeHashes(const interfaces::MnListPtr& mnList); - Q_SIGNALS: void doubleClicked(const QModelIndex&); From 5af2bb9ef11a8181d31ae769af5f6f64bd738b5a Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:43:06 +0530 Subject: [PATCH 02/30] qt: expose `NotifyGovernanceChanged` signal to UI code --- src/governance/governance.cpp | 2 ++ src/governance/object.cpp | 2 ++ src/interfaces/node.h | 4 ++++ src/node/interface_ui.cpp | 3 +++ src/node/interface_ui.h | 3 +++ src/node/interfaces.cpp | 4 ++++ src/qt/clientmodel.cpp | 4 ++++ src/qt/clientmodel.h | 1 + 8 files changed, 23 insertions(+) diff --git a/src/governance/governance.cpp b/src/governance/governance.cpp index 43741675c9a4..31d1a864c23d 100644 --- a/src/governance/governance.cpp +++ b/src/governance/governance.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -318,6 +319,7 @@ void CGovernanceManager::AddGovernanceObjectInternal(CGovernanceObject& insert_o // SEND NOTIFICATION TO SCRIPT/ZMQ GetMainSignals().NotifyGovernanceObject(std::make_shared(govobj->Object()), nHash.ToString()); + uiInterface.NotifyGovernanceChanged(); } void CGovernanceManager::AddGovernanceObject(CGovernanceObject& govobj, const CNode* pfrom) diff --git a/src/governance/object.cpp b/src/governance/object.cpp index fb4f75548d05..956f209b4292 100644 --- a/src/governance/object.cpp +++ b/src/governance/object.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -155,6 +156,7 @@ bool CGovernanceObject::ProcessVote(CMasternodeMetaMan& mn_metaman, CGovernanceM // SEND NOTIFICATION TO SCRIPT/ZMQ GetMainSignals().NotifyGovernanceVote(std::make_shared(tip_mn_list), std::make_shared(vote), vote.GetHash().ToString()); + uiInterface.NotifyGovernanceChanged(); return true; } diff --git a/src/interfaces/node.h b/src/interfaces/node.h index b6fbef3de525..f73f4ce6df12 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -466,6 +466,10 @@ class Node std::function; virtual std::unique_ptr handleNotifyHeaderTip(NotifyHeaderTipFn fn) = 0; + //! Register handler for governance data messages. + using NotifyGovernanceChangedFn = std::function; + virtual std::unique_ptr handleNotifyGovernanceChanged(NotifyGovernanceChangedFn fn) = 0; + //! Register handler for masternode list update messages. using NotifyMasternodeListChangedFn = std::function NotifyBlockTip; boost::signals2::signal NotifyChainLock; boost::signals2::signal NotifyHeaderTip; + boost::signals2::signal NotifyGovernanceChanged; boost::signals2::signal NotifyMasternodeListChanged; boost::signals2::signal NotifyAdditionalDataSyncProgressChanged; boost::signals2::signal BannedListChanged; @@ -46,6 +47,7 @@ ADD_SIGNALS_IMPL_WRAPPER(ShowProgress); ADD_SIGNALS_IMPL_WRAPPER(NotifyBlockTip); ADD_SIGNALS_IMPL_WRAPPER(NotifyChainLock); ADD_SIGNALS_IMPL_WRAPPER(NotifyHeaderTip); +ADD_SIGNALS_IMPL_WRAPPER(NotifyGovernanceChanged); ADD_SIGNALS_IMPL_WRAPPER(NotifyMasternodeListChanged); ADD_SIGNALS_IMPL_WRAPPER(NotifyAdditionalDataSyncProgressChanged); ADD_SIGNALS_IMPL_WRAPPER(BannedListChanged); @@ -61,6 +63,7 @@ void CClientUIInterface::ShowProgress(const std::string& title, int nProgress, b void CClientUIInterface::NotifyBlockTip(SynchronizationState s, const CBlockIndex* i) { return g_ui_signals.NotifyBlockTip(s, i); } void CClientUIInterface::NotifyChainLock(const std::string& bestChainLockHash, int bestChainLockHeight) { return g_ui_signals.NotifyChainLock(bestChainLockHash, bestChainLockHeight); } void CClientUIInterface::NotifyHeaderTip(SynchronizationState s, const CBlockIndex* i) { return g_ui_signals.NotifyHeaderTip(s, i); } +void CClientUIInterface::NotifyGovernanceChanged() { return g_ui_signals.NotifyGovernanceChanged(); } void CClientUIInterface::NotifyMasternodeListChanged(const CDeterministicMNList& list, const CBlockIndex* i) { return g_ui_signals.NotifyMasternodeListChanged(list, i); } void CClientUIInterface::NotifyAdditionalDataSyncProgressChanged(double nSyncProgress) { return g_ui_signals.NotifyAdditionalDataSyncProgressChanged(nSyncProgress); } void CClientUIInterface::BannedListChanged() { return g_ui_signals.BannedListChanged(); } diff --git a/src/node/interface_ui.h b/src/node/interface_ui.h index a2e5ab62301d..7d7856eef9dc 100644 --- a/src/node/interface_ui.h +++ b/src/node/interface_ui.h @@ -115,6 +115,9 @@ class CClientUIInterface /** Masternode list has changed */ ADD_SIGNALS_DECL_WRAPPER(NotifyMasternodeListChanged, void, const CDeterministicMNList&, const CBlockIndex*); + /** Governance data changed */ + ADD_SIGNALS_DECL_WRAPPER(NotifyGovernanceChanged, void); + /** Additional data sync progress changed */ ADD_SIGNALS_DECL_WRAPPER(NotifyAdditionalDataSyncProgressChanged, void, double nSyncProgress); diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 018739f399da..aa001c8434be 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -858,6 +858,10 @@ class NodeImpl : public Node /* verification progress is unused when a header was received */ 0); })); } + std::unique_ptr handleNotifyGovernanceChanged(NotifyGovernanceChangedFn fn) override + { + return MakeHandler(::uiInterface.NotifyGovernanceChanged_connect(fn)); + } std::unique_ptr handleNotifyMasternodeListChanged(NotifyMasternodeListChangedFn fn) override { return MakeHandler( diff --git a/src/qt/clientmodel.cpp b/src/qt/clientmodel.cpp index 81978e0da16f..be899166bebc 100644 --- a/src/qt/clientmodel.cpp +++ b/src/qt/clientmodel.cpp @@ -321,6 +321,10 @@ void ClientModel::subscribeToCoreSignals() [this](const CDeterministicMNList& newList, const CBlockIndex* pindex) { setMasternodeList(interfaces::MakeMNList(newList), pindex); })); + m_event_handlers.emplace_back(m_node.handleNotifyGovernanceChanged( + [this]() { + Q_EMIT governanceChanged(); + })); } void ClientModel::unsubscribeFromCoreSignals() diff --git a/src/qt/clientmodel.h b/src/qt/clientmodel.h index fbf325fbd8b4..279d49195ae3 100644 --- a/src/qt/clientmodel.h +++ b/src/qt/clientmodel.h @@ -128,6 +128,7 @@ class ClientModel : public QObject Q_SIGNALS: void numConnectionsChanged(int count); + void governanceChanged(); void masternodeListChanged() const; void chainLockChanged(const QString& bestChainLockHash, int bestChainLockHeight); void numBlocksChanged(int count, const QDateTime& blockDate, const QString& blockHash, double nVerificationProgress, bool header, SynchronizationState sync_state); From c85e5cb13326cfa9fa99e20b816f00c7285a78b9 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:19:30 +0530 Subject: [PATCH 03/30] qt: move proposal list fetch to thread and use debounce timer We also reduce the refresh rate to when the chain is syncing to prevent contention. Co-authored-by: UdjinM6 --- src/qt/governancelist.cpp | 171 +++++++++++++++++++++++++------------- src/qt/governancelist.h | 44 ++++++---- 2 files changed, 139 insertions(+), 76 deletions(-) diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index ae560bc8ca32..827c7afa3ea7 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -26,18 +26,20 @@ #include #include +#include // // Governance Tab main widget. // - GovernanceList::GovernanceList(QWidget* parent) : QWidget(parent), - ui(std::make_unique()), - proposalModel(new ProposalModel(this)), - proposalModelProxy(new QSortFilterProxyModel(this)), - proposalContextMenu(new QMenu(this)), - timer(new QTimer(this)) + ui{new Ui::GovernanceList}, + proposalModel{new ProposalModel(this)}, + proposalContextMenu{new QMenu(this)}, + m_worker(new QObject), + proposalModelProxy{new QSortFilterProxyModel(this)}, + m_thread{new QThread(this)}, + m_timer{new QTimer(this)} { ui->setupUi(this); @@ -77,31 +79,49 @@ GovernanceList::GovernanceList(QWidget* parent) : connect(ui->btnCreateProposal, &QPushButton::clicked, this, &GovernanceList::showCreateProposalDialog); connect(ui->govTableView, &QTableView::doubleClicked, this, &GovernanceList::showAdditionalInfo); - connect(timer, &QTimer::timeout, this, &GovernanceList::updateProposalList); - // Initialize masternode count to 0 ui->mnCountLabel->setText("0"); GUIUtil::updateFonts(); + + // Background thread for calculating proposal list + m_worker->moveToThread(m_thread); + // Make sure executor object is deleted in its own thread + connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater); + m_thread->start(); + + // Debounce timer to apply proposal list changes + m_timer->setSingleShot(true); + connect(m_timer, &QTimer::timeout, this, &GovernanceList::updateProposalList); } -GovernanceList::~GovernanceList() = default; +GovernanceList::~GovernanceList() +{ + m_timer->stop(); + m_thread->quit(); + m_thread->wait(); + delete ui; +} void GovernanceList::setClientModel(ClientModel* model) { this->clientModel = model; - if (model != nullptr) { - connect(model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &GovernanceList::updateDisplayUnit); - - updateProposalList(); + if (clientModel) { + connect(clientModel, &ClientModel::governanceChanged, this, &GovernanceList::handleProposalListChanged); + connect(clientModel->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &GovernanceList::updateDisplayUnit); + m_timer->start(0); + } else { + m_timer->stop(); } } void GovernanceList::setWalletModel(WalletModel* model) { this->walletModel = model; - if (model && clientModel) { - updateVotingCapability(); + if (walletModel && clientModel) { + m_timer->start(0); + } else { + m_timer->stop(); } } @@ -113,35 +133,88 @@ void GovernanceList::updateDisplayUnit() } } +void GovernanceList::handleProposalListChanged() +{ + if (!clientModel || m_timer->isActive()) { + // Too early or already processing, nothing to do + return; + } + int delay{GOVERNANCELIST_UPDATE_SECONDS * 1000}; + if (!clientModel->masternodeSync().isBlockchainSynced()) { + // Currently syncing, reduce refreshes + delay *= 6; + } + m_timer->start(delay); +} + void GovernanceList::updateProposalList() { - if (this->clientModel) { - // A proposal is considered passing if (YES votes - NO votes) >= (Total Weight of Masternodes / 10), - // count total valid (ENABLED) masternodes to determine passing threshold. - // Need to query number of masternodes here with access to clientModel. - const int nWeightedMnCount = clientModel->getMasternodeList().first->getValidWeightedMNsCount(); - const int nAbsVoteReq = std::max(Params().GetConsensus().nGovernanceMinQuorum, nWeightedMnCount / 10); - proposalModel->setVotingParams(nAbsVoteReq); - - std::vector govObjList; - clientModel->getAllGovernanceObjects(govObjList); - ProposalList newProposals; - for (const auto& govObj : govObjList) { - if (govObj.GetObjectType() != GovernanceObject::PROPOSAL) { - continue; // Skip triggers. - } - newProposals.emplace_back(std::make_unique(this->clientModel, govObj)); - } - proposalModel->reconcile(std::move(newProposals)); + if (!clientModel || clientModel->node().shutdownRequested()) { + return; + } - // Update voting capability if we now have both client and wallet models - if (walletModel) { - updateVotingCapability(); + if (m_in_progress.exchange(true)) { + // Already applying, re-arm for next attempt + handleProposalListChanged(); + return; + } + + QMetaObject::invokeMethod(m_worker, [this] { + auto result = std::make_shared(calcProposalList()); + m_in_progress.store(false); + QTimer::singleShot(0, this, [this, result] { + setProposalList(std::move(*result)); + }); + }); +} + +GovernanceList::CalcProposalList GovernanceList::calcProposalList() const +{ + CalcProposalList ret; + if (!clientModel || clientModel->node().shutdownRequested()) { + return ret; + } + + const auto [dmn, pindex] = clientModel->getMasternodeList(); + if (!dmn || !pindex) { + return ret; + } + + // A proposal is considered passing if (YES votes - NO votes) >= (Total Weight of Masternodes / 10), + // count total valid (ENABLED) masternodes to determine passing threshold. + // Need to query number of masternodes here with access to clientModel. + const int nWeightedMnCount = dmn->getValidWeightedMNsCount(); + ret.m_abs_vote_req = std::max(Params().GetConsensus().nGovernanceMinQuorum, nWeightedMnCount / 10); + + std::vector govObjList; + clientModel->getAllGovernanceObjects(govObjList); + for (const auto& govObj : govObjList) { + if (govObj.GetObjectType() != GovernanceObject::PROPOSAL) { + continue; // Skip triggers. } + ret.m_proposals.emplace_back(std::make_unique(this->clientModel, govObj)); } - // Schedule next update. - timer->start(GOVERNANCELIST_UPDATE_SECONDS * 1000); + // Discover voting capability if we now have both client and wallet models + if (walletModel) { + dmn->forEachMN(/*only_valid=*/true, [&](const auto& dmn) { + // Check if wallet owns the voting key using the same logic as RPC + const auto script = GetScriptForDestination(PKHash(dmn.getKeyIdVoting())); + if (walletModel->wallet().isSpendable(script)) { + ret.m_votable_masternodes[dmn.getProTxHash()] = dmn.getKeyIdVoting(); + } + }); + } + + return ret; +} + +void GovernanceList::setProposalList(CalcProposalList&& data) +{ + proposalModel->setVotingParams(data.m_abs_vote_req); + proposalModel->reconcile(std::move(data.m_proposals)); + votableMasternodes = std::move(data.m_votable_masternodes); + updateMasternodeCount(); } void GovernanceList::updateProposalCount() const @@ -213,26 +286,6 @@ void GovernanceList::showAdditionalInfo(const QModelIndex& index) QMessageBox::information(this, windowTitle, json); } -void GovernanceList::updateVotingCapability() -{ - if (!walletModel || !clientModel) return; - - auto [mn_list, pindex] = clientModel->getMasternodeList(); - if (!pindex) return; - - votableMasternodes.clear(); - mn_list->forEachMN(/*only_valid=*/true, [&](const auto& dmn) { - // Check if wallet owns the voting key using the same logic as RPC - const CScript script = GetScriptForDestination(PKHash(dmn.getKeyIdVoting())); - if (walletModel->wallet().isSpendable(script)) { - votableMasternodes[dmn.getProTxHash()] = dmn.getKeyIdVoting(); - } - }); - - // Update masternode count display - updateMasternodeCount(); -} - void GovernanceList::updateMasternodeCount() const { if (ui && ui->mnCountLabel) { @@ -340,5 +393,5 @@ void GovernanceList::voteForProposal(vote_outcome_enum_t outcome) QMessageBox::information(this, tr("Voting Results"), message); // Update proposal list to show new vote counts - updateProposalList(); + handleProposalListChanged(); } diff --git a/src/qt/governancelist.h b/src/qt/governancelist.h index 40114453c1b8..83465ca75b9a 100644 --- a/src/qt/governancelist.h +++ b/src/qt/governancelist.h @@ -7,34 +7,38 @@ #include #include +#include + +#include #include #include #include +#include #include +#include #include -#include inline constexpr int GOVERNANCELIST_UPDATE_SECONDS = 10; -namespace Ui { -class GovernanceList; -} - class ClientModel; class ProposalModel; class WalletModel; class ProposalWizard; - class CDeterministicMNList; enum vote_outcome_enum_t : int; +namespace Ui { +class GovernanceList; +} // namespace Ui /** Governance Manager page widget */ class GovernanceList : public QWidget { Q_OBJECT + Ui::GovernanceList* ui; + public: explicit GovernanceList(QWidget* parent = nullptr); ~GovernanceList() override; @@ -42,21 +46,27 @@ class GovernanceList : public QWidget void setWalletModel(WalletModel* walletModel); private: + struct CalcProposalList { + int m_abs_vote_req{0}; + ProposalList m_proposals; + Uint256HashMap m_votable_masternodes; + }; + ClientModel* clientModel{nullptr}; + ProposalModel* proposalModel{nullptr}; + QMenu* proposalContextMenu{nullptr}; + QObject* m_worker{nullptr}; + QSortFilterProxyModel* proposalModelProxy{nullptr}; + QThread* m_thread{nullptr}; + QTimer* m_timer{nullptr}; + std::atomic m_in_progress{false}; + Uint256HashMap votableMasternodes; WalletModel* walletModel{nullptr}; - std::unique_ptr ui; - ProposalModel* proposalModel; - QSortFilterProxyModel* proposalModelProxy; - - QMenu* proposalContextMenu; - QTimer* timer; - - // Voting-related members - std::map votableMasternodes; // proTxHash -> voting keyID - - void updateVotingCapability(); bool canVote() const { return !votableMasternodes.empty(); } + CalcProposalList calcProposalList() const; + void handleProposalListChanged(); + void setProposalList(CalcProposalList&& data); void voteForProposal(vote_outcome_enum_t outcome); private Q_SLOTS: From a7328e9b5d929e83a6ba76a85cf32ec1bec92983 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:27:26 +0530 Subject: [PATCH 04/30] qt: add moon phase icons for governance cycle indicator --- contrib/devtools/gen_moon_icons.py | 126 +++++++++++++++++++++++++++++ src/Makefile.qt.include | 8 ++ src/qt/dash.qrc | 8 ++ src/qt/res/icons/moon_0.png | Bin 0 -> 705 bytes src/qt/res/icons/moon_1.png | Bin 0 -> 692 bytes src/qt/res/icons/moon_2.png | Bin 0 -> 622 bytes src/qt/res/icons/moon_3.png | Bin 0 -> 752 bytes src/qt/res/icons/moon_4.png | Bin 0 -> 677 bytes src/qt/res/icons/moon_5.png | Bin 0 -> 753 bytes src/qt/res/icons/moon_6.png | Bin 0 -> 624 bytes src/qt/res/icons/moon_7.png | Bin 0 -> 692 bytes 11 files changed, 142 insertions(+) create mode 100755 contrib/devtools/gen_moon_icons.py create mode 100644 src/qt/res/icons/moon_0.png create mode 100644 src/qt/res/icons/moon_1.png create mode 100644 src/qt/res/icons/moon_2.png create mode 100644 src/qt/res/icons/moon_3.png create mode 100644 src/qt/res/icons/moon_4.png create mode 100644 src/qt/res/icons/moon_5.png create mode 100644 src/qt/res/icons/moon_6.png create mode 100644 src/qt/res/icons/moon_7.png diff --git a/contrib/devtools/gen_moon_icons.py b/contrib/devtools/gen_moon_icons.py new file mode 100755 index 000000000000..cc3ef499a361 --- /dev/null +++ b/contrib/devtools/gen_moon_icons.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import math +import os +import struct +import zlib + +OUTPUT_PX = 64 +CENTER = OUTPUT_PX / 2 +RADIUS = 26 +STROKE_WIDTH = 2.0 +INNER_GAP_INSET = 3.0 + + +def make_png(pixels): + """Encode RGBA pixel data as a PNG file.""" + raw = bytearray() + for y in range(OUTPUT_PX): + raw.append(0) # filter byte: None + for x in range(OUTPUT_PX): + raw.extend(pixels[y][x]) + + def chunk(chunk_type, data): + c = chunk_type + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF) + + ihdr = struct.pack('>IIBBBBB', OUTPUT_PX, OUTPUT_PX, 8, 6, 0, 0, 0) # 8-bit RGBA + idat = zlib.compress(bytes(raw), 9) + + out = b'\x89PNG\r\n\x1a\n' + out += chunk(b'IHDR', ihdr) + out += chunk(b'IDAT', idat) + out += chunk(b'IEND', b'') + return out + + +def moon_frame(frame): + """ + frame 0 = new moon (filled disc + thin inner outline gap) + frame 1 = waxing crescent (small sliver on right) + frame 2 = first quarter (right half illuminated) + frame 3 = waxing gibbous (mostly illuminated) + frame 4 = full moon (outline only) + frame 5 = waning gibbous (mostly illuminated, from left) + frame 6 = last quarter (left half illuminated) + frame 7 = waning crescent (small sliver on left) + """ + if frame > 4: + # Waning: horizontal mirror of the corresponding waxing frame + pixels = moon_frame(8 - frame) + for y in range(OUTPUT_PX): + pixels[y] = pixels[y][::-1] + return pixels + + # Precompute frame-specific parameters + if frame == 0: + gap_outer = RADIUS - INNER_GAP_INSET + gap_inner = gap_outer - STROKE_WIDTH + else: + frame_angle = frame * math.pi / 4 + + pixels = [[(0, 0, 0, 0)] * OUTPUT_PX for _ in range(OUTPUT_PX)] + for y in range(OUTPUT_PX): + for x in range(OUTPUT_PX): + dx = x - CENTER + 0.5 + dy = y - CENTER + 0.5 + dist = math.sqrt(dx * dx + dy * dy) + if dist > RADIUS + 1: + continue + disc_alpha = min(1.0, RADIUS + 1 - dist) + if frame == 0: + # New moon: filled disc with a thin inner outline gap + if dist < gap_inner - 0.5: + fill = 1.0 + elif dist < gap_inner + 0.5: + fill = gap_inner + 0.5 - dist + elif dist < gap_outer - 0.5: + fill = 0.0 + elif dist < gap_outer + 0.5: + fill = dist - (gap_outer - 0.5) + else: + fill = 1.0 + else: + # Frames 1-4: terminator (shadow boundary) + outline + if abs(dy) >= RADIUS: + terminator_x = 0 + else: + half_chord = math.sqrt(RADIUS * RADIUS - dy * dy) + terminator_x = math.cos(frame_angle) * half_chord + edge_dist = terminator_x - dx + if edge_dist > 1: + dark_alpha = 1.0 + elif edge_dist < -1: + dark_alpha = 0.0 + else: + dark_alpha = (edge_dist + 1) / 2 + outline_alpha = 0.0 + inner_edge = RADIUS - STROKE_WIDTH + if dist > inner_edge: + outline_alpha = min(1.0, (dist - inner_edge) / STROKE_WIDTH) + fill = max(dark_alpha, outline_alpha) + alpha = int(max(0, min(255, disc_alpha * fill * 255))) + if alpha > 0: + pixels[y][x] = (0, 0, 0, alpha) + return pixels + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.join(script_dir, '..', '..') + icon_dir = os.path.join(repo_root, 'src', 'qt', 'res', 'icons') + for frame in range(8): + pixels = moon_frame(frame) + png_data = make_png(pixels) + filename = f'moon_{frame}.png' + filepath = os.path.join(icon_dir, filename) + with open(filepath, 'wb') as f: + f.write(png_data) + print(f' {filename} ({len(png_data)} bytes)') + + +if __name__ == '__main__': + main() diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 3dc81d225e5b..5a0dbd946702 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -211,6 +211,14 @@ QT_RES_ICONS = \ qt/res/icons/hd_enabled.png \ qt/res/icons/lock_closed.png \ qt/res/icons/lock_open.png \ + qt/res/icons/moon_0.png \ + qt/res/icons/moon_1.png \ + qt/res/icons/moon_2.png \ + qt/res/icons/moon_3.png \ + qt/res/icons/moon_4.png \ + qt/res/icons/moon_5.png \ + qt/res/icons/moon_6.png \ + qt/res/icons/moon_7.png \ qt/res/icons/proxy.png \ qt/res/icons/remove.png \ qt/res/icons/synced.png \ diff --git a/src/qt/dash.qrc b/src/qt/dash.qrc index e6cd77ad8549..c572e93185cf 100644 --- a/src/qt/dash.qrc +++ b/src/qt/dash.qrc @@ -27,6 +27,14 @@ res/icons/hd_enabled.png res/icons/fontbigger.png res/icons/fontsmaller.png + res/icons/moon_0.png + res/icons/moon_1.png + res/icons/moon_2.png + res/icons/moon_3.png + res/icons/moon_4.png + res/icons/moon_5.png + res/icons/moon_6.png + res/icons/moon_7.png res/icons/proxy.png diff --git a/src/qt/res/icons/moon_0.png b/src/qt/res/icons/moon_0.png new file mode 100644 index 0000000000000000000000000000000000000000..9abbcab9e74c2ef100255877c8b52193b860b0fe GIT binary patch literal 705 zcmV;y0zUnTP)Hn3Q=O4z&;5XJHk12Ywd9_ zok#avvzZ+pWhqm@78QKd-%A`jRptwE4ca&FGFyxdfo?$*7cB-qK@71l1jun0$DaQ4 zS3son**q}|Y?O5(T$`SRr@906`IBJHvsl&s4R-dvTXJi*(`(v&uPd0k#tMSA(cgf3fn n`N>S3LA?5CiiL%R#cBBl=FEbo6h^Th00000NkvXXu0mjfi>W>_ literal 0 HcmV?d00001 diff --git a/src/qt/res/icons/moon_1.png b/src/qt/res/icons/moon_1.png new file mode 100644 index 0000000000000000000000000000000000000000..39edf76874e8cf23f4f5303ae0e49b48e94b387a GIT binary patch literal 692 zcmV;l0!#ggP)vjc^y0}&2764iv;@+@DYyjJAFZ`BZ$M;;`pL3s{aybbJ2?>eJq6F&T zIj}&NSo~X^pIIuw9bkhoaR0*y{N-nDepkza?126hg5&rGdf+RV@PF;-Alw_q&J4oC z*qkCgjt+;vpti%wc5_tNSoR@jlEI_=^Cs1WBMbHADnNul?VLx1; zc%Rqc5pyblCW|nf_jIro5byDWJ3}r|gchfRt@CQ1gtuTlkYAB&!sR@6hniyIJ$`|` zP@ZM(4wJNf!a%_Sc)S5?pb z;VBo&j?S)c8s5_bL@kEBhq}YDMg=&HF+N2aqw%uNf1h)(6{E%CL2IIQpsrZ~ykoOx zF9hL(TITDjYQ8^Zu2P5)mOdgKP<8iqlaW9O&BI7$mV=qYs zi(u?Ex!`m#_M&9gyMvNfWy3zf*vryk+ic`@`KT_^$QLG}b_*k4nfX^ijCyHGLPA19 a>9_;T>7jnG`xzAg0000Vm|@^ literal 0 HcmV?d00001 diff --git a/src/qt/res/icons/moon_2.png b/src/qt/res/icons/moon_2.png new file mode 100644 index 0000000000000000000000000000000000000000..b29f769ebafc8d0650a4d50bfeb75887638d798b GIT binary patch literal 622 zcmV-!0+IcRP)@kUIZ_ywMb z7vhz8$F&ju%Tkaz@sJU4mH-ptlsF?UFdH_+9YlHHUULqCt7#nnBk;5UuNn906Nkh% zq{t4lBgQkV!V`Q6Gj4bV3w|?Y;(*^0PPsJ7<=iiE@3Euhu+~@wu?M47GQ)f@=(U{C5#2=VmW*i zb%<-`0(jU@xFD9sU0)Ld43P$f+~KVS0`y^wZZ_tB9Rhf;$M?K-z;BTN3oM7eE$**w z0cI$K%e>R3Edor@CI?NozD)@*!E-_pKvF=8pe%!8gv1IY>3E~m~4v5wzw^> zkj0A;1*cz)DLDUfw5k@F&Fk^1+##z25(>^AkZ2)vKp?OhKg+DZr*2~&%>V!Z07*qo IM6N<$f@%;E>;M1& literal 0 HcmV?d00001 diff --git a/src/qt/res/icons/moon_3.png b/src/qt/res/icons/moon_3.png new file mode 100644 index 0000000000000000000000000000000000000000..cfde845e091e33d58d0170937dc133376d3a5921 GIT binary patch literal 752 zcmVzrjy%h|>w#tJ7X)9{d{VK6d7IXJ+^Ig1}@lnM|pv07_s7 z)Io#P`CEy{{8t9oz$xf~YtRKhK^uH0ZT=?z{pGQzJU5dL*#=E8ASd8}Pr3@WKpAY3 zGJmV`m;;_8PU-Vn+gUJNx^WmW{1>QzBFIm1ojlK}aEP3+cZ9r$ciI50h~dH!c#d~_8#)#pR@I2)QA;I!BxL}(KX>|LzC;t!Z&uJ*;m_9Gq zwwVrxxG_JB_$zaaM>+O{Hrtq5v>z}nz49O%R*l~O_5c+9-mYls@NbQ zQoluJSRtz2Bo%dn+HG>t;h=V--i2Z;F-QNZ0XeLyh}zK;iP18 zs~RCTY_O?Q$FSegVO&v?t0GCp*tEO#bXYRvCJk}E7=#J7EpM6a56rtTXdXgrJKSP* z=&|f1cS?9=5@Uj`mFk2oYwyqlH9ExBZI$viM}V|E2tW&4^QvU?9tj!+{vg8UeHB$* zjt=(?Q*8fN6}hGo>v|Y~F1D}#qxen*N87^yvt{(m7Yea9SMO67q8OGx|x0f_NsP*(l9& zaFcBESc~WMcuhzSc~9>l!#LoR>~nZ;gX>@|$9-zNMy@@H2obOi?@$Lv?D!{e7u*1= z4(@#${1Oo%WLtG#I^X3`{|>(3lXHz5;JcUzA=_RWR+*^I7_$<`D+_GzCip%xRX#BqB&t<+5U_%U#l!1&scWDBC123rLI?C!uyw8^f5(%(6vt( zvCcO=Ghh;mzf%MlxwN-qnw%BS1Q65pEn^fovCRSP?Rv#O$OCzmAwb|_RyU@%_Z5%9 z(+m-=G>aT^R=44?0QZ@n(gYY@CCgs2q*Of?;2QWQLx6BzhuG#~#&!wTA@;c1lqNuX zUf0+6H2KBxlqM~7=?*6}KD81E@DM!65MVM-{GZeImH`jIA1?DigXOPP5&>k{?DGr( z8hPEHB*3kb2yhGhFhhV`uCT*tZ?hxs922$>bO$&gKlZ9yalR4<7M)Gvw(R4-J#kI z5(4#GWTvsFc9T?81hw1bqT!%+qh!1~sN5w(!7gB9pVr1UH-Wbx zLGMhmyCysH;5Cx0*_YkfH@g$h69@zXfi(<>KJksXB!=J;zxlYvsSXFk4KXF2h)3d{ z_(S{#vjS7x6SP&3S7JmwqqA$`3U7K&d?8MWlN$mQ8KKR_BOk^I-trp5dqx}*`&X`i zzEc3j1pm=EjEONi{z-f%j)>iV?kg*RVvPT61VRk;FX9K@_PwYofMWQ_j`t9#4w&Eh zobHrtqpkoMo}u@M=)OZF&jZ_2#ddTEkl|SdHanjOqb+f9Be>ZXKn2KwjbXn-=jV3> zIhz8gkzjN>Y%uH>8ai)602LFAMf(Hhd|y+WCIm3DI*hT@t!e~3n*wCIeL{?NeyUkm zlLEv#?d=gJr_&+k}SIGKcBnRuGu^j9VM^%c<_IUi#9kMNu$icouMma>bMN*d{uq~6joDOUYC5x*A z%Tn2*PheXtU9`B@hS%jm9tWnZTz{>dBk{0000< KMNUMnLSTY;*cCYd literal 0 HcmV?d00001 diff --git a/src/qt/res/icons/moon_7.png b/src/qt/res/icons/moon_7.png new file mode 100644 index 0000000000000000000000000000000000000000..393bb7184f868774ab33d2470d9342bf8c3dd5b9 GIT binary patch literal 692 zcmV;l0!#ggP)5wwtiUDQGe zL?K{vN05WV#!l8R@GXqXuA0s6X2y9R{F)gxdC!?Q-^|XYQX-K^BuY#fkOga?0E(o* zwk&_atRw}`#iWyFnE`GlB-3A#pn#IgEZTxz-b?XR|{k&iD6%7@GD@D z?ZaTVkHM=WA=c?o;jmvA$Lb<+jK=JE%fg^(w5Xv34X?)TF#^~Ij1hm7<|7Y4>Km7n#1|^{DI*{e^w-V65 z9OH3UTx7d-e>Ze`e%b(k3`;;)uCTyuZ!VyJ2mCWE0flb2z87F7p#Qf)9w5{f+4=-n zKLxBWg4QpC)*B(~t*~r{WIHSeB61`qhoW*UE)PcJ(U?3OmB-_9LE@dPzary0L~Ace zsY$T*nw&Zvti34dT^+2vD(m$L)?SwO+GZ=S%lqvjt$bm^Z?&-Um6?wcV%1AiB9Ta> awcY^ENuhpbj4jvz0000 Date: Fri, 13 Feb 2026 20:26:01 +0530 Subject: [PATCH 05/30] qt: add governance cycle status bar icon --- src/interfaces/node.h | 2 + src/node/interfaces.cpp | 36 ++++++++++ src/qt/bitcoingui.cpp | 142 +++++++++++++++++++++++++++++++++++++++- src/qt/bitcoingui.h | 23 +++++++ src/qt/guiconstants.h | 3 + 5 files changed, 205 insertions(+), 1 deletion(-) diff --git a/src/interfaces/node.h b/src/interfaces/node.h index f73f4ce6df12..99dc38522bf7 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -151,6 +151,7 @@ class GOV int requiredConfs{6}; }; virtual GovernanceInfo getGovernanceInfo() = 0; + virtual CAmount getAllocatedBudget() = 0; virtual std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) = 0; virtual bool submitProposal(const uint256& parent, int32_t revision, int64_t created_time, const std::string& data_hex, @@ -175,6 +176,7 @@ class Sync public: virtual ~Sync() {} virtual bool isBlockchainSynced() = 0; + virtual bool isGovernanceSynced() = 0; virtual bool isSynced() = 0; virtual std::string getSyncStatus() = 0; virtual void setContext(node::NodeContext* context) {} diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index aa001c8434be..c43b5dd018d7 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -293,6 +293,35 @@ class GOVImpl : public GOV } return info; } + CAmount getAllocatedBudget() override + { + if (context().govman != nullptr && context().chainman != nullptr && context().dmnman != nullptr) { + const auto tip_mn_list{context().dmnman->GetListAtChainTip()}; + if (const auto proposals{context().govman->GetApprovedProposals(tip_mn_list)}; !proposals.empty()) { + int32_t last_sb{0}, next_sb{0}; + CSuperblock::GetNearestSuperblocksHeights(context().chainman->ActiveHeight(), last_sb, next_sb); + const CAmount budget{CSuperblock::GetPaymentsLimit(context().chainman->ActiveChain(), next_sb)}; + + CAmount allocated{0}; + for (const auto& proposal : proposals) { + UniValue json = proposal->GetJSONObject(); + CAmount payment_amount{0}; + try { + payment_amount = ParsePaymentAmount(json["payment_amount"].getValStr()); + } catch (...) { + continue; + } + if (allocated + payment_amount > budget) { + // Budget is saturated, cannot fulfill proposal + continue; + } + allocated += payment_amount; + } + return allocated; + } + } + return 0; + } std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) override { @@ -402,6 +431,13 @@ class MasternodeSyncImpl : public Masternode::Sync } return false; } + bool isGovernanceSynced() override + { + if (context().mn_sync != nullptr) { + return context().mn_sync->GetAssetID() > MASTERNODE_SYNC_GOVERNANCE; + } + return false; + } std::string getSyncStatus() override { if (context().mn_sync != nullptr) { diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index dc5055a089c5..55b8cb315dd4 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include @@ -52,12 +53,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -69,6 +72,19 @@ #include #include +namespace { +// Total governance clock frames. Frame 0 is reserved for the superblock +// maturity window; frames 1 through GOV_CYCLE_FRAME_COUNT-1 are used for the +// voting cycle and sync animation. +constexpr int GOV_CYCLE_FRAME_COUNT{8}; + +// Per-frame interval for the governance sync animation. Divided by 2 because +// the moon phases represent a cycle (wax + wane), not a single rotation. +constexpr int GOV_CYCLE_FRAME_MS{STATUSBAR_ICON_CYCLE_MS / (GOV_CYCLE_FRAME_COUNT - 1) / 2}; + +// Per-frame interval for the spinner animation +constexpr int SPINNER_FRAME_MS{STATUSBAR_ICON_CYCLE_MS / SPINNER_FRAMES}; +} // anonymous namespace const std::string BitcoinGUI::DEFAULT_UIPLATFORM = #if defined(Q_OS_MACOS) @@ -164,6 +180,7 @@ BitcoinGUI::BitcoinGUI(interfaces::Node& node, const NetworkStyle* networkStyle, labelWalletEncryptionIcon = new QLabel(); labelWalletHDStatusIcon = new QLabel(); labelConnectionsIcon = new GUIUtil::ClickableLabel(); + labelGovernanceCycleIcon = new GUIUtil::ClickableLabel(); labelProxyIcon = new GUIUtil::ClickableLabel(); labelBlocksIcon = new GUIUtil::ClickableLabel(); if(enableWallet) @@ -180,9 +197,16 @@ BitcoinGUI::BitcoinGUI(interfaces::Node& node, const NetworkStyle* networkStyle, frameBlocksLayout->addStretch(); frameBlocksLayout->addWidget(labelConnectionsIcon); frameBlocksLayout->addStretch(); + frameBlocksLayout->addWidget(labelGovernanceCycleIcon); + frameBlocksLayout->addStretch(); frameBlocksLayout->addWidget(labelBlocksIcon); frameBlocksLayout->addStretch(); + // Hidden until governance sync completes + // Pre-cache pixmaps for all color/frame combos + labelGovernanceCycleIcon->hide(); + refreshGovernanceCycleIcons(); + // Hide the spinner/synced icon by default to avoid // that the spinner starts before we have any connections labelBlocksIcon->hide(); @@ -224,6 +248,9 @@ BitcoinGUI::BitcoinGUI(interfaces::Node& node, const NetworkStyle* networkStyle, modalOverlay = new ModalOverlay(enableWallet, this->centralWidget()); connect(labelBlocksIcon, &GUIUtil::ClickableLabel::clicked, this, &BitcoinGUI::showModalOverlay); connect(progressBar, &GUIUtil::ClickableProgressBar::clicked, this, &BitcoinGUI::showModalOverlay); +#ifdef ENABLE_WALLET + connect(labelGovernanceCycleIcon, &GUIUtil::ClickableLabel::clicked, this, &BitcoinGUI::gotoGovernancePage); +#endif #ifdef Q_OS_MACOS m_app_nap_inhibitor = new CAppNapInhibitor; @@ -294,7 +321,7 @@ void BitcoinGUI::startSpinner() } labelBlocksIcon->setPixmap(getNextFrame()); }); - timerSpinner->start(40); + timerSpinner->start(SPINNER_FRAME_MS); } void BitcoinGUI::stopSpinner() @@ -866,6 +893,7 @@ void BitcoinGUI::setClientModel(ClientModel *_clientModel, interfaces::BlockAndH rpcConsole->setClientModel(_clientModel, tip_info->block_height, tip_info->block_time, tip_info->block_hash, tip_info->verification_progress); updateProxyIcon(); + updateGovernanceCycleIcon(); #ifdef ENABLE_WALLET if(walletFrame) @@ -879,6 +907,7 @@ void BitcoinGUI::setClientModel(ClientModel *_clientModel, interfaces::BlockAndH unitDisplayControl->setOptionsModel(optionsModel); m_mask_values_action->setChecked(optionsModel->getOption(OptionsModel::OptionID::MaskValues).toBool()); + connect(optionsModel, &OptionsModel::displayUnitChanged, this, &BitcoinGUI::updateGovernanceCycleIcon); connect(optionsModel, &OptionsModel::showCoinJoinChanged, this, &BitcoinGUI::updateCoinJoinVisibility); connect(optionsModel, &OptionsModel::showGovernanceChanged, this, &BitcoinGUI::updateGovernanceVisibility); connect(optionsModel, &OptionsModel::showMasternodesChanged, this, &BitcoinGUI::updateMasternodesVisibility); @@ -1383,6 +1412,7 @@ void BitcoinGUI::updateNetworkState() void BitcoinGUI::setNumConnections(int count) { updateNetworkState(); + updateGovernanceCycleIcon(); } void BitcoinGUI::setNetworkActive(bool network_active) @@ -1485,6 +1515,7 @@ void BitcoinGUI::updateGovernanceVisibility() governanceButton->setVisible(fShow); } + updateGovernanceCycleIcon(); GUIUtil::updateButtonGroupShortcuts(tabGroup); updateWidth(); } @@ -1567,6 +1598,7 @@ void BitcoinGUI::setNumBlocks(int count, const QDateTime& blockDate, const QStri return; updateProgressBarVisibility(); + updateGovernanceCycleIcon(); // Prevent orphan statusbar messages (e.g. hover Quit in main menu, wait until chain-sync starts -> garbled text) statusBar()->clearMessage(); @@ -1684,6 +1716,7 @@ void BitcoinGUI::setAdditionalDataSyncProgress(double nSyncProgress) #endif // ENABLE_WALLET updateProgressBarVisibility(); + updateGovernanceCycleIcon(); if(m_node.masternodeSync().isSynced()) { stopSpinner(); @@ -1706,6 +1739,111 @@ void BitcoinGUI::setAdditionalDataSyncProgress(double nSyncProgress) progressBar->setToolTip(tooltip); } +void BitcoinGUI::startGovernanceSyncAnimation() +{ + if (labelGovernanceCycleIcon == nullptr || m_timer_governance_sync != nullptr) { + return; + } + + auto elapsed = std::make_shared(); + elapsed->start(); + m_timer_governance_sync = new QTimer(this); + QObject::connect(m_timer_governance_sync, &QTimer::timeout, [this, elapsed]() { + if (m_timer_governance_sync == nullptr) { + return; + } + // Derive frame from wall-clock time so queued timer events don't cause speed-up + const auto frame{static_cast((elapsed->elapsed() / GOV_CYCLE_FRAME_MS) % (GOV_CYCLE_FRAME_COUNT - 1) + 1)}; + labelGovernanceCycleIcon->setPixmap(m_gov_cycle_pixmaps.at({ToUnderlying(GUIUtil::ThemedColor::ORANGE), frame})); + }); + m_timer_governance_sync->start(GOV_CYCLE_FRAME_MS); +} + +void BitcoinGUI::stopGovernanceSyncAnimation() +{ + if (m_timer_governance_sync == nullptr) { + return; + } + m_timer_governance_sync->deleteLater(); + m_timer_governance_sync = nullptr; +} + +void BitcoinGUI::refreshGovernanceCycleIcons() +{ + stopGovernanceSyncAnimation(); + m_gov_cycle_pixmaps.clear(); + for (auto color : {GUIUtil::ThemedColor::ORANGE, GUIUtil::ThemedColor::GREEN, GUIUtil::ThemedColor::BLUE}) { + for (int i = 0; i < GOV_CYCLE_FRAME_COUNT; ++i) { + m_gov_cycle_pixmaps[{ToUnderlying(color), i}] = + GUIUtil::getIcon(QString("moon_%1").arg(i), color).pixmap(STATUSBAR_ICONSIZE, STATUSBAR_ICONSIZE); + } + } +} + +void BitcoinGUI::updateGovernanceCycleIcon() +{ + if (!labelGovernanceCycleIcon || !clientModel || !clientModel->getOptionsModel()) { + return; + } + + const auto& options_model{*clientModel->getOptionsModel()}; + if (!m_node.gov().isEnabled() || !options_model.getShowGovernanceTab() || clientModel->getNumConnections() == 0) { + stopGovernanceSyncAnimation(); + labelGovernanceCycleIcon->hide(); + m_last_gov_cycle_height.reset(); + return; + } + + if (!m_node.masternodeSync().isGovernanceSynced()) { + if (!m_node.masternodeSync().isBlockchainSynced()) { + labelGovernanceCycleIcon->setToolTip(tr("Waiting for blockchain sync…")); + } else { + labelGovernanceCycleIcon->setToolTip(tr("Synchronizing governance data…")); + } + labelGovernanceCycleIcon->show(); + startGovernanceSyncAnimation(); + m_last_gov_cycle_height.reset(); + return; + } + + const int current_height{m_node.getNumBlocks()}; + if (current_height == m_last_gov_cycle_height) { + // Governance clock only changes with new blocks; skip redundant updates + // triggered by connection changes, sync progress, or display unit signals. + return; + } else { + m_last_gov_cycle_height = current_height; + } + stopGovernanceSyncAnimation(); + + const auto gov_info{m_node.gov().getGovernanceInfo()}; + const auto remaining_blocks{std::max(0, gov_info.nextsuperblock - current_height)}; + const auto days{static_cast(static_cast(remaining_blocks) * gov_info.targetSpacing / (24*60*60))}; + const bool awaiting_superblock{current_height % gov_info.superblockcycle >= gov_info.superblockcycle - gov_info.superblockmaturitywindow}; + + QString tooltip1{}; + if (awaiting_superblock) { + labelGovernanceCycleIcon->setPixmap(m_gov_cycle_pixmaps.at({ToUnderlying(GUIUtil::ThemedColor::BLUE), 0})); + tooltip1 = tr("~%n day(s) (%1 blocks) left for superblock", "", days).arg(remaining_blocks); + } else { + const auto cycle_blocks{gov_info.superblockcycle - gov_info.superblockmaturitywindow}; + const auto blocks_elapsed{gov_info.superblockcycle - remaining_blocks - gov_info.superblockmaturitywindow}; + const auto progress{static_cast(std::max(0, blocks_elapsed)) / static_cast(std::max(1, cycle_blocks))}; + const auto frame{std::clamp(static_cast(progress * (GOV_CYCLE_FRAME_COUNT - 1)), 0, GOV_CYCLE_FRAME_COUNT - 2) + 1}; + labelGovernanceCycleIcon->setPixmap(m_gov_cycle_pixmaps.at({ToUnderlying(GUIUtil::ThemedColor::GREEN), frame})); + tooltip1 = tr("~%n day(s) (%1 blocks) left for voting", "", days).arg(remaining_blocks); + } + + const auto allocated_budget{m_node.gov().getAllocatedBudget()}; + const auto budget_pct{gov_info.governancebudget > 0 + ? static_cast(static_cast(allocated_budget) / static_cast(gov_info.governancebudget) * 100.0) + : 0}; + const auto unit{options_model.getDisplayUnit()}; + const auto tooltip2{tr("~%1% of budget committed (%2 %3).").arg(budget_pct).arg(allocated_budget / BitcoinUnits::factor(unit)).arg(BitcoinUnits::name(unit))}; + labelGovernanceCycleIcon->setToolTip(QString("%1
%2
").arg(tooltip1).arg(tooltip2)); + labelGovernanceCycleIcon->show(); +} + void BitcoinGUI::message(const QString& title, QString message, unsigned int style, bool* ret, const QString& detailed_message) { // Default title. On macOS, the window title is ignored (as required by the macOS Guidelines). @@ -1798,6 +1936,8 @@ void BitcoinGUI::changeEvent(QEvent *e) if (m_node.masternodeSync().isSynced()) { labelBlocksIcon->setPixmap(GUIUtil::getIcon("synced", GUIUtil::ThemedColor::GREEN).pixmap(STATUSBAR_ICONSIZE, STATUSBAR_ICONSIZE)); } + refreshGovernanceCycleIcons(); + updateGovernanceCycleIcon(); } } diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 7c8af4f63386..db820e46b1f3 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -18,10 +18,13 @@ #include #include #include +#include #include #include +#include #include +#include #ifdef Q_OS_MACOS #include @@ -126,6 +129,7 @@ class BitcoinGUI : public QMainWindow QLabel* labelWalletEncryptionIcon = nullptr; QLabel* labelWalletHDStatusIcon = nullptr; GUIUtil::ClickableLabel* labelConnectionsIcon = nullptr; + GUIUtil::ClickableLabel* labelGovernanceCycleIcon = nullptr; GUIUtil::ClickableLabel* labelProxyIcon = nullptr; GUIUtil::ClickableLabel* labelBlocksIcon = nullptr; QLabel* progressBarLabel = nullptr; @@ -230,6 +234,13 @@ class BitcoinGUI : public QMainWindow QTimer* timerCustomCss = nullptr; const NetworkStyle* const m_network_style; + /** Last block height of icon update. Used to skip redundant updates from non-block signals. */ + std::optional m_last_gov_cycle_height; + /** Timer to animate the governance clock while syncing. */ + QTimer* m_timer_governance_sync{nullptr}; + /** Pre-cached governance clock pixmaps keyed by {ThemedColor, frame}. */ + std::map, QPixmap> m_gov_cycle_pixmaps; + /** Create the main UI actions. */ void createActions(); /** Create the menu bar and sub-menus. */ @@ -250,6 +261,18 @@ class BitcoinGUI : public QMainWindow /** Update UI with latest network info from model. */ void updateNetworkState(); + /** Regenerate all pre-cached governance clock pixmaps (e.g. after a theme change). */ + void refreshGovernanceCycleIcons(); + + /** Recompute the governance clock state, pixmap and tooltip. */ + void updateGovernanceCycleIcon(); + + /** Start the moon-phase animation while governance data is syncing. */ + void startGovernanceSyncAnimation(); + + /** Stop the governance sync animation. */ + void stopGovernanceSyncAnimation(); + void updateHeadersSyncProgressLabel(); void updateProgressBarVisibility(); diff --git a/src/qt/guiconstants.h b/src/qt/guiconstants.h index 4dd734b4d4d4..c612b45593d8 100644 --- a/src/qt/guiconstants.h +++ b/src/qt/guiconstants.h @@ -41,6 +41,9 @@ static const int TOOLTIP_WRAP_THRESHOLD = 80; /* Number of frames in spinner animation */ #define SPINNER_FRAMES 90 +/* Duration of one full status-bar icon animation cycle in milliseconds. */ +static constexpr int STATUSBAR_ICON_CYCLE_MS{3600}; + #define QAPP_ORG_NAME "Dash" #define QAPP_ORG_DOMAIN "dash.org" #define QAPP_APP_NAME_DEFAULT "Dash-Qt" From 8257eac054a0527e7d07ae71280cdfa44c0500f2 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:28:15 +0530 Subject: [PATCH 06/30] qt: decouple chain sync indicator from governance sync --- src/qt/bitcoingui.cpp | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 55b8cb315dd4..c52c0a5afa7b 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -1468,11 +1468,11 @@ void BitcoinGUI::updateProgressBarVisibility() return; } // Show the progress bar label if the network is active + we are out of sync or we have no connections. - bool fShowProgressBarLabel = m_node.getNetworkActive() && (!m_node.masternodeSync().isSynced() || clientModel->getNumConnections() == 0); + bool fShowProgressBarLabel = m_node.getNetworkActive() && (!m_node.masternodeSync().isBlockchainSynced() || clientModel->getNumConnections() == 0); // Show the progress bar only if the the network active + we are not synced + we have any connection. Unlike with the label // which gives an info text about the connecting phase there is no reason to show the progress bar if we don't have connections // since it will not get any updates in this case. - bool fShowProgressBar = m_node.getNetworkActive() && !m_node.masternodeSync().isSynced() && clientModel->getNumConnections() > 0; + bool fShowProgressBar = m_node.getNetworkActive() && !m_node.masternodeSync().isBlockchainSynced() && clientModel->getNumConnections() > 0; progressBarLabel->setVisible(fShowProgressBarLabel); progressBar->setVisible(fShowProgressBar); } @@ -1670,7 +1670,7 @@ void BitcoinGUI::setNumBlocks(int count, const QDateTime& blockDate, const QStri tooltip += tr("Last received block was generated %1 ago.").arg(timeBehindText); tooltip += QString("
"); tooltip += tr("Transactions after this will not yet be visible."); - } else if (!m_node.gov().isEnabled()) { + } else { setAdditionalDataSyncProgress(1); } @@ -1704,11 +1704,7 @@ void BitcoinGUI::setAdditionalDataSyncProgress(double nSyncProgress) // Prevent orphan statusbar messages (e.g. hover Quit in main menu, wait until chain-sync starts -> garbelled text) statusBar()->clearMessage(); - QString tooltip; - - // Set icon state: spinning if catching up, tick otherwise - QString strSyncStatus; - tooltip = tr("Up to date") + QString(".
") + tooltip; + QString tooltip{tr("Up to date")}; #ifdef ENABLE_WALLET if(walletFrame) @@ -1718,18 +1714,8 @@ void BitcoinGUI::setAdditionalDataSyncProgress(double nSyncProgress) updateProgressBarVisibility(); updateGovernanceCycleIcon(); - if(m_node.masternodeSync().isSynced()) { - stopSpinner(); - labelBlocksIcon->setPixmap(GUIUtil::getIcon("synced", GUIUtil::ThemedColor::GREEN).pixmap(STATUSBAR_ICONSIZE, STATUSBAR_ICONSIZE)); - } else { - progressBar->setFormat(tr("Synchronizing additional data: %p%")); - progressBar->setMaximum(1000000000); - progressBar->setValue(nSyncProgress * 1000000000.0 + 0.5); - } - - strSyncStatus = QString(m_node.masternodeSync().getSyncStatus().c_str()); - progressBarLabel->setText(strSyncStatus); - tooltip = strSyncStatus + QString("
") + tooltip; + stopSpinner(); + labelBlocksIcon->setPixmap(GUIUtil::getIcon("synced", GUIUtil::ThemedColor::GREEN).pixmap(STATUSBAR_ICONSIZE, STATUSBAR_ICONSIZE)); // Don't word-wrap this (fixed-width) tooltip tooltip = QString("") + tooltip + QString(""); From 9bb7b0a5cb88e1def5a86a7267e763fae8228d34 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:55:14 +0530 Subject: [PATCH 07/30] qt: reorder columns in "Governance" tab, make width elastic for key cols --- src/qt/governancelist.cpp | 87 +++++++++++++++++++++++++++++++-------- src/qt/governancelist.h | 8 +++- src/qt/proposalmodel.cpp | 20 --------- src/qt/proposalmodel.h | 7 ++-- 4 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index 827c7afa3ea7..0e34c18e0200 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -27,6 +27,12 @@ #include #include #include +#include +#include + +namespace { +constexpr int TITLE_MIN_WIDTH{220}; +} // anonymous namespace // // Governance Tab main widget. @@ -47,22 +53,19 @@ GovernanceList::GovernanceList(QWidget* parent) : {GUIUtil::FontWeight::Bold, 14}); GUIUtil::setFont({ui->label_filter_2}, {GUIUtil::FontWeight::Normal, 15}); - proposalModelProxy->setSourceModel(proposalModel); + ui->govTableView->setContextMenuPolicy(Qt::CustomContextMenu); ui->govTableView->setModel(proposalModelProxy); ui->govTableView->setSelectionBehavior(QAbstractItemView::SelectRows); - ui->govTableView->horizontalHeader()->setStretchLastSection(true); - ui->govTableView->verticalHeader()->setVisible(false); - - for (int i = 0; i < proposalModel->columnCount(); ++i) { - ui->govTableView->setColumnWidth(i, proposalModel->columnWidth(i)); - } - - // Set up sorting. - proposalModelProxy->setSortRole(Qt::EditRole); ui->govTableView->setSortingEnabled(true); - ui->govTableView->sortByColumn(ProposalModel::Column::START_DATE, Qt::DescendingOrder); + ui->govTableView->sortByColumn(ProposalModel::Column::TITLE, Qt::AscendingOrder); + ui->govTableView->verticalHeader()->setVisible(false); + connect(ui->govTableView, &QTableView::customContextMenuRequested, this, &GovernanceList::showProposalContextMenu); + connect(ui->govTableView, &QTableView::doubleClicked, this, &GovernanceList::showAdditionalInfo); + connect(ui->govTableView->horizontalHeader(), &QHeaderView::sectionResized, this, &GovernanceList::refreshColumnWidths); // Set up filtering. + proposalModelProxy->setSourceModel(proposalModel); + proposalModelProxy->setSortRole(Qt::EditRole); proposalModelProxy->setFilterKeyColumn(ProposalModel::Column::TITLE); // filter by title column... connect(ui->filterLineEdit, &QLineEdit::textChanged, proposalModelProxy, &QSortFilterProxyModel::setFilterFixedString); @@ -71,13 +74,8 @@ GovernanceList::GovernanceList(QWidget* parent) : connect(proposalModelProxy, &QSortFilterProxyModel::rowsRemoved, this, &GovernanceList::updateProposalCount); connect(proposalModelProxy, &QSortFilterProxyModel::layoutChanged, this, &GovernanceList::updateProposalCount); - // Enable CustomContextMenu on the table to make the view emit customContextMenuRequested signal. - ui->govTableView->setContextMenuPolicy(Qt::CustomContextMenu); - connect(ui->govTableView, &QTableView::customContextMenuRequested, this, &GovernanceList::showProposalContextMenu); - // Create Proposal button connect(ui->btnCreateProposal, &QPushButton::clicked, this, &GovernanceList::showCreateProposalDialog); - connect(ui->govTableView, &QTableView::doubleClicked, this, &GovernanceList::showAdditionalInfo); // Initialize masternode count to 0 ui->mnCountLabel->setText("0"); @@ -217,9 +215,10 @@ void GovernanceList::setProposalList(CalcProposalList&& data) updateMasternodeCount(); } -void GovernanceList::updateProposalCount() const +void GovernanceList::updateProposalCount() { ui->countLabel->setText(QString::number(proposalModelProxy->rowCount())); + refreshColumnWidths(); } void GovernanceList::showCreateProposalDialog() @@ -395,3 +394,57 @@ void GovernanceList::voteForProposal(vote_outcome_enum_t outcome) // Update proposal list to show new vote counts handleProposalListChanged(); } + +void GovernanceList::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + refreshColumnWidths(); +} + +void GovernanceList::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + refreshColumnWidths(); +} + +void GovernanceList::refreshColumnWidths() +{ + // Bail out if resize in progress or viewport is too small + const int tableWidth = ui->govTableView->viewport()->width(); + if (m_col_refresh || tableWidth <= 0) { + return; + } else { + m_col_refresh = true; + } + + auto* header = ui->govTableView->horizontalHeader(); + header->setMinimumSectionSize(0); + header->setSectionResizeMode(ProposalModel::Column::PAYMENT_AMOUNT, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ProposalModel::Column::START_DATE, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ProposalModel::Column::END_DATE, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ProposalModel::Column::IS_ACTIVE, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ProposalModel::Column::VOTING_STATUS, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ProposalModel::Column::HASH, QHeaderView::ResizeToContents); + + // Calculate width used by ResizeToContents columns + const int availableWidth = [&header, &tableWidth](){ + int fixedWidth = 0; + for (int idx = 0; idx < ProposalModel::Column::_COUNT; idx++) { + if (idx != ProposalModel::Column::TITLE && idx != ProposalModel::Column::HASH) { + fixedWidth += header->sectionSize(idx); + } + } + return std::max(0, tableWidth - fixedWidth); + }(); + + // Hash gets what's left after Title takes its minimum, clamped to [0, hashContentWidth] + const int hashContentWidth = header->sectionSize(ProposalModel::Column::HASH); + const int hashWidth = std::clamp(availableWidth - TITLE_MIN_WIDTH, 0, hashContentWidth); + const int titleWidth = availableWidth - hashWidth; + header->setSectionResizeMode(ProposalModel::Column::TITLE, QHeaderView::Interactive); + header->setSectionResizeMode(ProposalModel::Column::HASH, QHeaderView::Interactive); + header->resizeSection(ProposalModel::Column::TITLE, titleWidth); + header->resizeSection(ProposalModel::Column::HASH, hashWidth); + + m_col_refresh = false; +} diff --git a/src/qt/governancelist.h b/src/qt/governancelist.h index 83465ca75b9a..bb03f2bff35f 100644 --- a/src/qt/governancelist.h +++ b/src/qt/governancelist.h @@ -59,6 +59,7 @@ class GovernanceList : public QWidget QSortFilterProxyModel* proposalModelProxy{nullptr}; QThread* m_thread{nullptr}; QTimer* m_timer{nullptr}; + std::atomic m_col_refresh{false}; std::atomic m_in_progress{false}; Uint256HashMap votableMasternodes; WalletModel* walletModel{nullptr}; @@ -66,13 +67,18 @@ class GovernanceList : public QWidget bool canVote() const { return !votableMasternodes.empty(); } CalcProposalList calcProposalList() const; void handleProposalListChanged(); + void refreshColumnWidths(); void setProposalList(CalcProposalList&& data); void voteForProposal(vote_outcome_enum_t outcome); +protected: + void showEvent(QShowEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + private Q_SLOTS: void updateDisplayUnit(); void updateProposalList(); - void updateProposalCount() const; + void updateProposalCount(); void updateMasternodeCount() const; void showProposalContextMenu(const QPoint& pos); void showAdditionalInfo(const QModelIndex& index); diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index ea0ccbff362a..a5abfce03291 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -187,26 +187,6 @@ QVariant ProposalModel::headerData(int section, Qt::Orientation orientation, int } } -int ProposalModel::columnWidth(int section) -{ - switch (section) { - case Column::HASH: - return 80; - case Column::TITLE: - return 220; - case Column::START_DATE: - case Column::END_DATE: - case Column::PAYMENT_AMOUNT: - return 110; - case Column::IS_ACTIVE: - return 80; - case Column::VOTING_STATUS: - return 220; - default: - return 80; - } -} - void ProposalModel::append(std::unique_ptr&& proposal) { beginInsertRows({}, rowCount(), rowCount()); diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index e2918298d9bf..3dda518631f0 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -63,13 +63,13 @@ class ProposalModel : public QAbstractTableModel QAbstractTableModel(parent){}; enum Column : int { - HASH = 0, - TITLE, + TITLE = 0, + PAYMENT_AMOUNT, START_DATE, END_DATE, - PAYMENT_AMOUNT, IS_ACTIVE, VOTING_STATUS, + HASH, _COUNT // for internal use only }; @@ -78,7 +78,6 @@ class ProposalModel : public QAbstractTableModel QVariant data(const QModelIndex& index, int role) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; - static int columnWidth(int section); void append(std::unique_ptr&& proposal); void remove(int row); void reconcile(ProposalList&& proposals); From b2b9aa19b0d171113fda8dc594cca93c7c3c35f7 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:29:16 +0530 Subject: [PATCH 08/30] qt: change layout of "Governance" tab controls for readability Review with `git log -p -n1 --ignore-space-change --color-moved=dimmed_zebra`. --- src/qt/forms/governancelist.ui | 168 ++++++++++++--------------------- src/qt/governancelist.cpp | 1 - 2 files changed, 60 insertions(+), 109 deletions(-) diff --git a/src/qt/forms/governancelist.ui b/src/qt/forms/governancelist.ui index 58fad21b9106..2094d9815899 100644 --- a/src/qt/forms/governancelist.ui +++ b/src/qt/forms/governancelist.ui @@ -32,91 +32,70 @@ 0 - - - Qt::Horizontal + + + 0 - - - 40 - 20 - - - + + + + Filter proposal list + + + Filter by Title + + + + + + + Masternode Count: + + + Number of masternodes this wallet can vote with (masternodes for which this wallet holds the voting key) + + + + + + + 0 + + + + - - - - - 0 + + + + + + + + + Create Proposal + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 3 - - - - Filter List: - - - - - - - Filter proposal list - - - Filter by Title - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - Masternode Count: - - - Number of masternodes this wallet can vote with (masternodes for which this wallet holds the voting key) - - - - - - - 0 - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - @@ -131,35 +110,8 @@ - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - Create Proposal - - - - - - - diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index 0e34c18e0200..447fc79b3be7 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -51,7 +51,6 @@ GovernanceList::GovernanceList(QWidget* parent) : GUIUtil::setFont({ui->label_count_2, ui->countLabel, ui->label_mn_count, ui->mnCountLabel}, {GUIUtil::FontWeight::Bold, 14}); - GUIUtil::setFont({ui->label_filter_2}, {GUIUtil::FontWeight::Normal, 15}); ui->govTableView->setContextMenuPolicy(Qt::CustomContextMenu); ui->govTableView->setModel(proposalModelProxy); From c0d8454f10422363e66ee61e0678f7c59c358a30 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:47:37 +0530 Subject: [PATCH 09/30] qt: report compact voting status and expanded details in tooltip --- src/interfaces/node.h | 7 +++++- src/node/interfaces.cpp | 10 +++++--- src/qt/proposalmodel.cpp | 53 +++++++++++++++++++++------------------- src/qt/proposalmodel.h | 8 ++++-- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/interfaces/node.h b/src/interfaces/node.h index 99dc38522bf7..7482ceb1e411 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -134,7 +134,12 @@ class GOV public: virtual ~GOV() {} virtual void getAllNewerThan(std::vector &objs, int64_t nMoreThanTime) = 0; - virtual int32_t getObjAbsYesCount(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; + struct Votes { + int32_t m_abs{0}; + int32_t m_no{0}; + int32_t m_yes{0}; + }; + virtual Votes getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; virtual bool getObjLocalValidity(const CGovernanceObject& obj, std::string& error, bool check_collateral) = 0; virtual bool isEnabled() = 0; virtual bool processVoteAndRelay(const CGovernanceVote& vote, std::string& error) = 0; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index c43b5dd018d7..5027b98bb4d2 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -229,12 +229,16 @@ class GOVImpl : public GOV context().govman->GetAllNewerThan(objs, nMoreThanTime); } } - int32_t getObjAbsYesCount(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override + Votes getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) override { + Votes ret; if (context().govman != nullptr && context().dmnman != nullptr) { - return obj.GetAbsoluteYesCount(context().dmnman->GetListAtChainTip(), vote_signal); + const auto& tip_mn_list{context().dmnman->GetListAtChainTip()}; + ret.m_abs = obj.GetAbstainCount(tip_mn_list, vote_signal); + ret.m_no = obj.GetNoCount(tip_mn_list, vote_signal); + ret.m_yes = obj.GetYesCount(tip_mn_list, vote_signal); } - return 0; + return ret; } bool getObjLocalValidity(const CGovernanceObject& obj, std::string& error, bool check_collateral) override { diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index a5abfce03291..dd2d8e0e1b2d 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -28,6 +28,10 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) return; } + if (clientModel) { + m_votes = clientModel->node().gov().getObjVotes(govObj, VOTE_SIGNAL_FUNDING); + } + if (const UniValue& titleValue = prop_data.find_value("name"); titleValue.isStr()) { m_title = QString::fromStdString(titleValue.get_str()); } @@ -57,29 +61,13 @@ QString Proposal::toJson() const bool Proposal::isActive() const { + if (!clientModel) { + return false; + } std::string strError; return clientModel->node().gov().getObjLocalValidity(govObj, strError, false); } -int Proposal::GetAbsoluteYesCount() const -{ - return clientModel->node().gov().getObjAbsYesCount(govObj, VOTE_SIGNAL_FUNDING); -} - -QString Proposal::votingStatus(const int nAbsVoteReq) const -{ - // Voting status... - // TODO: determine if voting is in progress vs. funded or not funded for past proposals. - // see CSuperblock::GetNearestSuperblocksHeights(nBlockHeight, nLastSuperblock, nNextSuperblock); - const int absYesCount = clientModel->node().gov().getObjAbsYesCount(govObj, VOTE_SIGNAL_FUNDING); - if (absYesCount >= nAbsVoteReq) { - // Could use govObj.IsSetCachedFunding here, but need nAbsVoteReq to display numbers anyway. - return QObject::tr("Passing +%1").arg(absYesCount - nAbsVoteReq); - } else { - return QObject::tr("Needs additional %1 votes").arg(nAbsVoteReq - absYesCount); - } -} - void Proposal::openUrl() const { QDesktopServices::openUrl(QUrl(m_url)); @@ -104,7 +92,7 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const if (!index.isValid() || !isValidRow(index.row())) { return {}; } - if (role != Qt::DisplayRole && role != Qt::EditRole) { + if (role != Qt::DisplayRole && role != Qt::EditRole && role != Qt::ToolTipRole) { return {}; } @@ -127,8 +115,13 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const } case Column::IS_ACTIVE: return proposal->isActive() ? tr("Yes") : tr("No"); - case Column::VOTING_STATUS: - return proposal->votingStatus(nAbsVoteReq); + case Column::VOTING_STATUS: { + const int margin = proposal->getAbsoluteYesCount() - nAbsVoteReq; + return QString("%1Y, %2N, %3A (%4%5)").arg(proposal->getYesCount()).arg(proposal->getNoCount()) + .arg(proposal->getAbstainCount()).arg(margin > 0 ? "+" : "") + .arg(margin); + + } default: return {}; }; @@ -151,12 +144,22 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const case Column::IS_ACTIVE: return proposal->isActive(); case Column::VOTING_STATUS: - return proposal->GetAbsoluteYesCount(); + return proposal->getAbsoluteYesCount(); default: return {}; }; break; } + case Qt::ToolTipRole: + { + if (index.column() == Column::VOTING_STATUS) { + const int margin = proposal->getAbsoluteYesCount() - nAbsVoteReq; + return tr("%1 Yes, %2 No, %3 Abstain, %4").arg(proposal->getYesCount()).arg(proposal->getNoCount()) + .arg(proposal->getAbstainCount()) + .arg((margin >= 0 ? tr("passing with %1 votes") : tr("needs %1 more votes")).arg(std::abs(margin))); + } + return {}; + } }; return {}; } @@ -181,7 +184,7 @@ QVariant ProposalModel::headerData(int section, Qt::Orientation orientation, int case Column::IS_ACTIVE: return tr("Active"); case Column::VOTING_STATUS: - return tr("Status"); + return tr("Votes"); default: return {}; } @@ -219,7 +222,7 @@ void ProposalModel::reconcile(ProposalList&& proposals) if (it != m_data.end()) { const auto idx{static_cast(std::distance(m_data.begin(), it))}; keep_index[static_cast(idx)] = true; - if ((*it)->GetAbsoluteYesCount() != proposal->GetAbsoluteYesCount()) { + if ((*it)->getAbsoluteYesCount() != proposal->getAbsoluteYesCount()) { // Replace proposal to update vote count *it = std::move(proposal); Q_EMIT dataChanged(createIndex(idx, Column::VOTING_STATUS), createIndex(idx, Column::VOTING_STATUS)); diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index 3dda518631f0..9ce38d6b0752 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_QT_PROPOSALMODEL_H #define BITCOIN_QT_PROPOSALMODEL_H +#include #include #include @@ -24,6 +25,7 @@ class Proposal const CGovernanceObject govObj; CAmount m_paymentAmount{0}; + interfaces::GOV::Votes m_votes; QDateTime m_endDate{}; QDateTime m_startDate{}; QString m_hash{}; @@ -35,14 +37,16 @@ class Proposal bool isActive() const; CAmount paymentAmount() const { return m_paymentAmount; } - int GetAbsoluteYesCount() const; + int32_t getAbsoluteYesCount() const { return m_votes.m_yes - m_votes.m_no; } + int32_t getAbstainCount() const { return m_votes.m_abs; } + int32_t getNoCount() const { return m_votes.m_no; } + int32_t getYesCount() const { return m_votes.m_yes; } QDateTime endDate() const { return m_endDate; } QDateTime startDate() const { return m_startDate; } QString hash() const { return m_hash; } QString title() const { return m_title; } QString toJson() const; QString url() const { return m_url; } - QString votingStatus(int nAbsVoteReq) const; void openUrl() const; }; From f45fe8551972ade6a9395e39fff76cd173e55d36 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 22 Jan 2026 07:53:20 +0530 Subject: [PATCH 10/30] qt: use monospace font for hashes in "Governance" tab --- src/qt/proposalmodel.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index dd2d8e0e1b2d..95beeb2b8b0c 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -6,9 +6,12 @@ #include +#include + #include #include +#include #include #include @@ -92,6 +95,14 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const if (!index.isValid() || !isValidRow(index.row())) { return {}; } + if (index.column() == Column::HASH) { + if (role == Qt::FontRole) { + return GUIUtil::fixedPitchFont(); + } + if (role == Qt::ForegroundRole) { + return QVariant::fromValue(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::UNCONFIRMED)); + } + } if (role != Qt::DisplayRole && role != Qt::EditRole && role != Qt::ToolTipRole) { return {}; } From 4295471d99d4e07b6389866d2797408e3a90ddc7 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:36:08 +0530 Subject: [PATCH 11/30] qt: report proposal info using QTextEdit instead of an alert with JSON This is closer to how we report details for transactions and more translation-friendly and easier to copy. --- src/qt/governancelist.cpp | 16 ++++++++++------ src/qt/proposalmodel.cpp | 18 ++++++++++++++++++ src/qt/proposalmodel.h | 2 ++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index 447fc79b3be7..afe3e7a366e9 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -269,19 +270,22 @@ void GovernanceList::showProposalContextMenu(const QPoint& pos) void GovernanceList::showAdditionalInfo(const QModelIndex& index) { - if (!index.isValid()) { + if (!index.isValid() || !clientModel) { return; } const auto proposal = proposalModel->getProposalAt(proposalModelProxy->mapToSource(index)); - if (proposal == nullptr) { + if (!proposal) { return; } - const auto windowTitle = tr("Proposal Info: %1").arg(proposal->title()); - const auto json = proposal->toJson(); - - QMessageBox::information(this, windowTitle, json); + DescriptionDialog* dialog = new DescriptionDialog( + tr("Details for %1").arg(proposal->title()), + proposal->toHtml(clientModel->getOptionsModel()->getDisplayUnit()), + /*parent=*/this); + dialog->resize(800, 380); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); } void GovernanceList::updateMasternodeCount() const diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index 95beeb2b8b0c..3a35deaf0546 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -56,6 +56,24 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) } } +QString Proposal::toHtml(const BitcoinUnit& unit) const +{ + QString ret; + ret.reserve(4000); + ret += ""; + ret += "" + QObject::tr("Title") + ": " + GUIUtil::HtmlEscape(m_title) + "
"; + if (!m_url.isEmpty()) { + ret += "" + QObject::tr("URL") + ": " + GUIUtil::HtmlEscape(m_url) + "
"; + } + ret += "" + QObject::tr("Payment Amount") + ": " + BitcoinUnits::formatHtmlWithUnit(unit, m_paymentAmount) + "
"; + ret += "" + QObject::tr("Payment Start") + ": " + GUIUtil::dateTimeStr(m_startDate) + "
"; + ret += "" + QObject::tr("Payment End") + ": " + GUIUtil::dateTimeStr(m_endDate) + "
"; + ret += "" + QObject::tr("Object Hash") + ": " + m_hash + "
"; + ret += "
"; + ret += ""; + return ret; +} + QString Proposal::toJson() const { const auto json = govObj.GetInnerJson(); diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index 9ce38d6b0752..a6071b65ade7 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -45,6 +46,7 @@ class Proposal QDateTime startDate() const { return m_startDate; } QString hash() const { return m_hash; } QString title() const { return m_title; } + QString toHtml(const BitcoinUnit& unit) const; QString toJson() const; QString url() const { return m_url; } From 552f591a58adbb09216ccd04d6f4063edf61d9e9 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 22 Jan 2026 07:52:42 +0530 Subject: [PATCH 12/30] qt: report more proposal information in description, calculate payments --- src/qt/proposalmodel.cpp | 29 +++++++++++++++++++++++++++-- src/qt/proposalmodel.h | 13 +++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index 3a35deaf0546..4e83da748b40 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -4,10 +4,12 @@ #include +#include #include #include +#include #include #include @@ -24,7 +26,10 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) : clientModel{_clientModel}, govObj{_govObj}, - m_hash{QString::fromStdString(govObj.GetHash().ToString())} + m_date_collateral{QDateTime::fromSecsSinceEpoch(govObj.GetCreationTime())}, + m_hash_collateral{QString::fromStdString(govObj.GetCollateralHash().ToString())}, + m_hash_object{QString::fromStdString(govObj.GetHash().ToString())}, + m_hash_parent{QString::fromStdString(govObj.Object().hashParent.ToString())} { UniValue prop_data; if (!prop_data.read(govObj.GetDataAsPlainString())) { @@ -47,6 +52,10 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) m_endDate = QDateTime::fromSecsSinceEpoch(paymentEndValue.getInt()); } + if (const UniValue& addressValue = prop_data.find_value("payment_address"); addressValue.isStr()) { + m_address = QString::fromStdString(addressValue.get_str()); + } + if (const UniValue& amountValue = prop_data.find_value("payment_amount"); amountValue.isNum()) { m_paymentAmount = llround(amountValue.get_real() * COIN); } @@ -56,6 +65,17 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) } } +int Proposal::paymentsRequested() const +{ + if (!m_startDate.isValid() || !m_endDate.isValid()) { + return 1; + } + const auto& consensus_params = Params().GetConsensus(); + const int64_t superblock_cycle = consensus_params.nPowTargetSpacing * consensus_params.nSuperblockCycle; + const int64_t proposal_duration = m_endDate.toSecsSinceEpoch() - m_startDate.toSecsSinceEpoch(); + return std::max(1, ((proposal_duration + superblock_cycle - 1) / superblock_cycle)); +} + QString Proposal::toHtml(const BitcoinUnit& unit) const { QString ret; @@ -65,10 +85,15 @@ QString Proposal::toHtml(const BitcoinUnit& unit) const if (!m_url.isEmpty()) { ret += "" + QObject::tr("URL") + ": " + GUIUtil::HtmlEscape(m_url) + "
"; } + ret += "" + QObject::tr("Destination Address") + ": " + GUIUtil::HtmlEscape(m_address) + "
"; ret += "" + QObject::tr("Payment Amount") + ": " + BitcoinUnits::formatHtmlWithUnit(unit, m_paymentAmount) + "
"; + ret += "" + QObject::tr("Payments Requested") + ": " + QString::number(paymentsRequested()) + "
"; ret += "" + QObject::tr("Payment Start") + ": " + GUIUtil::dateTimeStr(m_startDate) + "
"; ret += "" + QObject::tr("Payment End") + ": " + GUIUtil::dateTimeStr(m_endDate) + "
"; - ret += "" + QObject::tr("Object Hash") + ": " + m_hash + "
"; + ret += "" + QObject::tr("Object Hash") + ": " + m_hash_object + "
"; + ret += "" + QObject::tr("Parent Hash") + ": " + m_hash_parent + "
"; + ret += "" + QObject::tr("Collateral Date") + ": " + GUIUtil::dateTimeStr(m_date_collateral) + "
"; + ret += "" + QObject::tr("Collateral Hash") + ": " + m_hash_collateral + "
"; ret += "
"; ret += ""; return ret; diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index a6071b65ade7..4bb6decfde5d 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -27,9 +27,13 @@ class Proposal CAmount m_paymentAmount{0}; interfaces::GOV::Votes m_votes; + QDateTime m_date_collateral{}; QDateTime m_endDate{}; QDateTime m_startDate{}; - QString m_hash{}; + QString m_address{}; + QString m_hash_collateral{}; + QString m_hash_object{}; + QString m_hash_parent{}; QString m_title{}; QString m_url{}; @@ -38,13 +42,18 @@ class Proposal bool isActive() const; CAmount paymentAmount() const { return m_paymentAmount; } + int paymentsRequested() const; int32_t getAbsoluteYesCount() const { return m_votes.m_yes - m_votes.m_no; } int32_t getAbstainCount() const { return m_votes.m_abs; } int32_t getNoCount() const { return m_votes.m_no; } int32_t getYesCount() const { return m_votes.m_yes; } + QDateTime collateralDate() const { return m_date_collateral; } QDateTime endDate() const { return m_endDate; } QDateTime startDate() const { return m_startDate; } - QString hash() const { return m_hash; } + QString collateralHash() const { return m_hash_collateral; } + QString hash() const { return m_hash_object; } + QString parentHash() const { return m_hash_parent; } + QString paymentAddress() const { return m_address; } QString title() const { return m_title; } QString toHtml(const BitcoinUnit& unit) const; QString toJson() const; From af65c0eda45ae4030ebbf098f505ef8906d054ce Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Feb 2026 02:22:54 +0530 Subject: [PATCH 13/30] qt: cleanup proposal context menu, add copy JSON and visit URL options Visiting URLs could be dangerous, add a warning before we open a link in someone's browser. --- src/qt/governancelist.cpp | 65 ++++++++++++++++++++++++++++++++++----- src/qt/governancelist.h | 2 ++ src/qt/proposalmodel.cpp | 8 ----- src/qt/proposalmodel.h | 2 -- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index afe3e7a366e9..92957329f5b2 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -26,10 +26,14 @@ #include #include +#include +#include +#include #include #include #include #include +#include namespace { constexpr int TITLE_MIN_WIDTH{220}; @@ -246,16 +250,15 @@ void GovernanceList::showProposalContextMenu(const QPoint& pos) } const auto proposal = proposalModel->getProposalAt(proposalModelProxy->mapToSource(index)); - if (proposal == nullptr) { + if (!proposal) { return; } - // right click menu with option to open proposal url - QString proposal_url = proposal->url(); - proposal_url.replace(QChar('&'), QString("&&")); - proposalContextMenu->clear(); - proposalContextMenu->addAction(proposal_url, [proposal]() { proposal->openUrl(); }); + proposalContextMenu->addAction(tr("Copy Raw JSON"), this, &GovernanceList::copyProposalJson); + if (!proposal->url().isEmpty()) { + proposalContextMenu->addAction(tr("Open Proposal URL…"), this, &GovernanceList::openProposalUrl); + } // Add voting options if wallet is available and has voting capability if (walletModel && canVote()) { @@ -301,6 +304,54 @@ void GovernanceList::voteNo() { voteForProposal(VOTE_OUTCOME_NO); } void GovernanceList::voteAbstain() { voteForProposal(VOTE_OUTCOME_ABSTAIN); } +void GovernanceList::openProposalUrl() +{ + const auto selection = ui->govTableView->selectionModel()->selectedRows(); + if (selection.isEmpty()) { + return; + } + + const auto proposal = proposalModel->getProposalAt(proposalModelProxy->mapToSource(selection.first())); + if (!proposal || proposal->url().isEmpty()) { + return; + } + + const QUrl url{QUrl(proposal->url())}; + if (const QString scheme = url.isValid() ? url.scheme().toLower() : QString(); + scheme != QLatin1String("http") && scheme != QLatin1String("https")) { + QMessageBox::critical(this, tr("Error"), tr("Cannot validate URL, potentially malformed or unknown protocol.")); + return; + } + QMessageBox::StandardButton reply = QMessageBox::warning( + this, + tr("External Link Warning"), + tr("You are about to open the following URL in your default browser\n\n%1\n\n" + "This content was submitted by a user. It may not match what is described in the title.\n\n" + "Do you wish to continue?").arg(proposal->url()), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + QDesktopServices::openUrl(url); + } +} + +void GovernanceList::copyProposalJson() +{ + const auto selection = ui->govTableView->selectionModel()->selectedRows(); + if (selection.isEmpty()) { + return; + } + + const auto proposal = proposalModel->getProposalAt(proposalModelProxy->mapToSource(selection.first())); + if (!proposal) { + return; + } + + QApplication::clipboard()->setText(proposal->toJson()); +} + void GovernanceList::voteForProposal(vote_outcome_enum_t outcome) { if (!walletModel) { @@ -322,7 +373,7 @@ void GovernanceList::voteForProposal(vote_outcome_enum_t outcome) const auto index = selection.first(); const auto proposal = proposalModel->getProposalAt(proposalModelProxy->mapToSource(index)); - if (proposal == nullptr) return; + if (!proposal) return; const uint256 proposalHash(uint256S(proposal->hash().toStdString())); diff --git a/src/qt/governancelist.h b/src/qt/governancelist.h index bb03f2bff35f..2c22281d7a17 100644 --- a/src/qt/governancelist.h +++ b/src/qt/governancelist.h @@ -83,6 +83,8 @@ private Q_SLOTS: void showProposalContextMenu(const QPoint& pos); void showAdditionalInfo(const QModelIndex& index); void showCreateProposalDialog(); + void openProposalUrl(); + void copyProposalJson(); // Voting slots void voteYes(); diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index 4e83da748b40..56668c2171b2 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -15,9 +15,6 @@ #include #include -#include -#include - #include #include @@ -114,11 +111,6 @@ bool Proposal::isActive() const return clientModel->node().gov().getObjLocalValidity(govObj, strError, false); } -void Proposal::openUrl() const -{ - QDesktopServices::openUrl(QUrl(m_url)); -} - /// /// Proposal Model /// diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index 4bb6decfde5d..5d7f4d48262c 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -58,8 +58,6 @@ class Proposal QString toHtml(const BitcoinUnit& unit) const; QString toJson() const; QString url() const { return m_url; } - - void openUrl() const; }; using ProposalList = std::vector>; From 5f95737441924af03c99097af64d91cada935bf2 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:27:50 +0530 Subject: [PATCH 14/30] qt: add voting ballot icon --- src/Makefile.qt.include | 1 + src/qt/dash.qrc | 1 + src/qt/res/icons/voting.png | Bin 0 -> 679 bytes src/qt/res/src/voting.svg | 13 +++++++++++++ 4 files changed, 15 insertions(+) create mode 100644 src/qt/res/icons/voting.png create mode 100644 src/qt/res/src/voting.svg diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 5a0dbd946702..83ff5e97a413 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -230,6 +230,7 @@ QT_RES_ICONS = \ qt/res/icons/transaction5.png \ qt/res/icons/transaction_abandoned.png \ qt/res/icons/transaction_locked.png \ + qt/res/icons/voting.png \ qt/res/icons/warning.png BITCOIN_QT_BASE_CPP = \ diff --git a/src/qt/dash.qrc b/src/qt/dash.qrc index c572e93185cf..9edc2a2e734d 100644 --- a/src/qt/dash.qrc +++ b/src/qt/dash.qrc @@ -36,6 +36,7 @@ res/icons/moon_6.png res/icons/moon_7.png res/icons/proxy.png + res/icons/voting.png
res/css/general.css diff --git a/src/qt/res/icons/voting.png b/src/qt/res/icons/voting.png new file mode 100644 index 0000000000000000000000000000000000000000..b79b43dbe83b9a723df0530e7c28c72ba40b5f12 GIT binary patch literal 679 zcmV;Y0$BZtP)tCTOjgYXzuq4-eCrL^x$~A35>UB@Akh~MG*#u+C}9(HToqXvX3tq zZU=#FDt`gzaT61m!!mXQDE!0i&692L^w(MtDAAgiWRDPT$&=06gDFb-iY`~wdIl~_vL6#}+9$@~>vyM|6lYqG z?TIrEw*|_+7b=IXzmKA;()=w#UJ4b&)DJP?8_}X9KTEjZI4Y6E(htm1d`gmkop7Hb zh+^o6xO6q*cO(@a5bi}JSrPR&Per+Z6Itv%2!gG%+t+=azXw5}BnU*8nihje_8tNVQrhmNtHGA(*!gDO+XXS1T+CnKoigeQYTx} literal 0 HcmV?d00001 diff --git a/src/qt/res/src/voting.svg b/src/qt/res/src/voting.svg new file mode 100644 index 000000000000..76c33d167312 --- /dev/null +++ b/src/qt/res/src/voting.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + From 28b67d3ca0ce207eb6ef14191ea38def405b1a69 Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:38:45 +0530 Subject: [PATCH 15/30] qt: replace "Active" column with icons that reflect voting status Co-authored-by: UdjinM6 --- src/Makefile.qt.include | 1 + src/interfaces/node.h | 9 +- src/node/interfaces.cpp | 37 +++++--- src/qt/bitcoingui.cpp | 2 +- src/qt/dash.qrc | 1 + src/qt/governancelist.cpp | 19 +++- src/qt/governancelist.h | 2 + src/qt/proposalmodel.cpp | 152 +++++++++++++++++++++++++++--- src/qt/proposalmodel.h | 51 ++++++++-- src/qt/res/icons/transaction6.png | Bin 0 -> 1083 bytes 10 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 src/qt/res/icons/transaction6.png diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 83ff5e97a413..18e92b926e04 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -228,6 +228,7 @@ QT_RES_ICONS = \ qt/res/icons/transaction3.png \ qt/res/icons/transaction4.png \ qt/res/icons/transaction5.png \ + qt/res/icons/transaction6.png \ qt/res/icons/transaction_abandoned.png \ qt/res/icons/transaction_locked.png \ qt/res/icons/voting.png \ diff --git a/src/interfaces/node.h b/src/interfaces/node.h index 7482ceb1e411..f7ea7d7cfcb4 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -10,6 +10,7 @@ #include // For banmap_t #include // For Network #include // For ConnectionDirection +#include // For StaticSaltedHasher #include // For SecureString #include #include // For util::SettingsValue @@ -23,6 +24,7 @@ #include #include #include +#include #include class BanMan; @@ -156,7 +158,12 @@ class GOV int requiredConfs{6}; }; virtual GovernanceInfo getGovernanceInfo() = 0; - virtual CAmount getAllocatedBudget() = 0; + virtual std::optional getProposalFundedHeight(const uint256& proposal_hash) = 0; + struct FundableResult { + std::unordered_set hashes; + CAmount allocated{0}; + }; + virtual FundableResult getFundableProposalHashes() = 0; virtual std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) = 0; virtual bool submitProposal(const uint256& parent, int32_t revision, int64_t created_time, const std::string& data_hex, diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 5027b98bb4d2..1f55d7c487d4 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -275,13 +275,7 @@ class GOVImpl : public GOV const Consensus::Params& consensusParams = Params().GetConsensus(); if (ctx.chainman) { - const CBlockIndex* tip = WITH_LOCK(::cs_main, return ctx.chainman->ActiveChain().Tip()); - int last = 0; - int next = 0; - const int height = tip ? tip->nHeight : 0; - CSuperblock::GetNearestSuperblocksHeights(height, last, next); - info.lastsuperblock = last; - info.nextsuperblock = next; + CSuperblock::GetNearestSuperblocksHeights(ctx.chainman->ActiveHeight(), info.lastsuperblock, info.nextsuperblock); } info.proposalfee = GOVERNANCE_PROPOSAL_FEE_TX; info.superblockcycle = consensusParams.nSuperblockCycle; @@ -297,16 +291,30 @@ class GOVImpl : public GOV } return info; } - CAmount getAllocatedBudget() override + std::optional getProposalFundedHeight(const uint256& proposal_hash) override { + if (context().govman != nullptr && context().chainman != nullptr) { + const int32_t nTipHeight = context().chainman->ActiveHeight(); + for (const auto& trigger : context().govman->GetActiveTriggers()) { + if (!trigger || trigger->GetBlockHeight() > nTipHeight) continue; + for (const auto& hash : trigger->GetProposalHashes()) { + if (hash == proposal_hash) { + return trigger->GetBlockHeight(); + } + } + } + } + return std::nullopt; + } + FundableResult getFundableProposalHashes() override + { + FundableResult result; if (context().govman != nullptr && context().chainman != nullptr && context().dmnman != nullptr) { const auto tip_mn_list{context().dmnman->GetListAtChainTip()}; if (const auto proposals{context().govman->GetApprovedProposals(tip_mn_list)}; !proposals.empty()) { int32_t last_sb{0}, next_sb{0}; CSuperblock::GetNearestSuperblocksHeights(context().chainman->ActiveHeight(), last_sb, next_sb); const CAmount budget{CSuperblock::GetPaymentsLimit(context().chainman->ActiveChain(), next_sb)}; - - CAmount allocated{0}; for (const auto& proposal : proposals) { UniValue json = proposal->GetJSONObject(); CAmount payment_amount{0}; @@ -315,16 +323,17 @@ class GOVImpl : public GOV } catch (...) { continue; } - if (allocated + payment_amount > budget) { + if (result.allocated + payment_amount > budget) { // Budget is saturated, cannot fulfill proposal continue; } - allocated += payment_amount; + result.allocated += payment_amount; + result.hashes.insert(proposal->GetHash()); } - return allocated; + return result; } } - return 0; + return result; } std::optional createProposal(int32_t revision, int64_t created_time, const std::string& data_hex, std::string& error) override diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index c52c0a5afa7b..6dced4fd4b41 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -1820,7 +1820,7 @@ void BitcoinGUI::updateGovernanceCycleIcon() tooltip1 = tr("~%n day(s) (%1 blocks) left for voting", "", days).arg(remaining_blocks); } - const auto allocated_budget{m_node.gov().getAllocatedBudget()}; + const auto allocated_budget{m_node.gov().getFundableProposalHashes().allocated}; const auto budget_pct{gov_info.governancebudget > 0 ? static_cast(static_cast(allocated_budget) / static_cast(gov_info.governancebudget) * 100.0) : 0}; diff --git a/src/qt/dash.qrc b/src/qt/dash.qrc index 9edc2a2e734d..20621ba996b9 100644 --- a/src/qt/dash.qrc +++ b/src/qt/dash.qrc @@ -13,6 +13,7 @@ res/icons/transaction3.png res/icons/transaction4.png res/icons/transaction5.png + res/icons/transaction6.png res/icons/transaction_abandoned.png res/icons/transaction_locked.png res/icons/eye.png diff --git a/src/qt/governancelist.cpp b/src/qt/governancelist.cpp index 92957329f5b2..3c8876ee74d8 100644 --- a/src/qt/governancelist.cpp +++ b/src/qt/governancelist.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -105,6 +106,14 @@ GovernanceList::~GovernanceList() delete ui; } +void GovernanceList::changeEvent(QEvent* event) +{ + QWidget::changeEvent(event); + if (event->type() == QEvent::StyleChange) { + QTimer::singleShot(0, proposalModel, &ProposalModel::refreshIcons); + } +} + void GovernanceList::setClientModel(ClientModel* model) { this->clientModel = model; @@ -188,15 +197,19 @@ GovernanceList::CalcProposalList GovernanceList::calcProposalList() const const int nWeightedMnCount = dmn->getValidWeightedMNsCount(); ret.m_abs_vote_req = std::max(Params().GetConsensus().nGovernanceMinQuorum, nWeightedMnCount / 10); + const auto gov_info{clientModel->node().gov().getGovernanceInfo()}; std::vector govObjList; clientModel->getAllGovernanceObjects(govObjList); for (const auto& govObj : govObjList) { if (govObj.GetObjectType() != GovernanceObject::PROPOSAL) { continue; // Skip triggers. } - ret.m_proposals.emplace_back(std::make_unique(this->clientModel, govObj)); + ret.m_proposals.emplace_back(std::make_unique(this->clientModel, govObj, gov_info, gov_info.requiredConfs)); } + auto fundable{clientModel->node().gov().getFundableProposalHashes()}; + ret.m_fundable_hashes = std::move(fundable.hashes); + // Discover voting capability if we now have both client and wallet models if (walletModel) { dmn->forEachMN(/*only_valid=*/true, [&](const auto& dmn) { @@ -214,7 +227,7 @@ GovernanceList::CalcProposalList GovernanceList::calcProposalList() const void GovernanceList::setProposalList(CalcProposalList&& data) { proposalModel->setVotingParams(data.m_abs_vote_req); - proposalModel->reconcile(std::move(data.m_proposals)); + proposalModel->reconcile(std::move(data.m_proposals), std::move(data.m_fundable_hashes)); votableMasternodes = std::move(data.m_votable_masternodes); updateMasternodeCount(); } @@ -473,10 +486,10 @@ void GovernanceList::refreshColumnWidths() auto* header = ui->govTableView->horizontalHeader(); header->setMinimumSectionSize(0); + header->setSectionResizeMode(ProposalModel::Column::STATUS, QHeaderView::ResizeToContents); header->setSectionResizeMode(ProposalModel::Column::PAYMENT_AMOUNT, QHeaderView::ResizeToContents); header->setSectionResizeMode(ProposalModel::Column::START_DATE, QHeaderView::ResizeToContents); header->setSectionResizeMode(ProposalModel::Column::END_DATE, QHeaderView::ResizeToContents); - header->setSectionResizeMode(ProposalModel::Column::IS_ACTIVE, QHeaderView::ResizeToContents); header->setSectionResizeMode(ProposalModel::Column::VOTING_STATUS, QHeaderView::ResizeToContents); header->setSectionResizeMode(ProposalModel::Column::HASH, QHeaderView::ResizeToContents); diff --git a/src/qt/governancelist.h b/src/qt/governancelist.h index 2c22281d7a17..0d26270721ef 100644 --- a/src/qt/governancelist.h +++ b/src/qt/governancelist.h @@ -50,6 +50,7 @@ class GovernanceList : public QWidget int m_abs_vote_req{0}; ProposalList m_proposals; Uint256HashMap m_votable_masternodes; + Uint256HashSet m_fundable_hashes; }; ClientModel* clientModel{nullptr}; @@ -72,6 +73,7 @@ class GovernanceList : public QWidget void voteForProposal(vote_outcome_enum_t outcome); protected: + void changeEvent(QEvent* event) override; void showEvent(QShowEvent* event) override; void resizeEvent(QResizeEvent* event) override; diff --git a/src/qt/proposalmodel.cpp b/src/qt/proposalmodel.cpp index 56668c2171b2..bee4d2896174 100644 --- a/src/qt/proposalmodel.cpp +++ b/src/qt/proposalmodel.cpp @@ -20,13 +20,18 @@ #include #include -Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) : +Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj, + const interfaces::GOV::GovernanceInfo& govInfo, int collateral_confs) : clientModel{_clientModel}, govObj{_govObj}, + m_block_height{_clientModel ? _clientModel->getNumBlocks() : 0}, + m_collateral_confs{collateral_confs}, + m_gov_info{govInfo}, m_date_collateral{QDateTime::fromSecsSinceEpoch(govObj.GetCreationTime())}, m_hash_collateral{QString::fromStdString(govObj.GetCollateralHash().ToString())}, m_hash_object{QString::fromStdString(govObj.GetHash().ToString())}, - m_hash_parent{QString::fromStdString(govObj.Object().hashParent.ToString())} + m_hash_parent{QString::fromStdString(govObj.Object().hashParent.ToString())}, + m_objHash{govObj.GetHash()} { UniValue prop_data; if (!prop_data.read(govObj.GetDataAsPlainString())) { @@ -34,6 +39,7 @@ Proposal::Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj) } if (clientModel) { + m_funded_height = clientModel->node().gov().getProposalFundedHeight(govObj.GetHash()); m_votes = clientModel->node().gov().getObjVotes(govObj, VOTE_SIGNAL_FUNDING); } @@ -102,6 +108,44 @@ QString Proposal::toJson() const return QString::fromStdString(json.write(2)); } +int Proposal::blocksUntilSuperblock() const +{ + return m_gov_info.nextsuperblock - m_block_height; +} + +int Proposal::collateralConfs() const +{ + return m_collateral_confs; +} + +int Proposal::requiredConfs() const +{ + return m_gov_info.requiredConfs; +} + +ProposalStatus Proposal::status(bool is_fundable) const +{ + if (getFundedHeight().has_value()) { + return ProposalStatus::Funded; + } + if (QDateTime::currentDateTime() >= endDate()) { + return ProposalStatus::Lapsed; + } + if (m_collateral_confs < m_gov_info.requiredConfs) { + return ProposalStatus::Confirming; + } + if (m_gov_info.superblockcycle <= 0) { + return ProposalStatus::Voting; + } + if (m_block_height % m_gov_info.superblockcycle >= m_gov_info.superblockcycle - m_gov_info.superblockmaturitywindow) { + return is_fundable ? ProposalStatus::Passing : ProposalStatus::Failing; + } + if (getAbsoluteYesCount() < m_gov_info.fundingthreshold) { + return ProposalStatus::Voting; + } + return is_fundable ? ProposalStatus::Passing : ProposalStatus::Unfunded; +} + bool Proposal::isActive() const { if (!clientModel) { @@ -115,6 +159,29 @@ bool Proposal::isActive() const /// Proposal Model /// +ProposalModel::ProposalModel(QObject* parent) : + QAbstractTableModel(parent) +{ + refreshIcons(); +} + +void ProposalModel::refreshIcons() +{ + m_icon_confirming = { + GUIUtil::getIcon("transaction_0", GUIUtil::ThemedColor::ORANGE), + GUIUtil::getIcon("transaction_1", GUIUtil::ThemedColor::ORANGE), + GUIUtil::getIcon("transaction_2", GUIUtil::ThemedColor::ORANGE), + GUIUtil::getIcon("transaction_3", GUIUtil::ThemedColor::ORANGE), + GUIUtil::getIcon("transaction_4", GUIUtil::ThemedColor::ORANGE), + GUIUtil::getIcon("transaction_5", GUIUtil::ThemedColor::ORANGE) + }; + m_icon_failing = GUIUtil::getIcon("voting", GUIUtil::ThemedColor::RED); + m_icon_lapsed = GUIUtil::getIcon("transaction_6", GUIUtil::ThemedColor::RED); + m_icon_passing = GUIUtil::getIcon("synced", GUIUtil::ThemedColor::GREEN); + m_icon_unfunded = GUIUtil::getIcon("voting", GUIUtil::ThemedColor::RED); + m_icon_voting = GUIUtil::getIcon("voting", GUIUtil::ThemedColor::ORANGE); +} + int ProposalModel::rowCount(const QModelIndex& index) const { return static_cast(m_data.size()); @@ -138,15 +205,18 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const return QVariant::fromValue(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::UNCONFIRMED)); } } - if (role != Qt::DisplayRole && role != Qt::EditRole && role != Qt::ToolTipRole) { + if (role != Qt::DisplayRole && role != Qt::DecorationRole && role != Qt::EditRole && role != Qt::ToolTipRole) { return {}; } const auto* proposal = m_data[index.row()].get(); + const bool isFundable = m_fundable_hashes.count(proposal->objHash()) > 0; switch(role) { case Qt::DisplayRole: { switch (index.column()) { + case Column::STATUS: + return {}; case Column::HASH: return proposal->hash(); case Column::TITLE: @@ -159,8 +229,6 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const return BitcoinUnits::floorWithUnit(m_display_unit, proposal->paymentAmount(), false, BitcoinUnits::SeparatorStyle::ALWAYS); } - case Column::IS_ACTIVE: - return proposal->isActive() ? tr("Yes") : tr("No"); case Column::VOTING_STATUS: { const int margin = proposal->getAbsoluteYesCount() - nAbsVoteReq; return QString("%1Y, %2N, %3A (%4%5)").arg(proposal->getYesCount()).arg(proposal->getNoCount()) @@ -177,6 +245,8 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const { // Edit role is used for sorting, so return the raw values where possible switch (index.column()) { + case Column::STATUS: + return static_cast(proposal->status(isFundable)); case Column::HASH: return proposal->hash(); case Column::TITLE: @@ -187,8 +257,6 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const return proposal->endDate(); case Column::PAYMENT_AMOUNT: return qlonglong(proposal->paymentAmount()); - case Column::IS_ACTIVE: - return proposal->isActive(); case Column::VOTING_STATUS: return proposal->getAbsoluteYesCount(); default: @@ -198,6 +266,34 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const } case Qt::ToolTipRole: { + if (index.column() == Column::STATUS) { + switch (proposal->status(isFundable)) { + case ProposalStatus::Confirming: { + return tr("Pending, %1 of %2 confirmations").arg(proposal->collateralConfs()).arg(proposal->requiredConfs()); + } + case ProposalStatus::Voting: { + const int margin = nAbsVoteReq - proposal->getAbsoluteYesCount(); + return tr("Voting, needs %1 more votes for funding").arg(std::max(0, margin)); + } + case ProposalStatus::Passing: { + return tr("Passing with %1 votes").arg(proposal->getAbsoluteYesCount()); + } + case ProposalStatus::Unfunded: { + return tr("Passing with %1 votes but budget saturated, may not be funded").arg(proposal->getAbsoluteYesCount()); + } + case ProposalStatus::Failing: { + const int margin = nAbsVoteReq - proposal->getAbsoluteYesCount(); + return tr("Failed, needed %1 more votes").arg(std::max(0, margin)); + } + case ProposalStatus::Funded: { + const auto height{proposal->getFundedHeight()}; + return height.has_value() ? tr("Funded at block %1").arg(height.value()) : tr("Funded"); + } + case ProposalStatus::Lapsed: { + return tr("Lapsed, past proposal end date"); + } + } // no default case, so the compiler can warn about missing cases + } if (index.column() == Column::VOTING_STATUS) { const int margin = proposal->getAbsoluteYesCount() - nAbsVoteReq; return tr("%1 Yes, %2 No, %3 Abstain, %4").arg(proposal->getYesCount()).arg(proposal->getNoCount()) @@ -206,7 +302,28 @@ QVariant ProposalModel::data(const QModelIndex& index, int role) const } return {}; } - }; + case Qt::DecorationRole: + { + if (index.column() == Column::STATUS) { + switch (proposal->status(isFundable)) { + case ProposalStatus::Confirming: + return m_icon_confirming[std::clamp(proposal->collateralConfs(), 0, 5)]; + case ProposalStatus::Voting: + return m_icon_voting; + case ProposalStatus::Unfunded: + return m_icon_unfunded; + case ProposalStatus::Failing: + return m_icon_failing; + case ProposalStatus::Passing: + case ProposalStatus::Funded: + return m_icon_passing; + case ProposalStatus::Lapsed: + return m_icon_lapsed; + } // no default case, so the compiler can warn about missing cases + } + return {}; + } + } return {}; } @@ -217,6 +334,8 @@ QVariant ProposalModel::headerData(int section, Qt::Orientation orientation, int } switch (section) { + case Column::STATUS: + return {}; case Column::HASH: return tr("Hash"); case Column::TITLE: @@ -227,8 +346,6 @@ QVariant ProposalModel::headerData(int section, Qt::Orientation orientation, int return tr("End"); case Column::PAYMENT_AMOUNT: return tr("Amount"); - case Column::IS_ACTIVE: - return tr("Active"); case Column::VOTING_STATUS: return tr("Votes"); default: @@ -253,8 +370,10 @@ void ProposalModel::remove(int row) endRemoveRows(); } -void ProposalModel::reconcile(ProposalList&& proposals) +void ProposalModel::reconcile(ProposalList&& proposals, std::unordered_set&& fundable_hashes) { + m_fundable_hashes = std::move(fundable_hashes); + // Track which existing proposals to keep. After processing new proposals, // remove any existing proposals that weren't found in the new set. const int original_sz{rowCount()}; @@ -268,10 +387,10 @@ void ProposalModel::reconcile(ProposalList&& proposals) if (it != m_data.end()) { const auto idx{static_cast(std::distance(m_data.begin(), it))}; keep_index[static_cast(idx)] = true; - if ((*it)->getAbsoluteYesCount() != proposal->getAbsoluteYesCount()) { - // Replace proposal to update vote count + if ((*it)->getAbsoluteYesCount() != proposal->getAbsoluteYesCount() || (*it)->collateralConfs() != proposal->collateralConfs()) { + // Replace proposal to update vote count or confirmation depth *it = std::move(proposal); - Q_EMIT dataChanged(createIndex(idx, Column::VOTING_STATUS), createIndex(idx, Column::VOTING_STATUS)); + Q_EMIT dataChanged(createIndex(idx, Column::STATUS), createIndex(idx, Column::VOTING_STATUS)); } // else: no changes, proposal unique_ptr goes out of scope and gets deleted } else { @@ -285,6 +404,11 @@ void ProposalModel::reconcile(ProposalList&& proposals) remove(idx); } } + + // Fundable set may have changed; refresh all status icons + if (!m_data.empty()) { + Q_EMIT dataChanged(createIndex(0, Column::STATUS), createIndex(rowCount() - 1, Column::STATUS)); + } } void ProposalModel::setDisplayUnit(const BitcoinUnit& display_unit) diff --git a/src/qt/proposalmodel.h b/src/qt/proposalmodel.h index 5d7f4d48262c..a5dd412b6cf3 100644 --- a/src/qt/proposalmodel.h +++ b/src/qt/proposalmodel.h @@ -7,23 +7,42 @@ #include #include +#include +#include #include #include #include +#include #include +#include #include +#include +#include #include class ClientModel; +enum class ProposalStatus : uint8_t { + Confirming, + Failing, + Funded, + Lapsed, + Passing, + Unfunded, + Voting, +}; + class Proposal { private: ClientModel* clientModel; const CGovernanceObject govObj; + int m_block_height{0}; + int m_collateral_confs{0}; + interfaces::GOV::GovernanceInfo m_gov_info; CAmount m_paymentAmount{0}; interfaces::GOV::Votes m_votes; @@ -36,17 +55,25 @@ class Proposal QString m_hash_parent{}; QString m_title{}; QString m_url{}; + std::optional m_funded_height{}; + uint256 m_objHash{}; public: - explicit Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj); + explicit Proposal(ClientModel* _clientModel, const CGovernanceObject& _govObj, + const interfaces::GOV::GovernanceInfo& govInfo, int collateral_confs); bool isActive() const; CAmount paymentAmount() const { return m_paymentAmount; } + const uint256& objHash() const { return m_objHash; } + int blocksUntilSuperblock() const; + int collateralConfs() const; int paymentsRequested() const; + int requiredConfs() const; int32_t getAbsoluteYesCount() const { return m_votes.m_yes - m_votes.m_no; } int32_t getAbstainCount() const { return m_votes.m_abs; } int32_t getNoCount() const { return m_votes.m_no; } int32_t getYesCount() const { return m_votes.m_yes; } + ProposalStatus status(bool is_fundable) const; QDateTime collateralDate() const { return m_date_collateral; } QDateTime endDate() const { return m_endDate; } QDateTime startDate() const { return m_startDate; } @@ -58,6 +85,7 @@ class Proposal QString toHtml(const BitcoinUnit& unit) const; QString toJson() const; QString url() const { return m_url; } + std::optional getFundedHeight() const { return m_funded_height; } }; using ProposalList = std::vector>; @@ -67,20 +95,26 @@ class ProposalModel : public QAbstractTableModel Q_OBJECT private: - ProposalList m_data; - int nAbsVoteReq = 0; BitcoinUnit m_display_unit{BitcoinUnit::DASH}; + int nAbsVoteReq{0}; + ProposalList m_data; + QIcon m_icon_failing; + QIcon m_icon_lapsed; + QIcon m_icon_passing; + QIcon m_icon_unfunded; + QIcon m_icon_voting; + std::array m_icon_confirming; + Uint256HashSet m_fundable_hashes; public: - explicit ProposalModel(QObject* parent = nullptr) : - QAbstractTableModel(parent){}; + explicit ProposalModel(QObject* parent = nullptr); enum Column : int { - TITLE = 0, + STATUS = 0, + TITLE, PAYMENT_AMOUNT, START_DATE, END_DATE, - IS_ACTIVE, VOTING_STATUS, HASH, _COUNT // for internal use only @@ -93,7 +127,8 @@ class ProposalModel : public QAbstractTableModel void append(std::unique_ptr&& proposal); void remove(int row); - void reconcile(ProposalList&& proposals); + void reconcile(ProposalList&& proposals, Uint256HashSet&& fundable_hashes); + void refreshIcons(); void setDisplayUnit(const BitcoinUnit& display_unit); void setVotingParams(int nAbsVoteReq); const Proposal* getProposalAt(const QModelIndex& index) const; diff --git a/src/qt/res/icons/transaction6.png b/src/qt/res/icons/transaction6.png new file mode 100644 index 0000000000000000000000000000000000000000..a57bbf7f1443b393fc68975a6941dee93cdb1459 GIT binary patch literal 1083 zcmV-B1jPG^P)e^`?zeQ8SO0b`n3KIbD^IFu+c1hjB)7~mj}xXX|7AdyOm zSsCXV7FML7g~bBnvGv9hnxsM_HdaAsP+>9?0GL$SN@ToSx2R&K2XMJB&TL8X>asqHUtAWDPOxE;!T!8Y5x2<6|7G9Nk(jd?Dx&ZklX!{m2He9StBdCado zVUq@Mh!H0EmUOfew`fz;Z-MyFliwOb(-8{CNy4VxIvRT zg3$8%c9=U{j~RrFy5J;Np;1b;|(YRmsqe$ih7y}wUuUJ&>$K&titKsm7G{F%=Eo@SFZjkLA5 zQ|^h~3-rvyybD1m-PZtcl%^&!pOtw*U|u6M8B+mV)dV)O`+wI5>l*2*3Sd)V)|t)H zydW^Etck*=4*(~%6>B?d^Mb&((zKWsx+~$FvW>zIsaC(RqG}yx&RYqoYS_-?;(;@o zpR0mTLmHSzdB}jZZ&Ay~N?_^Ni z=~vtk6dr^KoYhvSJGlY4qckl}clLrc?Iip`ZX=*-F0slRu@nQ#ilog<_U3`lu$A_2 z&;_uhFoyXe7XV*soBmzEodwgy9Z0%N6~OB_lDT)zE?d8pWq&B$&`1j^fcFAvxO|W0 z4UQ=Q1Rex><#{pr+2eU1Q`nkNepfS_sQ>_hhk?F%T}~hRxC+u@ujhp)CF1* z06ne*OZ_F~L?kuJ=-be%RLie^;%igZvZoPFd);X|A(KSqqZElsLEwf5p;xN*RH*4w z$y2pq$?9)7&%Y7L>c{+t4IE~K_c_kwKFR8L`BpB5L`kGeZ-ug+)1blgQ2woG?Qi~r z1z)EyAE$TZ02KXbB^3N@=LwFph#l+U(q=LK!)4N^+fO*hX+ETd!yqL}fP+Ju0S>av z5m6_#Z9#rv4~Zs=@#O002ovPDHLkV1nFB B`t$$* literal 0 HcmV?d00001 From 1a1d56e3e61cf6fdb734ae12ccb9a29d86e3967b Mon Sep 17 00:00:00 2001 From: Kittywhiskers Van Gogh <63189531+kwvg@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:46:25 +0530 Subject: [PATCH 16/30] qt: list locally recorded proposals in "Governance" tab --- src/interfaces/node.h | 1 + src/interfaces/wallet.h | 4 ++ src/node/interfaces.cpp | 19 +++++- src/qt/forms/governancelist.ui | 13 ++++ src/qt/governancelist.cpp | 105 ++++++++++++++++++++++++++++----- src/qt/governancelist.h | 14 +++++ src/wallet/interfaces.cpp | 9 +++ 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/src/interfaces/node.h b/src/interfaces/node.h index f7ea7d7cfcb4..22e2c45c0743 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -143,6 +143,7 @@ class GOV }; virtual Votes getObjVotes(const CGovernanceObject& obj, vote_signal_enum_t vote_signal) = 0; virtual bool getObjLocalValidity(const CGovernanceObject& obj, std::string& error, bool check_collateral) = 0; + virtual bool existsObj(const uint256& hash) = 0; virtual bool isEnabled() = 0; virtual bool processVoteAndRelay(const CGovernanceVote& vote, std::string& error) = 0; struct GovernanceInfo { diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 11e1cda99978..dd4e53dac764 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -7,6 +7,7 @@ #include // For CAmount #include +#include #include // For ChainClient #include // For CKeyID and CScriptID (definitions needed in CTxDestination instantiation) #include