Skip to content

Commit ce7c96b

Browse files
committed
fix(key-wallet): validate restored coins and retain cross-account inputs
1 parent cdbcaa9 commit ce7c96b

2 files changed

Lines changed: 149 additions & 2 deletions

File tree

key-wallet/src/tests/persisted_transaction_restore_tests.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::transaction_checking::{
1111
BlockInfo, TransactionContext, TransactionType, WalletTransactionChecker,
1212
};
1313
use crate::utxo::Utxo;
14+
use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
1415
use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError};
1516
use crate::wallet::ManagedWalletInfo;
1617
use crate::AccountType;
@@ -789,3 +790,146 @@ fn should_reject_balance_overflow_before_installing_coins() {
789790
.is_err());
790791
assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty());
791792
}
793+
794+
#[tokio::test]
795+
async fn should_preserve_cross_account_input_recognition_after_restore() {
796+
let mut template = TestWalletContext::new_random();
797+
let other_xpub = template.wallet.accounts.standard_bip32_accounts.get(&0).unwrap().account_xpub;
798+
let other_address = template
799+
.managed_wallet
800+
.first_bip32_managed_account_mut()
801+
.unwrap()
802+
.next_receive_address(Some(&other_xpub), true)
803+
.unwrap();
804+
let mut restored = template.managed_wallet.clone();
805+
let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]);
806+
let parent = OutPoint {
807+
txid: funding.txid(),
808+
vout: 0,
809+
};
810+
let mut spending = spend(parent);
811+
spending.output[0].script_pubkey = other_address.script_pubkey();
812+
template
813+
.managed_wallet
814+
.check_core_transaction(
815+
&spending,
816+
TransactionContext::Mempool,
817+
&mut template.wallet,
818+
true,
819+
true,
820+
)
821+
.await;
822+
let context = TransactionContext::InBlock(BlockInfo::new(
823+
40,
824+
BlockHash::from_byte_array([40; 32]),
825+
1_700_000_000,
826+
));
827+
template
828+
.managed_wallet
829+
.check_core_transaction(&funding, context.clone(), &mut template.wallet, true, true)
830+
.await;
831+
let records = template
832+
.managed_wallet
833+
.accounts
834+
.all_accounts()
835+
.into_iter()
836+
.flat_map(|account| account.transactions().values().cloned())
837+
.collect();
838+
restored
839+
.restore_persisted_state(PersistedWalletState {
840+
transactions: records,
841+
utxos: template
842+
.managed_wallet
843+
.accounts
844+
.all_accounts()
845+
.into_iter()
846+
.filter_map(|account| account.as_funds())
847+
.flat_map(|account| {
848+
account
849+
.utxos
850+
.values()
851+
.cloned()
852+
.map(|utxo| (account.managed_account_type().to_account_type(), utxo))
853+
})
854+
.collect(),
855+
additional_spent_outpoints: template
856+
.managed_wallet
857+
.observed_spent_outpoints()
858+
.iter()
859+
.map(|(outpoint, height)| (*outpoint, Some(*height)))
860+
.chain([(parent, None)])
861+
.collect(),
862+
})
863+
.unwrap();
864+
let live_result = template
865+
.managed_wallet
866+
.check_core_transaction(&spending, context.clone(), &mut template.wallet, true, true)
867+
.await;
868+
let restored_result =
869+
restored.check_core_transaction(&spending, context, &mut template.wallet, true, true).await;
870+
assert_eq!(live_result.new_records.len(), 1);
871+
assert_eq!(live_result.new_records[0].account_type, bip44());
872+
assert_eq!(live_result.new_records[0].net_amount, -1_000_000);
873+
assert_eq!(live_result.new_records[0].input_details.len(), 1);
874+
assert_eq!(restored_result.new_records.len(), live_result.new_records.len());
875+
let debit = &restored_result.new_records[0];
876+
assert_eq!(debit.account_type, bip44());
877+
assert_eq!(debit.net_amount, -1_000_000);
878+
assert_eq!(debit.input_details.len(), 1);
879+
assert_eq!(debit.input_details[0].value, 1_000_000);
880+
}
881+
882+
#[test]
883+
fn should_reject_coin_owned_by_another_wallet() {
884+
let owner = TestWalletContext::new_random();
885+
let mut receiver = TestWalletContext::new_random();
886+
assert!(!receiver.bip44_account().contains_address(&owner.receive_address));
887+
let tx = Transaction::dummy(&owner.receive_address, 0..1, &[1_000_000]);
888+
let outpoint = OutPoint {
889+
txid: tx.txid(),
890+
vout: 0,
891+
};
892+
let mut coin = Utxo::new(outpoint, tx.output[0].clone(), owner.receive_address, 100, false);
893+
coin.is_confirmed = true;
894+
let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState {
895+
utxos: vec![(bip44(), coin)],
896+
..Default::default()
897+
});
898+
assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint)));
899+
assert!(receiver.bip44_account().utxos.is_empty());
900+
}
901+
902+
#[test]
903+
fn should_reject_coinbase_flag_disagreeing_with_record() {
904+
let mut receiver = TestWalletContext::new_random();
905+
receiver.managed_wallet.update_last_processed_height(100);
906+
let mut tx = Transaction::dummy(&receiver.receive_address, 0..1, &[1_000_000]);
907+
tx.input[0].previous_output = OutPoint::null();
908+
assert!(tx.is_coin_base());
909+
let outpoint = OutPoint {
910+
txid: tx.txid(),
911+
vout: 0,
912+
};
913+
let mut coin =
914+
Utxo::new(outpoint, tx.output[0].clone(), receiver.receive_address.clone(), 100, false);
915+
coin.is_confirmed = true;
916+
let record = TransactionRecord::new(
917+
tx,
918+
bip44(),
919+
TransactionContext::InBlock(BlockInfo::new(100, BlockHash::all_zeros(), 0)),
920+
TransactionType::Standard,
921+
TransactionDirection::Incoming,
922+
vec![],
923+
vec![],
924+
1_000_000,
925+
);
926+
let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState {
927+
transactions: vec![record],
928+
utxos: vec![(bip44(), coin)],
929+
..Default::default()
930+
});
931+
assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint)));
932+
assert!(receiver.bip44_account().utxos.is_empty());
933+
assert!(receiver.bip44_account().transactions().is_empty());
934+
assert!(receiver.managed_wallet.observed_spent_outpoints().is_empty());
935+
}

key-wallet/src/wallet/managed_wallet_info/persistence.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ pub enum RestoreError {
4646
DuplicateRecord(Txid, AccountType),
4747
/// More than one unspent coin names the same outpoint.
4848
DuplicateUtxo(OutPoint),
49-
/// A coin's address, script or corresponding transaction output is inconsistent.
49+
/// A coin's ownership, script or funding transaction metadata is inconsistent.
5050
InvalidUtxo(OutPoint),
5151
/// A coin is simultaneously unspent and claimed spent.
5252
SpentUtxo(OutPoint),
@@ -143,7 +143,8 @@ impl ManagedWalletInfo {
143143
vout: detail.index,
144144
};
145145
if matches!(detail.role, OutputRole::Received | OutputRole::Change)
146-
&& (self.observed_spent_outpoints.contains_key(&outpoint)
146+
&& (record_inputs.contains(&outpoint)
147+
|| self.observed_spent_outpoints.contains_key(&outpoint)
147148
|| self.unattributed_spent_outpoints.contains(&outpoint))
148149
&& !account_claims.contains(&(record.account_type, outpoint))
149150
{
@@ -264,10 +265,12 @@ impl ManagedWalletInfo {
264265
return Err(RestoreError::SpentUtxo(utxo.outpoint));
265266
}
266267
if (utxo.is_coinbase && utxo.height.checked_add(100).is_none())
268+
|| !account.contains_address(&utxo.address)
267269
|| utxo.address.script_pubkey() != utxo.txout.script_pubkey
268270
|| !utxo.address.as_unchecked().is_valid_for_network(self.network)
269271
|| transactions.get(&utxo.outpoint.txid).is_some_and(|transaction| {
270272
transaction.output.get(utxo.outpoint.vout as usize) != Some(&utxo.txout)
273+
|| transaction.is_coin_base() != utxo.is_coinbase
271274
})
272275
{
273276
return Err(RestoreError::InvalidUtxo(utxo.outpoint));

0 commit comments

Comments
 (0)