fix(tx): track async operations to completion (send/shield/auto-shield)

z_sendmany returns an opid immediately; the tx is built/signed/broadcast
asynchronously afterward. The send path showed "Transaction sent successfully!"
and cleared the form on opid receipt, so a later async failure contradicted it.
Shield/merge stored the opid only in a dialog-local static (never polled), and
auto-shield ran a blocking z_shieldcoinbase on the UI thread and discarded its
opid — async failures of all three were silently lost.

- Add App::trackOperation(opid) so shield/merge/auto-shield register with the
  shared opid poller (failures surface, balances refresh on completion).
- Defer the full-node send's success/failure to the poller via per-opid callbacks
  (parseOperationStatusPoll now exposes failureByOpid); the "Sending..." spinner
  covers the finalizing window, and the form is kept until terminal status.
- Dispatch auto-shield through the worker thread and use the configured fee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:16:48 -05:00
parent 20b22410e9
commit 1bc7f5c8cd
6 changed files with 113 additions and 28 deletions

View File

@@ -739,9 +739,25 @@ void App::update()
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids); auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
return [this, parsed = std::move(parsed)]() mutable { return [this, parsed = std::move(parsed)]() mutable {
opid_poll_in_progress_ = false; opid_poll_in_progress_ = false;
for (const auto& msg : parsed.failureMessages) {
ui::Notifications::instance().error(msg); // Successes: hand the real txid to any waiting send UI callback.
std::unordered_set<std::string> successfulOpids;
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
successfulOpids.insert(opid);
markPendingSendTransactionSucceeded(opid, txid);
send_txids_.insert(txid);
invokeSendResultCallback(opid, true, txid);
} }
// Failures: route to the originating send UI when there is one (it shows
// its own error toast); otherwise surface a generic notification (this is
// how shield/merge/auto-shield failures become visible).
for (const auto& [opid, msg] : parsed.failureByOpid) {
if (!invokeSendResultCallback(opid, false, msg)) {
ui::Notifications::instance().error(msg);
}
}
std::vector<std::string> terminalOpids = std::move(parsed.doneOpids); std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
terminalOpids.insert(terminalOpids.end(), terminalOpids.insert(terminalOpids.end(),
parsed.staleOpids.begin(), parsed.staleOpids.end()); parsed.staleOpids.begin(), parsed.staleOpids.end());
@@ -750,13 +766,14 @@ void App::update()
std::remove(pending_opids_.begin(), pending_opids_.end(), id), std::remove(pending_opids_.begin(), pending_opids_.end(), id),
pending_opids_.end()); pending_opids_.end());
} }
// Stale opids (no longer reported by the daemon): let any waiting send UI
// know the outcome couldn't be confirmed rather than spinning forever.
for (const auto& opid : parsed.staleOpids) {
invokeSendResultCallback(opid, false, TR("send_status_unconfirmed"));
}
if (parsed.anySuccess) { if (parsed.anySuccess) {
std::unordered_set<std::string> successfulOpids;
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
successfulOpids.insert(opid);
markPendingSendTransactionSucceeded(opid, txid);
send_txids_.insert(txid);
}
std::vector<std::string> successOpids; std::vector<std::string> successOpids;
std::vector<std::string> failedOrStaleOpids; std::vector<std::string> failedOrStaleOpids;
for (const auto& opid : terminalOpids) { for (const auto& opid : terminalOpids) {

View File

@@ -250,11 +250,18 @@ public:
// Wallet backup // Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback); void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
// Transaction operations // Transaction operations
void sendTransaction(const std::string& from, const std::string& to, void sendTransaction(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo, double amount, double fee, const std::string& memo,
std::function<void(bool success, const std::string& result)> callback); std::function<void(bool success, const std::string& result)> callback);
// Register a daemon async operation id (z_shieldcoinbase / z_mergetoaddress /
// 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);
// Force refresh // Force refresh
void refreshNow(); void refreshNow();
void refreshMiningInfo(); void refreshMiningInfo();
@@ -399,6 +406,10 @@ private:
const std::string& txid); const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids, void removePendingSendTransactions(const std::vector<std::string>& opids,
bool restoreBalances); bool restoreBalances);
// Deliver a deferred z_sendmany result to its waiting UI callback once the opid
// reaches a terminal status. Returns true if a callback was registered (and fired).
bool invokeSendResultCallback(const std::string& opid, bool ok,
const std::string& result);
void applyPendingSendBalanceDeltas(bool includeAggregateBalances); void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
std::string transactionHistoryCacheWalletIdentity() const; std::string transactionHistoryCacheWalletIdentity() const;
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity); bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
@@ -576,6 +587,10 @@ private:
std::int64_t timestamp = 0; std::int64_t timestamp = 0;
}; };
std::unordered_map<std::string, PendingSendInfo> pending_send_info_; std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
// z_sendmany UI callbacks held until the opid reaches a terminal status, so the
// user isn't told "sent successfully" before the tx is actually built/broadcast.
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>
pending_send_callbacks_;
// Txids from completed z_sendmany operations. // Txids from completed z_sendmany operations.
// Ensures shielded sends are discoverable by z_viewtransaction // Ensures shielded sends are discoverable by z_viewtransaction
// even when they don't appear in listtransactions or // even when they don't appear in listtransactions or

