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);
return [this, parsed = std::move(parsed)]() mutable {
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);
terminalOpids.insert(terminalOpids.end(),
parsed.staleOpids.begin(), parsed.staleOpids.end());
@@ -750,13 +766,14 @@ void App::update()
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
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) {
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> failedOrStaleOpids;
for (const auto& opid : terminalOpids) {

View File

@@ -250,11 +250,18 @@ public:
// Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
// Transaction operations
void sendTransaction(const std::string& from, const std::string& to,
// Transaction operations
void sendTransaction(const std::string& from, const std::string& to,
double amount, double fee, const std::string& memo,
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
void refreshNow();
void refreshMiningInfo();
@@ -399,6 +406,10 @@ private:
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
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);
std::string transactionHistoryCacheWalletIdentity() const;
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
@@ -576,6 +587,10 @@ private:
std::int64_t timestamp = 0;
};
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.
// Ensures shielded sends are discoverable by z_viewtransaction
// 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);
}
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)
{
for (const auto& [opid, pending] : pending_send_info_) {
@@ -1024,21 +1045,39 @@ void App::refreshCoreData()
break;
}
}
if (!targetZAddr.empty() && rpc_) {
if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
state_.transparent_balance, targetZAddr.c_str());
rpc_->z_shieldCoinbase("*", targetZAddr, 0.0001, 50,
[this](const json& result) {
if (result.contains("opid")) {
DEBUG_LOGF("[AutoShield] Started: %s\n",
result["opid"].get<std::string>().c_str());
// Use the user-configured fee, formatted fixed-decimal so the daemon's
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
const std::string feeStr =
util::formatAmountFixed(settings_ ? settings_->getDefaultFee() : 0.0001);
// 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;
},
[this](const std::string& err) {
DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str());
auto_shield_pending_ = false;
});
if (!opid.empty()) {
DEBUG_LOGF("[AutoShield] Started: %s\n", opid.c_str());
// Surface the async result + refresh balances on completion.
trackOperation(opid);
}
};
});
} else {
auto_shield_pending_ = false;
}
@@ -2070,15 +2109,21 @@ void App::sendTransaction(const std::string& from, const std::string& to,
transactions_dirty_ = true;
last_tx_block_height_ = -1;
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()) {
pending_opids_.push_back(result_str);
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 {
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 (...) {}
}
parsed.failureMessages.push_back(msg);
parsed.failureByOpid[opid] = msg;
}
}

View File

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

View File

@@ -181,7 +181,7 @@ void ShieldDialog::render(App* app)
double fee = s_fee;
int limit = s_utxo_limit;
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;
std::string error;
try {
@@ -190,12 +190,15 @@ void ShieldDialog::render(App* app)
} catch (const std::exception& e) {
error = e.what();
}
return [result, error]() {
return [app, result, error]() {
s_operation_pending = false;
if (error.empty()) {
s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id;
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 {
s_status_message = "Error: " + error;
Notifications::instance().error("Shield failed: " + error);
@@ -210,7 +213,7 @@ void ShieldDialog::render(App* app)
double fee = s_fee;
int limit = s_utxo_limit;
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();
for (const auto& addr : fromAddrs) addrs.push_back(addr);
nlohmann::json result;
@@ -221,12 +224,15 @@ void ShieldDialog::render(App* app)
} catch (const std::exception& e) {
error = e.what();
}
return [result, error]() {
return [app, result, error]() {
s_operation_pending = false;
if (error.empty()) {
s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id;
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 {
s_status_message = "Error: " + error;
Notifications::instance().error("Merge failed: " + error);