Skip to content

Commit 6ebaa76

Browse files
fix(wallet): detect spend-only transactions via UTXO spend tracking; add regression test
1 parent 7335eba commit 6ebaa76

File tree

2 files changed

+115
-5
lines changed

2 files changed

+115
-5
lines changed

key-wallet/src/transaction_checking/account_checker.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ impl ManagedAccount {
391391
let mut involved_change_addresses = Vec::new();
392392
let mut involved_other_addresses = Vec::new(); // For non-standard accounts
393393
let mut received = 0u64;
394-
let sent = 0u64;
394+
let mut sent = 0u64;
395395
let mut provider_payout_involved = false;
396396

397397
// Check provider payouts in special transactions
@@ -455,15 +455,35 @@ impl ManagedAccount {
455455
}
456456
}
457457

458-
// Check inputs (sent) - would need UTXO information to properly calculate
459-
// For now, we just mark that addresses are involved
460-
// In a real implementation, we'd look up the previous outputs being spent
458+
// Check inputs (sent) - rely on tracked UTXOs to determine spends
459+
if !tx.is_coin_base() {
460+
for input in &tx.input {
461+
if let Some(utxo) = self.utxos.get(&input.previous_output) {
462+
sent = sent.saturating_add(utxo.txout.value);
463+
464+
if let Some(address_info) = self.get_address_info(&utxo.address) {
465+
match self.classify_address(&utxo.address) {
466+
AddressClassification::External => {
467+
involved_receive_addresses.push(address_info);
468+
}
469+
AddressClassification::Internal => {
470+
involved_change_addresses.push(address_info);
471+
}
472+
AddressClassification::Other => {
473+
involved_other_addresses.push(address_info);
474+
}
475+
}
476+
}
477+
}
478+
}
479+
}
461480

462481
// Create the appropriate AccountTypeMatch based on account type
463482
let has_addresses = !involved_receive_addresses.is_empty()
464483
|| !involved_change_addresses.is_empty()
465484
|| !involved_other_addresses.is_empty()
466-
|| provider_payout_involved;
485+
|| provider_payout_involved
486+
|| sent > 0;
467487

468488
if has_addresses {
469489
let account_type_match = match &self.account_type {

key-wallet/src/transaction_checking/wallet_checker.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,96 @@ mod tests {
568568
assert!(managed_account.transactions.contains_key(&coinbase_tx.txid()));
569569
}
570570

571+
/// Test that spending a wallet-owned UTXO without creating change is detected
572+
#[test]
573+
fn test_wallet_checker_detects_spend_only_transaction() {
574+
let network = Network::Testnet;
575+
let wallet = Wallet::new_random(&[network], WalletAccountCreationOptions::Default)
576+
.expect("Should create wallet");
577+
578+
let mut managed_wallet =
579+
ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string());
580+
581+
// Prepare a managed BIP44 account and derive a receive address
582+
let account_collection = wallet.accounts.get(&network).expect("Should have accounts");
583+
let wallet_account =
584+
account_collection.standard_bip44_accounts.get(&0).expect("Should have BIP44 account");
585+
586+
let receive_address = managed_wallet
587+
.first_bip44_managed_account_mut(network)
588+
.expect("Should have managed account")
589+
.next_receive_address(Some(&wallet_account.account_xpub), true)
590+
.expect("Should derive receive address");
591+
592+
// Fund the wallet with a transaction paying to the receive address
593+
let funding_value = 50_000_000u64;
594+
let funding_tx = create_transaction_to_address(&receive_address, funding_value);
595+
let funding_context = TransactionContext::InBlock {
596+
height: 1,
597+
block_hash: Some(BlockHash::from_slice(&[2u8; 32]).expect("Should create block hash")),
598+
timestamp: Some(1_650_000_000),
599+
};
600+
601+
let funding_result =
602+
managed_wallet.check_transaction(&funding_tx, network, funding_context, Some(&wallet));
603+
assert!(funding_result.is_relevant, "Funding transaction must be relevant");
604+
assert_eq!(funding_result.total_received, funding_value);
605+
606+
// Build a spend transaction that sends funds to an external address only
607+
let external_address = Address::p2pkh(
608+
&dashcore::PublicKey::from_slice(&[0x02; 33]).expect("Should create pubkey"),
609+
network,
610+
);
611+
let spend_tx = Transaction {
612+
version: 2,
613+
lock_time: 0,
614+
input: vec![TxIn {
615+
previous_output: OutPoint {
616+
txid: funding_tx.txid(),
617+
vout: 0,
618+
},
619+
script_sig: ScriptBuf::new(),
620+
sequence: 0xffffffff,
621+
witness: dashcore::Witness::new(),
622+
}],
623+
output: vec![TxOut {
624+
value: funding_value - 1_000, // leave a small fee
625+
script_pubkey: external_address.script_pubkey(),
626+
}],
627+
special_transaction_payload: None,
628+
};
629+
630+
let spend_context = TransactionContext::InBlock {
631+
height: 2,
632+
block_hash: Some(BlockHash::from_slice(&[3u8; 32]).expect("Should create block hash")),
633+
timestamp: Some(1_650_000_100),
634+
};
635+
636+
let spend_result =
637+
managed_wallet.check_transaction(&spend_tx, network, spend_context, Some(&wallet));
638+
639+
assert!(spend_result.is_relevant, "Spend transaction should be detected");
640+
assert_eq!(spend_result.total_received, 0);
641+
assert_eq!(spend_result.total_sent, funding_value);
642+
643+
// Ensure the UTXO was removed and the transaction record reflects the spend
644+
let account = managed_wallet
645+
.accounts
646+
.get(&network)
647+
.expect("Should have managed accounts")
648+
.standard_bip44_accounts
649+
.get(&0)
650+
.expect("Should have managed BIP44 account");
651+
652+
assert!(account.utxos.is_empty(), "Spent UTXO should be removed");
653+
654+
let record = account
655+
.transactions
656+
.get(&spend_tx.txid())
657+
.expect("Spend transaction should be recorded");
658+
assert_eq!(record.net_amount, -(funding_value as i64));
659+
}
660+
571661
/// Test mempool context for timestamp/height handling
572662
#[test]
573663
fn test_wallet_checker_mempool_context() {

0 commit comments

Comments
 (0)