View File

@@ -730,6 +730,27 @@ void App::removePendingSendTransactions(const std::vector<std::string>& opids,
state_.last_tx_update = std::time(nullptr); state_.last_tx_update = std::time(nullptr);
} }
void App::trackOperation(const std::string& opid)
{
if (opid.empty()) return;
// Touched only from the main thread (sendTransaction's MainCb and the opid poller's
// MainCb both run via drainResults()), so no locking is needed.
if (std::find(pending_opids_.begin(), pending_opids_.end(), opid) != pending_opids_.end())
return;
pending_opids_.push_back(opid);
}
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
const std::string& result)
{
auto it = pending_send_callbacks_.find(opid);
if (it == pending_send_callbacks_.end()) return false;
auto cb = std::move(it->second);
pending_send_callbacks_.erase(it);
if (cb) cb(ok, result);
return true;
}
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances) void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
{ {
for (const auto& [opid, pending] : pending_send_info_) { for (const auto& [opid, pending] : pending_send_info_) {
@@ -1024,21 +1045,39 @@ void App::refreshCoreData()
break; break;
} }
} }
if (!targetZAddr.empty() && rpc_) { if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n", DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
state_.transparent_balance, targetZAddr.c_str()); state_.transparent_balance, targetZAddr.c_str());
rpc_->z_shieldCoinbase("*", targetZAddr, 0.0001, 50, // Use the user-configured fee, formatted fixed-decimal so the daemon's
[this](const json& result) { // ParseFixedPoint accepts it (a small double would serialize to "5e-05").
if (result.contains("opid")) { const std::string feeStr =
DEBUG_LOGF("[AutoShield] Started: %s\n", util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
result["opid"].get<std::string>().c_str()); // This callback runs on the UI thread (drainResults). Build/broadcast
// on the worker thread — never block the UI with synchronous RPC.
worker_->post([this, targetZAddr, feeStr]() -> rpc::RPCWorker::MainCb {
std::string opid;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Auto-shield / z_shieldcoinbase");
auto result = rpc_->call("z_shieldcoinbase",
{std::string("*"), targetZAddr, feeStr, 50});
opid = result.value("opid", "");
} catch (const std::exception& e) {
err = e.what();
}
return [this, opid, err]() {
auto_shield_pending_ = false;
if (!err.empty()) {
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
return;
} }
auto_shield_pending_ = false; if (!opid.empty()) {
}, DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
[this](const std::string& err) { // Surface the async result + refresh balances on completion.
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str()); trackOperation(opid);
auto_shield_pending_ = false; }
}); };
});
} else { } else {
auto_shield_pending_ = false; auto_shield_pending_ = false;
} }
@@ -2070,15 +2109,21 @@ void App::sendTransaction(const std::string& from, const std::string& to,
transactions_dirty_ = true; transactions_dirty_ = true;
last_tx_block_height_ = -1; last_tx_block_height_ = -1;
network_refresh_.markWalletMutationRefresh(); network_refresh_.markWalletMutationRefresh();
// Track the opid so we can poll for completion // 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.
if (!result_str.empty()) { if (!result_str.empty()) {
pending_opids_.push_back(result_str); pending_opids_.push_back(result_str);
upsertPendingSendTransaction(result_str, from, to, amount, memo); upsertPendingSendTransaction(result_str, from, to, amount, memo);
if (callback) pending_send_callbacks_[result_str] = callback;
} else if (callback) {
callback(true, result_str); // no opid to track — report as-is
} }
} else { } else {
send_progress_active_ = false; send_progress_active_ = false;
if (callback) callback(false, result_str);
} }
if (callback) callback(ok, result_str);
}; };
}); });
} }

