Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.

Commit 6b91a8c

Browse files
committed
Change outputs should be part of unilateral actions
This also removes tx creation code from the wallet entirely for the multi party sessions. Coin, output and change selection all happens when enumurating actions.
1 parent 8e986ab commit 6b91a8c

4 files changed

Lines changed: 208 additions & 253 deletions

File tree

src/actions.rs

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{iter::Sum, ops::Add};
22

33
use bdk_coin_select::{Target, TargetFee, TargetOutputs};
4+
use bitcoin::Amount;
45
use log::debug;
56

67
use crate::{
@@ -44,12 +45,12 @@ fn piecewise_linear(x: f64, points: &[(f64, f64)]) -> f64 {
4445
/// An Action a wallet can perform
4546
#[derive(Debug)]
4647
pub(crate) enum Action {
47-
/// Spend a payment obligation unilaterally with pre-selected inputs
48-
UnilateralPayments(Vec<PaymentObligationId>, Vec<Outpoint>),
48+
/// Spend a payment obligation unilaterally with pre-selected inputs and pre-computed change
49+
UnilateralPayments(Vec<PaymentObligationId>, Vec<Outpoint>, Vec<Amount>),
4950
/// Accept a cospend invitation
5051
AcceptCospendProposal((MessageId, BulletinBoardId)),
51-
/// Contribute outputs to a cospend session that is waiting for them
52-
ContributeOutputsToSession(BulletinBoardId, Vec<PaymentObligationId>),
52+
/// Contribute outputs to a cospend session that is waiting for them, with pre-computed change
53+
ContributeOutputsToSession(BulletinBoardId, Vec<PaymentObligationId>, Vec<Amount>),
5354
/// Continue to participate in a multi-party payjoin
5455
ContinueParticipateInCospend(BulletinBoardId),
5556
/// Taker records non-committal interest in cospending with each orderbook UTXO
@@ -163,8 +164,8 @@ fn simulate_one_action(wallet_handle: &WalletHandleMut, action: &Action) -> Pred
163164

164165
// POs handled: derived from action since confirmation is deferred to block
165166
let payment_obligations_handled: Vec<PaymentObligationId> = match action {
166-
Action::UnilateralPayments(po_ids, _) => po_ids.clone(),
167-
Action::ContributeOutputsToSession(_, po_ids) => po_ids.clone(),
167+
Action::UnilateralPayments(po_ids, _, _) => po_ids.clone(),
168+
Action::ContributeOutputsToSession(_, po_ids, _) => po_ids.clone(),
168169
_ => vec![],
169170
};
170171

@@ -296,6 +297,36 @@ fn target_for_obligations(pos: &[PaymentObligationData], wallet: &WalletHandleMu
296297
}
297298
}
298299

300+
/// Compute pre-selected change outputs for a `ContributeOutputsToSession` action.
301+
/// If the session has pre-selected inputs (from the aggregator), uses those exactly.
302+
/// Otherwise falls back to full BNB / spend-all selection over all wallet UTXOs.
303+
fn change_for_session_contribution(
304+
bb_id: &BulletinBoardId,
305+
pos: &[PaymentObligationData],
306+
wallet: &WalletHandleMut,
307+
) -> Vec<Amount> {
308+
let session = wallet
309+
.info()
310+
.active_multi_party_payjoins
311+
.get(bb_id)
312+
.unwrap();
313+
let session_input_outpoints: Vec<Outpoint> =
314+
session.inputs.iter().map(|i| i.outpoint).collect();
315+
let target = target_for_obligations(pos, wallet);
316+
if session_input_outpoints.is_empty() {
317+
let candidates = wallet.handle().coin_candidates();
318+
if let Some((_, change)) = select_bnb(&candidates, target) {
319+
return change;
320+
}
321+
select_all(&candidates, target).1
322+
} else {
323+
let candidates = wallet
324+
.handle()
325+
.coin_candidates_for(&session_input_outpoints);
326+
select_all(&candidates, target).1
327+
}
328+
}
329+
299330
#[derive(Debug, Clone)]
300331
pub(crate) struct UnilateralSpender;
301332

@@ -315,12 +346,12 @@ impl Strategy for UnilateralSpender {
315346
let mut actions = vec![];
316347
for po in state.payment_obligations.iter() {
317348
let target = target_for_obligations(std::slice::from_ref(po), wallet);
318-
if let Some(inputs) = select_bnb(&candidates, target) {
319-
actions.push(Action::UnilateralPayments(vec![po.id], inputs));
349+
if let Some((inputs, change)) = select_bnb(&candidates, target) {
350+
actions.push(Action::UnilateralPayments(vec![po.id], inputs, change));
320351
}
321-
let all_inputs = select_all(&candidates);
352+
let (all_inputs, change) = select_all(&candidates, target);
322353
if !all_inputs.is_empty() {
323-
actions.push(Action::UnilateralPayments(vec![po.id], all_inputs));
354+
actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change));
324355
}
325356
}
326357
if actions.is_empty() {
@@ -346,11 +377,12 @@ impl Strategy for Consolidator {
346377
wallet: &WalletHandleMut,
347378
) -> Vec<Action> {
348379
let candidates = wallet.handle().coin_candidates();
349-
let all_inputs = select_all(&candidates);
350380
let mut actions = Vec::new();
351381
for po in state.payment_obligations.iter() {
382+
let target = target_for_obligations(std::slice::from_ref(po), wallet);
383+
let (all_inputs, change) = select_all(&candidates, target);
352384
if !all_inputs.is_empty() {
353-
actions.push(Action::UnilateralPayments(vec![po.id], all_inputs.clone()));
385+
actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change));
354386
}
355387
}
356388
actions.push(Action::Wait);
@@ -380,12 +412,12 @@ impl Strategy for BatchSpender {
380412
let target = target_for_obligations(&state.payment_obligations, wallet);
381413
let candidates = wallet.handle().coin_candidates();
382414
let mut actions = vec![];
383-
if let Some(inputs) = select_bnb(&candidates, target) {
384-
actions.push(Action::UnilateralPayments(po_ids.clone(), inputs));
415+
if let Some((inputs, change)) = select_bnb(&candidates, target) {
416+
actions.push(Action::UnilateralPayments(po_ids.clone(), inputs, change));
385417
}
386-
let all_inputs = select_all(&candidates);
418+
let (all_inputs, change) = select_all(&candidates, target);
387419
if !all_inputs.is_empty() {
388-
actions.push(Action::UnilateralPayments(po_ids, all_inputs));
420+
actions.push(Action::UnilateralPayments(po_ids, all_inputs, change));
389421
}
390422
if actions.is_empty() {
391423
actions.push(Action::Wait);
@@ -424,11 +456,17 @@ impl Strategy for MakerStrategy {
424456
}
425457
}
426458

427-
// Contribute outputs to sessions that are waiting for them (SentInputs state)
459+
// Contribute outputs to sessions that are waiting for them (AcceptedProposal state)
428460
for (bb_id, session) in wallet.info().active_multi_party_payjoins.iter() {
429461
if session.state == TxConstructionState::AcceptedProposal {
430462
for po in state.payment_obligations.iter() {
431-
actions.push(Action::ContributeOutputsToSession(*bb_id, vec![po.id]));
463+
let change =
464+
change_for_session_contribution(bb_id, std::slice::from_ref(po), wallet);
465+
actions.push(Action::ContributeOutputsToSession(
466+
*bb_id,
467+
vec![po.id],
468+
change,
469+
));
432470
}
433471
}
434472
}
@@ -446,11 +484,11 @@ impl Strategy for MakerStrategy {
446484
} else {
447485
vec![]
448486
};
449-
// Selected inputs are already embedded in each action no simulation needed.
487+
// Selected inputs are already embedded in each action i.e no simulation needed.
450488
let per_action_inputs: Vec<std::collections::HashSet<Outpoint>> = unilateral_actions
451489
.iter()
452490
.filter_map(|a| match a {
453-
Action::UnilateralPayments(_, inputs) => Some(inputs.iter().copied().collect()),
491+
Action::UnilateralPayments(_, inputs, _) => Some(inputs.iter().copied().collect()),
454492
_ => None,
455493
})
456494
.collect();
@@ -490,11 +528,17 @@ impl Strategy for TakerStrategy {
490528
) -> Vec<Action> {
491529
let mut actions = vec![];
492530

493-
// Contribute outputs to sessions awaiting them (SentInputs state)
531+
// Contribute outputs to sessions awaiting them (AcceptedProposal state)
494532
for (bb_id, session) in wallet.info().active_multi_party_payjoins.iter() {
495533
if session.state == TxConstructionState::AcceptedProposal {
496534
for po in state.payment_obligations.iter() {
497-
actions.push(Action::ContributeOutputsToSession(*bb_id, vec![po.id]));
535+
let change =
536+
change_for_session_contribution(bb_id, std::slice::from_ref(po), wallet);
537+
actions.push(Action::ContributeOutputsToSession(
538+
*bb_id,
539+
vec![po.id],
540+
change,
541+
));
498542
}
499543
}
500544
}
@@ -724,7 +768,7 @@ mod tests {
724768
assert!(actions.iter().any(|a| matches!(a, Action::Wait)));
725769
assert!(!actions
726770
.iter()
727-
.any(|a| matches!(a, Action::UnilateralPayments(_, _))));
771+
.any(|a| matches!(a, Action::UnilateralPayments(_, _, _))));
728772
}
729773

730774
#[test]
@@ -752,13 +796,16 @@ mod tests {
752796

753797
let actions = strategy.enumerate_candidate_actions(&view, &wallet);
754798

755-
// No UTXOs coin selection produces nothing, falls back to Wait.
799+
// No UTXOs coin selection produces nothing, falls back to Wait.
756800
assert_eq!(actions.len(), 1);
757801
assert!(matches!(actions[0], Action::Wait));
758802
}
759803

760804
#[test]
761805
fn test_composite_strategy_combines_actions() {
806+
// TODO: this test is kinda useless, we need to add UTXOs to the sim and test the composite strategy.
807+
// Otherwise we are just testing that both strategies fall back to Wait when there are no UTXOs.
808+
// This is bc coin selection uses `wallet.handle().coin_candidates();` not `state.utxos`.
762809
let mut sim = test_sim();
763810
let wallet = WalletId(0).with_mut(&mut sim);
764811
let composite = CompositeStrategy {
@@ -785,24 +832,9 @@ mod tests {
785832

786833
let actions = composite.enumerate_candidate_actions(&view, &wallet);
787834

788-
// Should include actions from both strategies
789-
// UnilateralSpender: 2 actions (one per obligation, single-PO each)
790-
// BatchSpender: 1 action (all obligations in one tx)
791-
assert_eq!(actions.len(), 3);
792-
793-
let single_po_count = actions
794-
.iter()
795-
.filter(|a| matches!(a, Action::UnilateralPayments(ids, _) if ids.len() == 1))
796-
.count();
797-
assert_eq!(single_po_count, 2);
798-
799-
let batch_count = actions
800-
.iter()
801-
.filter(|a| matches!(a, Action::UnilateralPayments(ids, _) if ids.len() == 2))
802-
.count();
803-
assert_eq!(batch_count, 1);
804-
// No UTXOs both strategies fall back to Wait, composite collects both.
805-
assert_eq!(actions.len(), 3);
835+
// Wallet has no UTXOs in the sim, both strategies fall back to Wait.
836+
// Composite collects one Wait from each strategy.
837+
assert_eq!(actions.len(), 2);
806838
assert!(actions.iter().all(|a| matches!(a, Action::Wait)));
807839
}
808840

@@ -820,7 +852,7 @@ mod tests {
820852
from: WalletId(0),
821853
to: WalletId(1),
822854
};
823-
// No orderbook UTXOs taker has nothing to propose to
855+
// No orderbook UTXOs, taker has nothing to propose to
824856
let view = create_test_wallet_view(vec![po]);
825857

826858
let actions = strategy.enumerate_candidate_actions(&view, &wallet);
@@ -982,7 +1014,7 @@ mod tests {
9821014

9831015
assert!(actions
9841016
.iter()
985-
.any(|a| matches!(a, Action::ContributeOutputsToSession(id, ids) if *id == bb_id && ids.len() == 1)));
1017+
.any(|a| matches!(a, Action::ContributeOutputsToSession(id, ids, _) if *id == bb_id && ids.len() == 1)));
9861018
// Should NOT emit ContinueParticipateInCospend for this session
9871019
assert!(!actions
9881020
.iter()

src/coin_selection.rs

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use bdk_coin_select::{
22
metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, Target,
33
TR_DUST_RELAY_MIN_VALUE,
44
};
5+
use bitcoin::Amount;
56
use log::warn;
67

78
use crate::transaction::Outpoint;
@@ -18,30 +19,50 @@ pub(crate) fn long_term_feerate() -> bdk_coin_select::FeeRate {
1819
bdk_coin_select::FeeRate::from_sat_per_wu(2.5)
1920
}
2021

21-
/// Run BNB coin selection over candidates for the given target.
22-
/// Falls back to greedy selection if BNB finds no solution.
23-
/// Returns None if no selection can meet the target.
24-
pub(crate) fn select_bnb(candidates: &[CoinCandidate], target: Target) -> Option<Vec<Outpoint>> {
25-
let bdk_candidates: Vec<Candidate> = candidates
22+
fn change_policy_for(target: Target) -> ChangePolicy {
23+
ChangePolicy::min_value_and_waste(
24+
DrainWeights::default(),
25+
TR_DUST_RELAY_MIN_VALUE,
26+
target.fee.rate,
27+
long_term_feerate(),
28+
)
29+
}
30+
31+
fn bdk_candidates(candidates: &[CoinCandidate]) -> Vec<Candidate> {
32+
candidates
2633
.iter()
2734
.map(|c| Candidate {
2835
value: c.amount_sats,
2936
weight: c.weight_wu,
3037
input_count: 1,
3138
is_segwit: c.is_segwit,
3239
})
33-
.collect();
40+
.collect()
41+
}
3442

35-
let mut coin_selector = CoinSelector::new(&bdk_candidates);
43+
fn drain_to_change(drain: bdk_coin_select::Drain) -> Vec<Amount> {
44+
if drain.value > 0 {
45+
vec![Amount::from_sat(drain.value)]
46+
} else {
47+
vec![]
48+
}
49+
}
50+
51+
/// Run BNB coin selection over candidates for the given target.
52+
/// Falls back to greedy selection if BNB finds no solution.
53+
/// Returns None if no selection can meet the target.
54+
/// Returns (selected_inputs, change_outputs).
55+
pub(crate) fn select_bnb(
56+
candidates: &[CoinCandidate],
57+
target: Target,
58+
) -> Option<(Vec<Outpoint>, Vec<Amount>)> {
59+
let bdk = bdk_candidates(candidates);
60+
let mut coin_selector = CoinSelector::new(&bdk);
3661

37-
let drain_weights = DrainWeights::default();
38-
let dust_limit = TR_DUST_RELAY_MIN_VALUE;
39-
let ltfr = long_term_feerate();
40-
let change_policy =
41-
ChangePolicy::min_value_and_waste(drain_weights, dust_limit, target.fee.rate, ltfr);
62+
let change_policy = change_policy_for(target);
4263
let metric = LowestFee {
4364
target,
44-
long_term_feerate: ltfr,
65+
long_term_feerate: long_term_feerate(),
4566
change_policy,
4667
};
4768

@@ -52,15 +73,26 @@ pub(crate) fn select_bnb(candidates: &[CoinCandidate], target: Target) -> Option
5273
}
5374
}
5475

55-
Some(
56-
coin_selector
57-
.apply_selection(candidates)
58-
.map(|c| c.outpoint)
59-
.collect(),
60-
)
76+
let inputs = coin_selector
77+
.apply_selection(candidates)
78+
.map(|c| c.outpoint)
79+
.collect();
80+
let change = drain_to_change(coin_selector.drain(target, change_policy));
81+
Some((inputs, change))
6182
}
6283

63-
/// Return all candidate outpoints (consolidation / spend-all strategy).
64-
pub(crate) fn select_all(candidates: &[CoinCandidate]) -> Vec<Outpoint> {
65-
candidates.iter().map(|c| c.outpoint).collect()
84+
/// Select all candidates (consolidation / spend-all strategy).
85+
/// Returns (selected_inputs, change_outputs).
86+
pub(crate) fn select_all(
87+
candidates: &[CoinCandidate],
88+
target: Target,
89+
) -> (Vec<Outpoint>, Vec<Amount>) {
90+
let bdk = bdk_candidates(candidates);
91+
let mut coin_selector = CoinSelector::new(&bdk);
92+
coin_selector.select_all();
93+
94+
let change_policy = change_policy_for(target);
95+
let inputs = candidates.iter().map(|c| c.outpoint).collect();
96+
let change = drain_to_change(coin_selector.drain(target, change_policy));
97+
(inputs, change)
6698
}

0 commit comments

Comments
 (0)