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

@@ -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);