wallet2: exclude outputs from an unconfirmed send in reserve proofs

get_reserve_proof picks outputs with is_spent(td, true). That only
counts an output spent once its spending tx has confirmed. commit_tx
marks an output spent as soon as the tx goes to the daemon, well before
it confirms. spent_height stays 0 until then. So an output already used
as input to a pending send still passes as unspent here.

A wallet can prove reserve over outputs it already committed to a
pending send. check_reserve_proof reports them unspent, but they are
gone as soon as that send confirms. Reported with a stagenet repro in
#6595.

Switched to is_spent(td, false), which the wallet's default balance
already uses for this reason. Both the zero-balance guard and the
account_minreserve check used to go through balance_all()/balance(),
which fold in change and self-transfer amounts from our own
unconfirmed txs (m_unconfirmed_txs, m_unconfirmed_payments). Those
amounts have no matching row in m_transfers yet, so once an output is
tied up in a pending send, is_spent(td, false) drops it from
selected_transfers while the pending change keeps the balance call
non-zero, and the guard passes over an empty or thinner selection than
it should. Now both guards sum selected_transfers directly, computed
once, so they match exactly what the proof is built over.
This commit is contained in:
Cole Munz
2026-08-30 23:48:21 -05:00
parent 78fb311ebb
commit ba3884e467
+14 -4
View File
@@ -12424,22 +12424,32 @@ bool wallet2::check_tx_proof(const cryptonote::transaction &tx, const cryptonote
std::string wallet2::get_reserve_proof(const boost::optional<std::pair<uint32_t, uint64_t>> &account_minreserve, const std::string &message) std::string wallet2::get_reserve_proof(const boost::optional<std::pair<uint32_t, uint64_t>> &account_minreserve, const std::string &message)
{ {
THROW_WALLET_EXCEPTION_IF(m_watch_only || m_multisig, error::wallet_internal_error, "Reserve proof can only be generated by a full wallet"); THROW_WALLET_EXCEPTION_IF(m_watch_only || m_multisig, error::wallet_internal_error, "Reserve proof can only be generated by a full wallet");
THROW_WALLET_EXCEPTION_IF(balance_all(true) == 0, error::wallet_internal_error, "Zero balance");
THROW_WALLET_EXCEPTION_IF(account_minreserve && balance(account_minreserve->first, true) < account_minreserve->second, error::wallet_internal_error,
"Not enough balance in this account for the requested minimum reserve amount");
// determine which outputs to include in the proof // determine which outputs to include in the proof
// is_spent(td, false) excludes an output already used as input to a pending, unconfirmed
// send too, not just a confirmed one, so we don't claim reserve we no longer have
std::vector<size_t> selected_transfers; std::vector<size_t> selected_transfers;
for (size_t i = 0; i < m_transfers.size(); ++i) for (size_t i = 0; i < m_transfers.size(); ++i)
{ {
const transfer_details &td = m_transfers[i]; const transfer_details &td = m_transfers[i];
if (!is_spent(td, true) && !td.m_frozen && (!account_minreserve || account_minreserve->first == td.m_subaddr_index.major)) if (!is_spent(td, false) && !td.m_frozen && (!account_minreserve || account_minreserve->first == td.m_subaddr_index.major))
selected_transfers.push_back(i); selected_transfers.push_back(i);
} }
// sum what's actually selected instead of calling balance_all()/balance(): those also fold
// in pending change and self-transfer amounts from our own unconfirmed txs, which have no
// matching entry in m_transfers yet and can leave a balance-based guard non-zero even once
// every output backing it has been excluded above
uint64_t available = 0;
for (size_t idx : selected_transfers)
available += m_transfers[idx].amount();
THROW_WALLET_EXCEPTION_IF(available == 0, error::wallet_internal_error, "Zero balance");
if (account_minreserve) if (account_minreserve)
{ {
THROW_WALLET_EXCEPTION_IF(account_minreserve->second == 0, error::wallet_internal_error, "Proved amount must be greater than 0"); THROW_WALLET_EXCEPTION_IF(account_minreserve->second == 0, error::wallet_internal_error, "Proved amount must be greater than 0");
THROW_WALLET_EXCEPTION_IF(available < account_minreserve->second, error::wallet_internal_error,
"Not enough balance in this account for the requested minimum reserve amount");
// minimize the number of outputs included in the proof, by only picking the N largest outputs that can cover the requested min reserve amount // minimize the number of outputs included in the proof, by only picking the N largest outputs that can cover the requested min reserve amount
std::sort(selected_transfers.begin(), selected_transfers.end(), [&](const size_t a, const size_t b) std::sort(selected_transfers.begin(), selected_transfers.end(), [&](const size_t a, const size_t b)
{ return m_transfers[a].amount() > m_transfers[b].amount(); }); { return m_transfers[a].amount() > m_transfers[b].amount(); });