perf(chat): sends re-read by origin, chat scans under the scan budget
A z_sendmany submit set off a burst of wallet scans before anything had changed. Ops now re-read the wallet when they finish, by who started them: the user's sends and dialog ops get a re-read plus one forced balance once none of their ops is pending; client auto-shield re-reads unforced; chat messages and splits refresh only the chat note view. A pending send no longer forces scans, and chat txids no longer enter the tracked-send set. Chat ingest skips stored messages before decrypting and caches undecryptable ones per identity. The inbox scan runs when getwalletinfo's txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety net is due; the note-buffer scan runs on need. Both are admitted by the scan budget, on a steady clock. The chat payer comes from a per-address view of verified notes, with debits re-applied across in-flight scans; splits are sized to one payer and only when they can refill the buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+46
-25
@@ -832,6 +832,10 @@ void App::update()
|
||||
const double scanNow = scanClockNow();
|
||||
if (!network_refresh_.jobInProgress(Job::Core)) scan_governor_.dropOrphans(ScanStream::Balance, scanNow);
|
||||
if (!network_refresh_.jobInProgress(Job::Addresses)) scan_governor_.dropOrphans(ScanStream::Addresses, scanNow);
|
||||
// The chat scans aren't refresh jobs: their in-flight flag is set with the ticket and cleared by the reply
|
||||
// that completes it.
|
||||
if (!chat_fast_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatFast, scanNow);
|
||||
if (!chat_note_scan_in_flight_) scan_governor_.dropOrphans(ScanStream::ChatNotes, scanNow);
|
||||
if (!network_refresh_.jobInProgress(Job::Transactions)) {
|
||||
scan_governor_.dropOrphans(ScanStream::History, scanNow);
|
||||
scan_governor_.dropOrphans(ScanStream::RecentTx, scanNow);
|
||||
@@ -945,12 +949,14 @@ void App::update()
|
||||
// it can act; compiled away when the feature is off (constexpr gate inside).
|
||||
maybeProvisionChatIdentity();
|
||||
|
||||
// Chat note-buffer coordinator (BOTH variants). Full-node only: refresh the per-note counts via a
|
||||
// rate-limited z_listunspent worker scan (lite gets its counts from the refresh model, above). Then
|
||||
// pump: drain queued chat sends against verified notes and build/refill the buffer during idle. Both
|
||||
// self-gate to no-ops unless chat is engaged; the pump serializes to one send outstanding at a time.
|
||||
refreshChatNoteBudgetNode();
|
||||
// Chat note-buffer coordinator (BOTH variants). Pump: drain queued chat sends against verified notes and
|
||||
// build/refill the buffer during idle. Then, full-node only, refresh the per-note counts via the governed
|
||||
// z_listunspent scan (lite gets its counts from the refresh model, above). The pump goes first: a scan
|
||||
// posted in the same frame then queues on worker_ behind the new z_sendmany, which locks its notes, so
|
||||
// the scan's reply already reflects the spend. Both self-gate to no-ops unless chat is engaged; the pump
|
||||
// serializes to one send outstanding at a time.
|
||||
pumpChatNoteBuffer();
|
||||
refreshChatNoteBudgetNode();
|
||||
|
||||
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
||||
maybeRemindSeedBackup();
|
||||
@@ -1337,14 +1343,25 @@ void App::update()
|
||||
}
|
||||
return [this, parsed = std::move(parsed)]() mutable {
|
||||
opid_poll_in_progress_ = false;
|
||||
using Origin = services::NetworkRefreshService::OperationOrigin;
|
||||
// Classify first: the moves below consume the parse.
|
||||
const auto batch = services::NetworkRefreshService::planOperationBatch(parsed, op_origin_);
|
||||
auto originOf = [this](const std::string& id) {
|
||||
const auto it = op_origin_.find(id);
|
||||
return it == op_origin_.end() ? Origin::User : it->second;
|
||||
};
|
||||
|
||||
// Successes: hand the real txid to any waiting send UI callback.
|
||||
std::unordered_set<std::string> successfulOpids;
|
||||
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
|
||||
successfulOpids.insert(opid);
|
||||
markPendingSendTransactionSucceeded(opid, txid);
|
||||
send_txids_.insert(txid);
|
||||
send_txid_heights_.emplace(txid, state_.sync.blocks);
|
||||
// A chat op's record is its echo: tracking it would refresh the history every block and keep
|
||||
// the balance poll reading minconf=1 until it settles.
|
||||
if (originOf(opid) != Origin::Chat) {
|
||||
send_txids_.insert(txid);
|
||||
send_txid_heights_.emplace(txid, state_.sync.blocks);
|
||||
}
|
||||
invokeSendResultCallback(opid, true, txid);
|
||||
}
|
||||
|
||||
@@ -1373,9 +1390,6 @@ void App::update()
|
||||
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
||||
pending_opids_.end());
|
||||
}
|
||||
// An op finished (or vanished): whatever it did to the wallet, the scans gated on change
|
||||
// signals must look again.
|
||||
if (!terminalOpids.empty()) noteWalletMutation();
|
||||
|
||||
// Stale opids (no longer reported by the daemon): let any waiting send UI
|
||||
// know the outcome couldn't be confirmed rather than spinning forever.
|
||||
@@ -1392,15 +1406,21 @@ void App::update()
|
||||
}
|
||||
removePendingSendTransactions(successOpids, false);
|
||||
removePendingSendTransactions(failedOrStaleOpids, true);
|
||||
// Transaction confirmed by daemon — force immediate data refresh
|
||||
markTransactionsDirty();
|
||||
addresses_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
} else {
|
||||
removePendingSendTransactions(terminalOpids, true);
|
||||
maybeFinishTransactionSendProgress();
|
||||
}
|
||||
// After the failure loop: maybeRetrySendForFeeGap reads the fee-gap marker there.
|
||||
for (const auto& id : terminalOpids) {
|
||||
op_origin_.erase(id);
|
||||
send_feegap_retried_opids_.erase(id);
|
||||
}
|
||||
// A user op re-reads the wallet and forces the headline balance (once no user op is pending);
|
||||
// the client auto-shield re-reads it unforced. A chat op only refreshes the chat note view: its
|
||||
// tx moves the mempool signal, and its record is the echo.
|
||||
if (batch.userSucceeded || batch.autoSucceeded) markWalletMutated(/*forceBalance=*/batch.userSucceeded);
|
||||
else if (batch.walletTerminal) noteWalletMutation(); // failed or vanished: look again (conservative)
|
||||
if (batch.chatTerminal) chat_note_rescan_requested_ = true; // the chat note view (payer, budget) changed
|
||||
maybeFinishTransactionSendProgress();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1459,13 +1479,14 @@ void App::update()
|
||||
shouldRunWalletTransactionRefresh()) {
|
||||
if (refreshTransactionData()) network_refresh_.retrySoon(RefreshTimer::Transactions, kScanRetrySec);
|
||||
}
|
||||
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
|
||||
// (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
|
||||
// normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity /
|
||||
// connection / on lite); the in-flight guard keeps overlapping RPCs from stacking.
|
||||
// 0-conf chat on the Chat page: a cheap txcount probe each tick (the user's chat poll rate), and the
|
||||
// inbox scan when the wallet gained a transaction, as the scan budget admits it. A scan the budget held
|
||||
// back asks again within kScanRetrySec (a waiting stream must keep asking). The normal harvest only
|
||||
// re-scans on a new block. Self-gated (no-op without a chat identity / connection / on lite).
|
||||
chat_fast_scan_accum_ += logic_dt_;
|
||||
const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable
|
||||
if (chat_fast_scan_accum_ >= chatPoll) {
|
||||
const float chatTick = chat_inbox_scan_wanted_ ? std::min(chatPoll, kScanRetrySec) : chatPoll;
|
||||
if (chat_fast_scan_accum_ >= chatTick) {
|
||||
chat_fast_scan_accum_ = 0.0f;
|
||||
fastScanChatMemos();
|
||||
}
|
||||
@@ -1487,12 +1508,12 @@ void App::update()
|
||||
refreshAddressData();
|
||||
}
|
||||
} else if (addressTier != services::ScanTier::None && network_refresh_.isDue(RefreshTimer::Addresses)) {
|
||||
// Explicit need (a changed address set, or an in-flight send tracking its change output)
|
||||
// refreshes now (list-only while the scans are held). The routine poll on a wallet page runs
|
||||
// Explicit need (a changed address set, a committed send or other mutation) refreshes now
|
||||
// (list-only while the scans are held). The routine poll on a wallet page runs
|
||||
// only when something it depends on changed (tip, mempool, a wallet mutation) or its safety
|
||||
// sweep is due, and only as the scan budget allows; held back, it asks again soon.
|
||||
bool retry = false;
|
||||
if (addresses_dirty_ || hasTransactionSendProgress()) {
|
||||
if (addresses_dirty_) {
|
||||
refreshAddressData();
|
||||
} else if (walletDataPage && addressTier == services::ScanTier::Full) {
|
||||
using services::ScanStream;
|
||||
@@ -5325,7 +5346,7 @@ void App::refreshNow()
|
||||
noteWalletMutation(); // the user asked: the change-gated scans run, the balance too
|
||||
requestForcedBalanceRefresh();
|
||||
markTransactionsDirty(); // Force transaction list update
|
||||
addresses_dirty_ = true; // Force address/balance update
|
||||
markAddressesDirty(); // Force address/balance update
|
||||
last_tx_block_height_ = -1; // Reset tx cache
|
||||
// A full receive re-scan (explicit, so it runs back to back), but keep the sent sweep's watermark:
|
||||
// every press would otherwise cost a whole-wallet z_listsentbyaddress.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include "services/connect_scan_gate.h"
|
||||
#include "services/scan_policy.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
#include "services/chat_scan_policy.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
@@ -436,8 +437,10 @@ public:
|
||||
// auto-shield) with the shared opid poller so its eventual success/failure is
|
||||
// surfaced and balances/transactions refresh on completion. z_sendmany uses the
|
||||
// richer pending-send path internally; this is for operations with no optimistic
|
||||
// transaction row of their own.
|
||||
void trackOperation(const std::string& opid);
|
||||
// transaction row of their own. The origin decides what its completion re-reads: the client
|
||||
// auto-shield is Auto (re-read, balance unforced); everything the user starts is User.
|
||||
void trackOperation(const std::string& opid,
|
||||
services::NetworkRefreshService::OperationOrigin origin = services::NetworkRefreshService::OperationOrigin::User);
|
||||
// The connected daemon rejected the opid-filtered z_getoperationstatus, so polls use the unfiltered
|
||||
// form (every retained op) for the rest of this connection.
|
||||
bool opidFilterUnsupported() const { return opid_filter_unsupported_.load(std::memory_order_relaxed); }
|
||||
@@ -935,12 +938,38 @@ private:
|
||||
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
|
||||
// refill forever). Prevents runaway splitting that would drain balance into fees.
|
||||
bool chat_split_outstanding_ = false;
|
||||
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
|
||||
double chat_split_submitted_at_ = 0.0; // scanClockNow() when the outstanding split was submitted (watchdog)
|
||||
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
|
||||
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
|
||||
// balance poll discards per-note data). Set with its scan ticket; cleared only by its reply.
|
||||
bool chat_note_scan_in_flight_ = false;
|
||||
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
|
||||
double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off)
|
||||
double chat_note_scan_last_ = 0.0; // scanClockNow() of the last note-scan dispatch or address-scan feed
|
||||
std::int64_t chat_note_scan_tip_ = -1; // the settled tip the note view was read at (-1: look again)
|
||||
bool chat_note_rescan_requested_ = false; // a chat op finished: its notes changed
|
||||
bool chat_note_first_look_sent_ = false; // the session's one explicit look (Chat page, no model)
|
||||
services::ChatPayerMap chat_verified_funds_by_addr_; // verified notes by address (the chat payer view)
|
||||
std::int64_t chat_split_hold_tip_ = -1; // a split failed at this tip: none before the next block
|
||||
// Chat spends the payer view's producers (a note scan, an address pass) posted earlier can't see: worker_ is
|
||||
// FIFO, so a reply reflects every spend submitted before it was posted, none after. Re-applied on its reply.
|
||||
struct ChatSpendLogEntry {
|
||||
std::uint64_t seq = 0;
|
||||
std::string payer;
|
||||
services::ChatPayerSpend spend = services::ChatPayerSpend::OneNote;
|
||||
};
|
||||
std::uint64_t chat_spend_seq_ = 0;
|
||||
std::deque<ChatSpendLogEntry> chat_spend_log_;
|
||||
std::int64_t chat_note_scan_spend_seq_ = -1; // the spend seq an outstanding note scan was posted at
|
||||
std::int64_t addr_pass_spend_seq_ = -1; // ...and an outstanding address pass
|
||||
void debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend);
|
||||
void reapplyChatSpendsAfter(std::uint64_t seq);
|
||||
// The 0-conf inbox: a txcount probe per chat tick, the inbox scan when the wallet gained a tx.
|
||||
std::int64_t chat_inbox_basis_txcount_ = -1; // txcount the last successful inbox scan is current for
|
||||
double chat_inbox_last_scan_at_ = 0.0; // scanClockNow() at that scan's dispatch (safety net)
|
||||
bool chat_inbox_scan_wanted_ = false; // the probe found a need the budget hasn't admitted yet
|
||||
std::int64_t chat_inbox_wanted_txcount_ = -1; // the count that need was found at: the next basis
|
||||
double chat_inbox_probe_at_ = 0.0; // scanClockNow() of the last probe
|
||||
double chat_inbox_probe_ms_ = 0.0; // its cost (self-throttle)
|
||||
bool chatInboxScanAllowed() const; // the inbox's gates (identity, the hard gates, the Chat page)
|
||||
void admitChatInboxScan(); // ask the scan budget; post the inbox scan when admitted
|
||||
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
|
||||
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
|
||||
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
|
||||
@@ -949,14 +978,21 @@ private:
|
||||
|
||||
// Coordinator helpers (both variants unless noted; see app_network.cpp).
|
||||
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
|
||||
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
|
||||
// Full node: the z_listunspent note-buffer scan, on need (services::shouldScanChatNotes), under the scan
|
||||
// budget (ChatNotes); also off the Chat page while a queued send or a split waits on it.
|
||||
void refreshChatNoteBudgetNode();
|
||||
// Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
|
||||
// so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
|
||||
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes);
|
||||
// feedTip: the tip that pass may vouch for (-1 when the tip moved too recently for the wallet notifier).
|
||||
// spendSeq: the chat spend seq the pass was posted at (its reply re-applies the spends after it).
|
||||
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes,
|
||||
std::int64_t feedTip, std::uint64_t spendSeq);
|
||||
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
|
||||
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
bool shouldSplitChatBuffer();
|
||||
// What one split can spend: on the full node the richest verified payer (z_sendmany pays from ONE address).
|
||||
std::uint64_t chatSplitAffordableZat() const;
|
||||
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
|
||||
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
|
||||
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
|
||||
@@ -1010,23 +1046,23 @@ private:
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
|
||||
// reply address. The reply-to in the memo stays the identity address, so paying from a different
|
||||
// funded note is transport-transparent. Empty if no z-address can cover the fee.
|
||||
std::string chatPayFromZaddr(double fee) const;
|
||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||
// the identity reply address; from the chat note view's verified notes when there is one. The reply-to in
|
||||
// the memo stays the identity address, so paying from a different funded note is transport-transparent.
|
||||
// Empty if no z-address can cover it.
|
||||
std::string chatPayFromZaddr(double need) const;
|
||||
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
|
||||
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
|
||||
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
|
||||
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
|
||||
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
|
||||
// Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle
|
||||
// so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped
|
||||
// (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity.
|
||||
// Full-node 0-conf fast path on the Chat page: a txcount probe each chat tick, and the inbox scan (the chat
|
||||
// reply address at minconf=0) when the wallet gained a tx, as the scan budget admits it (ChatFast), so
|
||||
// incoming messages surface at mempool speed. Hidden conversations are skipped (they still un-hide via the
|
||||
// normal confirmed harvest). Self-gated; no-op without a chat identity.
|
||||
void fastScanChatMemos();
|
||||
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
|
||||
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
|
||||
double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off)
|
||||
double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress)
|
||||
bool chat_fast_scan_in_flight_ = false; // the inbox probe or scan is out; cleared only by its reply
|
||||
float chat_fast_scan_accum_ = 0.0f; // seconds since the last chat tick
|
||||
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
@@ -1377,6 +1413,11 @@ private:
|
||||
// that actually sent the forced balance call, not at dispatch, so a dispatch the job queue refuses
|
||||
// (a Core job already in flight) or one that skips the balance (node behind or busy) keeps it.
|
||||
void requestForcedBalanceRefresh() { force_balance_refresh_ = true; ++force_balance_seq_; }
|
||||
// A wallet mutation committed (a User/Auto op succeeded, or a send returned no opid): re-read what it changed.
|
||||
void markWalletMutated(bool forceBalance);
|
||||
bool hasPendingUserOperations() const {
|
||||
return services::NetworkRefreshService::anyUserOperationPending(pending_opids_, op_origin_);
|
||||
}
|
||||
// One budget for every wallet-wide scan the GUI sends on its own (balance, addresses, history, the
|
||||
// between-blocks probe): one in flight at a time across both lanes, ~15% of wall-clock in total. They
|
||||
// used to be throttled one by one at 10% each, which added up to 30-40% with all of them due.
|
||||
@@ -1385,6 +1426,7 @@ private:
|
||||
// The change signals the routine scans are gated on, besides the tip hash (state_.sync.best_blockhash).
|
||||
std::uint64_t wallet_mutation_epoch_ = 0; // bumped by noteWalletMutation()
|
||||
bool mempool_known_ = false; // the last Core poll's getmempoolinfo
|
||||
double tip_changed_at_ = 0.0; // scanClockNow() when a Core poll saw a new tip hash (chat settle)
|
||||
std::int64_t mempool_txs_ = 0;
|
||||
std::int64_t mempool_bytes_ = 0;
|
||||
bool window_background_ = false; // setWindowBackground()
|
||||
@@ -1556,6 +1598,7 @@ private:
|
||||
std::size_t history_transparent_rows_ = 0; // rows the last listtransactions chain returned
|
||||
int history_transparent_tip_ = -1; // ...and the tip it ran at (same tip: rows can be reused)
|
||||
std::uint64_t tx_dirty_generation_ = 0; // bumped by markTransactionsDirty()
|
||||
std::uint64_t addr_dirty_generation_ = 0; // bumped by markAddressesDirty()
|
||||
// Addresses holding notes the history doesn't show yet (from the address scan's z_listunspent): scanned
|
||||
// first in the next wallet-wide cycle, so a receive or payout shows within one cycle on a large wallet
|
||||
// instead of when the routine pass reaches its address. Each txid hints once per session after it is
|
||||
@@ -1626,6 +1669,8 @@ private:
|
||||
bool send_progress_active_ = false;
|
||||
int send_submissions_in_flight_ = 0;
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
// Non-User ops only (absent = User): what each op's completion re-reads.
|
||||
std::unordered_map<std::string, services::NetworkRefreshService::OperationOrigin> op_origin_;
|
||||
struct PendingSendInfo {
|
||||
std::string from;
|
||||
std::string to;
|
||||
@@ -1814,6 +1859,8 @@ private:
|
||||
void startTransactionCycle(bool full, bool routineAllowed, services::ScanTicket ticket); // Full or Light
|
||||
void startRecentTransactionPoll(bool probe, services::ScanTicket ticket); // between blocks
|
||||
void markTransactionsDirty() { transactions_dirty_ = true; ++tx_dirty_generation_; }
|
||||
// A pass sent before this can't clear it (refreshAddressData compares the generation).
|
||||
void markAddressesDirty() { addresses_dirty_ = true; ++addr_dirty_generation_; }
|
||||
std::vector<std::string> shieldedAddressList() const;
|
||||
services::ShieldedHistoryPass::Work historyScanWork() const;
|
||||
// From the address scan's z_listunspent: addresses holding received notes the history doesn't show
|
||||
|
||||
+375
-125
@@ -760,7 +760,7 @@ void App::onConnected()
|
||||
encryption_state_prefetched_ = initialPrefetchQueued;
|
||||
|
||||
// Addresses are unknown on fresh connect — force a fetch
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
// Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the
|
||||
// sync profile now if the connect's getinfo already shows it behind.
|
||||
armScanGate(prefetchedInfo);
|
||||
@@ -842,10 +842,26 @@ void App::onDisconnected(const std::string& reason)
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_submitted_at_ = 0.0;
|
||||
inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed)
|
||||
// The chat scans' bases describe this connection's wallet (a reconnect may be another daemon). Their in-flight
|
||||
// flags are cleared by their replies; scan_governor_.reset() below turns their tickets into no-ops.
|
||||
chat_inbox_basis_txcount_ = -1;
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_wanted_txcount_ = -1;
|
||||
chat_inbox_probe_at_ = 0.0; // a reconnect may be another daemon: its probe cost is unknown
|
||||
chat_inbox_probe_ms_ = 0.0;
|
||||
chat_note_scan_tip_ = -1;
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_verified_note_budget_ = 0; // nor a budget from the previous connection's view
|
||||
chat_split_hold_tip_ = -1;
|
||||
chat_spend_log_.clear();
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
addr_pass_spend_seq_ = -1;
|
||||
network_refresh_.resetJobs();
|
||||
rescan_status_poll_in_progress_ = false;
|
||||
opid_poll_in_progress_ = false;
|
||||
opid_filter_unsupported_ = false; // a reconnect may be to a different daemon
|
||||
op_origin_.clear();
|
||||
send_feegap_retried_opids_.clear();
|
||||
last_total_balance_zat_.reset();
|
||||
history_transparent_rows_ = 0; // the history's scan state (the pass, epochs and watermark are
|
||||
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
|
||||
@@ -1130,7 +1146,7 @@ bool App::currentPageNeedsWalletDataRefresh() const
|
||||
bool App::shouldRunWalletTransactionRefresh() const
|
||||
{
|
||||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||||
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
||||
if (!send_txids_.empty()) return true;
|
||||
return transactions_dirty_;
|
||||
}
|
||||
|
||||
@@ -1307,7 +1323,7 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
|
||||
}
|
||||
}
|
||||
|
||||
void App::trackOperation(const std::string& opid)
|
||||
void App::trackOperation(const std::string& opid, services::NetworkRefreshService::OperationOrigin origin)
|
||||
{
|
||||
if (opid.empty()) return;
|
||||
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
|
||||
@@ -1315,6 +1331,25 @@ void App::trackOperation(const std::string& opid)
|
||||
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
|
||||
return;
|
||||
pending_opids_.push_back(opid);
|
||||
if (origin != services::NetworkRefreshService::OperationOrigin::User) op_origin_[opid] = origin;
|
||||
}
|
||||
|
||||
void App::markWalletMutated(bool forceBalance)
|
||||
{
|
||||
noteWalletMutation(); // the change-gated streams look again
|
||||
markAddressesDirty(); // explicit address pass; one already in flight can't clear it
|
||||
markTransactionsDirty(); // explicit history cycle; same guard (tx_dirty_generation_)
|
||||
last_tx_block_height_ = -1;
|
||||
if (forceBalance) {
|
||||
requestForcedBalanceRefresh(); // applied once no User op is pending (refreshCoreData)
|
||||
// A user op (send, shield, merge, sweep) may have spent notes the chat payer view still lists, and the
|
||||
// pending debit on state_.z_addresses omits the fee and the unverified change, so it can't stand in.
|
||||
// Hold chat sends and splits until the post-commit address pass (marked above) re-feeds the view.
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_verified_note_budget_ = 0;
|
||||
chat_verified_shielded_zat_ = 0;
|
||||
}
|
||||
network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now
|
||||
}
|
||||
|
||||
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
@@ -2041,7 +2076,7 @@ void App::refreshCoreData(bool allowBalance)
|
||||
balanceIn.gateOpen = scanGateOpen();
|
||||
balanceIn.background = window_background_;
|
||||
balanceIn.firstReadTried = first_balance_read_tried_;
|
||||
balanceIn.forced = force_balance_refresh_;
|
||||
balanceIn.forced = force_balance_refresh_ && !hasPendingUserOperations(); // a batch of user ops forces once, at its end
|
||||
balanceIn.neverFetched = state_.last_balance_update == 0;
|
||||
balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow);
|
||||
balanceIn.visible = currentScanVisibility().balance;
|
||||
@@ -2066,8 +2101,9 @@ void App::refreshCoreData(bool allowBalance)
|
||||
const bool includeBalance = static_cast<bool>(balanceTicket);
|
||||
// The forced request this poll carries. The result callback clears the flag only if the balance
|
||||
// call was really sent and no newer request came in; clearing it here lost it whenever the enqueue
|
||||
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy).
|
||||
const std::uint32_t forcedSeq = (includeBalance && force_balance_refresh_) ? force_balance_seq_ : 0;
|
||||
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy). Only a force
|
||||
// applicable now: a routine poll admitted while it is deferred (a user op still pending) doesn't clear it.
|
||||
const std::uint32_t forcedSeq = (includeBalance && balanceIn.forced) ? force_balance_seq_ : 0;
|
||||
NetworkRefreshService::CoreRefreshPlan plan;
|
||||
plan.includeBalance = includeBalance;
|
||||
plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main
|
||||
@@ -2106,7 +2142,11 @@ void App::refreshCoreData(bool allowBalance)
|
||||
scan_governor_.complete(t, result.balanceScanMs, outcome, scanClockNow());
|
||||
}
|
||||
if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk;
|
||||
const std::string prevTipHash = state_.sync.best_blockhash;
|
||||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||||
// The chat note view waits a moment after a new tip: the wallet notifier stores a block's txs
|
||||
// ~1 s after it connects.
|
||||
if (state_.sync.best_blockhash != prevTipHash) tip_changed_at_ = scanClockNow();
|
||||
if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown();
|
||||
if (result.balanceOk) {
|
||||
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
|
||||
@@ -2238,8 +2278,8 @@ void App::refreshCoreData(bool allowBalance)
|
||||
}
|
||||
if (!opid.empty()) {
|
||||
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
|
||||
// Surface the async result + refresh balances on completion.
|
||||
trackOperation(opid);
|
||||
// Surface the async result + refresh balances on completion (unforced).
|
||||
trackOperation(opid, services::NetworkRefreshService::OperationOrigin::Auto);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -2297,11 +2337,19 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly]() -> rpc::RPCWorker::MainCb {
|
||||
const std::uint64_t addrDirtyGen = addr_dirty_generation_;
|
||||
// The tip the chat note view may claim from this pass: only one the wallet notifier has caught up with.
|
||||
const std::int64_t chatFeedTip =
|
||||
scanClockNow() - tip_changed_at_ >= services::kChatTipSettleSec ? state_.sync.blocks : -1;
|
||||
const std::uint64_t chatSpendSeq = chat_spend_seq_;
|
||||
addr_pass_spend_seq_ = static_cast<std::int64_t>(chatSpendSeq);
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly, addrDirtyGen, chatFeedTip, chatSpendSeq]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly);
|
||||
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, result = std::move(result)]() mutable {
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, addrDirtyGen, chatFeedTip,
|
||||
chatSpendSeq, result = std::move(result)]() mutable {
|
||||
if (addr_pass_spend_seq_ == static_cast<std::int64_t>(chatSpendSeq)) addr_pass_spend_seq_ = -1;
|
||||
const bool addrListOk = result.addressListOk; // capture before the move
|
||||
// What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the
|
||||
// previous balances rather than writing the zeros of the calls that didn't answer).
|
||||
@@ -2329,9 +2377,13 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
auto unspentNotes = std::move(result.unspentNotes);
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances
|
||||
if (!appliedListOnly) {
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes);
|
||||
// Not from a pass sent before a commit marked since: its notes predate it (the owed pass feeds it).
|
||||
if (addr_dirty_generation_ == addrDirtyGen)
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes, chatFeedTip, chatSpendSeq);
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
addresses_dirty_ = false; // a list-only pass still owes the full one
|
||||
// A mutation marked after this pass was sent isn't in it (keeps the send progress until the
|
||||
// post-commit balances). A list-only pass still owes the full one.
|
||||
if (addr_dirty_generation_ == addrDirtyGen) addresses_dirty_ = false;
|
||||
address_balances_loaded_ = true;
|
||||
} else if (!listOnly) {
|
||||
// The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps
|
||||
@@ -2840,7 +2892,7 @@ void App::refreshBalance()
|
||||
|
||||
void App::refreshAddresses()
|
||||
{
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
refreshAddressData();
|
||||
}
|
||||
|
||||
@@ -3707,7 +3759,7 @@ void App::setMiningAddress(const std::string& addr, bool mining)
|
||||
void App::invalidateAddressValidationCache()
|
||||
{
|
||||
address_validation_cache_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before
|
||||
noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
@@ -3822,7 +3874,24 @@ void App::resetChatSession()
|
||||
chat_note_model_seen_ = false;
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_submitted_at_ = 0.0;
|
||||
chat_note_scan_in_flight_ = false; // a stale in-flight note scan is dropped by its scanGen guard
|
||||
// The chat scans' in-flight flags are cleared only by their replies, which complete their scan tickets (see
|
||||
// the orphan check in update()): clearing them here would let that check drop a live ticket while the
|
||||
// daemon still runs the scan. Their results are dropped by the session guard.
|
||||
chat_note_rescan_requested_ = false;
|
||||
chat_note_scan_tip_ = -1;
|
||||
chat_note_scan_last_ = 0.0;
|
||||
chat_note_first_look_sent_ = false;
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_split_hold_tip_ = -1;
|
||||
chat_inbox_basis_txcount_ = -1;
|
||||
chat_inbox_last_scan_at_ = 0.0;
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_wanted_txcount_ = -1;
|
||||
chat_inbox_probe_at_ = 0.0;
|
||||
chat_inbox_probe_ms_ = 0.0;
|
||||
chat_spend_log_.clear();
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
addr_pass_spend_seq_ = -1;
|
||||
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
|
||||
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
@@ -3834,7 +3903,6 @@ void App::resetChatSession()
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
|
||||
chat_identity_unavailable_ = false;
|
||||
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
|
||||
// completion callback can't provision that secret under the new wallet.
|
||||
@@ -3955,6 +4023,22 @@ void App::maybeProvisionChatIdentity()
|
||||
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use
|
||||
// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable
|
||||
// one so replies land somewhere we control and fees can be paid.
|
||||
// The chat note-buffer coordinator's constants (see below), here so the payer helpers can use them.
|
||||
namespace {
|
||||
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
|
||||
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
|
||||
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
|
||||
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
|
||||
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
|
||||
constexpr int kChatConfsRequired = 5;
|
||||
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
|
||||
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
|
||||
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
|
||||
inline std::uint64_t chatDrgxToZat(double drgx) {
|
||||
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string App::chatReplyZaddr()
|
||||
{
|
||||
// Reuse the previously-chosen address as long as we still own it spendably.
|
||||
@@ -3983,25 +4067,62 @@ std::string App::chatReplyZaddr()
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string App::chatPayFromZaddr(double fee) const
|
||||
std::string App::chatPayFromZaddr(double need) const
|
||||
{
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= need. Prefer the identity
|
||||
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
|
||||
// spendable z-address that can cover the fee. The memo still advertises the identity address as
|
||||
// spendable z-address that can cover it. The memo still advertises the identity address as
|
||||
// reply-to, so paying from a different note doesn't change who the peer replies to.
|
||||
std::string reply;
|
||||
if (settings_) reply = settings_->getChatReplyZaddr();
|
||||
// The chat note scan (or the address scan's feed) knows each address's verified notes; on the Chat page that
|
||||
// is fresher than state_.z_addresses' balances, which only the address scan refreshes.
|
||||
if (!chat_verified_funds_by_addr_.empty())
|
||||
return services::chooseChatPayer(chat_verified_funds_by_addr_, reply, chatDrgxToZat(need));
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
|
||||
if (a.address == reply && a.has_spending_key && a.spendableBalance >= need) return reply;
|
||||
|
||||
std::string best;
|
||||
double bestBal = -1.0;
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
|
||||
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= need && a.spendableBalance > bestBal) {
|
||||
best = a.address;
|
||||
bestBal = a.spendableBalance;
|
||||
}
|
||||
return best; // empty → no z-address can cover the fee
|
||||
return best; // empty → no z-address can cover it
|
||||
}
|
||||
|
||||
void App::debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend)
|
||||
{
|
||||
// Entries every outstanding reply already reflects (posted after them) can go; none outstanding: all.
|
||||
std::int64_t keepAfter = -1;
|
||||
for (std::int64_t seq : {chat_note_scan_spend_seq_, addr_pass_spend_seq_})
|
||||
if (seq >= 0) keepAfter = keepAfter < 0 ? seq : std::min(keepAfter, seq);
|
||||
if (keepAfter < 0) chat_spend_log_.clear();
|
||||
while (!chat_spend_log_.empty() &&
|
||||
(static_cast<std::int64_t>(chat_spend_log_.front().seq) <= keepAfter || chat_spend_log_.size() >= 64))
|
||||
chat_spend_log_.pop_front();
|
||||
chat_spend_log_.push_back({++chat_spend_seq_, payer, spend});
|
||||
services::debitChatPayer(chat_verified_funds_by_addr_, payer, spend);
|
||||
}
|
||||
|
||||
void App::reapplyChatSpendsAfter(std::uint64_t seq)
|
||||
{
|
||||
for (const auto& e : chat_spend_log_) {
|
||||
if (e.seq <= seq) continue;
|
||||
services::debitChatPayer(chat_verified_funds_by_addr_, e.payer, e.spend);
|
||||
if (e.spend == services::ChatPayerSpend::OneNote)
|
||||
chat_verified_note_budget_ = std::max(0, chat_verified_note_budget_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t App::chatSplitAffordableZat() const
|
||||
{
|
||||
// What one split can spend: z_sendmany pays from ONE address, so on the full node the richest verified
|
||||
// payer (lite: the verified shielded balance, as before).
|
||||
if (!lite_wallet_ && !chat_verified_funds_by_addr_.empty())
|
||||
return services::richestChatPayerZat(chat_verified_funds_by_addr_);
|
||||
return chat_verified_shielded_zat_;
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
@@ -4083,11 +4204,14 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||||
[this, echoLocalId, sessionGen](bool ok, const std::string& result) {
|
||||
if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
|
||||
if (!ok) chat_note_rescan_requested_ = true; // covers a submit-time failure the poller never sees
|
||||
inflight_op_ = LiteInflightOp{}; // release the channel for this session
|
||||
LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
|
||||
onChatBroadcastResult(op, ok, result);
|
||||
},
|
||||
/*background*/ true); // status is shown on the chat message, not the global send UI
|
||||
// Until the next note scan restores the truth: the send spends one whole note (largest first) of `from`.
|
||||
debitChatPayerLogged(from, services::ChatPayerSpend::OneNote);
|
||||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||||
}
|
||||
|
||||
@@ -4208,20 +4332,7 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
|
||||
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
|
||||
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
|
||||
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
|
||||
namespace {
|
||||
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
|
||||
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
|
||||
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
|
||||
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
|
||||
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
|
||||
constexpr int kChatConfsRequired = 5;
|
||||
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
|
||||
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
|
||||
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
|
||||
inline std::uint64_t chatDrgxToZat(double drgx) {
|
||||
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
|
||||
}
|
||||
} // namespace
|
||||
// (The coordinator's constants and chatDrgxToZat are defined above chatReplyZaddr.)
|
||||
|
||||
// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
|
||||
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
|
||||
@@ -4318,7 +4429,7 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model)
|
||||
// recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
}
|
||||
@@ -4338,42 +4449,69 @@ void App::refreshChatNoteBudgetNode() {
|
||||
// chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
|
||||
// send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
|
||||
// The same hard gates as every wallet scan, except that a queued chat send still drains while the window
|
||||
// is in the background (the address scan that also feeds the budget waits for the window).
|
||||
// is in the background (the address scan that also feeds the budget waits for the window). Not for a
|
||||
// split alone: nothing needs the buffer in the background, and a split that never refills would scan
|
||||
// every block for as long as the window stays minimized.
|
||||
{
|
||||
services::ScanContext sc = scanContext();
|
||||
if (!chat_send_queue_.empty() || chat_split_outstanding_) sc.background = false;
|
||||
if (!chat_send_queue_.empty()) sc.background = false;
|
||||
if (!services::wholeWalletScanAllowed(sc)) return;
|
||||
}
|
||||
// Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
|
||||
// z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
|
||||
// already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
|
||||
// fallback for the Chat tab (where the address refresh doesn't run).
|
||||
if (current_page_ != ui::NavPage::Chat) return;
|
||||
if (chat_note_scan_in_flight_) return; // one scan at a time
|
||||
const double now = ImGui::GetTime();
|
||||
// Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many
|
||||
// seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same
|
||||
// duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor.
|
||||
double minInterval = 4.0;
|
||||
if (chat_note_scan_ms_ > 0.0)
|
||||
minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10);
|
||||
if (now - chat_note_scan_last_ < minInterval) return;
|
||||
|
||||
if (chat_note_scan_in_flight_) return; // cleared only by its reply (which completes its ticket)
|
||||
// The address pass reads the same z_listunspent and feeds this view: not a second one while it runs or is owed.
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (network_refresh_.jobInProgress(Job::Addresses) || (addresses_dirty_ && addressScanCanRunNow())) return;
|
||||
using services::ScanStream;
|
||||
const double now = scanClockNow();
|
||||
// On need only: something can use the view (the Chat page, a queued send, a split) and it can have changed
|
||||
// (first look, a chat op finished, a settled block that matters, or the fallback). How often: the budget's.
|
||||
services::ChatNoteScanInputs in;
|
||||
in.onChatPage = currentScanVisibility().chat;
|
||||
in.queueWaiting = !chat_send_queue_.empty();
|
||||
in.splitOutstanding = chat_split_outstanding_;
|
||||
in.rescanRequested = chat_note_rescan_requested_;
|
||||
in.modelSeen = chat_note_model_seen_;
|
||||
in.verifiedBudget = chat_verified_note_budget_;
|
||||
in.pipelineNotes = chat_pipeline_note_count_;
|
||||
in.tipHeight = state_.sync.blocks;
|
||||
in.lastScanTip = chat_note_scan_tip_;
|
||||
in.nowSec = now;
|
||||
in.lastScanAtSec = chat_note_scan_last_;
|
||||
in.tipChangedAtSec = tip_changed_at_;
|
||||
in.fallbackSec = services::chatNoteFallbackSec(scan_governor_.emaCostMs(ScanStream::ChatNotes));
|
||||
if (!services::shouldScanChatNotes(in)) return;
|
||||
const auto sig = currentScanSignals();
|
||||
services::ScanTicket ticket;
|
||||
if (!chat_note_model_seen_ && in.onChatPage && !chat_note_first_look_sent_) {
|
||||
// The page can't show the buffer, or drain a send, without one look: explicit (never held back, but
|
||||
// charged), once per session.
|
||||
ticket = scan_governor_.beginExplicit(ScanStream::ChatNotes, sig, now);
|
||||
chat_note_first_look_sent_ = true;
|
||||
} else {
|
||||
ticket = scan_governor_.tryAdmit(ScanStream::ChatNotes, in.onChatPage || in.queueWaiting, sig, now);
|
||||
if (!ticket) return; // asks again next frame while the need holds
|
||||
}
|
||||
const bool servedRescan = chat_note_rescan_requested_;
|
||||
chat_note_rescan_requested_ = false;
|
||||
if (now - tip_changed_at_ >= services::kChatTipSettleSec) chat_note_scan_tip_ = state_.sync.blocks; // settled only
|
||||
chat_note_scan_last_ = now;
|
||||
chat_note_scan_in_flight_ = true;
|
||||
const std::uint64_t spendSeq = chat_spend_seq_;
|
||||
chat_note_scan_spend_seq_ = static_cast<std::int64_t>(spendSeq);
|
||||
const int scanGen = chat_session_generation_;
|
||||
const int confsNeeded = chatConfsRequired(); // == 1 for full node
|
||||
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, scanGen, confsNeeded, minVal, ticket, servedRescan, spendSeq]() -> rpc::RPCWorker::MainCb {
|
||||
int verified = 0, pipeline = 0;
|
||||
std::uint64_t verifiedZat = 0;
|
||||
services::ChatPayerMap funds;
|
||||
bool ok = false;
|
||||
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
|
||||
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
|
||||
if (notes.is_array()) {
|
||||
ok = true;
|
||||
for (const auto& nz : notes) {
|
||||
if (!nz.is_object()) continue;
|
||||
if (nz.value("locked", false)) continue; // tied up by an in-flight send
|
||||
@@ -4384,24 +4522,50 @@ void App::refreshChatNoteBudgetNode() {
|
||||
? nz["rawconfirmations"].get<int>()
|
||||
: nz.value("confirmations", 0);
|
||||
++pipeline; // unspent self-note (verified or maturing)
|
||||
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
|
||||
if (confs >= confsNeeded) {
|
||||
++verified;
|
||||
verifiedZat += amtZat;
|
||||
if (nz.contains("address") && nz["address"].is_string()) {
|
||||
auto& f = funds[nz["address"].get<std::string>()];
|
||||
f.zat += amtZat;
|
||||
++f.notes;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok = true; // after the loop: a throw mid-loop applies nothing
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
} catch (const std::exception& e) {
|
||||
failKind = rpc::rpcFailureKindOf(e);
|
||||
} catch (...) {
|
||||
failKind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const double scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() {
|
||||
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
|
||||
return [this, scanGen, ticket, servedRescan, spendSeq, ok, failKind, verified, pipeline, verifiedZat,
|
||||
funds = std::move(funds), scanMs]() mutable {
|
||||
chat_note_scan_in_flight_ = false;
|
||||
if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off
|
||||
if (!ok) return; // z_listunspent unavailable — leave caches as-is
|
||||
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
|
||||
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
|
||||
if (scanGen != chat_session_generation_) return; // wallet switched — drop
|
||||
// onDisconnected cleared the view and this scan's seq since the post: its notes are the previous
|
||||
// connection's, and the spends to re-apply are gone from the log. Drop it (the reconnect's address
|
||||
// pass feeds the view), like the address pass whose reply resetJobs makes stale.
|
||||
if (chat_note_scan_spend_seq_ != static_cast<std::int64_t>(spendSeq)) return;
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
if (!ok) { // still owed (the 4 s floor paces the retry)
|
||||
if (servedRescan) chat_note_rescan_requested_ = true;
|
||||
chat_note_scan_tip_ = -1;
|
||||
return;
|
||||
}
|
||||
chat_note_model_seen_ = true;
|
||||
chat_verified_note_budget_ = verified;
|
||||
chat_pipeline_note_count_ = pipeline;
|
||||
chat_verified_shielded_zat_ = verifiedZat;
|
||||
chat_verified_funds_by_addr_ = std::move(funds);
|
||||
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this scan was posted
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
};
|
||||
@@ -4413,27 +4577,42 @@ void App::refreshChatNoteBudgetNode() {
|
||||
// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the
|
||||
// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off.
|
||||
void App::updateChatNoteBudgetFromUnspent(
|
||||
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes) {
|
||||
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes, std::int64_t feedTip,
|
||||
std::uint64_t spendSeq) {
|
||||
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
const int confsNeeded = chatConfsRequired();
|
||||
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
int verified = 0, pipeline = 0;
|
||||
std::uint64_t verifiedZat = 0;
|
||||
services::ChatPayerMap funds;
|
||||
for (const auto& nz : unspentNotes) {
|
||||
if (nz.locked) continue; // tied up by an in-flight send
|
||||
const std::uint64_t amtZat = chatDrgxToZat(nz.amount);
|
||||
if (amtZat < minVal) continue; // skip dust below a fee
|
||||
++pipeline; // unspent self-note (verified or maturing)
|
||||
if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; }
|
||||
if (nz.confirmations >= confsNeeded) {
|
||||
++verified;
|
||||
verifiedZat += amtZat;
|
||||
if (!nz.address.empty()) {
|
||||
auto& f = funds[nz.address];
|
||||
f.zat += amtZat;
|
||||
++f.notes;
|
||||
}
|
||||
}
|
||||
}
|
||||
chat_note_model_seen_ = true;
|
||||
chat_verified_note_budget_ = verified;
|
||||
chat_pipeline_note_count_ = pipeline;
|
||||
chat_verified_shielded_zat_ = verifiedZat;
|
||||
chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off
|
||||
chat_verified_funds_by_addr_ = std::move(funds);
|
||||
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this pass was posted
|
||||
// Counts as a fresh scan: the dedicated one backs off. Not the rescan request: this pass may predate the
|
||||
// chat op that asked for it.
|
||||
chat_note_scan_last_ = scanClockNow();
|
||||
if (feedTip >= 0) chat_note_scan_tip_ = feedTip;
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
}
|
||||
@@ -4483,13 +4662,13 @@ bool App::broadcastSelfSplitNode(int noteCount) {
|
||||
const std::string myZ = chatReplyZaddr();
|
||||
if (myZ.empty()) return false;
|
||||
const double fee = kChatMinFeeDrgx;
|
||||
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
|
||||
// there). The note count now includes notes at every address and is sized against the total verified
|
||||
// balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
|
||||
// aren't at the reply address (which paying strictly from myZ would).
|
||||
const std::string from = chatPayFromZaddr(fee);
|
||||
if (from.empty()) return false; // no fundable note — pump retries after a scan
|
||||
const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys
|
||||
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
|
||||
// there). ONE address pays the whole split (n notes + the fee), not just the fee: the pump sizes n to the
|
||||
// richest verified payer (chatSplitAffordableZat), so one covers it. Paying from wherever the funds sit
|
||||
// can't stall when they aren't at the reply address (which paying strictly from myZ would).
|
||||
const std::string from = chatPayFromZaddr(n * kChatNoteSizeDrgx + fee);
|
||||
if (from.empty()) return false; // no fundable payer — no RPC; the pump retries after a scan
|
||||
nlohmann::json recipients = nlohmann::json::array();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
nlohmann::json r;
|
||||
@@ -4506,10 +4685,16 @@ bool App::broadcastSelfSplitNode(int noteCount) {
|
||||
// On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
|
||||
// next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
|
||||
// insufficient-funds throw) clear it now so the pump can retry after the next scan
|
||||
// instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
|
||||
if (!ok) chat_split_outstanding_ = false;
|
||||
// instead of waiting out the 1200s watchdog (no notes were created, so it's safe),
|
||||
// and no other split before the next block.
|
||||
if (!ok) {
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_hold_tip_ = state_.sync.blocks;
|
||||
chat_note_rescan_requested_ = true;
|
||||
}
|
||||
},
|
||||
/*background*/ true); // autonomous buffer maintenance — no global send UI
|
||||
debitChatPayerLogged(from, services::ChatPayerSpend::All);
|
||||
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
|
||||
" notes (self-send) so messages can be sent back-to-back");
|
||||
return true;
|
||||
@@ -4530,9 +4715,13 @@ bool App::shouldSplitChatBuffer() {
|
||||
if (lite_wallet_ ? (bool)lite_send_callback_
|
||||
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
|
||||
if (chat_split_outstanding_) return false;
|
||||
if (!lite_wallet_ && chat_split_hold_tip_ >= 0 && state_.sync.blocks <= chat_split_hold_tip_) return false; // failed here: once per block
|
||||
if (!chat_note_model_seen_) return false;
|
||||
if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it
|
||||
if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
|
||||
// Only a split that can lift the pipeline to the trigger by itself: once it mines, the pipeline check
|
||||
// releases the flag. A smaller one never does, and the watchdog would re-split the same notes every 20 min,
|
||||
// a fee each time. (A payer below that sends one message per confirmation through its change instead.)
|
||||
if (chatSplitAffordableZat() < chatDrgxToZat(kChatRefillTrigger * kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4605,8 +4794,8 @@ void App::pumpChatNoteBuffer() {
|
||||
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
|
||||
const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
int affordable = 0;
|
||||
if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
|
||||
affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
|
||||
const std::uint64_t affordableZat = chatSplitAffordableZat(); // one payer funds the whole split
|
||||
if (affordableZat > feeZat && noteZat > 0) affordable = static_cast<int>((affordableZat - feeZat) / noteZat);
|
||||
const int n = std::min(need, affordable);
|
||||
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
|
||||
: broadcastSelfSplitNode(n));
|
||||
@@ -4615,7 +4804,7 @@ void App::pumpChatNoteBuffer() {
|
||||
// Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
|
||||
// invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
|
||||
chat_split_outstanding_ = true;
|
||||
chat_split_submitted_at_ = ImGui::GetTime();
|
||||
chat_split_submitted_at_ = scanClockNow(); // steady: the watchdog also runs while minimized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4670,40 +4859,94 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
||||
}
|
||||
}
|
||||
|
||||
bool App::chatInboxScanAllowed() const
|
||||
{
|
||||
// Full-node only (lite has its own harvest).
|
||||
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return false;
|
||||
if (!state_.connected || !rpc_ || !worker_) return false;
|
||||
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...), and only
|
||||
// while the Chat page is shown: off it, incoming messages still arrive through the confirmed block-tip
|
||||
// harvest (+ toast) within ~1 block.
|
||||
return services::wholeWalletScanAllowed(scanContext()) && currentScanVisibility().chat;
|
||||
}
|
||||
|
||||
void App::fastScanChatMemos()
|
||||
{
|
||||
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
|
||||
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
|
||||
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
|
||||
if (lite_wallet_) return;
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
|
||||
// z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one
|
||||
// address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
|
||||
// up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
|
||||
// harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
|
||||
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...).
|
||||
if (!services::wholeWalletScanAllowed(scanContext())) return;
|
||||
// Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
|
||||
// Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
|
||||
if (current_page_ != ui::NavPage::Chat) return;
|
||||
const double nowT = ImGui::GetTime();
|
||||
if (chat_fast_scan_ms_ > 0.0 &&
|
||||
nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return;
|
||||
const std::string addr = chatReplyZaddr();
|
||||
if (addr.empty()) return;
|
||||
|
||||
chat_fast_scan_last_ = nowT;
|
||||
// 0-conf delivery while the Chat page is shown: a cheap probe (getwalletinfo's txcount) each tick, and the
|
||||
// inbox scan (z_listreceivedbyaddress of the reply address: O(mapWallet), the daemon iterates every wtx
|
||||
// even when filtered to one address, under cs_main) only when the wallet gained a transaction or the safety
|
||||
// net is due, and only as the scan budget admits it (ChatFast). The block-tip harvest still ingests every
|
||||
// message on confirmation (the store dedups on txid+position).
|
||||
if (!chatInboxScanAllowed()) return;
|
||||
if (chat_fast_scan_in_flight_) return; // the probe or the scan is out
|
||||
if (chat_inbox_scan_wanted_) { // keep asking (a waiter must keep asking)
|
||||
admitChatInboxScan();
|
||||
return;
|
||||
}
|
||||
// A wallet scan is in the daemon: the probe would queue behind it for cs_main, and nothing could be
|
||||
// admitted after it anyway.
|
||||
if (scan_governor_.anyInFlight()) return;
|
||||
const double now = scanClockNow();
|
||||
if (!services::chatProbeDue(chat_inbox_probe_ms_, chat_inbox_probe_at_, now)) return;
|
||||
chat_inbox_probe_at_ = now;
|
||||
chat_fast_scan_in_flight_ = true;
|
||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||||
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||
std::int64_t txCount = -1;
|
||||
bool answered = true; // false: nothing learned this tick (transport, busy, warmup)
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / inbox probe");
|
||||
txCount = services::parseWalletTxCount(rpc_->call("getwalletinfo"));
|
||||
} catch (const rpc::RpcError& e) {
|
||||
answered = e.kind() != rpc::RpcFailureKind::Warmup; // an app error: no count, scan as the budget allows
|
||||
} catch (...) {
|
||||
answered = false;
|
||||
}
|
||||
const double probeMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [this, scanGen, txCount, answered, probeMs]() {
|
||||
chat_fast_scan_in_flight_ = false; // ours, whatever the session
|
||||
// Its cost, from probes that answered only (a timed-out one measured its wait), smoothed so one slow
|
||||
// probe doesn't silence the next ones.
|
||||
if (answered && probeMs > 0.0) {
|
||||
chat_inbox_probe_ms_ = chat_inbox_probe_ms_ <= 0.0
|
||||
? probeMs : std::min(probeMs, 0.7 * chat_inbox_probe_ms_ + 0.3 * probeMs);
|
||||
}
|
||||
if (scanGen != chat_session_generation_ || !answered) return;
|
||||
services::ChatInboxScanInputs in;
|
||||
in.basisTxCount = chat_inbox_basis_txcount_;
|
||||
in.txCount = txCount;
|
||||
in.nowSec = scanClockNow();
|
||||
in.lastScanAtSec = chat_inbox_last_scan_at_;
|
||||
in.safetyNetSec = services::chatInboxSafetyNetSec(scan_governor_.emaCostMs(services::ScanStream::ChatFast));
|
||||
if (!services::chatInboxScanNeeded(in)) return;
|
||||
chat_inbox_scan_wanted_ = true;
|
||||
chat_inbox_wanted_txcount_ = txCount; // read before the scan: the basis can only under-count (one extra scan)
|
||||
admitChatInboxScan();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::admitChatInboxScan()
|
||||
{
|
||||
if (!chatInboxScanAllowed() || chat_fast_scan_in_flight_) return; // a gate may have closed since the probe
|
||||
const std::string addr = chatReplyZaddr();
|
||||
if (addr.empty()) return;
|
||||
auto ticket = scan_governor_.tryAdmit(services::ScanStream::ChatFast, /*visible=*/true,
|
||||
currentScanSignals(), scanClockNow());
|
||||
if (!ticket) return; // still wanted: the next chat tick asks again
|
||||
chat_fast_scan_in_flight_ = true;
|
||||
const std::int64_t basis = chat_inbox_wanted_txcount_;
|
||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||||
worker_->post([this, addr, scanGen, ticket, basis]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||||
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
|
||||
std::string scanError;
|
||||
bool ok = false;
|
||||
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf inbox scan");
|
||||
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
||||
if (received.is_array()) {
|
||||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||||
@@ -4729,21 +4972,31 @@ void App::fastScanChatMemos()
|
||||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
|
||||
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
||||
}
|
||||
ok = true;
|
||||
}
|
||||
} catch (const std::exception& e) { scanError = e.what(); }
|
||||
} catch (const std::exception& e) {
|
||||
scanError = e.what();
|
||||
failKind = rpc::rpcFailureKindOf(e);
|
||||
} catch (...) {
|
||||
scanError = "unknown error";
|
||||
failKind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const int parsedCount = static_cast<int>(metadata.size());
|
||||
const double scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable {
|
||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||||
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
|
||||
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
|
||||
if (scanGen != chat_session_generation_) return;
|
||||
return [this, scanGen, ticket, basis, ok, failKind, metadata = std::move(metadata), rawMemoCount, parsedCount,
|
||||
scanError, scanMs]() mutable {
|
||||
chat_fast_scan_in_flight_ = false;
|
||||
if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off
|
||||
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
|
||||
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
|
||||
// are reaching us at all, and how many parse as chat vs are unrelated memos.
|
||||
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
|
||||
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
|
||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||||
// session: drop it.
|
||||
if (scanGen != chat_session_generation_) return;
|
||||
if (ok) { // an empty inbox is a result too: before the early returns below
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_basis_txcount_ = basis;
|
||||
chat_inbox_last_scan_at_ = ticket.dispatchedAt;
|
||||
}
|
||||
if (!scanError.empty()) {
|
||||
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
|
||||
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
|
||||
@@ -6347,26 +6600,23 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||
if (ok) {
|
||||
// A send changes address balances — refresh on next cycle
|
||||
addresses_dirty_ = true;
|
||||
// Force transaction list refresh so the sent tx appears immediately
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
requestForcedBalanceRefresh(); // the user's own send must update the balance now, past the throttle
|
||||
noteWalletMutation();
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||
// broadcast asynchronously by the daemon. Defer the user-facing
|
||||
// success/failure to the opid poller (app.cpp) so we don't report
|
||||
// "sent successfully" for an operation that may still fail.
|
||||
// "sent successfully" for an operation that may still fail. The wallet changes
|
||||
// when the op commits, and the poller re-reads it then, by origin
|
||||
// (markWalletMutated); meanwhile the pending row and the local spendable debit
|
||||
// show the send. `background` = a chat message or buffer split.
|
||||
if (!result_str.empty()) {
|
||||
pending_opids_.push_back(result_str);
|
||||
if (background) op_origin_[result_str] = services::NetworkRefreshService::OperationOrigin::Chat;
|
||||
if (markFeeGapRetry)
|
||||
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
|
||||
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
|
||||
if (callback) pending_send_callbacks_[result_str] = callback;
|
||||
} else if (callback) {
|
||||
callback(true, result_str); // no opid to track — report as-is
|
||||
} else {
|
||||
if (!background) markWalletMutated(/*forceBalance=*/true); // no opid: no completion event
|
||||
if (callback) callback(true, result_str); // no opid to track — report as-is
|
||||
}
|
||||
} else {
|
||||
if (!background) send_progress_active_ = false;
|
||||
|
||||
@@ -1797,8 +1797,7 @@ void App::renderDecryptWalletDialog() {
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
invalidateAddressValidationCache(); // marks the addresses dirty
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ ChatService::~ChatService() {
|
||||
void ChatService::setIdentity(const ChatKeyPair& keys) {
|
||||
identity_ = keys;
|
||||
has_identity_ = true;
|
||||
undecryptable_.clear(); // another identity may decrypt them
|
||||
}
|
||||
|
||||
void ChatService::clearIdentity() {
|
||||
wipeChatKeyPair(identity_);
|
||||
has_identity_ = false;
|
||||
undecryptable_.clear();
|
||||
}
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
@@ -37,6 +39,9 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
|
||||
int added = 0;
|
||||
for (const auto& meta : metadata) {
|
||||
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||
// decrypt for all but the new messages.
|
||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
||||
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
||||
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
|
||||
@@ -78,11 +83,23 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
} else {
|
||||
message.kind = ChatMessageKind::Message;
|
||||
// Keyed by the decrypt's inputs, not only (txid, position): another scan path that pairs a
|
||||
// different header with this payload gets its own try.
|
||||
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
|
||||
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
|
||||
if (undecryptable_.count(failKey)) continue;
|
||||
++decrypt_attempts_;
|
||||
std::string plaintext;
|
||||
const ChatCryptoStatus status = decryptIncoming(
|
||||
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
|
||||
meta.payload_memo, plaintext);
|
||||
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
|
||||
if (status != ChatCryptoStatus::Ok) { // drop undecryptable silently, and don't try it again
|
||||
if (status != ChatCryptoStatus::SodiumInitFailed) { // not a property of the message
|
||||
if (undecryptable_.size() >= 4096) undecryptable_.clear();
|
||||
undecryptable_.insert(failKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message.body = std::move(plaintext);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
@@ -99,9 +100,15 @@ public:
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics
|
||||
|
||||
private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
// Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever
|
||||
// received, so without it each poll re-ran the crypto for every undecryptable memo.
|
||||
std::unordered_set<std::string> undecryptable_;
|
||||
std::uint64_t decrypt_attempts_ = 0;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
std::string ChatStore::messageKey(const std::string& txid, std::size_t payloadPosition) {
|
||||
return txid + ":" + std::to_string(payloadPosition);
|
||||
}
|
||||
|
||||
std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
return message.txid + ":" + std::to_string(message.payload_position);
|
||||
return messageKey(message.txid, message.payload_position);
|
||||
}
|
||||
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
|
||||
@@ -20,6 +20,13 @@ public:
|
||||
// Returns true if newly inserted, false if a duplicate was ignored.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// The dedup key of a message: "<txid>:<payload_position>".
|
||||
static std::string messageKey(const std::string& txid, std::size_t payloadPosition);
|
||||
// A message with this (txid, position) is in the store (append would refuse it).
|
||||
bool contains(const std::string& txid, std::size_t payloadPosition) const {
|
||||
return seen_.count(messageKey(txid, payloadPosition)) != 0;
|
||||
}
|
||||
|
||||
// Messages in a conversation, in insertion order.
|
||||
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the HushChat scans are worth sending. Both are wallet-wide (they hold dragonxd's cs_main): the inbox
|
||||
// scan (z_listreceivedbyaddress of the chat address, every note ever) and the note-buffer scan
|
||||
// (z_listunspent). They used to run on a timer whether or not anything had changed. Here they run when
|
||||
// something they depend on can have changed, and the WalletScanGovernor decides when (the scan budget).
|
||||
//
|
||||
// Pure, header-only.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr double kChatTipSettleSec = 3.0; // the wallet notifier stores a block's txs ~1 s after it connects
|
||||
constexpr double kChatNoteScanFloorSec = 4.0; // spacing between note scans (a failing need can't spin), not a duty rule
|
||||
constexpr double kChatProbeMaxDuty = 0.02;
|
||||
constexpr double kChatProbeMaxSpacingSec = 60.0; // the probe is the inbox safety net's only path
|
||||
|
||||
// getwalletinfo's txcount (mapWallet.size(), read under cs_wallet): it changes only after the daemon's wallet
|
||||
// notifier has stored a tx and its notes. A mempool or tip change can run ahead of that (the notifier runs
|
||||
// about once a second), so the inbox is gated on this instead. -1 when absent, malformed or negative.
|
||||
inline std::int64_t parseWalletTxCount(const nlohmann::json& reply)
|
||||
{
|
||||
if (!reply.is_object() || !reply.contains("txcount") || !reply["txcount"].is_number_integer()) return -1;
|
||||
const auto v = reply["txcount"].get<std::int64_t>();
|
||||
return v < 0 ? -1 : v;
|
||||
}
|
||||
|
||||
// The inbox probe (getwalletinfo: three passes over the wallet under cs_main) isn't a governed scan: at most 2%
|
||||
// of wall-clock, and at least once a minute (it is the only way the inbox safety net is checked).
|
||||
inline bool chatProbeDue(double lastProbeMs, double lastProbeAtSec, double nowSec)
|
||||
{
|
||||
return nowSec - lastProbeAtSec >= std::min((std::max(0.0, lastProbeMs) / 1000.0) / kChatProbeMaxDuty,
|
||||
kChatProbeMaxSpacingSec);
|
||||
}
|
||||
|
||||
// Safety nets when no change signal fired: a minute, stretched with the scan's own cost (the inbox is gated on
|
||||
// txcount, so its net is only a backstop; the note fallback refreshes the buffer status and the payer view).
|
||||
inline double chatInboxSafetyNetSec(double emaCostMs) { return std::max(60.0, 50.0 * emaCostMs / 1000.0); }
|
||||
inline double chatNoteFallbackSec(double emaCostMs) { return std::max(60.0, 20.0 * emaCostMs / 1000.0); }
|
||||
|
||||
struct ChatInboxScanInputs {
|
||||
std::int64_t basisTxCount = -1; // the count the last successful scan is current for (-1: none)
|
||||
std::int64_t txCount = -1; // the probe's read
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double safetyNetSec = 60.0;
|
||||
};
|
||||
|
||||
// The inbox needs a scan: the count is unknown (no basis yet, or a daemon without txcount: as the budget
|
||||
// allows), the wallet gained or lost a transaction, or the safety net is due.
|
||||
inline bool chatInboxScanNeeded(const ChatInboxScanInputs& in)
|
||||
{
|
||||
if (in.txCount < 0 || in.basisTxCount < 0) return true;
|
||||
if (in.txCount != in.basisTxCount) return true;
|
||||
return in.nowSec - in.lastScanAtSec >= in.safetyNetSec;
|
||||
}
|
||||
|
||||
struct ChatNoteScanInputs {
|
||||
bool onChatPage = false; // the Chat page is shown (and the window isn't in the background)
|
||||
bool queueWaiting = false; // a chat send waits for a verified note
|
||||
bool splitOutstanding = false; // a self-split is confirming
|
||||
bool rescanRequested = false; // a chat operation finished
|
||||
bool modelSeen = false; // a scan (or the address scan's feed) succeeded this session
|
||||
int verifiedBudget = 0;
|
||||
int pipelineNotes = 0;
|
||||
std::int64_t tipHeight = 0;
|
||||
std::int64_t lastScanTip = -1;
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double tipChangedAtSec = 0.0;
|
||||
double fallbackSec = 60.0;
|
||||
double floorSec = kChatNoteScanFloorSec;
|
||||
double tipSettleSec = kChatTipSettleSec;
|
||||
};
|
||||
|
||||
// The note view is worth refreshing: something can use it (the Chat page, a waiting send, a split) and it
|
||||
// can have changed (first look, a chat op finished, a block that matters once the notifier caught up, or the
|
||||
// fallback). How often it actually runs is the scan budget's call.
|
||||
inline bool shouldScanChatNotes(const ChatNoteScanInputs& in)
|
||||
{
|
||||
if (!in.onChatPage && !in.queueWaiting && !in.splitOutstanding) return false; // nothing can use it
|
||||
const bool ever = in.lastScanAtSec > 0.0;
|
||||
if (ever && in.nowSec - in.lastScanAtSec < in.floorSec) return false;
|
||||
if (!in.modelSeen || in.rescanRequested) return true;
|
||||
const bool tipMoved = in.tipHeight != in.lastScanTip && in.nowSec - in.tipChangedAtSec >= in.tipSettleSec;
|
||||
const bool starved = in.queueWaiting && in.verifiedBudget < 1;
|
||||
const bool maturing = in.onChatPage && in.verifiedBudget < in.pipelineNotes; // notes still confirming
|
||||
if (tipMoved && (starved || in.splitOutstanding || maturing)) return true;
|
||||
return !ever || in.nowSec - in.lastScanAtSec >= in.fallbackSec;
|
||||
}
|
||||
|
||||
// Verified notes (at least a fee, unlocked, with the confirmations a chat send needs) by z-address.
|
||||
// z_sendmany pays from ONE address and spends its largest notes first.
|
||||
struct ChatPayerFunds {
|
||||
std::uint64_t zat = 0;
|
||||
int notes = 0;
|
||||
};
|
||||
using ChatPayerMap = std::unordered_map<std::string, ChatPayerFunds>;
|
||||
|
||||
// `preferred` if it has a verified note and covers needZat, else the richest that does (ties: the smallest
|
||||
// address). "" if none does.
|
||||
inline std::string chooseChatPayer(const ChatPayerMap& funds, const std::string& preferred, std::uint64_t needZat)
|
||||
{
|
||||
auto eligible = [&](const ChatPayerFunds& f) { return f.notes > 0 && f.zat >= needZat; };
|
||||
if (!preferred.empty()) {
|
||||
const auto it = funds.find(preferred);
|
||||
if (it != funds.end() && eligible(it->second)) return preferred;
|
||||
}
|
||||
std::string best;
|
||||
std::uint64_t bestZat = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (address.empty() || !eligible(f)) continue;
|
||||
if (best.empty() || f.zat > bestZat || (f.zat == bestZat && address < best)) {
|
||||
best = address;
|
||||
bestZat = f.zat;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
inline std::uint64_t richestChatPayerZat(const ChatPayerMap& funds)
|
||||
{
|
||||
std::uint64_t best = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (!address.empty() && f.notes > 0) best = std::max(best, f.zat);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
enum class ChatPayerSpend { OneNote, All };
|
||||
|
||||
// Until the next scan restores the truth: a chat send spends one whole note (its change is unverified for a
|
||||
// block); a split may spend all of the payer's verified notes.
|
||||
inline void debitChatPayer(ChatPayerMap& funds, const std::string& payer, ChatPayerSpend spend)
|
||||
{
|
||||
const auto it = funds.find(payer);
|
||||
if (it == funds.end()) return;
|
||||
auto& f = it->second;
|
||||
if (spend == ChatPayerSpend::All || f.notes <= 1) {
|
||||
f = {};
|
||||
return;
|
||||
}
|
||||
f.zat -= f.zat / static_cast<std::uint64_t>(f.notes);
|
||||
--f.notes;
|
||||
}
|
||||
|
||||
// How a chat scan's result is charged to the scan budget.
|
||||
inline ScanOutcome chatScanOutcome(bool ok, rpc::RpcFailureKind kind)
|
||||
{
|
||||
if (ok) return ScanOutcome::Ok;
|
||||
if (kind == rpc::RpcFailureKind::ReplyTimeout) return ScanOutcome::TimedOut; // the daemon still runs it
|
||||
if (kind == rpc::RpcFailureKind::Deferred || kind == rpc::RpcFailureKind::QueueFull)
|
||||
return ScanOutcome::Skipped; // never ran
|
||||
return ScanOutcome::Failed;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -1704,6 +1704,39 @@ std::optional<json> NetworkRefreshService::buildOperationFilterParams(const std:
|
||||
return params;
|
||||
}
|
||||
|
||||
NetworkRefreshService::OperationBatchPlan NetworkRefreshService::planOperationBatch(
|
||||
const OperationStatusPollResult& parsed, const std::unordered_map<std::string, OperationOrigin>& origins)
|
||||
{
|
||||
auto originOf = [&](const std::string& id) {
|
||||
const auto it = origins.find(id);
|
||||
return it == origins.end() ? OperationOrigin::User : it->second;
|
||||
};
|
||||
OperationBatchPlan plan;
|
||||
auto terminal = [&](const std::string& id) {
|
||||
if (originOf(id) == OperationOrigin::Chat) plan.chatTerminal = true;
|
||||
else plan.walletTerminal = true;
|
||||
};
|
||||
for (const auto& id : parsed.doneOpids) { // success, failed or cancelled
|
||||
terminal(id);
|
||||
if (parsed.failureByOpid.count(id)) continue; // failed: nothing committed
|
||||
const auto origin = originOf(id);
|
||||
if (origin == OperationOrigin::User) plan.userSucceeded = true; // a success without a txid too
|
||||
else if (origin == OperationOrigin::Auto) plan.autoSucceeded = true;
|
||||
}
|
||||
for (const auto& id : parsed.staleOpids) terminal(id);
|
||||
return plan;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::anyUserOperationPending(const std::vector<std::string>& pendingOpids,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins)
|
||||
{
|
||||
for (const auto& id : pendingOpids) {
|
||||
const auto it = origins.find(id);
|
||||
if (it == origins.end() || it->second == OperationOrigin::User) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
|
||||
{
|
||||
if (!result.ok) return;
|
||||
|
||||
@@ -311,6 +311,16 @@ public:
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
// Who started a tracked async operation, i.e. what its completion re-reads (App::markWalletMutated).
|
||||
// Absent from the App's origin map = User.
|
||||
enum class OperationOrigin : std::uint8_t { User, Auto, Chat };
|
||||
struct OperationBatchPlan {
|
||||
bool userSucceeded = false; // >= 1 User op succeeded: re-read the wallet, force the balance
|
||||
bool autoSucceeded = false; // >= 1 Auto op (client auto-shield) succeeded: re-read, unforced
|
||||
bool walletTerminal = false; // >= 1 non-chat op finished (any outcome) or went stale
|
||||
bool chatTerminal = false; // >= 1 chat op finished or went stale: refresh the chat note view
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
@@ -435,6 +445,12 @@ public:
|
||||
// retained. Built explicitly, because a one-element braced list of a json ({ids}) collapses to the
|
||||
// inner array, which the daemon rejects ("JSON value is not an array as expected").
|
||||
static std::optional<nlohmann::json> buildOperationFilterParams(const std::vector<std::string>& opids);
|
||||
// Classify a poll's terminal ops by origin (absent from the map = User).
|
||||
static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
// Any pending id is a User op (absent from the map = User).
|
||||
static bool anyUserOperationPending(const std::vector<std::string>& pendingOpids,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
|
||||
struct DaemonRescanScan {
|
||||
bool foundRescan = false;
|
||||
|
||||
@@ -52,7 +52,9 @@ BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
// attempt failed) the balance waits for the sync to finish, a forced one too.
|
||||
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
|
||||
if (in.syncing) return BalancePollDecision::Skip;
|
||||
if (in.forced) return BalancePollDecision::Explicit;
|
||||
// The user's own send, now committed. In the background it waits for the window (the routine rules below
|
||||
// still apply, e.g. the auto-shield poll); coming back forces it.
|
||||
if (in.forced && !in.background) return BalancePollDecision::Explicit;
|
||||
if (!in.changed) return BalancePollDecision::Skip;
|
||||
if (in.visible) {
|
||||
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
|
||||
|
||||
@@ -82,7 +82,7 @@ struct BalancePollInputs {
|
||||
bool background = false; // the window is in the background: no first read while syncing
|
||||
bool firstReadTried = false; // the first read while syncing was already sent this connection
|
||||
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
|
||||
bool forced = false; // the user's own send
|
||||
bool forced = false; // the user's own send (waits while the window is in the background)
|
||||
bool neverFetched = false; // no balance yet this session
|
||||
bool changed = true; // the governor's change gate
|
||||
bool visible = true; // the page shows the aggregate balance
|
||||
@@ -123,8 +123,10 @@ public:
|
||||
{kScanSignalAll, 300}, // Addresses
|
||||
{kScanSignalTip | kScanSignalEpoch, 0}, // History
|
||||
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
|
||||
{kScanSignalAll, 60}, // ChatNotes
|
||||
{kScanSignalAll, 60}, // ChatFast
|
||||
{0, 0}, // ChatNotes: admission only; the need is services::shouldScanChatNotes (tip settle, chat ops)
|
||||
{0, 0}, // ChatFast: admission only; the need is getwalletinfo's txcount (chatInboxScanNeeded). Not
|
||||
// the tip or mempool: they move before the wallet notifier stores the tx, so a scan fired
|
||||
// on them can read the old wallet and consume the change.
|
||||
{0, 0}, // Adhoc
|
||||
}};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user