diff --git a/mppj/config.toml b/mppj/config.toml deleted file mode 100644 index 945a150..0000000 --- a/mppj/config.toml +++ /dev/null @@ -1,44 +0,0 @@ -[simulation] -seed = 42 -max_timestep = 15 -num_payment_obligations = 5 - -# Takers: have payment obligations, build cospend proposals using the order book -[[wallet_types]] -name = "taker" -count = 1 -strategies = ["TakerStrategy"] -script_type = "p2wpkh" - -[wallet_types.scorer] -fee_savings_weight = 1.0 -privacy_weight = 1.0 -payment_obligation_weight = 2.0 -coordination_weight = 1.0 - -# Makers: register UTXOs in the order book and participate in cospends -[[wallet_types]] -name = "maker" -count = 3 -strategies = ["MakerStrategy"] -script_type = "p2wpkh" - -[wallet_types.scorer] -fee_savings_weight = 0.5 -privacy_weight = 1.0 -payment_obligation_weight = 1.0 -coordination_weight = 2.0 - -# Aggregators: create aggregate proposals from pending interests -# For now they are a distinct role that should not have payment obligations -[[wallet_types]] -name = "aggregator" -count = 1 -strategies = ["AggregatorStrategy"] -script_type = "p2wpkh" - -[wallet_types.scorer] -fee_savings_weight = 0.0 -privacy_weight = 0.0 -payment_obligation_weight = 0.0 -coordination_weight = 0.0 diff --git a/mppj/results.json b/mppj/results.json deleted file mode 100644 index 824cd12..0000000 --- a/mppj/results.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "total_payment_obligations": 8, - "percentage_payment_obligations_missed": 0.75, - "total_block_weight_wu": 10100, - "average_fee_cost_sats": 220, - "dust_utxo_count": 0, - "utxo_size_distribution_sats": [ - 3519, - 4483, - 5409, - 16713, - 4999983177, - 4999994481, - 4999995407, - 4999996371, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000000, - 5000000220, - 5000000220 - ], - "wallet_utxo_stats": [ - { - "wallet_id": 0, - "dust_count": 0, - "total_count": 21, - "p50_sats": 5000000000, - "p90_sats": 5000000000 - }, - { - "wallet_id": 1, - "dust_count": 0, - "total_count": 8, - "p50_sats": 5000000000, - "p90_sats": 5000000000 - }, - { - "wallet_id": 2, - "dust_count": 0, - "total_count": 8, - "p50_sats": 5000000000, - "p90_sats": 5000000000 - }, - { - "wallet_id": 3, - "dust_count": 0, - "total_count": 6, - "p50_sats": 5000000000, - "p90_sats": 5000000000 - }, - { - "wallet_id": 4, - "dust_count": 0, - "total_count": 5, - "p50_sats": 5000000000, - "p90_sats": 5000000000 - } - ] -} \ No newline at end of file diff --git a/src/actions.rs b/src/actions.rs index 027cfed..5516ee7 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1,15 +1,18 @@ use std::{iter::Sum, ops::Add}; +use bdk_coin_select::{Target, TargetFee, TargetOutputs}; +use bitcoin::Amount; use log::debug; use crate::{ bulletin_board::BulletinBoardId, + coin_selection::{select_all, select_bnb}, cospend::{CospendInterest, UtxoWithMetadata}, message::MessageId, transaction::Outpoint, tx_contruction::TxConstructionState, wallet::{PaymentObligationData, PaymentObligationId, WalletHandleMut}, - CoinSelectionStrategy, Simulation, TimeStep, + Simulation, TimeStep, }; fn piecewise_linear(x: f64, points: &[(f64, f64)]) -> f64 { @@ -42,12 +45,12 @@ fn piecewise_linear(x: f64, points: &[(f64, f64)]) -> f64 { /// An Action a wallet can perform #[derive(Debug)] pub(crate) enum Action { - /// Spend a payment obligation unilaterally - UnilateralPayments(Vec, CoinSelectionStrategy), + /// Spend a payment obligation unilaterally with pre-selected inputs and pre-computed change + UnilateralPayments(Vec, Vec, Vec), /// Accept a cospend invitation AcceptCospendProposal((MessageId, BulletinBoardId)), - /// Contribute outputs to a cospend session that is waiting for them - ContributeOutputsToSession(BulletinBoardId, Vec), + /// Contribute outputs to a cospend session that is waiting for them, with pre-computed change + ContributeOutputsToSession(BulletinBoardId, Vec, Vec), /// Continue to participate in a multi-party payjoin ContinueParticipateInCospend(BulletinBoardId), /// Taker records non-committal interest in cospending with each orderbook UTXO @@ -161,8 +164,8 @@ fn simulate_one_action(wallet_handle: &WalletHandleMut, action: &Action) -> Pred // POs handled: derived from action since confirmation is deferred to block let payment_obligations_handled: Vec = match action { - Action::UnilateralPayments(po_ids, _) => po_ids.clone(), - Action::ContributeOutputsToSession(_, po_ids) => po_ids.clone(), + Action::UnilateralPayments(po_ids, _, _) => po_ids.clone(), + Action::ContributeOutputsToSession(_, po_ids, _) => po_ids.clone(), _ => vec![], }; @@ -273,25 +276,86 @@ impl Add for ActionCost { } } +/// Build a BDK Target for a slice of payment obligations, estimating output weight +/// from each recipient wallet's script type. +fn target_for_obligations(pos: &[PaymentObligationData], wallet: &WalletHandleMut) -> Target { + let value_sum: u64 = pos.iter().map(|po| po.amount.to_sat()).sum(); + let weight_sum: u32 = pos + .iter() + .map(|po| po.to.with(wallet.sim).data().script_type.output_weight_wu()) + .sum(); + Target { + fee: TargetFee { + rate: bdk_coin_select::FeeRate::from_sat_per_vb(1.0), + replace: None, + }, + outputs: TargetOutputs { + value_sum, + weight_sum, + n_outputs: pos.len(), + }, + } +} + +/// Compute pre-selected change outputs for a `ContributeOutputsToSession` action. +/// If the session has pre-selected inputs (from the aggregator), uses those exactly. +/// Otherwise falls back to full BNB / spend-all selection over all wallet UTXOs. +fn change_for_session_contribution( + bb_id: &BulletinBoardId, + pos: &[PaymentObligationData], + wallet: &WalletHandleMut, +) -> Vec { + let session = wallet + .info() + .active_multi_party_payjoins + .get(bb_id) + .unwrap(); + let session_input_outpoints: Vec = + session.inputs.iter().map(|i| i.outpoint).collect(); + let target = target_for_obligations(pos, wallet); + if session_input_outpoints.is_empty() { + let candidates = wallet.handle().coin_candidates(); + if let Some((_, change)) = select_bnb(&candidates, target) { + return change; + } + select_all(&candidates, target).1 + } else { + let candidates = wallet + .handle() + .coin_candidates_for(&session_input_outpoints); + select_all(&candidates, target).1 + } +} + #[derive(Debug, Clone)] pub(crate) struct UnilateralSpender; impl Strategy for UnilateralSpender { - /// The decision space of the unilateral spender is the set of all payment obligations + /// The decision space of the unilateral spender is the set of all payment obligations. + /// For each obligation, enumerate both BNB and spend-all coin selections so the cost + /// function can pick the cheaper input set. fn enumerate_candidate_actions( &self, state: &WalletView, - _wallet: &WalletHandleMut, + wallet: &WalletHandleMut, ) -> Vec { if state.payment_obligations.is_empty() { return vec![Action::Wait]; } + let candidates = wallet.handle().coin_candidates(); let mut actions = vec![]; for po in state.payment_obligations.iter() { - actions.push(Action::UnilateralPayments( - vec![po.id], - CoinSelectionStrategy::Bnb, - )); + let target = target_for_obligations(std::slice::from_ref(po), wallet); + if let Some((inputs, change)) = select_bnb(&candidates, target) { + actions.push(Action::UnilateralPayments(vec![po.id], inputs, change)); + } + let (all_inputs, change) = select_all(&candidates, target); + if !all_inputs.is_empty() { + actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change)); + } + } + if actions.is_empty() { + actions.push(Action::Wait); } actions } @@ -305,19 +369,21 @@ impl Strategy for UnilateralSpender { pub(crate) struct Consolidator; impl Strategy for Consolidator { - /// Always uses SpendAll when paying. - /// trade-off. Fee savings from reducing UTXO fragmentation are captured when fee_savings_weight > 0. + /// Always uses spend-all when paying — forces consolidation regardless of fee efficiency. + /// Fee savings from reducing UTXO fragmentation are captured when fee_savings_weight > 0. fn enumerate_candidate_actions( &self, state: &WalletView, - _wallet: &WalletHandleMut, + wallet: &WalletHandleMut, ) -> Vec { + let candidates = wallet.handle().coin_candidates(); let mut actions = Vec::new(); for po in state.payment_obligations.iter() { - actions.push(Action::UnilateralPayments( - vec![po.id], - CoinSelectionStrategy::SpendAll, - )); + let target = target_for_obligations(std::slice::from_ref(po), wallet); + let (all_inputs, change) = select_all(&candidates, target); + if !all_inputs.is_empty() { + actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change)); + } } actions.push(Action::Wait); actions @@ -335,18 +401,28 @@ impl Strategy for BatchSpender { fn enumerate_candidate_actions( &self, state: &WalletView, - _wallet: &WalletHandleMut, + wallet: &WalletHandleMut, ) -> Vec { if state.payment_obligations.is_empty() { return vec![Action::Wait]; } // TODO: we may need to consider different partitioning strategies for the batch spend - let payment_obligation_ids: Vec = + let po_ids: Vec = state.payment_obligations.iter().map(|po| po.id).collect(); - vec![Action::UnilateralPayments( - payment_obligation_ids, - CoinSelectionStrategy::Bnb, - )] + let target = target_for_obligations(&state.payment_obligations, wallet); + let candidates = wallet.handle().coin_candidates(); + let mut actions = vec![]; + if let Some((inputs, change)) = select_bnb(&candidates, target) { + actions.push(Action::UnilateralPayments(po_ids.clone(), inputs, change)); + } + let (all_inputs, change) = select_all(&candidates, target); + if !all_inputs.is_empty() { + actions.push(Action::UnilateralPayments(po_ids, all_inputs, change)); + } + if actions.is_empty() { + actions.push(Action::Wait); + } + actions } fn clone_box(&self) -> Box { @@ -380,11 +456,17 @@ impl Strategy for MakerStrategy { } } - // Contribute outputs to sessions that are waiting for them (SentInputs state) + // Contribute outputs to sessions that are waiting for them (AcceptedProposal state) for (bb_id, session) in wallet.info().active_multi_party_payjoins.iter() { if session.state == TxConstructionState::AcceptedProposal { for po in state.payment_obligations.iter() { - actions.push(Action::ContributeOutputsToSession(*bb_id, vec![po.id])); + let change = + change_for_session_contribution(bb_id, std::slice::from_ref(po), wallet); + actions.push(Action::ContributeOutputsToSession( + *bb_id, + vec![po.id], + change, + )); } } } @@ -402,21 +484,19 @@ impl Strategy for MakerStrategy { } else { vec![] }; - let per_action_spent: Vec> = unilateral_actions + // Selected inputs are already embedded in each action i.e no simulation needed. + let per_action_inputs: Vec> = unilateral_actions .iter() - .filter(|a| matches!(a, Action::UnilateralPayments(_, _))) - .map(|action| { - simulate_one_action(wallet, action) - .utxos_spent - .into_iter() - .collect() + .filter_map(|a| match a { + Action::UnilateralPayments(_, inputs, _) => Some(inputs.iter().copied().collect()), + _ => None, }) .collect(); - let common_inputs: Vec = per_action_spent + let common_inputs: Vec = per_action_inputs .iter() .skip(1) .fold( - per_action_spent.first().cloned().unwrap_or_default(), + per_action_inputs.first().cloned().unwrap_or_default(), |acc, s| acc.intersection(s).copied().collect(), ) .iter() @@ -448,11 +528,17 @@ impl Strategy for TakerStrategy { ) -> Vec { let mut actions = vec![]; - // Contribute outputs to sessions awaiting them (SentInputs state) + // Contribute outputs to sessions awaiting them (AcceptedProposal state) for (bb_id, session) in wallet.info().active_multi_party_payjoins.iter() { if session.state == TxConstructionState::AcceptedProposal { for po in state.payment_obligations.iter() { - actions.push(Action::ContributeOutputsToSession(*bb_id, vec![po.id])); + let change = + change_for_session_contribution(bb_id, std::slice::from_ref(po), wallet); + actions.push(Action::ContributeOutputsToSession( + *bb_id, + vec![po.id], + change, + )); } } } @@ -640,7 +726,7 @@ mod tests { } #[test] - fn test_unilateral_spender() { + fn test_unilateral_spender_no_utxos() { let mut sim = test_sim(); let wallet = WalletId(0).with_mut(&mut sim); let strategy = UnilateralSpender; @@ -656,14 +742,13 @@ mod tests { let actions = strategy.enumerate_candidate_actions(&view, &wallet); + // Wallet has no UTXOs, coin selection produces nothing falls back to Wait. assert_eq!(actions.len(), 1); - assert!(actions - .iter() - .any(|a| matches!(a, Action::UnilateralPayments(ids, CoinSelectionStrategy::Bnb) if ids.len() == 1))); + assert!(matches!(actions[0], Action::Wait)); } #[test] - fn test_unilateral_consolidate_spender() { + fn test_unilateral_consolidate_spender_no_utxos() { let mut sim = test_sim(); let wallet = WalletId(0).with_mut(&mut sim); let strategy = Consolidator; @@ -679,18 +764,15 @@ mod tests { let actions = strategy.enumerate_candidate_actions(&view, &wallet); + // Consolidator always emits Wait, and skips UnilateralPayments when no UTXOs exist. assert!(actions.iter().any(|a| matches!(a, Action::Wait))); - // Consolidator always uses SpendAll (strategy commitment, not cost trade-off) - assert!(actions - .iter() - .any(|a| matches!(a, Action::UnilateralPayments(ids, CoinSelectionStrategy::SpendAll) if ids.len() == 1))); assert!(!actions .iter() - .any(|a| matches!(a, Action::UnilateralPayments(_, CoinSelectionStrategy::Bnb)))); + .any(|a| matches!(a, Action::UnilateralPayments(_, _, _)))); } #[test] - fn test_batch_spender_creates_batches() { + fn test_batch_spender_no_utxos() { let mut sim = test_sim(); let wallet = WalletId(0).with_mut(&mut sim); let strategy = BatchSpender; @@ -708,21 +790,22 @@ mod tests { reveal_time: TimeStep(0), amount: Amount::from_sat(2000), from: WalletId(0), - to: WalletId(2), + to: WalletId(1), }; let view = create_test_wallet_view(vec![po1, po2]); let actions = strategy.enumerate_candidate_actions(&view, &wallet); - // BatchSpender creates a single batch with all obligations + // No UTXOs coin selection produces nothing, falls back to Wait. assert_eq!(actions.len(), 1); - assert!(actions - .iter() - .any(|a| matches!(a, Action::UnilateralPayments(ids, _) if ids.len() == 2))); + assert!(matches!(actions[0], Action::Wait)); } #[test] fn test_composite_strategy_combines_actions() { + // TODO: this test is kinda useless, we need to add UTXOs to the sim and test the composite strategy. + // Otherwise we are just testing that both strategies fall back to Wait when there are no UTXOs. + // This is bc coin selection uses `wallet.handle().coin_candidates();` not `state.utxos`. let mut sim = test_sim(); let wallet = WalletId(0).with_mut(&mut sim); let composite = CompositeStrategy { @@ -743,28 +826,16 @@ mod tests { reveal_time: TimeStep(0), amount: Amount::from_sat(2000), from: WalletId(0), - to: WalletId(2), + to: WalletId(1), }; let view = create_test_wallet_view(vec![po1, po2]); let actions = composite.enumerate_candidate_actions(&view, &wallet); - // Should include actions from both strategies - // UnilateralSpender: 2 actions (one per obligation, single-PO each) - // BatchSpender: 1 action (all obligations in one tx) - assert_eq!(actions.len(), 3); - - let single_po_count = actions - .iter() - .filter(|a| matches!(a, Action::UnilateralPayments(ids, CoinSelectionStrategy::Bnb) if ids.len() == 1)) - .count(); - assert_eq!(single_po_count, 2); - - let batch_count = actions - .iter() - .filter(|a| matches!(a, Action::UnilateralPayments(ids, _) if ids.len() == 2)) - .count(); - assert_eq!(batch_count, 1); + // Wallet has no UTXOs in the sim, both strategies fall back to Wait. + // Composite collects one Wait from each strategy. + assert_eq!(actions.len(), 2); + assert!(actions.iter().all(|a| matches!(a, Action::Wait))); } #[test] @@ -781,7 +852,7 @@ mod tests { from: WalletId(0), to: WalletId(1), }; - // No orderbook UTXOs — taker has nothing to propose to + // No orderbook UTXOs, taker has nothing to propose to let view = create_test_wallet_view(vec![po]); let actions = strategy.enumerate_candidate_actions(&view, &wallet); @@ -943,7 +1014,7 @@ mod tests { assert!(actions .iter() - .any(|a| matches!(a, Action::ContributeOutputsToSession(id, ids) if *id == bb_id && ids.len() == 1))); + .any(|a| matches!(a, Action::ContributeOutputsToSession(id, ids, _) if *id == bb_id && ids.len() == 1))); // Should NOT emit ContinueParticipateInCospend for this session assert!(!actions .iter() diff --git a/src/coin_selection.rs b/src/coin_selection.rs new file mode 100644 index 0000000..8de8e68 --- /dev/null +++ b/src/coin_selection.rs @@ -0,0 +1,98 @@ +use bdk_coin_select::{ + metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, Target, + TR_DUST_RELAY_MIN_VALUE, +}; +use bitcoin::Amount; +use log::warn; + +use crate::transaction::Outpoint; + +pub(crate) struct CoinCandidate { + pub(crate) outpoint: Outpoint, + pub(crate) amount_sats: u64, + pub(crate) weight_wu: u32, + pub(crate) is_segwit: bool, +} + +/// Long-term feerate for coin selection (10 sat/vb = 2.5 sat/wu). +pub(crate) fn long_term_feerate() -> bdk_coin_select::FeeRate { + bdk_coin_select::FeeRate::from_sat_per_wu(2.5) +} + +fn change_policy_for(target: Target) -> ChangePolicy { + ChangePolicy::min_value_and_waste( + DrainWeights::default(), + TR_DUST_RELAY_MIN_VALUE, + target.fee.rate, + long_term_feerate(), + ) +} + +fn bdk_candidates(candidates: &[CoinCandidate]) -> Vec { + candidates + .iter() + .map(|c| Candidate { + value: c.amount_sats, + weight: c.weight_wu, + input_count: 1, + is_segwit: c.is_segwit, + }) + .collect() +} + +fn drain_to_change(drain: bdk_coin_select::Drain) -> Vec { + if drain.value > 0 { + vec![Amount::from_sat(drain.value)] + } else { + vec![] + } +} + +/// Run BNB coin selection over candidates for the given target. +/// Falls back to greedy selection if BNB finds no solution. +/// Returns None if no selection can meet the target. +/// Returns (selected_inputs, change_outputs). +pub(crate) fn select_bnb( + candidates: &[CoinCandidate], + target: Target, +) -> Option<(Vec, Vec)> { + let bdk = bdk_candidates(candidates); + let mut coin_selector = CoinSelector::new(&bdk); + + let change_policy = change_policy_for(target); + let metric = LowestFee { + target, + long_term_feerate: long_term_feerate(), + change_policy, + }; + + if let Err(err) = coin_selector.run_bnb(metric, 100_000) { + warn!("BNB failed to find a solution: {}", err); + if coin_selector.select_until_target_met(target).is_err() { + return None; + } + } + + let inputs = coin_selector + .apply_selection(candidates) + .map(|c| c.outpoint) + .collect(); + let change = drain_to_change(coin_selector.drain(target, change_policy)); + Some((inputs, change)) +} + +/// Select all candidates (consolidation / spend-all strategy). +/// Returns (selected_inputs, change_outputs). +pub(crate) fn select_all( + candidates: &[CoinCandidate], + target: Target, +) -> (Vec, Vec) { + let bdk = bdk_candidates(candidates); + let mut coin_selector = CoinSelector::new(&bdk); + coin_selector.select_all(); + + let change_policy = change_policy_for(target); + let inputs = candidates.iter().map(|c| c.outpoint).collect(); + let change = drain_to_change(coin_selector.drain(target, change_policy)); + (inputs, change) +} diff --git a/src/lib.rs b/src/lib.rs index 871dcda..0ae757e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ mod macros; mod actions; mod blocks; mod bulletin_board; +mod coin_selection; pub mod config; mod cospend; mod economic_graph; @@ -63,16 +64,6 @@ impl PrngFactory { } } -// all have RBF and non-RBF variants? -#[derive(Debug)] -#[allow(dead_code)] -enum CoinSelectionStrategy { - Fifo, - SpendAll, - Bnb, - // TODO brute force pre-computed for cost function -} - // total fee budget // - cap average over entire history, to work within estimated budget overall // - this is a soft fail, resulting in missed payments @@ -1087,33 +1078,29 @@ mod tests { }, }; - let long_term_feerate = bitcoin::FeeRate::from_sat_per_vb(10).unwrap(); + let candidates = alice.with(&sim).coin_candidates(); + let (selected_outpoints, change_amounts) = + crate::coin_selection::select_bnb(&candidates, target) + .unwrap_or_else(|| crate::coin_selection::select_all(&candidates, target)); let spend = alice .with_mut(&mut sim) - .new_tx(|tx, sim| { - // TODO use select_coins - let (inputs, drain) = - alice - .with(sim) - .select_coins(target, long_term_feerate, false, None); - - tx.inputs = inputs - .map(|o| Input { - outpoint: o.outpoint, - }) + .new_tx(|tx, _sim| { + tx.inputs = selected_outpoints + .iter() + .map(|op| Input { outpoint: *op }) .collect(); - tx.outputs = vec![ - Output { - amount: payment.amount, - address_id: bob_payment_addr, - }, - Output { - amount: Amount::from_sat(drain.value), + tx.outputs = vec![Output { + amount: payment.amount, + address_id: bob_payment_addr, + }]; + for &change_amount in &change_amounts { + tx.outputs.push(Output { + amount: change_amount, address_id: alice_change_addr, - }, - ]; + }); + } }) .id; sim.assert_invariants(); diff --git a/src/wallet.rs b/src/wallet.rs index b45ecc5..9a7ac19 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -2,19 +2,16 @@ use crate::{ actions::{Action, CompositeScorer, CompositeStrategy, WalletView}, blocks::BroadcastSetId, bulletin_board::BulletinBoardId, + coin_selection::CoinCandidate, cospend::UtxoWithMetadata, message::{MessageId, MessageType}, script_type::ScriptType, tx_contruction::{MultiPartyPayjoinSession, SentOutputs, SentReadyToSign, TxConstructionState}, - CoinSelectionStrategy, Simulation, TimeStep, -}; -use bdk_coin_select::{ - metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, Target, - TargetFee, TargetOutputs, TR_DUST_RELAY_MIN_VALUE, + Simulation, TimeStep, }; use bitcoin::{transaction::InputWeightPrediction, Amount}; use im::{HashMap, OrdSet, Vector}; -use log::{info, warn}; +use log::info; use crate::transaction::*; @@ -74,77 +71,32 @@ impl<'a> WalletHandle<'a> { outputs_amounts } - // TODO give utxo list as argument so that different variants can be used - // TODO return change information - pub(crate) fn select_coins( - &self, - target: Target, - long_term_feerate: bitcoin::FeeRate, - select_all: bool, - required_inputs: Option<&[Outpoint]>, - ) -> (impl Iterator>, Drain) { - // TODO change - // TODO group by address - let utxos: Vec> = match required_inputs { - Some(required) => self - .unspent_coins() - .filter(|o| required.contains(&o.outpoint())) - .collect(), - None => self.unspent_coins().collect(), - }; - - let candidates: Vec = utxos - .iter() - .map(|o| Candidate { - value: o.data().amount.to_sat(), - weight: o.address().data().script_type.input_weight_wu(), - input_count: 1, + /// Build coin selection candidates from this wallet's unspent coins. + pub(crate) fn coin_candidates(&self) -> Vec { + self.unspent_coins() + .map(|o| CoinCandidate { + outpoint: o.outpoint(), + amount_sats: o.data().amount.to_sat(), + weight_wu: o.address().data().script_type.input_weight_wu(), is_segwit: o.address().data().script_type.is_segwit(), }) - .collect(); - - let mut coin_selector = CoinSelector::new(&candidates); - if select_all { - coin_selector.select_all(); - } - let drain_weights = DrainWeights::default(); - - let dust_limit = TR_DUST_RELAY_MIN_VALUE; - - let long_term_feerate = bdk_coin_select::FeeRate::from_sat_per_wu( - long_term_feerate.to_sat_per_kwu() as f32 * 1e-3, - ); - - let change_policy = ChangePolicy::min_value_and_waste( - drain_weights, - dust_limit, - target.fee.rate, - long_term_feerate, - ); - - let metric = LowestFee { - target, - long_term_feerate, - change_policy, - }; - - if let Err(err) = coin_selector.run_bnb(metric, 100_000) { - // TODO: should be a error log - warn!("BNB failed to find a solution: {}", err); - - coin_selector.select_until_target_met(target).expect( - "coin selection should always succeed since payments consider budger lower bound", - ); - }; - - let selection = coin_selector - .apply_selection(&utxos) - .cloned() - .collect::>(); - - let change = coin_selector.drain(target, change_policy); + .collect() + } - (selection.into_iter(), change) + /// Build coin selection candidates for a specific set of outpoints. + pub(crate) fn coin_candidates_for(&self, outpoints: &[Outpoint]) -> Vec { + outpoints + .iter() + .map(|op| { + let o = op.with(self.sim); + CoinCandidate { + outpoint: *op, + amount_sats: o.data().amount.to_sat(), + weight_wu: o.address().data().script_type.input_weight_wu(), + is_segwit: o.address().data().script_type.is_segwit(), + } + }) + .collect() } fn potentially_spendable_txos(&self) -> impl Iterator> + '_ { @@ -193,73 +145,6 @@ impl<'a> WalletHandleMut<'a> { id } - /// stateless utility function to construct a transaction for a given payment obligation - fn construct_transaction_template( - &mut self, - payment_obligation_ids: &[PaymentObligationId], - change_addr: &AddressId, - select_all: bool, - required_inputs: Option<&[Outpoint]>, - ) -> TxData { - let mut amount_and_destination = vec![]; - for payment_obligation_id in payment_obligation_ids.iter() { - let payment_obligation = payment_obligation_id.with(self.sim).data().clone(); - let to_wallet = payment_obligation.to; - let to_address = to_wallet.with_mut(self.sim).new_address(); - amount_and_destination.push((payment_obligation.amount, to_address)); - } - - let amount = amount_and_destination - .iter() - .map(|(amount, _)| amount.to_sat()) - .sum(); - let output_weight_sum: u32 = amount_and_destination - .iter() - .map(|(_, address_id)| { - address_id - .with(self.sim) - .data() - .script_type - .output_weight_wu() - }) - .sum(); - let target = Target { - fee: TargetFee { - rate: bdk_coin_select::FeeRate::from_sat_per_vb(1.0), - replace: None, - }, - outputs: TargetOutputs { - value_sum: amount, - weight_sum: output_weight_sum, - n_outputs: amount_and_destination.len(), - }, - }; - let long_term_feerate = bitcoin::FeeRate::from_sat_per_vb(10).expect("valid fee rate"); - - let (selected_coins, drain) = - self.handle() - .select_coins(target, long_term_feerate, select_all, required_inputs); - let mut tx = TxData::default(); - let mut outputs = vec![]; - for (amount, address_id) in amount_and_destination.iter() { - outputs.push(Output { - amount: *amount, - address_id: *address_id, - }); - } - outputs.push(Output { - amount: Amount::from_sat(drain.value), - address_id: *change_addr, - }); - tx.inputs = selected_coins - .map(|o| Input { - outpoint: o.outpoint, - }) - .collect(); - tx.outputs = outputs; - tx - } - fn participate_in_multi_party_payjoin(&mut self, bulletin_board_id: &BulletinBoardId) { let session = self .info() @@ -457,12 +342,8 @@ impl<'a> WalletHandleMut<'a> { pub(crate) fn do_action(&'a mut self, action: &Action) { match action { Action::Wait => {} - // TODO: the next 3 actions can be folded into one spend action, param'd off # of po's and coin selection strategy. All of them are unilateral - Action::UnilateralPayments(po_ids, coin_selection_strategy) => { - self.handle_payment_obligations( - po_ids, - matches!(coin_selection_strategy, CoinSelectionStrategy::SpendAll), - ); + Action::UnilateralPayments(po_ids, selected_inputs, change_amounts) => { + self.handle_payment_obligations(po_ids, selected_inputs, change_amounts); } Action::AcceptCospendProposal((message_id, bulletin_board_id)) => { // Aggregator already pre-filled all inputs on the bulletin board. @@ -540,27 +421,25 @@ impl<'a> WalletHandleMut<'a> { .cospend_interests .retain(|i| !interests.contains(i)); } - Action::ContributeOutputsToSession(bulletin_board_id, po_ids) => { - let session_inputs = self - .info() - .active_multi_party_payjoins - .get(bulletin_board_id) - .unwrap() - .inputs - .clone(); - let input_outpoints: Vec = - session_inputs.iter().map(|i| i.outpoint).collect(); - let required = if input_outpoints.is_empty() { - None - } else { - Some(input_outpoints.as_slice()) - }; - let change_addr = self.new_address(); - let full_template = - self.construct_transaction_template(po_ids, &change_addr, false, required); - // Inputs are already pre-filled by the aggregator; broadcast our outputs directly. + Action::ContributeOutputsToSession(bulletin_board_id, po_ids, change_amounts) => { use crate::bulletin_board::BroadcastMessageType; - for output in full_template.outputs.iter() { + let mut outputs = vec![]; + for po_id in po_ids { + let po = po_id.with(self.sim).data().clone(); + let to_addr = po.to.with_mut(self.sim).new_address(); + outputs.push(Output { + amount: po.amount, + address_id: to_addr, + }); + } + for &change_amount in change_amounts { + let change_addr = self.new_address(); + outputs.push(Output { + amount: change_amount, + address_id: change_addr, + }); + } + for output in &outputs { self.sim.add_message_to_bulletin_board( *bulletin_board_id, BroadcastMessageType::ContributeOutputs(*output), @@ -607,16 +486,34 @@ impl<'a> WalletHandleMut<'a> { fn handle_payment_obligations( &'a mut self, payment_obligation_ids: &[PaymentObligationId], - select_all_utxos: bool, + selected_inputs: &[Outpoint], + change_amounts: &[Amount], ) { - let change_addr = self.new_address(); - let tx_template = self.construct_transaction_template( - payment_obligation_ids, - &change_addr, - select_all_utxos, - None, - ); - let tx_id = self.spend_tx(tx_template); + // Build recipient outputs. + let mut outputs = vec![]; + for po_id in payment_obligation_ids.iter() { + let po = po_id.with(self.sim).data().clone(); + let to_addr = po.to.with_mut(self.sim).new_address(); + outputs.push(Output { + amount: po.amount, + address_id: to_addr, + }); + } + // Add pre-computed change outputs. + for &change_amount in change_amounts.iter() { + let change_addr = self.new_address(); + outputs.push(Output { + amount: change_amount, + address_id: change_addr, + }); + } + let tx_id = self.spend_tx(TxData { + inputs: selected_inputs + .iter() + .map(|op| Input { outpoint: *op }) + .collect(), + outputs, + }); self.info_mut() .txid_to_payment_obligation_ids .insert(tx_id, payment_obligation_ids.to_vec());