perf(chat): sends re-read by origin, chat scans under the scan budget
A z_sendmany submit set off a burst of wallet scans before anything had changed. Ops now re-read the wallet when they finish, by who started them: the user's sends and dialog ops get a re-read plus one forced balance once none of their ops is pending; client auto-shield re-reads unforced; chat messages and splits refresh only the chat note view. A pending send no longer forces scans, and chat txids no longer enter the tracked-send set. Chat ingest skips stored messages before decrypting and caches undecryptable ones per identity. The inbox scan runs when getwalletinfo's txcount changed (a cheap probe, at most 2% duty) or a cost-scaled safety net is due; the note-buffer scan runs on need. Both are admitted by the scan budget, on a steady clock. The chat payer comes from a per-address view of verified notes, with debits re-applied across in-flight scans; splits are sized to one payer and only when they can refill the buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+375
-125
@@ -760,7 +760,7 @@ void App::onConnected()
|
||||
encryption_state_prefetched_ = initialPrefetchQueued;
|
||||
|
||||
// Addresses are unknown on fresh connect — force a fetch
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
// Hold the wallet scans until the node knows where the chain is (or the wallet is small), and latch the
|
||||
// sync profile now if the connect's getinfo already shows it behind.
|
||||
armScanGate(prefetchedInfo);
|
||||
@@ -842,10 +842,26 @@ void App::onDisconnected(const std::string& reason)
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_submitted_at_ = 0.0;
|
||||
inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed)
|
||||
// The chat scans' bases describe this connection's wallet (a reconnect may be another daemon). Their in-flight
|
||||
// flags are cleared by their replies; scan_governor_.reset() below turns their tickets into no-ops.
|
||||
chat_inbox_basis_txcount_ = -1;
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_wanted_txcount_ = -1;
|
||||
chat_inbox_probe_at_ = 0.0; // a reconnect may be another daemon: its probe cost is unknown
|
||||
chat_inbox_probe_ms_ = 0.0;
|
||||
chat_note_scan_tip_ = -1;
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_verified_note_budget_ = 0; // nor a budget from the previous connection's view
|
||||
chat_split_hold_tip_ = -1;
|
||||
chat_spend_log_.clear();
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
addr_pass_spend_seq_ = -1;
|
||||
network_refresh_.resetJobs();
|
||||
rescan_status_poll_in_progress_ = false;
|
||||
opid_poll_in_progress_ = false;
|
||||
opid_filter_unsupported_ = false; // a reconnect may be to a different daemon
|
||||
op_origin_.clear();
|
||||
send_feegap_retried_opids_.clear();
|
||||
last_total_balance_zat_.reset();
|
||||
history_transparent_rows_ = 0; // the history's scan state (the pass, epochs and watermark are
|
||||
history_transparent_tip_ = -1; // reset with the cache session below); cost estimates are kept
|
||||
@@ -1130,7 +1146,7 @@ bool App::currentPageNeedsWalletDataRefresh() const
|
||||
bool App::shouldRunWalletTransactionRefresh() const
|
||||
{
|
||||
if (currentPageNeedsWalletDataRefresh()) return true;
|
||||
if (hasTransactionSendProgress() || !send_txids_.empty()) return true;
|
||||
if (!send_txids_.empty()) return true;
|
||||
return transactions_dirty_;
|
||||
}
|
||||
|
||||
@@ -1307,7 +1323,7 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
|
||||
}
|
||||
}
|
||||
|
||||
void App::trackOperation(const std::string& opid)
|
||||
void App::trackOperation(const std::string& opid, services::NetworkRefreshService::OperationOrigin origin)
|
||||
{
|
||||
if (opid.empty()) return;
|
||||
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
|
||||
@@ -1315,6 +1331,25 @@ void App::trackOperation(const std::string& opid)
|
||||
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
|
||||
return;
|
||||
pending_opids_.push_back(opid);
|
||||
if (origin != services::NetworkRefreshService::OperationOrigin::User) op_origin_[opid] = origin;
|
||||
}
|
||||
|
||||
void App::markWalletMutated(bool forceBalance)
|
||||
{
|
||||
noteWalletMutation(); // the change-gated streams look again
|
||||
markAddressesDirty(); // explicit address pass; one already in flight can't clear it
|
||||
markTransactionsDirty(); // explicit history cycle; same guard (tx_dirty_generation_)
|
||||
last_tx_block_height_ = -1;
|
||||
if (forceBalance) {
|
||||
requestForcedBalanceRefresh(); // applied once no User op is pending (refreshCoreData)
|
||||
// A user op (send, shield, merge, sweep) may have spent notes the chat payer view still lists, and the
|
||||
// pending debit on state_.z_addresses omits the fee and the unverified change, so it can't stand in.
|
||||
// Hold chat sends and splits until the post-commit address pass (marked above) re-feeds the view.
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_verified_note_budget_ = 0;
|
||||
chat_verified_shielded_zat_ = 0;
|
||||
}
|
||||
network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now
|
||||
}
|
||||
|
||||
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
@@ -2041,7 +2076,7 @@ void App::refreshCoreData(bool allowBalance)
|
||||
balanceIn.gateOpen = scanGateOpen();
|
||||
balanceIn.background = window_background_;
|
||||
balanceIn.firstReadTried = first_balance_read_tried_;
|
||||
balanceIn.forced = force_balance_refresh_;
|
||||
balanceIn.forced = force_balance_refresh_ && !hasPendingUserOperations(); // a batch of user ops forces once, at its end
|
||||
balanceIn.neverFetched = state_.last_balance_update == 0;
|
||||
balanceIn.changed = scan_governor_.needed(services::ScanStream::Balance, scanSig, scanNow);
|
||||
balanceIn.visible = currentScanVisibility().balance;
|
||||
@@ -2066,8 +2101,9 @@ void App::refreshCoreData(bool allowBalance)
|
||||
const bool includeBalance = static_cast<bool>(balanceTicket);
|
||||
// The forced request this poll carries. The result callback clears the flag only if the balance
|
||||
// call was really sent and no newer request came in; clearing it here lost it whenever the enqueue
|
||||
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy).
|
||||
const std::uint32_t forcedSeq = (includeBalance && force_balance_refresh_) ? force_balance_seq_ : 0;
|
||||
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy). Only a force
|
||||
// applicable now: a routine poll admitted while it is deferred (a user op still pending) doesn't clear it.
|
||||
const std::uint32_t forcedSeq = (includeBalance && balanceIn.forced) ? force_balance_seq_ : 0;
|
||||
NetworkRefreshService::CoreRefreshPlan plan;
|
||||
plan.includeBalance = includeBalance;
|
||||
plan.fetchMempool = !balanceIn.syncing; // a change signal for the scans; takes no cs_main
|
||||
@@ -2106,7 +2142,11 @@ void App::refreshCoreData(bool allowBalance)
|
||||
scan_governor_.complete(t, result.balanceScanMs, outcome, scanClockNow());
|
||||
}
|
||||
if (result.balanceAttempted) state_.balance_last_attempt_failed = !result.balanceOk;
|
||||
const std::string prevTipHash = state_.sync.best_blockhash;
|
||||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||||
// The chat note view waits a moment after a new tip: the wallet notifier stores a block's txs
|
||||
// ~1 s after it connects.
|
||||
if (state_.sync.best_blockhash != prevTipHash) tip_changed_at_ = scanClockNow();
|
||||
if (result.blockchainOk && result.longestChain && *result.longestChain > 0) scan_gate_.noteTipKnown();
|
||||
if (result.balanceOk) {
|
||||
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
|
||||
@@ -2238,8 +2278,8 @@ void App::refreshCoreData(bool allowBalance)
|
||||
}
|
||||
if (!opid.empty()) {
|
||||
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
|
||||
// Surface the async result + refresh balances on completion.
|
||||
trackOperation(opid);
|
||||
// Surface the async result + refresh balances on completion (unforced).
|
||||
trackOperation(opid, services::NetworkRefreshService::OperationOrigin::Auto);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -2297,11 +2337,19 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
? NetworkRefreshService::AddressRefreshSnapshot{}
|
||||
: NetworkRefreshService::buildAddressRefreshSnapshot(state_);
|
||||
ui::NavPage tracePage = current_page_;
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly]() -> rpc::RPCWorker::MainCb {
|
||||
const std::uint64_t addrDirtyGen = addr_dirty_generation_;
|
||||
// The tip the chat note view may claim from this pass: only one the wallet notifier has caught up with.
|
||||
const std::int64_t chatFeedTip =
|
||||
scanClockNow() - tip_changed_at_ >= services::kChatTipSettleSec ? state_.sync.blocks : -1;
|
||||
const std::uint64_t chatSpendSeq = chat_spend_seq_;
|
||||
addr_pass_spend_seq_ = static_cast<std::int64_t>(chatSpendSeq);
|
||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Addresses, *worker_, [this, previousAddressCount, previousWalletIdentity, previousZ = std::move(previousZ), addressSnapshot = std::move(addressSnapshot), tracePage, ticket, listOnly, addrDirtyGen, chatFeedTip, chatSpendSeq]() -> rpc::RPCWorker::MainCb {
|
||||
AppRefreshRpcGateway refreshRpc(*rpc_, traceSource(tracePage, listOnly ? "Address list" : "Address refresh"));
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot, listOnly);
|
||||
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, result = std::move(result)]() mutable {
|
||||
return [this, previousAddressCount, previousWalletIdentity, previousZ, ticket, listOnly, addrDirtyGen, chatFeedTip,
|
||||
chatSpendSeq, result = std::move(result)]() mutable {
|
||||
if (addr_pass_spend_seq_ == static_cast<std::int64_t>(chatSpendSeq)) addr_pass_spend_seq_ = -1;
|
||||
const bool addrListOk = result.addressListOk; // capture before the move
|
||||
// What was applied: a full pass whose balance scan failed degrades to list-only (it keeps the
|
||||
// previous balances rather than writing the zeros of the calls that didn't answer).
|
||||
@@ -2329,9 +2377,13 @@ void App::refreshAddressData(services::ScanTicket ticket)
|
||||
auto unspentNotes = std::move(result.unspentNotes);
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); // list-only keeps the balances
|
||||
if (!appliedListOnly) {
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes);
|
||||
// Not from a pass sent before a commit marked since: its notes predate it (the owed pass feeds it).
|
||||
if (addr_dirty_generation_ == addrDirtyGen)
|
||||
updateChatNoteBudgetFromUnspent(unspentNotes, chatFeedTip, chatSpendSeq);
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
addresses_dirty_ = false; // a list-only pass still owes the full one
|
||||
// A mutation marked after this pass was sent isn't in it (keeps the send progress until the
|
||||
// post-commit balances). A list-only pass still owes the full one.
|
||||
if (addr_dirty_generation_ == addrDirtyGen) addresses_dirty_ = false;
|
||||
address_balances_loaded_ = true;
|
||||
} else if (!listOnly) {
|
||||
// The balance scan failed: retry after a hold scaled by what it cost (the abandoned call keeps
|
||||
@@ -2840,7 +2892,7 @@ void App::refreshBalance()
|
||||
|
||||
void App::refreshAddresses()
|
||||
{
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
refreshAddressData();
|
||||
}
|
||||
|
||||
@@ -3707,7 +3759,7 @@ void App::setMiningAddress(const std::string& addr, bool mining)
|
||||
void App::invalidateAddressValidationCache()
|
||||
{
|
||||
address_validation_cache_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
markAddressesDirty();
|
||||
address_retry_not_before_ = {}; // the address set changed: a refresh is owed now, whatever failed before
|
||||
noteWalletMutation(); // an import or sweep: the balance changes without the tip or mempool showing it
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
@@ -3822,7 +3874,24 @@ void App::resetChatSession()
|
||||
chat_note_model_seen_ = false;
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_submitted_at_ = 0.0;
|
||||
chat_note_scan_in_flight_ = false; // a stale in-flight note scan is dropped by its scanGen guard
|
||||
// The chat scans' in-flight flags are cleared only by their replies, which complete their scan tickets (see
|
||||
// the orphan check in update()): clearing them here would let that check drop a live ticket while the
|
||||
// daemon still runs the scan. Their results are dropped by the session guard.
|
||||
chat_note_rescan_requested_ = false;
|
||||
chat_note_scan_tip_ = -1;
|
||||
chat_note_scan_last_ = 0.0;
|
||||
chat_note_first_look_sent_ = false;
|
||||
chat_verified_funds_by_addr_.clear();
|
||||
chat_split_hold_tip_ = -1;
|
||||
chat_inbox_basis_txcount_ = -1;
|
||||
chat_inbox_last_scan_at_ = 0.0;
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_wanted_txcount_ = -1;
|
||||
chat_inbox_probe_at_ = 0.0;
|
||||
chat_inbox_probe_ms_ = 0.0;
|
||||
chat_spend_log_.clear();
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
addr_pass_spend_seq_ = -1;
|
||||
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
|
||||
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
@@ -3834,7 +3903,6 @@ void App::resetChatSession()
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
|
||||
chat_identity_unavailable_ = false;
|
||||
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
|
||||
// completion callback can't provision that secret under the new wallet.
|
||||
@@ -3955,6 +4023,22 @@ void App::maybeProvisionChatIdentity()
|
||||
// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use
|
||||
// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable
|
||||
// one so replies land somewhere we control and fees can be paid.
|
||||
// The chat note-buffer coordinator's constants (see below), here so the payer helpers can use them.
|
||||
namespace {
|
||||
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
|
||||
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
|
||||
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
|
||||
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
|
||||
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
|
||||
constexpr int kChatConfsRequired = 5;
|
||||
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
|
||||
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
|
||||
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
|
||||
inline std::uint64_t chatDrgxToZat(double drgx) {
|
||||
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string App::chatReplyZaddr()
|
||||
{
|
||||
// Reuse the previously-chosen address as long as we still own it spendably.
|
||||
@@ -3983,25 +4067,62 @@ std::string App::chatReplyZaddr()
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string App::chatPayFromZaddr(double fee) const
|
||||
std::string App::chatPayFromZaddr(double need) const
|
||||
{
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= need. Prefer the identity
|
||||
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
|
||||
// spendable z-address that can cover the fee. The memo still advertises the identity address as
|
||||
// spendable z-address that can cover it. The memo still advertises the identity address as
|
||||
// reply-to, so paying from a different note doesn't change who the peer replies to.
|
||||
std::string reply;
|
||||
if (settings_) reply = settings_->getChatReplyZaddr();
|
||||
// The chat note scan (or the address scan's feed) knows each address's verified notes; on the Chat page that
|
||||
// is fresher than state_.z_addresses' balances, which only the address scan refreshes.
|
||||
if (!chat_verified_funds_by_addr_.empty())
|
||||
return services::chooseChatPayer(chat_verified_funds_by_addr_, reply, chatDrgxToZat(need));
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
|
||||
if (a.address == reply && a.has_spending_key && a.spendableBalance >= need) return reply;
|
||||
|
||||
std::string best;
|
||||
double bestBal = -1.0;
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
|
||||
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= need && a.spendableBalance > bestBal) {
|
||||
best = a.address;
|
||||
bestBal = a.spendableBalance;
|
||||
}
|
||||
return best; // empty → no z-address can cover the fee
|
||||
return best; // empty → no z-address can cover it
|
||||
}
|
||||
|
||||
void App::debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend)
|
||||
{
|
||||
// Entries every outstanding reply already reflects (posted after them) can go; none outstanding: all.
|
||||
std::int64_t keepAfter = -1;
|
||||
for (std::int64_t seq : {chat_note_scan_spend_seq_, addr_pass_spend_seq_})
|
||||
if (seq >= 0) keepAfter = keepAfter < 0 ? seq : std::min(keepAfter, seq);
|
||||
if (keepAfter < 0) chat_spend_log_.clear();
|
||||
while (!chat_spend_log_.empty() &&
|
||||
(static_cast<std::int64_t>(chat_spend_log_.front().seq) <= keepAfter || chat_spend_log_.size() >= 64))
|
||||
chat_spend_log_.pop_front();
|
||||
chat_spend_log_.push_back({++chat_spend_seq_, payer, spend});
|
||||
services::debitChatPayer(chat_verified_funds_by_addr_, payer, spend);
|
||||
}
|
||||
|
||||
void App::reapplyChatSpendsAfter(std::uint64_t seq)
|
||||
{
|
||||
for (const auto& e : chat_spend_log_) {
|
||||
if (e.seq <= seq) continue;
|
||||
services::debitChatPayer(chat_verified_funds_by_addr_, e.payer, e.spend);
|
||||
if (e.spend == services::ChatPayerSpend::OneNote)
|
||||
chat_verified_note_budget_ = std::max(0, chat_verified_note_budget_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t App::chatSplitAffordableZat() const
|
||||
{
|
||||
// What one split can spend: z_sendmany pays from ONE address, so on the full node the richest verified
|
||||
// payer (lite: the verified shielded balance, as before).
|
||||
if (!lite_wallet_ && !chat_verified_funds_by_addr_.empty())
|
||||
return services::richestChatPayerZat(chat_verified_funds_by_addr_);
|
||||
return chat_verified_shielded_zat_;
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
@@ -4083,11 +4204,14 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||||
[this, echoLocalId, sessionGen](bool ok, const std::string& result) {
|
||||
if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
|
||||
if (!ok) chat_note_rescan_requested_ = true; // covers a submit-time failure the poller never sees
|
||||
inflight_op_ = LiteInflightOp{}; // release the channel for this session
|
||||
LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
|
||||
onChatBroadcastResult(op, ok, result);
|
||||
},
|
||||
/*background*/ true); // status is shown on the chat message, not the global send UI
|
||||
// Until the next note scan restores the truth: the send spends one whole note (largest first) of `from`.
|
||||
debitChatPayerLogged(from, services::ChatPayerSpend::OneNote);
|
||||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||||
}
|
||||
|
||||
@@ -4208,20 +4332,7 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
|
||||
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
|
||||
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
|
||||
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
|
||||
namespace {
|
||||
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
|
||||
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
|
||||
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
|
||||
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
|
||||
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
|
||||
constexpr int kChatConfsRequired = 5;
|
||||
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
|
||||
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
|
||||
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
|
||||
inline std::uint64_t chatDrgxToZat(double drgx) {
|
||||
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
|
||||
}
|
||||
} // namespace
|
||||
// (The coordinator's constants and chatDrgxToZat are defined above chatReplyZaddr.)
|
||||
|
||||
// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
|
||||
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
|
||||
@@ -4318,7 +4429,7 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model)
|
||||
// recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
}
|
||||
@@ -4338,42 +4449,69 @@ void App::refreshChatNoteBudgetNode() {
|
||||
// chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat
|
||||
// send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks).
|
||||
// The same hard gates as every wallet scan, except that a queued chat send still drains while the window
|
||||
// is in the background (the address scan that also feeds the budget waits for the window).
|
||||
// is in the background (the address scan that also feeds the budget waits for the window). Not for a
|
||||
// split alone: nothing needs the buffer in the background, and a split that never refills would scan
|
||||
// every block for as long as the window stays minimized.
|
||||
{
|
||||
services::ScanContext sc = scanContext();
|
||||
if (!chat_send_queue_.empty() || chat_split_outstanding_) sc.background = false;
|
||||
if (!chat_send_queue_.empty()) sc.background = false;
|
||||
if (!services::wholeWalletScanAllowed(sc)) return;
|
||||
}
|
||||
// Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy
|
||||
// z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent
|
||||
// already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a
|
||||
// fallback for the Chat tab (where the address refresh doesn't run).
|
||||
if (current_page_ != ui::NavPage::Chat) return;
|
||||
if (chat_note_scan_in_flight_) return; // one scan at a time
|
||||
const double now = ImGui::GetTime();
|
||||
// Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many
|
||||
// seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same
|
||||
// duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor.
|
||||
double minInterval = 4.0;
|
||||
if (chat_note_scan_ms_ > 0.0)
|
||||
minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10);
|
||||
if (now - chat_note_scan_last_ < minInterval) return;
|
||||
|
||||
if (chat_note_scan_in_flight_) return; // cleared only by its reply (which completes its ticket)
|
||||
// The address pass reads the same z_listunspent and feeds this view: not a second one while it runs or is owed.
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (network_refresh_.jobInProgress(Job::Addresses) || (addresses_dirty_ && addressScanCanRunNow())) return;
|
||||
using services::ScanStream;
|
||||
const double now = scanClockNow();
|
||||
// On need only: something can use the view (the Chat page, a queued send, a split) and it can have changed
|
||||
// (first look, a chat op finished, a settled block that matters, or the fallback). How often: the budget's.
|
||||
services::ChatNoteScanInputs in;
|
||||
in.onChatPage = currentScanVisibility().chat;
|
||||
in.queueWaiting = !chat_send_queue_.empty();
|
||||
in.splitOutstanding = chat_split_outstanding_;
|
||||
in.rescanRequested = chat_note_rescan_requested_;
|
||||
in.modelSeen = chat_note_model_seen_;
|
||||
in.verifiedBudget = chat_verified_note_budget_;
|
||||
in.pipelineNotes = chat_pipeline_note_count_;
|
||||
in.tipHeight = state_.sync.blocks;
|
||||
in.lastScanTip = chat_note_scan_tip_;
|
||||
in.nowSec = now;
|
||||
in.lastScanAtSec = chat_note_scan_last_;
|
||||
in.tipChangedAtSec = tip_changed_at_;
|
||||
in.fallbackSec = services::chatNoteFallbackSec(scan_governor_.emaCostMs(ScanStream::ChatNotes));
|
||||
if (!services::shouldScanChatNotes(in)) return;
|
||||
const auto sig = currentScanSignals();
|
||||
services::ScanTicket ticket;
|
||||
if (!chat_note_model_seen_ && in.onChatPage && !chat_note_first_look_sent_) {
|
||||
// The page can't show the buffer, or drain a send, without one look: explicit (never held back, but
|
||||
// charged), once per session.
|
||||
ticket = scan_governor_.beginExplicit(ScanStream::ChatNotes, sig, now);
|
||||
chat_note_first_look_sent_ = true;
|
||||
} else {
|
||||
ticket = scan_governor_.tryAdmit(ScanStream::ChatNotes, in.onChatPage || in.queueWaiting, sig, now);
|
||||
if (!ticket) return; // asks again next frame while the need holds
|
||||
}
|
||||
const bool servedRescan = chat_note_rescan_requested_;
|
||||
chat_note_rescan_requested_ = false;
|
||||
if (now - tip_changed_at_ >= services::kChatTipSettleSec) chat_note_scan_tip_ = state_.sync.blocks; // settled only
|
||||
chat_note_scan_last_ = now;
|
||||
chat_note_scan_in_flight_ = true;
|
||||
const std::uint64_t spendSeq = chat_spend_seq_;
|
||||
chat_note_scan_spend_seq_ = static_cast<std::int64_t>(spendSeq);
|
||||
const int scanGen = chat_session_generation_;
|
||||
const int confsNeeded = chatConfsRequired(); // == 1 for full node
|
||||
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, scanGen, confsNeeded, minVal, ticket, servedRescan, spendSeq]() -> rpc::RPCWorker::MainCb {
|
||||
int verified = 0, pipeline = 0;
|
||||
std::uint64_t verifiedZat = 0;
|
||||
services::ChatPayerMap funds;
|
||||
bool ok = false;
|
||||
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
|
||||
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
|
||||
if (notes.is_array()) {
|
||||
ok = true;
|
||||
for (const auto& nz : notes) {
|
||||
if (!nz.is_object()) continue;
|
||||
if (nz.value("locked", false)) continue; // tied up by an in-flight send
|
||||
@@ -4384,24 +4522,50 @@ void App::refreshChatNoteBudgetNode() {
|
||||
? nz["rawconfirmations"].get<int>()
|
||||
: nz.value("confirmations", 0);
|
||||
++pipeline; // unspent self-note (verified or maturing)
|
||||
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
|
||||
if (confs >= confsNeeded) {
|
||||
++verified;
|
||||
verifiedZat += amtZat;
|
||||
if (nz.contains("address") && nz["address"].is_string()) {
|
||||
auto& f = funds[nz["address"].get<std::string>()];
|
||||
f.zat += amtZat;
|
||||
++f.notes;
|
||||
}
|
||||
}
|
||||
}
|
||||
ok = true; // after the loop: a throw mid-loop applies nothing
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
} catch (const std::exception& e) {
|
||||
failKind = rpc::rpcFailureKindOf(e);
|
||||
} catch (...) {
|
||||
failKind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const double scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() {
|
||||
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
|
||||
return [this, scanGen, ticket, servedRescan, spendSeq, ok, failKind, verified, pipeline, verifiedZat,
|
||||
funds = std::move(funds), scanMs]() mutable {
|
||||
chat_note_scan_in_flight_ = false;
|
||||
if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off
|
||||
if (!ok) return; // z_listunspent unavailable — leave caches as-is
|
||||
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
|
||||
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
|
||||
if (scanGen != chat_session_generation_) return; // wallet switched — drop
|
||||
// onDisconnected cleared the view and this scan's seq since the post: its notes are the previous
|
||||
// connection's, and the spends to re-apply are gone from the log. Drop it (the reconnect's address
|
||||
// pass feeds the view), like the address pass whose reply resetJobs makes stale.
|
||||
if (chat_note_scan_spend_seq_ != static_cast<std::int64_t>(spendSeq)) return;
|
||||
chat_note_scan_spend_seq_ = -1;
|
||||
if (!ok) { // still owed (the 4 s floor paces the retry)
|
||||
if (servedRescan) chat_note_rescan_requested_ = true;
|
||||
chat_note_scan_tip_ = -1;
|
||||
return;
|
||||
}
|
||||
chat_note_model_seen_ = true;
|
||||
chat_verified_note_budget_ = verified;
|
||||
chat_pipeline_note_count_ = pipeline;
|
||||
chat_verified_shielded_zat_ = verifiedZat;
|
||||
chat_verified_funds_by_addr_ = std::move(funds);
|
||||
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this scan was posted
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
};
|
||||
@@ -4413,27 +4577,42 @@ void App::refreshChatNoteBudgetNode() {
|
||||
// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the
|
||||
// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off.
|
||||
void App::updateChatNoteBudgetFromUnspent(
|
||||
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes) {
|
||||
const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes, std::int64_t feedTip,
|
||||
std::uint64_t spendSeq) {
|
||||
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
const int confsNeeded = chatConfsRequired();
|
||||
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
int verified = 0, pipeline = 0;
|
||||
std::uint64_t verifiedZat = 0;
|
||||
services::ChatPayerMap funds;
|
||||
for (const auto& nz : unspentNotes) {
|
||||
if (nz.locked) continue; // tied up by an in-flight send
|
||||
const std::uint64_t amtZat = chatDrgxToZat(nz.amount);
|
||||
if (amtZat < minVal) continue; // skip dust below a fee
|
||||
++pipeline; // unspent self-note (verified or maturing)
|
||||
if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; }
|
||||
if (nz.confirmations >= confsNeeded) {
|
||||
++verified;
|
||||
verifiedZat += amtZat;
|
||||
if (!nz.address.empty()) {
|
||||
auto& f = funds[nz.address];
|
||||
f.zat += amtZat;
|
||||
++f.notes;
|
||||
}
|
||||
}
|
||||
}
|
||||
chat_note_model_seen_ = true;
|
||||
chat_verified_note_budget_ = verified;
|
||||
chat_pipeline_note_count_ = pipeline;
|
||||
chat_verified_shielded_zat_ = verifiedZat;
|
||||
chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off
|
||||
chat_verified_funds_by_addr_ = std::move(funds);
|
||||
reapplyChatSpendsAfter(spendSeq); // chat sends submitted after this pass was posted
|
||||
// Counts as a fresh scan: the dedicated one backs off. Not the rescan request: this pass may predate the
|
||||
// chat op that asked for it.
|
||||
chat_note_scan_last_ = scanClockNow();
|
||||
if (feedTip >= 0) chat_note_scan_tip_ = feedTip;
|
||||
if (chat_split_outstanding_ &&
|
||||
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
|
||||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
scanClockNow() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
|
||||
chat_split_outstanding_ = false;
|
||||
}
|
||||
}
|
||||
@@ -4483,13 +4662,13 @@ bool App::broadcastSelfSplitNode(int noteCount) {
|
||||
const std::string myZ = chatReplyZaddr();
|
||||
if (myZ.empty()) return false;
|
||||
const double fee = kChatMinFeeDrgx;
|
||||
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
|
||||
// there). The note count now includes notes at every address and is sized against the total verified
|
||||
// balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
|
||||
// aren't at the reply address (which paying strictly from myZ would).
|
||||
const std::string from = chatPayFromZaddr(fee);
|
||||
if (from.empty()) return false; // no fundable note — pump retries after a scan
|
||||
const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys
|
||||
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
|
||||
// there). ONE address pays the whole split (n notes + the fee), not just the fee: the pump sizes n to the
|
||||
// richest verified payer (chatSplitAffordableZat), so one covers it. Paying from wherever the funds sit
|
||||
// can't stall when they aren't at the reply address (which paying strictly from myZ would).
|
||||
const std::string from = chatPayFromZaddr(n * kChatNoteSizeDrgx + fee);
|
||||
if (from.empty()) return false; // no fundable payer — no RPC; the pump retries after a scan
|
||||
nlohmann::json recipients = nlohmann::json::array();
|
||||
for (int i = 0; i < n; ++i) {
|
||||
nlohmann::json r;
|
||||
@@ -4506,10 +4685,16 @@ bool App::broadcastSelfSplitNode(int noteCount) {
|
||||
// On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
|
||||
// next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
|
||||
// insufficient-funds throw) clear it now so the pump can retry after the next scan
|
||||
// instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
|
||||
if (!ok) chat_split_outstanding_ = false;
|
||||
// instead of waiting out the 1200s watchdog (no notes were created, so it's safe),
|
||||
// and no other split before the next block.
|
||||
if (!ok) {
|
||||
chat_split_outstanding_ = false;
|
||||
chat_split_hold_tip_ = state_.sync.blocks;
|
||||
chat_note_rescan_requested_ = true;
|
||||
}
|
||||
},
|
||||
/*background*/ true); // autonomous buffer maintenance — no global send UI
|
||||
debitChatPayerLogged(from, services::ChatPayerSpend::All);
|
||||
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
|
||||
" notes (self-send) so messages can be sent back-to-back");
|
||||
return true;
|
||||
@@ -4530,9 +4715,13 @@ bool App::shouldSplitChatBuffer() {
|
||||
if (lite_wallet_ ? (bool)lite_send_callback_
|
||||
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
|
||||
if (chat_split_outstanding_) return false;
|
||||
if (!lite_wallet_ && chat_split_hold_tip_ >= 0 && state_.sync.blocks <= chat_split_hold_tip_) return false; // failed here: once per block
|
||||
if (!chat_note_model_seen_) return false;
|
||||
if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it
|
||||
if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
|
||||
// Only a split that can lift the pipeline to the trigger by itself: once it mines, the pipeline check
|
||||
// releases the flag. A smaller one never does, and the watchdog would re-split the same notes every 20 min,
|
||||
// a fee each time. (A payer below that sends one message per confirmation through its change instead.)
|
||||
if (chatSplitAffordableZat() < chatDrgxToZat(kChatRefillTrigger * kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4605,8 +4794,8 @@ void App::pumpChatNoteBuffer() {
|
||||
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
|
||||
const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx);
|
||||
int affordable = 0;
|
||||
if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
|
||||
affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
|
||||
const std::uint64_t affordableZat = chatSplitAffordableZat(); // one payer funds the whole split
|
||||
if (affordableZat > feeZat && noteZat > 0) affordable = static_cast<int>((affordableZat - feeZat) / noteZat);
|
||||
const int n = std::min(need, affordable);
|
||||
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
|
||||
: broadcastSelfSplitNode(n));
|
||||
@@ -4615,7 +4804,7 @@ void App::pumpChatNoteBuffer() {
|
||||
// Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
|
||||
// invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
|
||||
chat_split_outstanding_ = true;
|
||||
chat_split_submitted_at_ = ImGui::GetTime();
|
||||
chat_split_submitted_at_ = scanClockNow(); // steady: the watchdog also runs while minimized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4670,40 +4859,94 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
||||
}
|
||||
}
|
||||
|
||||
bool App::chatInboxScanAllowed() const
|
||||
{
|
||||
// Full-node only (lite has its own harvest).
|
||||
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return false;
|
||||
if (!state_.connected || !rpc_ || !worker_) return false;
|
||||
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...), and only
|
||||
// while the Chat page is shown: off it, incoming messages still arrive through the confirmed block-tip
|
||||
// harvest (+ toast) within ~1 block.
|
||||
return services::wholeWalletScanAllowed(scanContext()) && currentScanVisibility().chat;
|
||||
}
|
||||
|
||||
void App::fastScanChatMemos()
|
||||
{
|
||||
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
|
||||
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
|
||||
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
|
||||
if (lite_wallet_) return;
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
|
||||
// z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one
|
||||
// address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching
|
||||
// up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip
|
||||
// harvest still ingests every message on confirmation; this only trades away mempool-speed delivery.
|
||||
// The same hard gates as every wallet scan (catch-up, the connect gate, the background, busy, ...).
|
||||
if (!services::wholeWalletScanAllowed(scanContext())) return;
|
||||
// Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the
|
||||
// Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block.
|
||||
if (current_page_ != ui::NavPage::Chat) return;
|
||||
const double nowT = ImGui::GetTime();
|
||||
if (chat_fast_scan_ms_ > 0.0 &&
|
||||
nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return;
|
||||
const std::string addr = chatReplyZaddr();
|
||||
if (addr.empty()) return;
|
||||
|
||||
chat_fast_scan_last_ = nowT;
|
||||
// 0-conf delivery while the Chat page is shown: a cheap probe (getwalletinfo's txcount) each tick, and the
|
||||
// inbox scan (z_listreceivedbyaddress of the reply address: O(mapWallet), the daemon iterates every wtx
|
||||
// even when filtered to one address, under cs_main) only when the wallet gained a transaction or the safety
|
||||
// net is due, and only as the scan budget admits it (ChatFast). The block-tip harvest still ingests every
|
||||
// message on confirmation (the store dedups on txid+position).
|
||||
if (!chatInboxScanAllowed()) return;
|
||||
if (chat_fast_scan_in_flight_) return; // the probe or the scan is out
|
||||
if (chat_inbox_scan_wanted_) { // keep asking (a waiter must keep asking)
|
||||
admitChatInboxScan();
|
||||
return;
|
||||
}
|
||||
// A wallet scan is in the daemon: the probe would queue behind it for cs_main, and nothing could be
|
||||
// admitted after it anyway.
|
||||
if (scan_governor_.anyInFlight()) return;
|
||||
const double now = scanClockNow();
|
||||
if (!services::chatProbeDue(chat_inbox_probe_ms_, chat_inbox_probe_at_, now)) return;
|
||||
chat_inbox_probe_at_ = now;
|
||||
chat_fast_scan_in_flight_ = true;
|
||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||||
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||
std::int64_t txCount = -1;
|
||||
bool answered = true; // false: nothing learned this tick (transport, busy, warmup)
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / inbox probe");
|
||||
txCount = services::parseWalletTxCount(rpc_->call("getwalletinfo"));
|
||||
} catch (const rpc::RpcError& e) {
|
||||
answered = e.kind() != rpc::RpcFailureKind::Warmup; // an app error: no count, scan as the budget allows
|
||||
} catch (...) {
|
||||
answered = false;
|
||||
}
|
||||
const double probeMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [this, scanGen, txCount, answered, probeMs]() {
|
||||
chat_fast_scan_in_flight_ = false; // ours, whatever the session
|
||||
// Its cost, from probes that answered only (a timed-out one measured its wait), smoothed so one slow
|
||||
// probe doesn't silence the next ones.
|
||||
if (answered && probeMs > 0.0) {
|
||||
chat_inbox_probe_ms_ = chat_inbox_probe_ms_ <= 0.0
|
||||
? probeMs : std::min(probeMs, 0.7 * chat_inbox_probe_ms_ + 0.3 * probeMs);
|
||||
}
|
||||
if (scanGen != chat_session_generation_ || !answered) return;
|
||||
services::ChatInboxScanInputs in;
|
||||
in.basisTxCount = chat_inbox_basis_txcount_;
|
||||
in.txCount = txCount;
|
||||
in.nowSec = scanClockNow();
|
||||
in.lastScanAtSec = chat_inbox_last_scan_at_;
|
||||
in.safetyNetSec = services::chatInboxSafetyNetSec(scan_governor_.emaCostMs(services::ScanStream::ChatFast));
|
||||
if (!services::chatInboxScanNeeded(in)) return;
|
||||
chat_inbox_scan_wanted_ = true;
|
||||
chat_inbox_wanted_txcount_ = txCount; // read before the scan: the basis can only under-count (one extra scan)
|
||||
admitChatInboxScan();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::admitChatInboxScan()
|
||||
{
|
||||
if (!chatInboxScanAllowed() || chat_fast_scan_in_flight_) return; // a gate may have closed since the probe
|
||||
const std::string addr = chatReplyZaddr();
|
||||
if (addr.empty()) return;
|
||||
auto ticket = scan_governor_.tryAdmit(services::ScanStream::ChatFast, /*visible=*/true,
|
||||
currentScanSignals(), scanClockNow());
|
||||
if (!ticket) return; // still wanted: the next chat tick asks again
|
||||
chat_fast_scan_in_flight_ = true;
|
||||
const std::int64_t basis = chat_inbox_wanted_txcount_;
|
||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||||
worker_->post([this, addr, scanGen, ticket, basis]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||||
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
|
||||
std::string scanError;
|
||||
bool ok = false;
|
||||
rpc::RpcFailureKind failKind = rpc::RpcFailureKind::None;
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf inbox scan");
|
||||
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
||||
if (received.is_array()) {
|
||||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||||
@@ -4729,21 +4972,31 @@ void App::fastScanChatMemos()
|
||||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
|
||||
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
||||
}
|
||||
ok = true;
|
||||
}
|
||||
} catch (const std::exception& e) { scanError = e.what(); }
|
||||
} catch (const std::exception& e) {
|
||||
scanError = e.what();
|
||||
failKind = rpc::rpcFailureKindOf(e);
|
||||
} catch (...) {
|
||||
scanError = "unknown error";
|
||||
failKind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const int parsedCount = static_cast<int>(metadata.size());
|
||||
const double scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable {
|
||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||||
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
|
||||
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
|
||||
if (scanGen != chat_session_generation_) return;
|
||||
return [this, scanGen, ticket, basis, ok, failKind, metadata = std::move(metadata), rawMemoCount, parsedCount,
|
||||
scanError, scanMs]() mutable {
|
||||
chat_fast_scan_in_flight_ = false;
|
||||
if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off
|
||||
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
|
||||
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
|
||||
// are reaching us at all, and how many parse as chat vs are unrelated memos.
|
||||
// Whatever the session: the daemon ran it. Before the guard, or a stale reply would hold the slot.
|
||||
scan_governor_.complete(ticket, scanMs, services::chatScanOutcome(ok, failKind), scanClockNow());
|
||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||||
// session: drop it.
|
||||
if (scanGen != chat_session_generation_) return;
|
||||
if (ok) { // an empty inbox is a result too: before the early returns below
|
||||
chat_inbox_scan_wanted_ = false;
|
||||
chat_inbox_basis_txcount_ = basis;
|
||||
chat_inbox_last_scan_at_ = ticket.dispatchedAt;
|
||||
}
|
||||
if (!scanError.empty()) {
|
||||
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
|
||||
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
|
||||
@@ -6347,26 +6600,23 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||
if (ok) {
|
||||
// A send changes address balances — refresh on next cycle
|
||||
addresses_dirty_ = true;
|
||||
// Force transaction list refresh so the sent tx appears immediately
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
requestForcedBalanceRefresh(); // the user's own send must update the balance now, past the throttle
|
||||
noteWalletMutation();
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||
// broadcast asynchronously by the daemon. Defer the user-facing
|
||||
// success/failure to the opid poller (app.cpp) so we don't report
|
||||
// "sent successfully" for an operation that may still fail.
|
||||
// "sent successfully" for an operation that may still fail. The wallet changes
|
||||
// when the op commits, and the poller re-reads it then, by origin
|
||||
// (markWalletMutated); meanwhile the pending row and the local spendable debit
|
||||
// show the send. `background` = a chat message or buffer split.
|
||||
if (!result_str.empty()) {
|
||||
pending_opids_.push_back(result_str);
|
||||
if (background) op_origin_[result_str] = services::NetworkRefreshService::OperationOrigin::Chat;
|
||||
if (markFeeGapRetry)
|
||||
send_feegap_retried_opids_.insert(result_str); // a retry of a retry is a real error
|
||||
upsertPendingSendTransaction(result_str, from, to, amount, memo, fee);
|
||||
if (callback) pending_send_callbacks_[result_str] = callback;
|
||||
} else if (callback) {
|
||||
callback(true, result_str); // no opid to track — report as-is
|
||||
} else {
|
||||
if (!background) markWalletMutated(/*forceBalance=*/true); // no opid: no completion event
|
||||
if (callback) callback(true, result_str); // no opid to track — report as-is
|
||||
}
|
||||
} else {
|
||||
if (!background) send_progress_active_ = false;
|
||||
|
||||
Reference in New Issue
Block a user