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:
33
src/app.cpp
33
src/app.cpp
@@ -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) {
|
||||||
|
|||||||
19
src/app.h
19
src/app.h
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,6 +1095,7 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
|||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
}
|
}
|
||||||
parsed.failureMessages.push_back(msg);
|
parsed.failureMessages.push_back(msg);
|
||||||
|
parsed.failureByOpid[opid] = msg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user