Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
116 changes: 114 additions & 2 deletions src/wallet/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, Error> {
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<Assignment>)> = 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()?;
Expand Down
11 changes: 11 additions & 0 deletions src/wallet/test/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
ffi::OsString,

Check warning on line 2 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `AtomicU64`, `Ordering`, `RwLock`, `ffi::OsString`, and `io::Write`

Check warning on line 2 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `ffi::OsString`
io::Write,
path::MAIN_SEPARATOR_STR,
process::{Command, Stdio},
Expand All @@ -9,30 +9,30 @@
},
};

use amplify::set;

Check warning on line 12 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `amplify::set`
use bdk_wallet::{bitcoin::Denomination, descriptor::ExtendedDescriptor};

Check warning on line 13 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `bitcoin::Denomination`
use biscuit_auth::{KeyPair, builder::date, macros::*};

Check warning on line 14 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `KeyPair`, `builder::date`, and `macros::*`
use chrono::{DateTime, Utc};

Check warning on line 15 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `DateTime` and `Utc`
use once_cell::sync::Lazy;
use regex::RegexSet;
use rgbstd::stl::{EmbeddedMedia as RgbEmbeddedMedia, ProofOfReserves as RgbProofOfReserves};

Check warning on line 18 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `EmbeddedMedia as RgbEmbeddedMedia` and `ProofOfReserves as RgbProofOfReserves`

Check warning on line 18 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `EmbeddedMedia as RgbEmbeddedMedia` and `ProofOfReserves as RgbProofOfReserves`
use serde_json::Value;

Check warning on line 19 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `serde_json::Value`
use serial_test::{parallel, serial};

Check warning on line 20 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `serial`
use std::{cell::RefCell, time::Instant};
use time::OffsetDateTime;

Check warning on line 22 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `time::OffsetDateTime`

use super::*;

#[cfg(any(feature = "electrum", feature = "esplora"))]
use crate::wallet::{
online::*,
rust_only::{check_indexer_url, check_proxy_url},

Check warning on line 29 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused import: `check_proxy_url`
utils::build_indexer,
};
use crate::{
keys::{Keys, generate_keys},
utils::{
KEYCHAIN_BTC, KEYCHAIN_RGB, RGB_RUNTIME_DIR, get_account_data,

Check warning on line 35 in src/wallet/test/mod.rs

View workflow job for this annotation

GitHub Actions / test_features

unused imports: `RGB_RUNTIME_DIR`, `get_account_data`, `recipient_id_from_script_buf`, and `script_buf_from_recipient_id`
get_account_derivation_children, get_coin_type, get_extended_derivation_path,
recipient_id_from_script_buf, script_buf_from_recipient_id,
},
Expand Down Expand Up @@ -92,6 +92,7 @@
thread_local! {
pub(crate) static MOCK_CHAIN_NET: RefCell<Option<ChainNet>> = const { RefCell::new(None) };
pub(crate) static MOCK_CHECK_FEE_RATE: RefCell<Vec<bool>> = const { RefCell::new(vec![]) };
pub(crate) static MOCK_CONSIGNMENT_RECIPIENT_ID: RefCell<Option<String>> = const { RefCell::new(None) };
pub(crate) static MOCK_CONTRACT_DATA: RefCell<Vec<Attachment>> = const { RefCell::new(vec![]) };
pub(crate) static MOCK_CONTRACT_DETAILS: RefCell<Option<String>> = const { RefCell::new(None) };
pub(crate) static MOCK_INPUT_UNSPENTS: RefCell<Vec<LocalUnspent>> = const { RefCell::new(vec![]) };
Expand Down Expand Up @@ -295,6 +296,16 @@
}
}

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<u32>) -> Option<u32> {
let mock = MOCK_VOUT.take();
if mock.is_some() {
Expand Down
122 changes: 122 additions & 0 deletions src/wallet/test/witness_receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Loading