Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
14 changes: 13 additions & 1 deletion dash-spv-ffi/src/bin/ffi_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ extern "C" fn on_transactions_swept(
txids: *const [u8; 32],
txids_count: usize,
superseded_by: *const [u8; 32],
released_outpoints: *const dash_spv_ffi::FFIOutPoint,
released_outpoints_count: usize,
balance: *const FFIBalance,
_account_balances: *const dash_spv_ffi::FFIAccountBalance,
_account_balances_count: u32,
Expand All @@ -243,12 +245,22 @@ extern "C" fn on_transactions_swept(
}
let list = unsafe { std::slice::from_raw_parts(txids, txids_count) };
let winner = unsafe { &*superseded_by };
let released = if released_outpoints.is_null() {
&[][..]
} else {
unsafe { std::slice::from_raw_parts(released_outpoints, released_outpoints_count) }
};
let b = read_balance(balance);
println!(
"[Wallet] TXs swept: wallet={}..., removed=[{}], superseded_by={}, balance[confirmed={}, unconfirmed={}]",
"[Wallet] TXs swept: wallet={}..., removed=[{}], superseded_by={}, released=[{}], balance[confirmed={}, unconfirmed={}]",
wallet_short,
list.iter().map(hex::encode).collect::<Vec<_>>().join(","),
hex::encode(winner),
released
.iter()
.map(|o| format!("{}:{}", hex::encode(o.txid), o.vout))
.collect::<Vec<_>>()
.join(","),
b.confirmed,
b.unconfirmed,
);
Expand Down
43 changes: 43 additions & 0 deletions dash-spv-ffi/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,28 @@ pub type OnTransactionDetectedCallback = Option<
),
>;

/// C representation of a Core [`OutPoint`](dashcore::OutPoint): the parent
/// txid and output index of a coin.
#[repr(C)]
pub struct FFIOutPoint {
/// Parent transaction id.
pub txid: [u8; 32],
/// Output index within the parent transaction.
pub vout: u32,
}

impl FFIOutPoint {
fn from_slice(outpoints: &[dashcore::OutPoint]) -> Vec<Self> {
outpoints
.iter()
.map(|outpoint| FFIOutPoint {
txid: *outpoint.txid.as_byte_array(),
vout: outpoint.vout,
})
.collect()
}
}

