Skip to content
Closed
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
1 change: 1 addition & 0 deletions src/bin/taker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ fn main() -> Result<(), TakerError> {
tx_count: 1,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};
taproot_taker.do_coinswap(taproot_swap_params)?;
}
Expand Down
142 changes: 122 additions & 20 deletions src/taker/api2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ pub struct SwapParams {
pub required_confirms: u32,
/// User selected UTXOs (optional, for manual UTXO selection)
pub manually_selected_outpoints: Option<Vec<OutPoint>>,
/// Optional list of maker addresses explicitly selected by the user for the swap.
/// If set, the taker will only attempt to use these makers instead of selecting
/// from the offerbook automatically.
pub manually_selected_makers: Option<Vec<MakerAddress>>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -738,6 +742,17 @@ impl Taker {

/// Choose makers for the swap by negotiating with them
fn choose_makers_for_swap(&mut self, swap_params: SwapParams) -> Result<(), TakerError> {
self.ongoing_swap_state.swap_params = swap_params.clone();

if let Some(manual_makers) = &swap_params.manually_selected_makers {
if manual_makers.len() != swap_params.maker_count {
return Err(TakerError::General(
"Number of manually selected makers must equal maker_count".to_string(),
));
}
return self.choose_manual_makers(manual_makers);
}

// Find suitable maker asks for an offer from the makers
let mut suitable_makers = self.find_suitable_makers(&swap_params);
log::info!(
Expand All @@ -758,7 +773,6 @@ impl Taker {

// Set swap params early so they're available for SwapDetails
self.ongoing_swap_state.swap_params = swap_params.clone();

// Send SwapDetails message to all the makers
// Receive the Ack or Nack message from the maker
for (maker_index, suitable_maker) in suitable_makers.iter_mut().enumerate() {
Expand Down Expand Up @@ -881,7 +895,90 @@ impl Taker {

Ok(())
}
fn choose_manual_makers(&mut self, manual_makers: &[MakerAddress]) -> Result<(), TakerError> {
for (chain_position, maker_addr) in manual_makers.iter().enumerate() {
// Find maker in offerbook
// active_makers returns only GOOD makers for the given protocol
let maker = self
.offerbook
.active_makers(&MakerProtocol::Taproot)
.into_iter()
.find(|oa| &oa.address == maker_addr)
.ok_or_else(|| {
TakerError::General(format!("Maker {} not found in offerbook", maker_addr))
})?;

// suitability check for makers
if !self.is_maker_suitable(&maker, &self.ongoing_swap_state.swap_params) {
return Err(TakerError::General(format!(
"Maker {} is not suitable for this swap",
maker.address
)));
}

// Perform the same GetOffer + SwapDetails handshake
let get_offer_msg = GetOffer {
id: self.ongoing_swap_state.id.clone(),
protocol_version_min: 1,
protocol_version_max: 1,
number_of_transactions: 1,
};

let response = self.send_to_maker_and_get_response(
&maker.address,
TakerToMakerMessage::GetOffer(get_offer_msg),
)?;

match response {
MakerToTakerMessage::RespOffer(fresh_offer) => {
let mut maker = maker.clone();
maker.offer.tweakable_point = fresh_offer.tweakable_point;

let maker_timelock = REFUND_LOCKTIME
+ REFUND_LOCKTIME_STEP
* (self.ongoing_swap_state.swap_params.maker_count - chain_position - 1)
as u16;

let swap_details = SwapDetails {
id: self.ongoing_swap_state.id.clone(),
amount: self.ongoing_swap_state.swap_params.send_amount,
no_of_tx: self.ongoing_swap_state.swap_params.tx_count as u8,
timelock: maker_timelock,
};

let ack = self.send_to_maker_and_get_response(
&maker.address,
TakerToMakerMessage::SwapDetails(swap_details),
)?;

match ack {
MakerToTakerMessage::AckResponse(AckResponse::Ack) => {
self.ongoing_swap_state.chosen_makers.push(maker);
}
_ => {
return Err(TakerError::General(format!(
"Maker {} rejected swap",
maker.address
)));
}
}
}
_ => {
return Err(TakerError::General(format!(
"Unexpected response from maker {}",
maker.address
)));
}
}
}

// Init storage
let n = self.ongoing_swap_state.chosen_makers.len();
self.ongoing_swap_state.maker_outgoing_privkeys = vec![None; n];
self.ongoing_swap_state.maker_contract_txs = vec![Vec::new(); n];

Ok(())
}
/// Fetch offers from available makers
/// This syncs the offerbook first and then returns a reference to it
pub fn fetch_offers(&mut self) -> Result<OfferBook, TakerError> {
Expand All @@ -907,6 +1004,29 @@ impl Taker {

Ok(response)
}
/// Checks whether a maker is suitable for the given swap parameters.
///
/// A maker is considered suitable if the taker's send amount:
/// - Covers the maker's minimum size plus estimated fees
/// - Does not exceed the maker's maximum allowed size
fn is_maker_suitable(&self, maker: &OfferAndAddress, swap_params: &SwapParams) -> bool {
let swap_amount = swap_params.send_amount;
let max_refund_locktime = REFUND_LOCKTIME * (swap_params.maker_count + 1) as u16;

let maker_fee = calculate_coinswap_fee(
swap_amount.to_sat(),
max_refund_locktime,
maker.offer.base_fee,
maker.offer.amount_relative_fee_pct,
maker.offer.time_relative_fee_pct,
);

let min_size_with_fee = Amount::from_sat(
maker.offer.min_size + maker_fee + 500, // estimated mining fee
);

swap_amount >= min_size_with_fee && swap_amount <= Amount::from_sat(maker.offer.max_size)
}

/// Find suitable makers for the given swap parameters
pub fn find_suitable_makers(&self, swap_params: &SwapParams) -> Vec<OfferAndAddress> {
Expand All @@ -923,25 +1043,7 @@ impl Taker {
.offerbook
.active_makers(&MakerProtocol::Taproot)
.into_iter()
.filter(|oa| {
let maker_fee = calculate_coinswap_fee(
swap_amount.to_sat(),
max_refund_locktime,
oa.offer.base_fee,
oa.offer.amount_relative_fee_pct,
oa.offer.time_relative_fee_pct,
);
let min_size_with_fee = bitcoin::Amount::from_sat(
oa.offer.min_size + maker_fee + 500, /* Estimated mining fee */
);
let is_suitable = swap_amount >= min_size_with_fee
&& swap_amount <= bitcoin::Amount::from_sat(oa.offer.max_size);

log::debug!("Evaluating maker {}: min_size={}, max_size={}, maker_fee={}, min_size_with_fee={}, swap_amount={}, suitable={}",
oa.address, oa.offer.min_size, oa.offer.max_size, maker_fee, min_size_with_fee.to_sat(), swap_amount.to_sat(), is_suitable);

is_suitable
})
.filter(|oa| self.is_maker_suitable(oa, swap_params))
.collect();

log::info!(
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_hashlock_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ fn test_taproot_hashlock_recovery_end_to_end() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Attempt the swap - it will fail when maker closes connection
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_maker_abort1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ fn test_taproot_maker_abort1() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Attempt the swap - it will fail when taker discovers there are not enough makers.
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_maker_abort2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ fn test_taproot_maker_abort2() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Attempt the swap - it will fail when maker closes connection
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_maker_abort3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ fn test_taproot_maker_abort3() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Attempt the swap
Expand Down
135 changes: 135 additions & 0 deletions tests/taproot_manual_maker_selection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#![cfg(feature = "integration-test")]

use bitcoin::Amount;
use coinswap::{
maker::{start_maker_server_taproot, TaprootMakerBehavior},
taker::{
api2::{SwapParams, TakerBehavior},
error::TakerError,
},
};
use std::convert::TryInto;

mod test_framework;
use test_framework::*;

use std::{sync::atomic::Ordering::Relaxed, thread, time::Duration};

#[test]
fn taproot_manual_maker_selection_smoke_test() {
std::env::set_var("COINSWAP_DISABLE_NOSTR", "1");

// Spin up FIVE makers
let maker_ports = [62000, 62001, 62002, 62003, 62004];
let makers_config_map = maker_ports
.iter()
.map(|p| (*p, None, TaprootMakerBehavior::Normal))
.collect::<Vec<_>>();

let taker_behavior = vec![TakerBehavior::Normal];

let (test_framework, mut takers, makers, block_generation_handle) =
TestFramework::init_taproot(makers_config_map, taker_behavior);

let taker = takers.get_mut(0).expect("taker exists");
let bitcoind = &test_framework.bitcoind;

// Fund wallets
fund_taproot_taker(taker, bitcoind, 3, Amount::from_btc(0.05).unwrap());
fund_taproot_makers(&makers, bitcoind, 3, Amount::from_btc(0.05).unwrap());

// Start maker servers
let maker_threads = makers
.iter()
.map(|maker| {
let maker = maker.clone();
thread::spawn(move || start_maker_server_taproot(maker).unwrap())
})
.collect::<Vec<_>>();

// Wait for maker setup
for maker in &makers {
while !maker.is_setup_complete.load(Relaxed) {
thread::sleep(Duration::from_secs(1));
}
}

// Allow offer discovery
for _ in 0..10 {
if !taker.is_offerbook_syncing() {
break;
}
thread::sleep(Duration::from_millis(500));
}

// Manually select ONLY TWO makers
let selected_makers = vec![
("127.0.0.1:62000").to_string().try_into().unwrap(),
("127.0.0.1:62001").to_string().try_into().unwrap(),
];

let swap_params = SwapParams {
send_amount: Amount::from_sat(500_000),
maker_count: 2,
tx_count: 1,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: Some(selected_makers.clone()),
};

// ✅ BEHAVIORAL ASSERTION
let result = taker.do_coinswap(swap_params);
let report = result
.expect("manual maker selection swap should succeed using only selected makers")
.expect("swap report should be present");

let used_makers = report.maker_addresses;
assert_eq!(used_makers.len(), 2, "Should have used exactly 2 makers");

// Check that used makers are exactly the ones we selected
let expected_makers: Vec<String> = selected_makers
.iter()
.map(|formatted| formatted.to_string())
.collect();

for maker in &used_makers {
assert!(
expected_makers.contains(maker),
"Used maker {} which was not manually selected",
maker
);
}
// --- NEGATIVE ASSERTION ---
// If the taker were silently pulling extra makers from the offerbook,
// this would succeed. It must FAIL.

let bad_params = SwapParams {
send_amount: Amount::from_sat(500_000),
maker_count: 3, // ❌ mismatch: only 2 manual makers provided
tx_count: 1,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: Some(vec![
("127.0.0.1:62000").to_string().try_into().unwrap(),
("127.0.0.1:62001").to_string().try_into().unwrap(),
]),
};

let err = taker.do_coinswap(bad_params).unwrap_err();

match err {
TakerError::General(msg) => {
assert!(
msg.contains("manually selected"),
"unexpected error message: {msg}"
);
}
other => panic!("expected General error, got {:?}", other),
}

// Cleanup
makers.iter().for_each(|m| m.shutdown.store(true, Relaxed));
maker_threads.into_iter().for_each(|t| t.join().unwrap());
test_framework.stop();
block_generation_handle.join().unwrap();
}
1 change: 1 addition & 0 deletions tests/taproot_multi_maker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ fn test_taproot_multi_maker() {
tx_count: 5,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Mine some blocks before the swap to ensure wallet is ready
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_multi_taker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fn test_taproot_multi_taker() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};
s.spawn(move || match taker.do_coinswap(swap_params) {
Ok(Some(_report)) => {
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ fn test_taproot_coinswap() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Mine some blocks before the swap to ensure wallet is ready
Expand Down
1 change: 1 addition & 0 deletions tests/taproot_taker_abort1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ fn test_taproot_taker_abort1() {
tx_count: 3,
required_confirms: 1,
manually_selected_outpoints: None,
manually_selected_makers: None,
};

// Attempt the swap - it will fail when taker closes connection
Expand Down
Loading