diff --git a/CMakeLists.txt b/CMakeLists.txt index 33b32ce..c70efdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,7 @@ set(APP_HEADERS src/services/wallet_scan_governor.h src/services/connect_scan_gate.h src/services/scan_policy.h + src/services/chat_scan_policy.h src/services/wallet_security_controller.h src/services/wallet_security_workflow.h src/services/wallet_security_workflow_executor.h diff --git a/src/app.cpp b/src/app.cpp index b5ca298..7458d13 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 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. diff --git a/src/app.h b/src/app.h index 2b06778..c2c4b35 100644 --- a/src/app.h +++ b/src/app.h @@ -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 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& 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& 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 pending_opids_; // opids to poll for completion + // Non-User ops only (absent = User): what each op's completion re-reads. + std::unordered_map 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 shieldedAddressList() const; services::ShieldedHistoryPass::Work historyScanWork() const; // From the address scan's z_listunspent: addresses holding received notes the history doesn't show diff --git a/src/app_network.cpp b/src/app_network.cpp index 8d13cce..f4dd3a0 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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(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(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(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::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(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::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(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() : 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()]; + 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( 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(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& unspentNotes) { + const std::vector& 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((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((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(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 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 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(metadata.size()); const double scanMs = std::chrono::duration( 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; diff --git a/src/app_security.cpp b/src/app_security.cpp index 1ac0ef4..b8e0358 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -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; diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index e817a10..e41f068 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -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& metadata, @@ -37,6 +39,9 @@ int ChatService::ingest(const std::vector& 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& 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); } diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index b570560..345c864 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -14,6 +14,7 @@ #include #include #include +#include #include 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 undecryptable_; + std::uint64_t decrypt_attempts_ = 0; ChatStore store_; ChatDatabase* db_ = nullptr; // optional; not owned std::function blocked_pred_; // true => cid is blocked (drop its messages) diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index b493f90..09125bd 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -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) { diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 4ab6bf2..6a0ada7 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -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: ":". + 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 conversation(const std::string& conversationId) const; diff --git a/src/services/chat_scan_policy.h b/src/services/chat_scan_policy.h new file mode 100644 index 0000000..9810d64 --- /dev/null +++ b/src/services/chat_scan_policy.h @@ -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 + +#include +#include +#include +#include + +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(); + 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; + +// `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(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 diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 98df15a..75f783d 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -1704,6 +1704,39 @@ std::optional NetworkRefreshService::buildOperationFilterParams(const std: return params; } +NetworkRefreshService::OperationBatchPlan NetworkRefreshService::planOperationBatch( + const OperationStatusPollResult& parsed, const std::unordered_map& 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& pendingOpids, + const std::unordered_map& 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; diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index aea4a40..7f61214 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -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& 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 buildOperationFilterParams(const std::vector& opids); + // Classify a poll's terminal ops by origin (absent from the map = User). + static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed, + const std::unordered_map& origins); + // Any pending id is a User op (absent from the map = User). + static bool anyUserOperationPending(const std::vector& pendingOpids, + const std::unordered_map& origins); struct DaemonRescanScan { bool foundRescan = false; diff --git a/src/services/wallet_scan_governor.cpp b/src/services/wallet_scan_governor.cpp index 80202c2..76f3614 100644 --- a/src/services/wallet_scan_governor.cpp +++ b/src/services/wallet_scan_governor.cpp @@ -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. diff --git a/src/services/wallet_scan_governor.h b/src/services/wallet_scan_governor.h index 1159c73..f28742d 100644 --- a/src/services/wallet_scan_governor.h +++ b/src/services/wallet_scan_governor.h @@ -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 }}; }; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 6164821..78902c2 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -31,6 +31,7 @@ #include "services/wallet_scan_governor.h" #include "services/scan_policy.h" #include "services/connect_scan_gate.h" +#include "services/chat_scan_policy.h" #include "services/network_refresh_service.h" #include "services/refresh_scheduler.h" #include "services/note_consolidation.h" @@ -8776,10 +8777,15 @@ void testHushChatService() svc.setIdentity(bob); EXPECT_TRUE(svc.hasIdentity()); + EXPECT_FALSE(svc.store().contains("tx0", 1)); EXPECT_EQ(svc.ingest(batch, {}, 1000), 2); + EXPECT_EQ(svc.decryptAttempts(), 2u); EXPECT_EQ((int)svc.store().size(), 2); EXPECT_EQ(svc.ingest(batch, {}, 1000), 0); // re-scan dedups + EXPECT_EQ(svc.decryptAttempts(), 2u); // ...without decrypting the stored ones again EXPECT_EQ((int)svc.store().size(), 2); + EXPECT_TRUE(svc.store().contains("tx0", 1)); + EXPECT_FALSE(svc.store().contains("tx0", 0)); std::vector conv = svc.store().conversation("conv-x"); EXPECT_EQ((int)conv.size(), 2); @@ -8798,6 +8804,7 @@ void testHushChatService() creq[0].payload_memo = "hi, add me"; creq[0].payload_position = 1; EXPECT_EQ(svc.ingest(creq, {}), 1); + EXPECT_EQ(svc.decryptAttempts(), 2u); // a contact request isn't decrypted std::vector cy = svc.store().conversation("conv-y"); EXPECT_EQ((int)cy.size(), 1); EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest); @@ -8810,6 +8817,30 @@ void testHushChatService() svc2.setIdentity(mallory); EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); EXPECT_TRUE(svc2.store().empty()); + EXPECT_EQ(svc2.decryptAttempts(), 2u); + EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); + EXPECT_EQ(svc2.decryptAttempts(), 2u); // undecryptable: not tried again... + svc2.setIdentity(mallory); + EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); + EXPECT_EQ(svc2.decryptAttempts(), 4u); // ...until the identity is set again + + // A junk payload is tried once; the same (txid, position) with another header gets its own try. + std::vector junk{batch[0]}; + junk[0].txid = "txbad"; + junk[0].payload_memo = "zz"; + const auto before = svc.decryptAttempts(); + EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); + EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); + EXPECT_EQ(svc.decryptAttempts(), before + 1); + junk[0].secretstream_header_hex = batch[1].secretstream_header_hex; + EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); + EXPECT_EQ(svc.decryptAttempts(), before + 2); + + // An erased conversation's messages aren't "stored" any more (a later scan re-ingests them). + svc.store().eraseConversation("conv-x"); + EXPECT_FALSE(svc.store().contains("tx0", 1)); + svc.store().clear(); + EXPECT_FALSE(svc.store().contains("txc", 1)); } // Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and @@ -8923,6 +8954,9 @@ void testHushChatDatabase() EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ(conv[0].body, std::string("persisted!")); EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242); + svc.setIdentity(bob); + EXPECT_EQ(svc.ingest(batch, times), 0); // reloaded: no decrypt for what is already stored + EXPECT_EQ(svc.decryptAttempts(), 0u); } } @@ -10886,6 +10920,12 @@ void testBalancePollDecisionAndVisibility() EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.neverFetched = true; x.firstReadTried = true; x.background = true; // at the tip: as before EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } + // A committed user send in the background waits for the window; the routine rules still apply. + { auto x = in; x.forced = true; x.background = true; x.visible = false; x.sinceLastScanSec = 100.0; + EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } + { auto x = in; x.forced = true; x.background = true; x.visible = false; x.autoShieldArmed = true; + x.sinceLastScanSec = 130.0; + EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = in; x.forced = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } { auto x = in; x.neverFetched = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } { auto x = in; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } @@ -11335,6 +11375,244 @@ void testAddressListOnlyCollector() } } +// C38: classify a poll's terminal ops by who started them. +void testOperationBatchPlan() +{ + using Refresh = dragonx::services::NetworkRefreshService; + using Origin = Refresh::OperationOrigin; + using nlohmann::json; + const json reply = json::array({ + json{{"id", "u1"}, {"status", "success"}, {"result", json{{"txid", "tx-u1"}}}}, + json{{"id", "u2"}, {"status", "failed"}, {"error", json{{"message", "bad"}}}}, + json{{"id", "u3"}, {"status", "success"}}, + json{{"id", "a1"}, {"status", "success"}, {"result", json{{"txid", "tx-a1"}}}}, + json{{"id", "c1"}, {"status", "success"}, {"result", json{{"txid", "tx-c1"}}}}, + json{{"id", "c2"}, {"status", "cancelled"}}, + }); + const std::unordered_map origins{{"a1", Origin::Auto}, {"c1", Origin::Chat}, + {"c2", Origin::Chat}, {"c3", Origin::Chat}}; + auto plan = [&](const std::vector& ids) { + return Refresh::planOperationBatch(Refresh::parseOperationStatusPoll(reply, ids), origins); + }; + auto all = plan({"u1", "u2", "u3", "a1", "c1", "c2", "c3"}); + EXPECT_TRUE(all.userSucceeded && all.autoSucceeded && all.walletTerminal && all.chatTerminal); + auto a = plan({"a1"}); + EXPECT_TRUE(a.autoSucceeded && a.walletTerminal && !a.userSucceeded && !a.chatTerminal); + for (const char* id : {"c1", "c2", "c3"}) { // a chat success, a cancelled one, one gone stale + auto c = plan({id}); + EXPECT_TRUE(c.chatTerminal && !c.walletTerminal && !c.userSucceeded && !c.autoSucceeded); + } + auto failed = plan({"u2"}); + EXPECT_TRUE(failed.walletTerminal && !failed.userSucceeded && !failed.autoSucceeded && !failed.chatTerminal); + EXPECT_TRUE(plan({"u3"}).userSucceeded); // a success without a txid counts + EXPECT_TRUE(plan({"u1"}).userSucceeded); // absent from the map: User + EXPECT_FALSE(Refresh::anyUserOperationPending({}, origins)); + EXPECT_FALSE(Refresh::anyUserOperationPending({"c1"}, origins)); + EXPECT_FALSE(Refresh::anyUserOperationPending({"a1", "c1"}, origins)); + EXPECT_TRUE(Refresh::anyUserOperationPending({"x"}, origins)); + EXPECT_TRUE(Refresh::anyUserOperationPending({"c1", "u9"}, origins)); +} + +// C79: when the chat scans are worth sending. +void testChatScanPolicy() +{ + using namespace dragonx::services; + using nlohmann::json; + EXPECT_EQ(parseWalletTxCount(json::object()), (std::int64_t)-1); + EXPECT_EQ(parseWalletTxCount(json{{"txcount", "42"}}), (std::int64_t)-1); + EXPECT_EQ(parseWalletTxCount(json{{"txcount", -3}}), (std::int64_t)-1); + EXPECT_EQ(parseWalletTxCount(json{{"txcount", 4.5}}), (std::int64_t)-1); + EXPECT_EQ(parseWalletTxCount(json::array({1})), (std::int64_t)-1); + EXPECT_EQ(parseWalletTxCount(json{{"txcount", 42}}), (std::int64_t)42); + EXPECT_TRUE(chatProbeDue(0.0, 100.0, 100.0)); // no cost known yet + EXPECT_FALSE(chatProbeDue(50.0, 100.0, 101.0)); // 50 ms: at most every 2.5 s (2%) + EXPECT_TRUE(chatProbeDue(50.0, 100.0, 102.6)); + EXPECT_FALSE(chatProbeDue(200.0, 100.0, 105.0)); // 200 ms: at most every 10 s + EXPECT_TRUE(chatProbeDue(200.0, 100.0, 110.0)); + EXPECT_FALSE(chatProbeDue(30000.0, 100.0, 159.0)); // however slow: at least once a minute + EXPECT_TRUE(chatProbeDue(30000.0, 100.0, 160.0)); + EXPECT_NEAR(chatInboxSafetyNetSec(0.0), 60.0, 1e-9); + EXPECT_NEAR(chatInboxSafetyNetSec(1000.0), 60.0, 1e-9); + EXPECT_NEAR(chatInboxSafetyNetSec(21000.0), 1050.0, 1e-9); + EXPECT_NEAR(chatNoteFallbackSec(21000.0), 420.0, 1e-9); + + ChatInboxScanInputs in; + in.basisTxCount = 10; + in.txCount = 10; + in.nowSec = 129.0; + in.lastScanAtSec = 100.0; + EXPECT_FALSE(chatInboxScanNeeded(in)); + { auto x = in; x.basisTxCount = -1; EXPECT_TRUE(chatInboxScanNeeded(x)); } + { auto x = in; x.txCount = -1; EXPECT_TRUE(chatInboxScanNeeded(x)); } + { auto x = in; x.txCount = 11; EXPECT_TRUE(chatInboxScanNeeded(x)); } + { auto x = in; x.nowSec = 160.0; EXPECT_TRUE(chatInboxScanNeeded(x)); } + + // An hour of 2.5 s ticks: a quiet wallet scans about once a minute; every count change is scanned at once. + auto simulate = [](double net, int changes) { + std::int64_t count = 100, basis = -1; + double last = 0.0; + int scans = 0; + bool everyChangeScanned = true; + for (double t = 0.0; t < 3600.0; t += 2.5) { + bool changed = false; + for (int i = 1; i <= changes; ++i) { + if (t >= 300.0 * i && t < 300.0 * i + 2.5) { ++count; changed = true; } + } + ChatInboxScanInputs x; + x.basisTxCount = basis; + x.txCount = count; + x.nowSec = t; + x.lastScanAtSec = last; + x.safetyNetSec = net; + const bool need = chatInboxScanNeeded(x); + if (changed && !need) everyChangeScanned = false; + if (need) { ++scans; basis = count; last = t; } + } + return std::make_pair(scans, everyChangeScanned); + }; + auto quiet = simulate(chatInboxSafetyNetSec(50.0), 0); + EXPECT_TRUE(quiet.first >= 59 && quiet.first <= 61); + auto busy = simulate(chatInboxSafetyNetSec(50.0), 10); + EXPECT_TRUE(busy.first <= 71 && busy.second); + EXPECT_TRUE(simulate(chatInboxSafetyNetSec(21000.0), 0).first <= 4); + + ChatNoteScanInputs n; + n.modelSeen = true; + n.verifiedBudget = 10; + n.pipelineNotes = 10; + n.tipHeight = 100; + n.lastScanTip = 100; + n.nowSec = 100.0; + n.lastScanAtSec = 90.0; + n.tipChangedAtSec = 0.0; + { auto x = n; x.rescanRequested = true; EXPECT_FALSE(shouldScanChatNotes(x)); } // nothing can use it + n.onChatPage = true; + EXPECT_FALSE(shouldScanChatNotes(n)); + { auto x = n; x.modelSeen = false; EXPECT_TRUE(shouldScanChatNotes(x)); } + { auto x = n; x.modelSeen = false; x.lastScanAtSec = 98.0; EXPECT_FALSE(shouldScanChatNotes(x)); } // the floor + { auto x = n; x.rescanRequested = true; EXPECT_TRUE(shouldScanChatNotes(x)); } + { // A starved queue and a settled new tip. + auto x = n; x.onChatPage = false; x.queueWaiting = true; x.verifiedBudget = 0; x.tipHeight = 101; + x.tipChangedAtSec = 96.0; + EXPECT_TRUE(shouldScanChatNotes(x)); + x.tipChangedAtSec = 98.0; // the notifier may not have caught up yet + EXPECT_FALSE(shouldScanChatNotes(x)); + x.tipHeight = 100; // no new tip: only the fallback + x.tipChangedAtSec = 0.0; + EXPECT_FALSE(shouldScanChatNotes(x)); + x.nowSec = 160.0; + EXPECT_TRUE(shouldScanChatNotes(x)); + } + { auto x = n; x.onChatPage = false; x.splitOutstanding = true; x.tipHeight = 101; x.tipChangedAtSec = 96.0; + EXPECT_TRUE(shouldScanChatNotes(x)); } + { auto x = n; x.verifiedBudget = 7; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_TRUE(shouldScanChatNotes(x)); } + { auto x = n; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_FALSE(shouldScanChatNotes(x)); } // 10/10 + + // An hour on the Chat page at 2 s ticks, a block every 36 s. + auto notesHour = [](int verified, int pipeline, double fallback) { + double last = 0.0; + std::int64_t scanTip = -1, tip = 0; + double tipAt = 0.0; + int scans = 0; + bool settled = true; + for (double t = 0.0; t < 3600.0; t += 2.0) { + const std::int64_t newTip = static_cast(t / 36.0); + if (newTip != tip) { tip = newTip; tipAt = t; } + ChatNoteScanInputs x; + x.onChatPage = true; + x.modelSeen = scans > 0; + x.verifiedBudget = verified; + x.pipelineNotes = pipeline; + x.tipHeight = tip; + x.lastScanTip = scanTip; + x.nowSec = t; + x.lastScanAtSec = last; + x.tipChangedAtSec = tipAt; + x.fallbackSec = fallback; + if (shouldScanChatNotes(x)) { + ++scans; + last = t; + if (t - tipAt >= kChatTipSettleSec) scanTip = tip; + else if (scans > 1 && verified < pipeline) settled = false; + } + } + return std::make_pair(scans, settled); + }; + auto unfunded = notesHour(0, 0, 60.0); + EXPECT_TRUE(unfunded.first >= 59 && unfunded.first <= 61); + auto healthy = notesHour(10, 10, 60.0); + EXPECT_TRUE(healthy.first >= 59 && healthy.first <= 61); + auto maturing = notesHour(7, 10, 60.0); + EXPECT_TRUE(maturing.first <= 102); // one per settled block, plus the first look (repeated once settled) + EXPECT_TRUE(notesHour(10, 10, chatNoteFallbackSec(20000.0)).first <= 10); + + using dragonx::rpc::RpcFailureKind; + EXPECT_TRUE(chatScanOutcome(true, RpcFailureKind::None) == ScanOutcome::Ok); + EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::ReplyTimeout) == ScanOutcome::TimedOut); + EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::Deferred) == ScanOutcome::Skipped); + EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::QueueFull) == ScanOutcome::Skipped); + EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::AppError) == ScanOutcome::Failed); + EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::None) == ScanOutcome::Failed); +} + +// S15: the chat payer from the verified-note view. +void testChooseChatPayer() +{ + using namespace dragonx::services; + ChatPayerMap funds{{"zA", {5000, 1}}, {"zB", {20000, 2}}, {"zC", {20000, 1}}}; + EXPECT_EQ(chooseChatPayer(funds, "zA", 10000), std::string("zB")); // tie with zC: the smaller address + EXPECT_EQ(chooseChatPayer(funds, "zC", 10000), std::string("zC")); + EXPECT_EQ(chooseChatPayer(funds, "zA", 30000), std::string()); + EXPECT_EQ(chooseChatPayer(funds, "zX", 10000), std::string("zB")); + EXPECT_EQ(chooseChatPayer({}, "zA", 1), std::string()); + EXPECT_EQ(chooseChatPayer(ChatPayerMap{{"zA", {20000, 0}}}, "", 1), std::string()); // no verified note + EXPECT_EQ(richestChatPayerZat(funds), (std::uint64_t)20000); + EXPECT_EQ(richestChatPayerZat(ChatPayerMap{{"zA", {90000, 0}}, {"zB", {10, 1}}}), (std::uint64_t)10); + ChatPayerMap d{{"zA", {30, 3}}, {"zB", {7, 1}}, {"zC", {50, 5}}}; + debitChatPayer(d, "zA", ChatPayerSpend::OneNote); + EXPECT_TRUE(d["zA"].zat == 20 && d["zA"].notes == 2); + debitChatPayer(d, "zB", ChatPayerSpend::OneNote); + EXPECT_TRUE(d["zB"].zat == 0 && d["zB"].notes == 0); + debitChatPayer(d, "zC", ChatPayerSpend::All); + EXPECT_TRUE(d["zC"].zat == 0 && d["zC"].notes == 0); + const auto size = d.size(); + debitChatPayer(d, "zQ", ChatPayerSpend::OneNote); // unknown: no-op + EXPECT_EQ(d.size(), size); +} + +// The chat scans share the governor's one slot, and their streams never read the change gate. +void testChatScanGovernorStreams() +{ + using namespace dragonx::services; + WalletScanGovernor gov; + ScanSignals a; + a.tipHash = "a"; + a.mempoolKnown = true; + auto bal = gov.tryAdmit(ScanStream::Balance, true, a, 0.0); + EXPECT_TRUE(static_cast(bal)); + EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::ChatFast, true, a, 1.0))); + gov.complete(bal, 0.0, ScanOutcome::Ok, 1.0); + auto fast = gov.tryAdmit(ScanStream::ChatFast, true, a, 2.0); + EXPECT_TRUE(static_cast(fast)); + gov.dropOrphans(ScanStream::ChatFast, 3.0); // its reply never came + auto notes = gov.tryAdmit(ScanStream::ChatNotes, true, a, 4.0); + EXPECT_TRUE(static_cast(notes)); + gov.complete(notes, 10.0, ScanOutcome::Ok, 5.0); + ScanSignals b; + b.tipHash = "b"; + b.mempoolKnown = true; + b.mempoolTxs = 9; + b.walletEpoch = 3; + EXPECT_FALSE(gov.needed(ScanStream::ChatNotes, b, 1e6)); // admission only + auto f2 = gov.beginExplicit(ScanStream::ChatFast, a, 6.0); + gov.complete(f2, 10.0, ScanOutcome::Ok, 7.0); + EXPECT_FALSE(gov.needed(ScanStream::ChatFast, b, 1e6)); + auto old = gov.beginExplicit(ScanStream::ChatNotes, a, 8.0); + gov.reset(); + gov.complete(old, 1e6, ScanOutcome::Ok, 9.0); // from before the reset: a no-op + EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Balance, true, a, 10.0))); +} + int main() { testConnectionConfig(); @@ -11358,6 +11636,7 @@ int main() testParseMoneyZat(); testSpendableBalanceNeeded(); testOperationFilterParams(); + testOperationBatchPlan(); testHistoryScanPolicy(); testShieldedHistoryPass(); testHistoryPassWithCollector(); @@ -11373,6 +11652,9 @@ int main() testConnectionInfoSeeding(); testConnectScanGate(); testScanPolicy(); + testChatScanPolicy(); + testChooseChatPayer(); + testChatScanGovernorStreams(); testAddressListOnlyCollector(); testNetworkRefreshResultModels(); testTrackedSendRetirement();