View File

@@ -1095,6 +1095,7 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
} catch (...) {} } catch (...) {}
} }
parsed.failureMessages.push_back(msg); parsed.failureMessages.push_back(msg);
parsed.failureByOpid[opid] = msg;
} }
} }

View File

@@ -198,6 +198,7 @@ public:
std::vector<std::string> successTxids; std::vector<std::string> successTxids;
std::unordered_map<std::string, std::string> successTxidsByOpid; std::unordered_map<std::string, std::string> successTxidsByOpid;
std::vector<std::string> failureMessages; std::vector<std::string> failureMessages;
std::unordered_map<std::string, std::string> failureByOpid; // opid -> error message
bool anySuccess = false; bool anySuccess = false;
}; };

View File

@@ -181,7 +181,7 @@ void ShieldDialog::render(App* app)
double fee = s_fee; double fee = s_fee;
int limit = s_utxo_limit; int limit = s_utxo_limit;
if (app->worker()) { if (app->worker()) {
app->worker()->post([rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb { app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb {
nlohmann::json result; nlohmann::json result;
std::string error; std::string error;
try { try {
@@ -190,12 +190,15 @@ void ShieldDialog::render(App* app)
} catch (const std::exception& e) { } catch (const std::exception& e) {
error = e.what(); error = e.what();
} }
return [result, error]() { return [app, result, error]() {
s_operation_pending = false; s_operation_pending = false;
if (error.empty()) { if (error.empty()) {
s_operation_id = result.value("opid", ""); s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id; s_status_message = "Operation submitted: " + s_operation_id;
Notifications::instance().success(TR("shield_started")); Notifications::instance().success(TR("shield_started"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else { } else {
s_status_message = "Error: " + error; s_status_message = "Error: " + error;
Notifications::instance().error("Shield failed: " + error); Notifications::instance().error("Shield failed: " + error);
@@ -210,7 +213,7 @@ void ShieldDialog::render(App* app)
double fee = s_fee; double fee = s_fee;
int limit = s_utxo_limit; int limit = s_utxo_limit;
if (app->worker()) { if (app->worker()) {
app->worker()->post([rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb { app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
nlohmann::json addrs = nlohmann::json::array(); nlohmann::json addrs = nlohmann::json::array();
for (const auto& addr : fromAddrs) addrs.push_back(addr); for (const auto& addr : fromAddrs) addrs.push_back(addr);
nlohmann::json result; nlohmann::json result;
@@ -221,12 +224,15 @@ void ShieldDialog::render(App* app)
} catch (const std::exception& e) { } catch (const std::exception& e) {
error = e.what(); error = e.what();
} }
return [result, error]() { return [app, result, error]() {
s_operation_pending = false; s_operation_pending = false;
if (error.empty()) { if (error.empty()) {
s_operation_id = result.value("opid", ""); s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id; s_status_message = "Operation submitted: " + s_operation_id;
Notifications::instance().success(TR("merge_started")); Notifications::instance().success(TR("merge_started"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else { } else {
s_status_message = "Error: " + error; s_status_message = "Error: " + error;
Notifications::instance().error("Merge failed: " + error); Notifications::instance().error("Merge failed: " + error);