Skip to content

Commit

Permalink
fix(ln-dlc-node): Use smarter coin selection algorithm
Browse files Browse the repository at this point in the history
Powered by `bdk_coin_select`.

Now, if we have a bunch of small UTXOs, we are able to chose the right
number of them to fund a new DLC channel.
  • Loading branch information
luckysori committed Feb 2, 2024
1 parent c0af623 commit fe4acea
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 38 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ln-dlc-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ description = "A common interface for using Lightning and DLC channels side-by-s
anyhow = { version = "1", features = ["backtrace"] }
async-trait = "0.1.71"
bdk = { version = "0.28.0", default-features = false, features = ["key-value-db", "use-esplora-blocking", "std"] }
bdk_coin_select = "0.2.0"
bip39 = { version = "2", features = ["rand_core"] }
bitcoin = "0.29.2"
dlc = { version = "0.4.0" }
Expand Down
142 changes: 107 additions & 35 deletions crates/ln-dlc-node/src/ldk_node_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ use bdk::FeeRate;
use bdk::SignOptions;
use bdk::SyncOptions;
use bdk::TransactionDetails;
use bdk_coin_select::metrics::LowestFee;
use bdk_coin_select::Candidate;
use bdk_coin_select::ChangePolicy;
use bdk_coin_select::CoinSelector;
use bdk_coin_select::DrainWeights;
use bdk_coin_select::Target;
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::psbt::PartiallySignedTransaction;
use bitcoin::Address;
Expand All @@ -23,13 +29,14 @@ use bitcoin::BlockHash;
use bitcoin::OutPoint;
use bitcoin::Script;
use bitcoin::Transaction;
use bitcoin::TxIn;
use bitcoin::Txid;
use bitcoin::VarInt;
use dlc_manager::Utxo;
use lightning::chain::chaininterface::BroadcasterInterface;
use lightning::chain::chaininterface::ConfirmationTarget;
use parking_lot::Mutex;
use parking_lot::MutexGuard;
use rust_bitcoin_coin_selection::select_coins;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
Expand All @@ -49,6 +56,8 @@ where
inner: Mutex<bdk::Wallet<D>>,
settings: RwLock<WalletSettings>,
fee_rate_estimator: Arc<F>,
// Only cleared upon restart. This means that if a locked outpoint ends up unspent, it will
// remain locked until the binary is restarted.
locked_outpoints: Mutex<Vec<OutPoint>>,
node_storage: Arc<N>,
}
Expand Down Expand Up @@ -186,55 +195,118 @@ where
pub fn get_utxos_for_dlc_funding_transaction(
&self,
amount: u64,
lock_utxos: bool,
fee_rate: Option<u64>,
should_lock_utxos: bool,
) -> Result<Vec<Utxo>> {
let utxos = self.get_utxos()?;
// get temporarily reserved utxo from in-memory storage
let network = {
let bdk = self.bdk_lock();
bdk.network()
};

let fee_rate = fee_rate.map(|fee_rate| fee_rate as f32).unwrap_or_else(|| {
self.get_fee_rate(ConfirmationTarget::Normal)
.as_sat_per_vb()
});

// Get temporarily reserved UTXOs from in-memory storage.
let mut reserved_outpoints = self.locked_outpoints.lock();

// filter reserved utxos from all known utxos to not accidentally double spend and those who
// have actually been spent already
let utxos = self.get_utxos()?;

// Filter out reserved and spent UTXOs to prevent double-spending attempts.
let utxos = utxos
.iter()
.filter(|utxo| !reserved_outpoints.contains(&utxo.outpoint))
.filter(|utxo| !utxo.is_spent)
.collect::<Vec<_>>();

let mut utxos = utxos
.into_iter()
.map(|x| UtxoWrap {
utxo: Utxo {
tx_out: x.txout.clone(),
outpoint: x.outpoint,
address: Address::from_script(
&x.txout.script_pubkey,
self.bdk_lock().network(),
)
.expect("to be a valid address"),
redeem_script: Default::default(),
reserved: false,
},
let candidates = utxos
.iter()
.map(|utxo| {
let tx_in = TxIn {
previous_output: utxo.outpoint,
..Default::default()
};

// Inspired by `rust-bitcoin:0.30.2`.
let segwit_weight = {
let legacy_weight = {
let script_sig_size = tx_in.script_sig.len();
(36 + VarInt(script_sig_size as u64).len() + script_sig_size + 4) * 4
};

legacy_weight + tx_in.witness.serialized_len()
};

// The 10101 wallet always generates SegWit addresses.
//
// TODO: Rework this once we use Taproot.
let is_witness_program = true;

Candidate::new(utxo.txout.value, segwit_weight as u32, is_witness_program)
})
.collect::<Vec<_>>();

// select enough utxos for our needs
let selected_local_utxos = select_coins(amount, 20, &mut utxos).with_context(|| {
format!("Could not reach target of {amount} sats with given UTXO pool")
})?;

// update our temporarily reserved utxos with the selected once.
// note: this storage is only cleared up on a restart, meaning, if the protocol
// fails later on, the utxos will remain reserved
if lock_utxos {
for utxo in selected_local_utxos.clone() {
reserved_outpoints.push(utxo.utxo.outpoint);
// This is a standard base weight (without inputs or change outputs) for on-chain DLCs. We
// assume that this value is still correct for DLC channels.
let funding_tx_base_weight = 212;

let target = Target {
feerate: bdk_coin_select::FeeRate::from_sat_per_vb(fee_rate),
min_fee: 0,
value: amount,
};

let mut coin_selector = CoinSelector::new(&candidates, funding_tx_base_weight);

let dust_limit = 0;
let long_term_feerate = bdk_coin_select::FeeRate::default_min_relay_fee();

let change_policy = ChangePolicy::min_value_and_waste(
DrainWeights::default(),
dust_limit,
target.feerate,
long_term_feerate,
);

let metric = LowestFee {
target,
long_term_feerate,
change_policy,
};

coin_selector
.run_bnb(metric, 100_000)
.context("Failed to select coins")?;

debug_assert!(coin_selector.is_target_met(target));

let indices = coin_selector.selected_indices();

let mut selected_utxos: Vec<Utxo> = Vec::with_capacity(indices.len());
for index in indices {
let utxo = &utxos[*index];

let address = Address::from_script(&utxo.txout.script_pubkey, network)
.expect("to be a valid address");

let utxo = Utxo {
tx_out: utxo.txout.clone(),
outpoint: utxo.outpoint,
address,
redeem_script: Script::new(),
reserved: false,
};

if should_lock_utxos {
// Add selected UTXOs to reserve to prevent future double-spend attempts.
reserved_outpoints.push(utxo.outpoint);
}

selected_utxos.push(utxo);
}

Ok(selected_local_utxos
.into_iter()
.map(|utxo| utxo.utxo)
.collect())
Ok(selected_utxos)
}

/// Build the PSBT for sending funds to a given script and signs it
Expand Down
4 changes: 2 additions & 2 deletions crates/ln-dlc-node/src/ln_dlc_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,12 @@ impl<S: TenTenOneStorage, N: Storage> dlc_manager::Wallet for LnDlcWallet<S, N>
fn get_utxos_for_amount(
&self,
amount: u64,
_: Option<u64>,
fee_rate: Option<u64>,
lock_utxos: bool,
) -> Result<Vec<Utxo>, Error> {
let utxos = self
.ldk_wallet()
.get_utxos_for_dlc_funding_transaction(amount, lock_utxos)
.get_utxos_for_dlc_funding_transaction(amount, fee_rate, lock_utxos)
.map_err(|error| {
Error::InvalidState(format!("Could not find utxos for amount: {error:?}"))
})?;
Expand Down
1 change: 0 additions & 1 deletion crates/tests-e2e/tests/open_position_small_utxos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use tokio::task::spawn_blocking;

#[tokio::test(flavor = "multi_thread")]
#[ignore = "need to be run with 'just e2e' command"]
#[should_panic]
async fn can_open_position_with_multiple_small_utxos() {
// Arrange

Expand Down

0 comments on commit fe4acea

Please sign in to comment.