feat(settings): sweep a private key's funds to your own wallet

Add a "Sweep to my wallet (don't keep the key)" option to the Import Private
Key dialog (spending keys only). Rather than keeping the key, it imports the
key, then sends ALL its funds to a destination you control — a freshly
generated shielded address by default, or an existing wallet address you pick.

Flow (App::sweepPrivateKey): import the key (rescan; the stock node has no
address index, so its UTXOs/notes can't be enumerated otherwise) → confirm it
holds spendable funds → resolve the destination → z_sendmany balance-minus-fee
from the key's address to the destination, via the existing tested submitZSendMany
wrapper + async-operation tracker. The imported key is left in the wallet with an
empty balance (there is no remove-key RPC) — stated plainly in the UI caveat.

Design notes forced by this daemon (verified against external/dragonx):
- z_importkey returns null here, so the shielded source address is found by
  diffing z_listaddresses before/after import (aborts if the pre-snapshot fails,
  so a wrong address can never be picked). Transparent uses importprivkey's return.
- z_sendmany, NOT z_mergetoaddress: moves a single-UTXO transparent source (the
  common paper-wallet case) and fails loudly instead of silently leaving a
  remainder. Amount computed in integer satoshis for an exact fixed-decimal string.
- Clear errors for no-funds / unconfirmed / dust-below-fee / already-imported.
- The mode + destination are locked during a running sweep; a sweep that finishes
  while the dialog is closed still shows its Done/Error+txid on reopen; sweep UI
  can't bleed into the watch-only viewing dialog.

i18n: 8 new sweep_* keys (en + 8 langs); dynamic status strings are English,
matching the beginSweepToSeedWallet precedent.

Reviewed across three adversarial multi-agent rounds (fund-safety focus): round 1
caught the feature was broken for shielded keys (z_importkey null) and rejected
single-UTXO transparent sources; round 2 caught reopen/double-submit/precision
issues; round 3 verified the fixes. NOT yet exercised against a live daemon —
needs a small-amount mainnet test before it's trusted, like migrate-to-seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:29:10 -05:00
parent 2a96c41e07
commit 828018de2b
13 changed files with 368 additions and 24 deletions

View File

@@ -2983,6 +2983,161 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
});
}
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
// key's funds (balance fee) to a destination the user already controls. The imported key is left in
// the wallet with an empty balance (there is no remove-key RPC) — the point is that the funds now sit
// on the user's own key. z_sendmany (not z_mergetoaddress): it moves a single-UTXO transparent source
// and fails loudly rather than silently leaving a remainder. Fund-moving — see the reviewed design.
void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMode,
const std::string& destExisting)
{
if (!state_.connected || !rpc_ || !worker_) {
sweep_status_ = "Not connected to the daemon.";
sweep_step_ = SweepStep::Error;
return;
}
// Trim (manual entry doesn't go through the dialog's Paste trimmer).
std::string key(rawKey);
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
// can't sign, and this must never accidentally route to z_importviewingkey.
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
sweep_step_ = SweepStep::Error;
return;
}
sweep_step_ = SweepStep::Running;
sweep_status_ = "Importing key & rescanning…";
sweep_txid_.clear();
sweep_dest_shown_.clear();
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE;
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb {
std::string err, dest, sourceAddr, amountStr;
double amount = 0.0;
try {
rpc::RPCClient::TraceScope trace("Settings / Sweep key");
// For a shielded key this daemon's z_importkey returns null (no address), so snapshot the
// z-address set before the import and diff it after to find the newly-controlled address.
std::vector<std::string> preZ;
bool preZok = false;
auto listZ = [&](std::vector<std::string>& out) {
out.clear();
auto la = rpc_->call("z_listaddresses");
if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get<std::string>());
};
if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } }
// 1. Import the key (blocks until the rescan completes → its funds become spendable).
std::string method;
nlohmann::json params;
if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
else { method = "importprivkey"; params = {key, "", true}; }
if (startHeight > 0 && shielded) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
// 2. Determine the swept address. importprivkey returns the t-address string; z_importkey
// returns null, so diff the z-address list to find the one the key just added.
if (shielded) {
// A failed pre-snapshot would make the diff treat pre-existing addresses as "new" and
// could pick the WRONG source — refuse rather than risk moving another address's funds.
if (!preZok) throw std::runtime_error("Couldn't read the wallet's addresses to identify the swept key. Try again.");
std::vector<std::string> postZ;
listZ(postZ);
std::vector<std::string> added;
for (const auto& s : postZ)
if (std::find(preZ.begin(), preZ.end(), s) == preZ.end()) added.push_back(s);
if (added.size() == 1) sourceAddr = added[0];
else if (added.empty()) throw std::runtime_error("__ALREADY__"); // key already in wallet
else throw std::runtime_error("Could not identify the key's address after import.");
} else {
if (r.is_string()) sourceAddr = r.get<std::string>();
else if (r.is_object() && r.contains("address") && r["address"].is_string())
sourceAddr = r["address"].get<std::string>();
}
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
auto balAt = [&](int minconf) -> double {
try {
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
return b.is_number() ? b.get<double>()
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
} catch (...) { return 0.0; }
};
double bal = balAt(1);
if (bal <= 0.0) {
throw std::runtime_error(balAt(0) > 0.0 ? "__UNCONFIRMED__" : "__NOFUNDS__");
}
if (bal <= fee) throw std::runtime_error("__DUST__");
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
if (destMode == 0) dest = rpc_->call("z_getnewaddress").get<std::string>();
else dest = destExisting;
if (dest.empty()) throw std::runtime_error("No destination address for the sweep.");
// Send everything; the fee consumes the remainder (no change/residue). Compute in integer
// satoshis so the fixed-decimal amount is exact for all realistic balances (double bal-fee
// is exact below 2^52 sat ≈ 45M DRGX; above that the JSON-parsed balance is itself lossy —
// a swept key that large is implausible and would fail safe with funds left in place).
const long long feeSats = (long long)std::llround(fee * 100000000.0);
const long long amtSats = (long long)std::llround(bal * 100000000.0) - feeSats;
if (amtSats <= 0) throw std::runtime_error("__DUST__");
char amtBuf[32];
snprintf(amtBuf, sizeof(amtBuf), "%lld.%08lld",
amtSats / 100000000LL, amtSats % 100000000LL);
amountStr = amtBuf;
amount = (double)amtSats / 100000000.0; // for send bookkeeping / display only
} catch (const std::exception& e) {
err = e.what();
} catch (...) {
err = "Sweep failed (unknown error)";
}
return [this, err, sourceAddr, dest, amount, amountStr, fee]() {
invalidateAddressValidationCache();
refreshAddresses();
if (!err.empty()) {
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
else sweep_status_ = err;
sweep_step_ = SweepStep::Error;
return;
}
sweep_dest_shown_ = dest;
sweep_status_ = "Sweeping funds to your address…";
// Send all funds from the swept address to the destination through the tested z_sendmany
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
nlohmann::json recipients = nlohmann::json::array();
nlohmann::json rcp;
rcp["address"] = dest;
rcp["amount"] = amountStr; // exact integer-satoshi fixed-decimal string
recipients.push_back(rcp);
submitZSendMany(sourceAddr, dest, amount, fee, "", recipients, "Settings / Sweep key",
/*markFeeGapRetry*/ false, [this](bool ok, const std::string& result) {
if (ok) {
sweep_txid_ = result;
sweep_status_.clear();
sweep_step_ = SweepStep::Done;
} else {
sweep_status_ = result.empty() ? "The sweep transaction failed." : result;
sweep_step_ = SweepStep::Error;
}
refreshBalance();
});
};
});
}
void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {