diff --git a/src/lib.rs b/src/lib.rs index 802a9a00..4ff7a2b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -248,7 +248,7 @@ use crate::utils::INDEXER_BATCH_SIZE; use crate::utils::INDEXER_PARALLEL_REQUESTS; #[cfg(any(feature = "electrum", feature = "esplora"))] #[cfg(test)] -use crate::wallet::test::{mock_input_unspents, mock_vout}; +use crate::wallet::test::{mock_consignment_recipient_id, mock_input_unspents, mock_vout}; #[cfg(any(feature = "electrum", feature = "esplora"))] use crate::{ api::{ diff --git a/src/wallet/online.rs b/src/wallet/online.rs index edbe8afd..3de0b75b 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -1760,6 +1760,113 @@ pub trait WalletOnline: WalletOffline { Ok(refresh_result) } + /// Reconcile orphaned pending witness TXOs with the RGB runtime. + /// + /// If no consignment ever references the TXID of a TXO in the pending witness state (e.g. + /// the same invoice was paid twice and only the replacement TX's consignment was delivered), + /// the flag would stay set forever. If the runtime already holds allocations for such a TXO + /// (received as part of another consignment's history), save them to the DB as a settled + /// transfer and clear the flag. TXOs unknown to the runtime are left untouched, since + /// spending them would burn anything a future consignment may still deliver. + fn reconcile_pending_witness_txos(&mut self, txn: &DbTxn) -> Result { + let db_data = txn.get_db_data(false)?; + let orphan_txos: Vec<&DbTxo> = db_data + .txos + .iter() + .filter(|t| t.pending_witness && t.exists && !t.spent) + .filter(|t| !db_data.colorings.iter().any(|c| c.txo_idx == t.idx)) + .filter(|t| { + !db_data + .batch_transfers + .iter() + .any(|b| b.txid.as_deref() == Some(t.txid.as_str())) + }) + .collect(); + if orphan_txos.is_empty() { + return Ok(false); + } + + let runtime = self.rgb_runtime()?; + let mut reconciled = false; + for txo in orphan_txos { + let outpoint: OutPoint = txo.outpoint().into(); + let mut asset_assignments: Vec<(String, Vec)> = vec![]; + let mut unknown_asset = false; + for contract_id in runtime.contracts_assigning([outpoint])? { + let asset_id = contract_id.to_string(); + if txn.get_asset(asset_id.clone())?.is_none() { + unknown_asset = true; + break; + } + let mut assignments = vec![]; + for opouts in runtime + .contract_assignments_for(contract_id, [outpoint])? + .into_values() + { + for (opout, state) in opouts { + if matches!(state, AllocatedState::Void) { + continue; + } + assignments.push(Assignment::from_opout_and_state(opout, &state)); + } + } + if !assignments.is_empty() { + asset_assignments.push((asset_id, assignments)); + } + } + if unknown_asset || asset_assignments.is_empty() { + continue; + } + + info!( + self.logger(), + "Reconciling pending witness TXO {} with the RGB runtime", + txo.outpoint() + ); + let batch_transfer = DbBatchTransferActMod { + txid: ActiveValue::Set(Some(txo.txid.clone())), + status: ActiveValue::Set(TransferStatus::Settled), + created_at: ActiveValue::Set(now().unix_timestamp()), + min_confirmations: ActiveValue::Set(0), + ..Default::default() + }; + let batch_transfer_idx = txn.set_batch_transfer(batch_transfer)?; + for (asset_id, assignments) in asset_assignments { + let asset_transfer = DbAssetTransferActMod { + user_driven: ActiveValue::Set(false), + batch_transfer_idx: ActiveValue::Set(batch_transfer_idx), + asset_id: ActiveValue::Set(Some(asset_id)), + ..Default::default() + }; + let asset_transfer_idx = txn.set_asset_transfer(asset_transfer)?; + let transfer = DbTransferActMod { + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + incoming: ActiveValue::Set(true), + recipient_type: ActiveValue::Set(Some(RecipientTypeFull::Witness { + vout: Some(txo.vout), + })), + ..Default::default() + }; + txn.set_transfer(transfer)?; + for assignment in assignments { + let db_coloring = DbColoringActMod { + txo_idx: ActiveValue::Set(txo.idx), + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + r#type: ActiveValue::Set(ColoringType::Receive), + assignment: ActiveValue::Set(assignment), + ..Default::default() + }; + txn.set_coloring(db_coloring)?; + } + } + let mut updated_txo: DbTxoActMod = txo.clone().into(); + updated_txo.pending_witness = ActiveValue::Set(false); + txn.update_txo(updated_txo)?; + reconciled = true; + } + Ok(reconciled) + } + fn select_rgb_inputs( &self, asset_id: String, @@ -2509,10 +2616,14 @@ pub trait WalletOnline: WalletOffline { let vout = mock_vout(recipient.local_recipient_data.vout()); #[cfg(not(test))] let vout = recipient.local_recipient_data.vout(); + #[cfg(test)] + let post_recipient_id = mock_consignment_recipient_id(recipient_id.clone()); + #[cfg(not(test))] + let post_recipient_id = recipient_id.clone(); let proxy_client = ProxyClient::new(&proxy_url)?; match self.post_consignment_to_proxy( &proxy_client, - recipient_id.clone(), + post_recipient_id, &consignment_path, txid.clone(), vout, @@ -3773,7 +3884,8 @@ pub trait RgbWalletOpsOnline: RgbWalletOpsOffline + WalletOnline { txn.check_asset_exists(aid.clone())?; } let res = self.refresh_impl(&txn, asset_id, filter, skip_sync)?; - if res.transfers_changed() { + let reconciled = self.reconcile_pending_witness_txos(&txn)?; + if res.transfers_changed() || reconciled { self.update_backup_info(&txn, false)?; } txn.commit()?; diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index 3e0c9d42..f3c324e0 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -92,6 +92,7 @@ static INIT: Once = Once::new(); thread_local! { pub(crate) static MOCK_CHAIN_NET: RefCell> = const { RefCell::new(None) }; pub(crate) static MOCK_CHECK_FEE_RATE: RefCell> = const { RefCell::new(vec![]) }; + pub(crate) static MOCK_CONSIGNMENT_RECIPIENT_ID: RefCell> = const { RefCell::new(None) }; pub(crate) static MOCK_CONTRACT_DATA: RefCell> = const { RefCell::new(vec![]) }; pub(crate) static MOCK_CONTRACT_DETAILS: RefCell> = const { RefCell::new(None) }; pub(crate) static MOCK_INPUT_UNSPENTS: RefCell> = const { RefCell::new(vec![]) }; @@ -295,6 +296,16 @@ pub fn mock_send_end_crash() -> bool { } } +pub fn mock_consignment_recipient_id(recipient_id: String) -> String { + let mock = MOCK_CONSIGNMENT_RECIPIENT_ID.take(); + if let Some(mock) = mock { + println!("mocking consignment recipient ID"); + mock + } else { + recipient_id + } +} + pub fn mock_vout(vout: Option) -> Option { let mock = MOCK_VOUT.take(); if mock.is_some() { diff --git a/src/wallet/test/witness_receive.rs b/src/wallet/test/witness_receive.rs index 9c9f32d4..2b4bc5d3 100644 --- a/src/wallet/test/witness_receive.rs +++ b/src/wallet/test/witness_receive.rs @@ -120,3 +120,125 @@ fn fail() { .unwrap_err(); assert_matches!(result, Error::InvalidExpiration); } + +// invoice paid on-chain without its consignment being delivered, then paid again with a TX +// spending the first one's change: check refresh reconciles the orphaned first TXO +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn orphaned_payment_recovery() { + initialize(); + + let amount: u64 = 66; + let amount_sat: u64 = 1000; + + let mut party = get_funded_party!(); + let mut rcv_party = get_funded_party!(); + + let asset = party.issue_asset_nia(None); + + let receive_data = rcv_party.witness_receive(); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount), + recipient_id: receive_data.recipient_id.clone(), + witness_data: Some(WitnessData { + amount_sat, + blinding: None, + }), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + + // 1st payment: donation send whose consignment gets lost (posted under a bogus recipient ID) + println!("setting MOCK_CONSIGNMENT_RECIPIENT_ID"); + MOCK_CONSIGNMENT_RECIPIENT_ID.replace(Some(s!("lost-consignment"))); + let txid_1 = party + .wallet + .send( + party.online, + recipient_map.clone(), + true, + FEE_RATE, + MIN_CONFIRMATIONS, + None, + ) + .unwrap() + .txid; + mine(false); + party.wait_for_refresh(Some(&asset.asset_id)); + + // receiver sees no consignment; sync quarantines the TXO paying the invoice script + rcv_party.list_unspents_with_sync(false); + rcv_party.refresh_result(None, &[]).unwrap(); + let db_data = rcv_party.db_data(false); + let orphan_txo = db_data.txos.iter().find(|t| t.txid == txid_1).unwrap(); + assert!(orphan_txo.pending_witness); + assert!(rcv_party.get_asset_balance_result(&asset.asset_id).is_err()); + + // 2nd payment: same invoice, spending the 1st TX's change, consignment delivered normally + let txid_2 = party + .wallet + .send( + party.online, + recipient_map, + true, + FEE_RATE, + MIN_CONFIRMATIONS, + None, + ) + .unwrap() + .txid; + rcv_party.wait_for_refresh(None); + mine(false); + rcv_party.wait_for_refresh(None); + // full scan to find the 2nd TX, since the 1st consumed the pending witness script + rcv_party.sync(SyncOptions { + keychain: SyncKeychain::Colored, + strategy: SyncStrategy::FullScan, + }); + + // both payments recovered: quarantine lifted, allocation saved, balance complete + let db_data = rcv_party.db_data(false); + let orphan_txo = db_data.txos.iter().find(|t| t.txid == txid_1).unwrap(); + assert!(!orphan_txo.pending_witness); + assert_eq!( + rcv_party.get_asset_balance(&asset.asset_id).settled, + amount * 2 + ); + let unspents = rcv_party.list_unspents_with_sync(false); + let orphan_unspent = unspents + .iter() + .find(|u| u.utxo.outpoint.txid == txid_1) + .unwrap(); + assert_eq!(orphan_unspent.utxo.btc_amount, amount_sat); + assert!(orphan_unspent.rgb_allocations.iter().any(|a| { + a.asset_id == Some(asset.asset_id.clone()) + && a.assignment == Assignment::Fungible(amount) + && a.settled + })); + let regular_unspent = unspents + .iter() + .find(|u| u.utxo.outpoint.txid == txid_2) + .unwrap(); + assert!(regular_unspent.rgb_allocations.iter().any(|a| { + a.asset_id == Some(asset.asset_id.clone()) + && a.assignment == Assignment::Fungible(amount) + && a.settled + })); + + // recovered sats and allocation are spendable: send everything back + let receive_data = party.blind_receive(); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount * 2), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid_3 = rcv_party.send_retry(&recipient_map); + assert!(!txid_3.is_empty()); +}