/// Callback for `WalletEvent::TransactionsSwept`.
///
/// Fires when the wallet removes transactions that a later, final transaction
Expand All @@ -766,6 +788,15 @@ pub type OnTransactionDetectedCallback = Option<
///
/// `txids` points to `txids_count` consecutive 32-byte txids.
/// `superseded_by` is the transaction whose arrival settled the inputs.
/// `released_outpoints` points to `released_outpoints_count` outpoints freed
/// by the removal: inputs the removed transactions claimed to spend that no
/// surviving record spends too. Mark these coins spendable again. This is
/// not the same set as `txids`' inputs — a loser spending A+B against a
/// winner spending only A leaves A marked and frees only B — and it cannot
/// be recomputed from `txids` on the consumer side: `superseded_by` need not
/// be wallet-relevant at all (it can spend our coin while paying only
/// external addresses), so it may never appear in any other callback. Null
/// with a zero count when the removal released nothing.
/// All pointer parameters are borrowed and only valid for the duration of the
/// callback. `balance` is the wallet's balance *after* the removal;
/// `account_balances` follows the same contract as on
Expand All @@ -776,6 +807,8 @@ pub type OnTransactionsSweptCallback = Option<
txids: *const [u8; 32],
txids_count: usize,
superseded_by: *const [u8; 32],
released_outpoints: *const FFIOutPoint,
released_outpoints_count: usize,
balance: *const FFIBalance,
account_balances: *const FFIAccountBalance,
account_balances_count: u32,
Expand Down Expand Up @@ -1058,6 +1091,7 @@ impl FFIWalletEventCallbacks {
wallet_id,
txids,
superseded_by,
released_outpoints,
balance,
account_balances,
} => {
Expand All @@ -1067,6 +1101,12 @@ impl FFIWalletEventCallbacks {
let raw_txids: Vec<[u8; 32]> =
txids.iter().map(|t| t.to_byte_array()).collect();
let raw_superseded_by = superseded_by.to_byte_array();
let ffi_released_outpoints = FFIOutPoint::from_slice(released_outpoints);
let released_outpoints_ptr = if ffi_released_outpoints.is_empty() {
ptr::null()
} else {
ffi_released_outpoints.as_ptr()
};
let ffi_balance = FFIBalance::from(*balance);
let ffi_account_balances = FFIAccountBalance::from_map(account_balances);
let account_balances_ptr = if ffi_account_balances.is_empty() {
Expand All @@ -1080,13 +1120,16 @@ impl FFIWalletEventCallbacks {
raw_txids.as_ptr(),
raw_txids.len(),
&raw_superseded_by as *const [u8; 32],
released_outpoints_ptr,
ffi_released_outpoints.len(),
&ffi_balance as *const FFIBalance,
account_balances_ptr,
ffi_account_balances.len() as u32,
self.user_data,
);

drop(ffi_account_balances);
drop(ffi_released_outpoints);
} else {
// Deliberately loud: every other wallet callback is
// additive, so a consumer that leaves this one unset keeps
Expand Down
27 changes: 25 additions & 2 deletions key-wallet-manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::fmt;
use dashcore::ephemerealdata::chain_lock::ChainLock;
use dashcore::ephemerealdata::instant_lock::InstantLock;
use dashcore::prelude::CoreBlockHeight;
use dashcore::{PublicKey, Txid};
use dashcore::{OutPoint, PublicKey, Txid};
use key_wallet::account::AccountType;
use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType};
use key_wallet::managed_account::transaction_record::TransactionRecord;
Expand Down Expand Up @@ -238,6 +238,27 @@ pub enum WalletEvent {
txids: Vec<Txid>,
/// The transaction whose arrival settled the inputs, for provenance.
superseded_by: Txid,
/// Outpoints the sweep released: inputs the removed transactions
/// claimed to spend that no surviving record spends too (a loser
/// spending A+B against a winner spending only A leaves A marked and
/// frees B). Mark these coins spendable again.
///
/// Upstream computes this distinction — see
/// `ManagedCoreFundsAccount::release_spent_marks` in key-wallet — and
/// then has nowhere else to put it: `superseded_by` need not be
/// wallet-relevant at all, so it can spend our coin while paying only
/// external addresses and never appear anywhere else in this
/// wallet's event stream. A consumer mirroring wallet state to disk
/// cannot recompute this set from the deleted `txids` alone — it
/// would have to know which of their inputs a *different*,
/// possibly-invisible transaction also claims — so guessing either
/// re-credits a coin the chain has already spent or leaves a
/// genuinely free one stranded as spent forever. Wallet-scoped
/// rather than attributed per removed transaction: a consumer holds
/// every input of every transaction it deletes here, so it only
/// needs to know which of them came free, not which removal freed
/// which.
released_outpoints: Vec<OutPoint>,
/// Wallet balance after the removal.
balance: WalletCoreBalance,
/// Post-event balance **snapshots** for accounts whose balance
Expand Down Expand Up @@ -412,14 +433,16 @@ impl fmt::Display for WalletEvent {
WalletEvent::TransactionsSwept {
txids,
superseded_by,
released_outpoints,
balance,
account_balances,
..
} => write!(
f,
"TransactionsSwept(count={}, superseded_by={}, balance={}, account_balances={})",
"TransactionsSwept(count={}, superseded_by={}, released={}, balance={}, account_balances={})",
txids.len(),
superseded_by,
released_outpoints.len(),
balance,
format_account_balances(account_balances),
),
Expand Down
12 changes: 12 additions & 0 deletions key-wallet-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ pub struct CheckTransactionsResult {
/// — 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>>,
/// Outpoints released as a side effect of `per_wallet_swept`, grouped by
/// wallet: inputs the removed transactions claimed to spend that no
/// surviving record spends too. Parallels `per_wallet_swept` rather than
/// folding into it because the two have different owners downstream —
/// see [`crate::events::WalletEvent::TransactionsSwept`] for why a
/// consumer needs this set named explicitly instead of re-deriving it.
pub per_wallet_released_outpoints: BTreeMap<WalletId, Vec<OutPoint>>,
}

impl CheckTransactionsResult {
Expand Down Expand Up @@ -647,6 +654,11 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletManager<T> {
.entry(*wallet_id)
.or_default()
.extend(check_result.swept_transactions);
result
.per_wallet_released_outpoints
.entry(*wallet_id)
.or_default()
.extend(check_result.released_outpoints);
}

if !check_result.new_addresses.is_empty() {
Expand Down
7 changes: 7 additions & 0 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,20 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM
// event: a sweep names the transaction that superseded the
// removed ones, and that attribution is lost once the block's
// transactions are folded together.
let mut per_wallet_released = check_result.per_wallet_released_outpoints;
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 released_outpoints = per_wallet_released.remove(&wallet_id).unwrap_or_default();
let event = WalletEvent::TransactionsSwept {
wallet_id,
txids,
superseded_by: tx.txid(),
released_outpoints,
balance: info.balance(),
account_balances: BTreeMap::new(),
};
Expand Down Expand Up @@ -208,17 +211,21 @@ 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.
let mut per_wallet_released =
std::mem::take(&mut check_result.per_wallet_released_outpoints);
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 released_outpoints = per_wallet_released.remove(&wallet_id).unwrap_or_default();
let event = WalletEvent::TransactionsSwept {
wallet_id,
txids,
superseded_by: tx.txid(),
released_outpoints,
balance: info.balance(),
account_balances: per_wallet_account_diff
.get(&wallet_id)
Expand Down
52 changes: 44 additions & 8 deletions key-wallet/src/managed_account/managed_core_funds_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ pub(crate) struct AbandonRemoval {
pub records: usize,
}

/// What [`ManagedCoreFundsAccount::drop_conflicted_transactions`] removed
/// from one account.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ConflictSweep {
/// Loser txids removed from this account.
pub txids: Vec<Txid>,
/// Outpoints released from `spent_outpoints` as a side effect: inputs
/// the removed losers claimed that no surviving record claims too. See
/// [`ManagedCoreFundsAccount::release_spent_marks`] — this is exactly
/// its return value, carried out so a caller mirroring wallet state can
/// learn which coins actually came free without redoing the
/// freed-versus-still-spent comparison itself.
pub released_outpoints: Vec<OutPoint>,
}

impl ManagedCoreFundsAccount {
/// Create a new managed funds account
pub fn new(managed_account_type: ManagedAccountType, network: Network) -> Self {
Expand Down Expand Up @@ -421,13 +436,23 @@ impl ManagedCoreFundsAccount {
/// considered, and a removed record's input stays marked when a survivor
/// spends it too (a loser spending A+B against a winner spending only A
/// must leave A marked and free B).
fn release_spent_marks(&mut self, freed: &HashSet<OutPoint>) {
///
/// Returns exactly the outpoints this call released — `freed` minus
/// whatever `still_spent` shows a survivor still claims. That
/// distinction is computed nowhere else: once this returns, freed-and-
/// released and freed-but-retained are indistinguishable in
/// `spent_outpoints` itself, so a caller that needs to tell a
/// persistence mirror which coins are genuinely free again has to catch
/// it here or not at all.
fn release_spent_marks(&mut self, freed: &HashSet<OutPoint>) -> HashSet<OutPoint> {
if freed.is_empty() {
return;
return HashSet::new();
}
let still_spent = rebuild_spent_outpoints(&self.keys);
let released: HashSet<OutPoint> = freed.difference(&still_spent).copied().collect();
self.spent_outpoints
.retain(|outpoint| !freed.contains(outpoint) || still_spent.contains(outpoint));
released
}

/// Remove every trace of `abandoned` from this account.
Expand Down Expand Up @@ -535,14 +560,20 @@ impl ManagedCoreFundsAccount {
/// per-account. That covers the ordinary shape — a resend keeps the same
/// funding account and so the same change account — but not every one.
///
/// Returns the txids it removed.
/// Returns the txids it removed, together with the outpoints that
/// removal released from `spent_outpoints` (see
/// [`Self::release_spent_marks`]). The latter is not derivable by a
/// caller from the txids alone: the winner that triggers this sweep does
/// not have to be wallet-relevant, so it may hold none of the loser's
/// inputs anywhere the caller can see, and the loser's own record is
/// already gone by the time this returns.
pub(crate) fn drop_conflicted_transactions(
&mut self,
tx: &Transaction,
context: &TransactionContext,
) -> Vec<Txid> {
) -> ConflictSweep {
if !(context.confirmed() || matches!(context, TransactionContext::InstantSend(_))) {
return Vec::new();
return ConflictSweep::default();
}

let winner = tx.txid();
Expand Down Expand Up @@ -577,7 +608,7 @@ impl ManagedCoreFundsAccount {
.collect();

if losers.is_empty() {
return Vec::new();
return ConflictSweep::default();
}

// A loser's change may already have funded further unconfirmed
Expand Down Expand Up @@ -639,12 +670,17 @@ impl ManagedCoreFundsAccount {
// the winner is recorded, so no live record claims the outpoint yet.
// Only the loser's *extra* inputs are genuinely released.
freed.retain(|outpoint| !spent.contains(outpoint));
self.release_spent_marks(&freed);
let released = self.release_spent_marks(&freed);
if changed {
self.keys.bump_monitor_revision();
}

losers.into_iter().collect()
let mut released_outpoints: Vec<OutPoint> = released.into_iter().collect();
released_outpoints.sort_unstable();
ConflictSweep {
txids: losers.into_iter().collect(),
released_outpoints,
}
}

/// Re-process an existing transaction with updated context (e.g.,
Expand Down
16 changes: 15 additions & 1 deletion key-wallet/src/transaction_checking/account_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::managed_account::managed_account_type::ManagedAccountType;
use crate::managed_account::transaction_record::TransactionRecord;
use crate::Address;
use dashcore::address::Payload;
use dashcore::blockdata::transaction::Transaction;
use dashcore::blockdata::transaction::{OutPoint, Transaction};
use dashcore::hashes::Hash as _;
use dashcore::transaction::TransactionPayload;
use dashcore::ScriptBuf;
Expand Down Expand Up @@ -87,6 +87,19 @@ pub struct TransactionCheckResult {
/// replay the dead transaction on the next load and re-create the phantom
/// balance this removal just cleared.
pub swept_transactions: Vec<Txid>,
/// Outpoints released from the wallet's spent-marks as a side effect of
/// `swept_transactions`: inputs the removed losers claimed to spend that
/// no surviving record spends too. Empty whenever `swept_transactions`
/// is.
///
/// A consumer mirroring wallet state needs this named explicitly rather
/// than inferring it from the deleted records: the winner that triggered
/// the sweep does not have to be wallet-relevant at all (it can spend our
/// coin and pay only external addresses), so it may never appear
/// anywhere else in this wallet's output, leaving no other way to learn
/// which of a loser's inputs are genuinely free again versus still
/// claimed by a surviving transaction.
pub released_outpoints: Vec<OutPoint>,
}

/// Enum representing the type of Core account that matched with embedded data
Expand Down Expand Up @@ -417,6 +430,7 @@ impl ManagedAccountCollection {
new_records: Vec::new(),
updated_records: Vec::new(),
swept_transactions: Vec::new(),
released_outpoints: Vec::new(),
};

for account_type in account_types {
Expand Down
Loading
Loading