Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d3af15e
fix(key-wallet): resolve trusted self-sends across the whole wallet
jeanpierreroma Aug 13, 2026
0611ba6
fix(key-wallet): drop the outputs of a transaction that lost its inputs
jeanpierreroma Aug 13, 2026
157b9d9
feat(key-wallet): abandon a dead transaction and everything built on it
jeanpierreroma Aug 13, 2026
b45603c
feat(key-wallet): let the abandon cascade follow an external spend view
jeanpierreroma Aug 13, 2026
90b6f0d
Merge branch 'dev' into fix/phantom-unconfirmed-balance
romchornyi Aug 13, 2026
4db8334
docs(key-wallet): fix the two broken intra-doc links on abandon_trans…
jeanpierreroma Aug 13, 2026
241f7cf
fix(key-wallet): address the review findings on the abandon path
jeanpierreroma Aug 13, 2026
15a597f
fix(key-wallet): close four gaps in the conflict sweep and the cascade
jeanpierreroma Aug 13, 2026
ba0ad6f
fix(key-wallet): three deeper review findings on the sweep and abandon
jeanpierreroma Aug 13, 2026
d8c9911
fix(key-wallet): restore the doc block, and make three assertions loa…
jeanpierreroma Aug 13, 2026
9944d6e
fix(key-wallet): InstantSend finality, wallet-wide abandon, targeted …
jeanpierreroma Aug 13, 2026
127ed34
fix(key-wallet): make the conflict sweep wallet-wide and ungated
jeanpierreroma Aug 13, 2026
800b043
feat(key-wallet-manager): expose abandon, and pin the rescan-recovery…
jeanpierreroma Aug 13, 2026
cf7ed1f
feat(key-wallet-manager): report swept transactions so mirrors can de…
jeanpierreroma Aug 14, 2026
6aa7b55
fix(key-wallet): stop the sweep freeing the outpoint the winner spends
jeanpierreroma Aug 14, 2026
ed360cc
feat(dash-spv-ffi): expose the sweep as a C callback
jeanpierreroma Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions dash-spv-ffi/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,24 @@ impl FFIWalletEventCallbacks {
/// Dispatch a WalletEvent to the appropriate callback.
pub fn dispatch(&self, event: &WalletEvent) {
match event {
// TODO(sweep-ffi): no C callback is exposed for this variant yet,
// so a consumer of this FFI does not learn that the wallet dropped
// a superseded transaction and will keep mirroring the dead rows.
// Adding one is new ABI surface and wants its own review; logged
// meanwhile so the gap is observable rather than silent.
WalletEvent::TransactionsSwept {
wallet_id,
txids,
superseded_by,
..
} => {
tracing::info!(
wallet_id = %hex::encode(wallet_id),
swept = txids.len(),
%superseded_by,
"TransactionsSwept has no FFI callback; consumers keep the removed rows"
);
}
WalletEvent::TransactionDetected {
wallet_id,
record,
Expand Down
41 changes: 41 additions & 0 deletions key-wallet-manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,29 @@ pub enum WalletEvent {
/// full balance after the change — not a delta.
account_balances: BTreeMap<AccountType, WalletCoreBalance>,
},
/// Transactions were removed from the wallet: each was a recorded spend
/// that a later, final transaction provably beat to one of its inputs, so
/// it can never confirm. Their outputs are gone from the UTXO set and
/// their records deleted.
///
/// The only removal-shaped event on this bus. A consumer mirroring wallet
/// state to disk must act on it — every other variant is additive, so
/// without this the mirror keeps the dead rows and replays them on the
/// next load, re-creating a balance the wallet has already corrected.
TransactionsSwept {
/// ID of the affected wallet.
wallet_id: WalletId,
/// Transactions removed. Delete these rows and any UTXO they created.
txids: Vec<Txid>,
/// The transaction whose arrival settled the inputs, for provenance.
superseded_by: Txid,
/// Wallet balance after the removal.
balance: WalletCoreBalance,
/// Post-event balance **snapshots** for accounts whose balance
/// changed as a result of this event. Each value is the account's
/// full balance after the change — not a delta.
account_balances: BTreeMap<AccountType, WalletCoreBalance>,
},
/// A block was processed for a wallet. Carries records bucketed by what
/// happened to them in this block, plus the post-block balance.
/// `inserted` is records first stored in this block, `updated` is
Expand Down Expand Up @@ -332,6 +355,10 @@ impl WalletEvent {
wallet_id,
..
}
| WalletEvent::TransactionsSwept {
wallet_id,
..
}
| WalletEvent::TransactionInstantLocked {
wallet_id,
..
Expand Down Expand Up @@ -382,6 +409,20 @@ impl fmt::Display for WalletEvent {
balance,
format_account_balances(account_balances),
),
WalletEvent::TransactionsSwept {
txids,
superseded_by,
balance,
account_balances,
..
} => write!(
f,
"TransactionsSwept(count={}, superseded_by={}, balance={}, account_balances={})",
txids.len(),
superseded_by,
balance,
format_account_balances(account_balances),
),
WalletEvent::BlockProcessed {
height,
chain_lock,
Expand Down
68 changes: 67 additions & 1 deletion key-wallet-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ pub use events::{DerivedAddress, WalletEvent};
pub use matching::{check_compact_filters_for_elements, FilterMatchKey};
pub use wallet_interface::{BlockProcessingResult, MempoolTransactionResult, WalletInterface};

use dashcore::blockdata::transaction::Transaction;
use dashcore::blockdata::transaction::{OutPoint, Transaction};
use dashcore::prelude::CoreBlockHeight;
use dashcore::Txid;
use key_wallet::account::AccountCollection;
use key_wallet::managed_account::transaction_record::TransactionRecord;
use key_wallet::transaction_checking::{DerivedAddressInfo, TransactionContext};
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
use key_wallet::wallet::managed_wallet_info::AbandonOutcome;
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
use key_wallet::{AccountType, Address, ExtendedPrivKey, Mnemonic, Network, Wallet};
use key_wallet::{ExtendedPubKey, WalletCoreBalance};
Expand Down Expand Up @@ -94,6 +96,12 @@ pub struct CheckTransactionsResult {
/// Records whose state was updated by this check (confirmation or
/// InstantSend lock on a previously stored record), grouped by wallet.
pub per_wallet_updated_records: BTreeMap<WalletId, Vec<TransactionRecord>>,
/// Transactions this check *removed*, grouped by wallet: recorded spends
/// the arriving transaction provably beat to one of its inputs. See
/// [`crate::events::WalletEvent::TransactionsSwept`]
/// — a consumer mirroring wallet state must delete these rows, since no
/// other signal on the bus reports a removal.
pub per_wallet_swept: BTreeMap<WalletId, Vec<Txid>>,
}

impl CheckTransactionsResult {
Expand Down Expand Up @@ -628,6 +636,19 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletManager<T> {
}
}

// Gathered outside the relevance branch above: a sweep can
// fire for a transaction this wallet finds irrelevant — the
// shared input is gone from `utxos` and the winner may pay
// only external addresses — and the removal still has to
// reach the consumer.
if !check_result.swept_transactions.is_empty() {
result
.per_wallet_swept
.entry(*wallet_id)
.or_default()
.extend(check_result.swept_transactions);
}

if !check_result.new_addresses.is_empty() {
result
.new_addresses
Expand Down Expand Up @@ -662,6 +683,51 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletManager<T> {
}

impl WalletManager<ManagedWalletInfo> {
/// Abandon `root` in `wallet_id`, and every recorded transaction
/// descending from it, then recompute the balance.
///
/// The manager-level entry point for
/// [`ManagedWalletInfo::abandon_transaction_with_spends`] — the only
/// production path that clears a transaction the network never accepted.
/// The conflict sweep cannot reach that case: it needs a competing final
/// spend to prove the loser dead, and a transaction nobody ever saw has
/// no competitor. Its outputs would otherwise be credited forever, and as
/// trusted self-send change they are counted confirmed and are spendable.
///
/// `external_spends` maps an outpoint to the transaction a caller's
/// persistence mirror recorded as spending it, for descendants whose own
/// records the load path never restored. Pass an empty map to walk only
/// the recorded transactions.
///
/// **This asserts the root is dead; it does not establish it.** There is
/// no negative signal on the p2p network — Dash Core removed BIP61
/// `reject` — so silence is not proof, and abandoning a transaction that
/// is merely quiet re-exposes its inputs to coin selection. Settled roots
/// are refused, but the judgement otherwise belongs to the caller that
/// owns broadcast policy.
///
/// Returns `None` when the wallet is unknown.
pub fn abandon_transaction(
&mut self,
wallet_id: &WalletId,
root: Txid,
external_spends: &BTreeMap<OutPoint, Txid>,
) -> Option<AbandonOutcome> {
let info = self.get_wallet_info_mut(wallet_id)?;
let outcome = info.abandon_transaction_with_spends(root, external_spends);
if !outcome.is_empty() {
info.update_balance();
tracing::info!(
%root,
abandoned = outcome.abandoned.len(),
records_removed = outcome.records_removed,
utxos_removed = outcome.utxos_removed,
"Abandoned a dead transaction and everything built on it"
);
}
Some(outcome)
}

/// Get receive address from a specific wallet and account
pub fn next_receive_address(
&mut self,
Expand Down
43 changes: 43 additions & 0 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM
for (wallet_id, records) in check_result.per_wallet_updated_records {
per_wallet_updated.entry(wallet_id).or_default().extend(records);
}
// Emitted per transaction rather than batched into the block
// event: a sweep names the transaction that superseded the
// removed ones, and that attribution is lost once the block's
// transactions are folded together.
for (wallet_id, txids) in check_result.per_wallet_swept {
if txids.is_empty() {
continue;
}
let Some(info) = self.wallet_infos.get(&wallet_id) else {
continue;
};
let event = WalletEvent::TransactionsSwept {
wallet_id,
txids,
superseded_by: tx.txid(),
balance: info.balance(),
account_balances: BTreeMap::new(),
};
self.emit_event(event);
}
}

self.finalize_block_advance(
Expand Down Expand Up @@ -185,6 +205,29 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM
}
}

// Removals, before the additive events: a consumer applying these in
// order sees the dead rows deleted first, so a replacement paying the
// same address cannot be clobbered by the delete that follows it.
for (wallet_id, txids) in std::mem::take(&mut check_result.per_wallet_swept) {
if txids.is_empty() {
continue;
}
let Some(info) = self.wallet_infos.get(&wallet_id) else {
continue;
};
let event = WalletEvent::TransactionsSwept {
wallet_id,
txids,
superseded_by: tx.txid(),
balance: info.balance(),
account_balances: per_wallet_account_diff
.get(&wallet_id)
.cloned()
.unwrap_or_default(),
};
self.emit_event(event);
}

if let Some(lock) = instant_lock {
for (wallet_id, records) in per_wallet_updated_records {
if records.is_empty() {
Expand Down
24 changes: 23 additions & 1 deletion key-wallet/src/managed_account/managed_account_collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
//! This module provides a structure for managing multiple accounts
//! across different networks in a hierarchical manner.

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

use dashcore::blockdata::transaction::OutPoint;
use dashcore::Transaction;

use crate::account::account_collection::{DashpayAccountKey, PlatformPaymentAccountKey};
use crate::gap_limit::DIP17_GAP_LIMIT;
Expand Down Expand Up @@ -934,6 +937,25 @@ impl ManagedAccountCollection {
accounts
}

/// Union, across every funds-bearing account, of the outpoints among
/// `tx`'s inputs that the wallet holds as final UTXOs.
///
/// A single account can only answer this for the coins it owns, but
/// pooled funding (asset locks draw from BIP44 + BIP32 + the DashPay
/// contact-receiving accounts) routinely spreads one transaction's inputs
/// across several. The union is what makes the trusted-self-send check in
/// [`ManagedCoreFundsAccount::record_transaction`] see the whole wallet.
///
/// Must be taken before any account processes `tx` — `update_utxos`
/// removes spent parents as it goes.
pub(crate) fn final_parents_of(&self, tx: &Transaction) -> BTreeSet<OutPoint> {
let mut parents = BTreeSet::new();
for funds in self.all_funding_accounts() {
funds.collect_final_parents(tx, &mut parents);
}
parents
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Get all accounts in the collection as mutable
/// [`ManagedAccountRefMut`] values.
pub fn all_accounts_mut(&mut self) -> Vec<ManagedAccountRefMut<'_>> {
Expand Down
34 changes: 27 additions & 7 deletions key-wallet/src/managed_account/managed_account_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::Network;
use dashcore::blockdata::transaction::OutPoint;
use dashcore::prelude::CoreBlockHeight;
use dashcore::{Address, ScriptBuf, Transaction, Txid};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

/// Immutable reference to a managed core account, either funds-bearing or
/// keys-only.
Expand Down Expand Up @@ -314,25 +314,35 @@ impl<'a> ManagedAccountRefMut<'a> {
context,
transaction_type,
&BTreeMap::new(),
&BTreeSet::new(),
)
}

/// Record a new transaction, reconciling it against `observed_spent` —
/// the wallet-level `observed_spent_outpoints` view
/// (dashpay/rust-dashcore#649); only the funds variant consults it (keys
/// accounts track no UTXOs/output details).
///
/// `external_final_parents` is the wallet-level view of input parents held
/// by sibling accounts, used for the trusted-self-send determination.
pub(crate) fn record_transaction_with_observed_spends(
&mut self,
tx: &Transaction,
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
external_final_parents: &BTreeSet<OutPoint>,
) -> TransactionRecord {
match self {
ManagedAccountRefMut::Funds(a) => {
a.record_transaction(tx, account_match, context, transaction_type, observed_spent)
}
ManagedAccountRefMut::Funds(a) => a.record_transaction(
tx,
account_match,
context,
transaction_type,
observed_spent,
external_final_parents,
),
ManagedAccountRefMut::Keys(a) => {
a.record_transaction(tx, account_match, context, transaction_type)
}
Expand Down Expand Up @@ -361,24 +371,34 @@ impl<'a> ManagedAccountRefMut<'a> {
context,
transaction_type,
&BTreeMap::new(),
&BTreeSet::new(),
)
}

/// Re-process an existing transaction, reconciling refreshed UTXO state
/// against `observed_spent` — the wallet-level `observed_spent_outpoints`
/// view (dashpay/rust-dashcore#649); only the funds variant consults it.
///
/// `external_final_parents` is the wallet-level view of input parents held
/// by sibling accounts, used for the trusted-self-send determination.
pub(crate) fn confirm_transaction_with_observed_spends(
&mut self,
tx: &Transaction,
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
external_final_parents: &BTreeSet<OutPoint>,
) -> Option<TransactionRecord> {
match self {
ManagedAccountRefMut::Funds(a) => {
a.confirm_transaction(tx, account_match, context, transaction_type, observed_spent)
}
ManagedAccountRefMut::Funds(a) => a.confirm_transaction(
tx,
account_match,
context,
transaction_type,
observed_spent,
external_final_parents,
),
ManagedAccountRefMut::Keys(a) => {
a.confirm_transaction(tx, account_match, context, transaction_type)
}
Expand Down
Loading
Loading