From 625fa3981e1901430304358fc65ba3072ecbaf90 Mon Sep 17 00:00:00 2001 From: Berserker Date: Wed, 8 Jul 2026 13:13:05 +0000 Subject: [PATCH] fix: recover witness UTXOs orphaned by a lost consignment A witness invoice can be paid on-chain without its consignment ever being delivered: the proxy can drop a successfully posted consignment (e.g. it restarts and loses its file store), and the sender may then pay the same invoice again with a new TX whose consignment does arrive. This only happens in donation mode, where the sender broadcasts right after posting the consignment; otherwise it waits for the recipient's ACK (which requires the recipient to already hold the consignment) before broadcasting, so a lost consignment cannot leave a confirmed orphan. The first payment's UTXO is quarantined with pending_witness set, but no transfer ever references its TXID, so the flag is never cleared: the UTXO stays unspendable forever and its allocation, received as part of the second consignment's history, lives only in the RGB runtime and never makes it into the DB (understated balance, sends failing with InsufficientAllocationSlots). On refresh, look for pending witness TXOs with no transfer referencing their TXID. When the runtime already holds allocations for one there is nothing left to wait for: save them to the DB as a settled transfer and clear the flag, so both the sats and the assets become usable again. TXOs the runtime knows nothing about are left as-is, since spending them would burn whatever a still-pending consignment may deliver. --- src/lib.rs | 2 +- src/wallet/online.rs | 116 ++++++++++++++++++++++++++- src/wallet/test/mod.rs | 11 +++ src/wallet/test/witness_receive.rs | 122 +++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) 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()); +}