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 rust/joinstr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ async = ["nostr-sdk", "tokio"]
[dependencies]
home = { workspace = true }
backoff = { workspace = true }
base64ct = { workspace = true, features = ["alloc"] }
bitcoin = { workspace = true }
bip39 = { workspace = true, features = ["rand"] }
hex-conservative = { workspace = true }
Expand Down
12 changes: 12 additions & 0 deletions rust/joinstr/src/coinjoin/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub enum Error {
Electrum(electrum::Error),
FailVerifyAmount,
AmountMissing,
InputValueOutOfRange(u64, u64, u64),
FeeBoundsViolation(u64, u64, u64),
Unknown(String),
}

Expand Down Expand Up @@ -65,6 +67,16 @@ impl Display for Error {
f,
"The input amount is missing and no electrum client provided"
),
Error::InputValueOutOfRange(value, min, max) => write!(
f,
"Input value {} sats is outside allowed range [{}, {}]",
value, min, max
),
Error::FeeBoundsViolation(fee, min, max) => write!(
f,
"Fee {} sats is outside allowed bounds [{}, {}]",
fee, min, max
),
Error::Unknown(e) => write!(f, "Unknown error: {}", e),
}
}
Expand Down
29 changes: 28 additions & 1 deletion rust/joinstr/src/coinjoin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,12 @@ where
}

// process outputs
// Python: output_amount = denomination_sats - int(fee_rate * 100)
let output_amount = self.denomination - Amount::from_sat(self.fee as u64 * 100);
let mut output: Vec<_> = addresses
.iter()
.map(|a| TxOut {
value: self.denomination,
value: output_amount,
script_pubkey: a.script_pubkey(),
})
.collect();
Expand Down Expand Up @@ -209,6 +211,19 @@ where
}
}

// Python: denomination + 500 <= input_value <= denomination + 5000
if let Some(input_value) = input.amount {
let min = self.denomination + Amount::from_sat(500);
let max = self.denomination + Amount::from_sat(5000);
if input_value < min || input_value > max {
return Err(Error::InputValueOutOfRange(
input_value.to_sat(),
min.to_sat(),
max.to_sat(),
));
}
}

// If an electrum client is provided, we verify our peer isn't lying
// about the input value
let mut retry = 0;
Expand Down Expand Up @@ -308,6 +323,18 @@ where

let fee = inp_amount - out_amount;

// Python: N * 100 <= fee <= N * 10000 (N = number of participants)
let n = self.inputs.len() as u64;
let min_fee = Amount::from_sat(n * 100);
let max_fee = Amount::from_sat(n * 10000);
if fee < min_fee || fee > max_fee {
return Err(Error::FeeBoundsViolation(
fee.to_sat(),
min_fee.to_sat(),
max_fee.to_sat(),
));
}

// sort lexically
self.inputs.sort_by(|a, b| {
a.txin
Expand Down
72 changes: 62 additions & 10 deletions rust/joinstr/src/joinstr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ impl Joinstr<'_> {
let mut connected = false;
while now() < timeout {
let mut inner = self.inner.lock().expect("poisoned");
if let Some(PoolMessage::Credentials(Credentials { id, key })) =
if let Some(PoolMessage::Credentials(Credentials { id, private_key, .. })) =
inner.client.try_receive_pool_msg()?
{
log::debug!(
Expand All @@ -467,7 +467,7 @@ impl Joinstr<'_> {
);
if id == inner.pool_as_ref()?.id {
// we create a new nostr client using pool keys and replace the actual one
let keys = Keys::new(key);
let keys = Keys::new(private_key);
let fg = &inner.client.name;
let name = format!("prev_{fg}");
let mut new_client = NostrClient::new(&name)
Expand Down Expand Up @@ -552,9 +552,33 @@ impl Joinstr<'_> {
(PoolMessage::Join(Some(npub)), send_response) => {
if !peers.contains(&npub) {
if send_response {
let pool_ref = inner.pool_as_ref()?;
let payload = inner.payload_as_ref().ok();
let response = PoolMessage::Credentials(Credentials {
id: inner.pool_as_ref()?.id.clone(),
key: inner.client.get_keys()?.secret_key().clone(),
id: pool_ref.id.clone(),
private_key: inner.client.get_keys()?.secret_key().clone(),
public_key: Some(pool_ref.public_key.to_string()),
denomination: payload.map(|p| p.denomination),
peers: payload.map(|p| p.peers),
timeout: payload.and_then(|p| match p.timeout {
Timeline::Simple(t) => Some(t),
_ => None,
}),
relay: payload.map(|p| p.relay.clone()),
fee_rate: payload.and_then(|p| match &p.fee {
Fee::Fixed(f) => Some(*f),
_ => None,
}),
transport: payload.map(|p| {
if p.transport.tor.as_ref().map_or(false, |t| t.enable) {
"tor".into()
} else if p.transport.vpn.as_ref().map_or(false, |v| v.enable) {
"vpn".into()
} else {
String::new()
}
}),
vpn_gateway: payload.and_then(|p| p.vpn_gateway.clone()),
});
inner.client.send_pool_message(&npub, response)?;
}
Expand Down Expand Up @@ -1249,9 +1273,10 @@ impl<'a> JoinstrInner<'a> {
denomination: self.denomination.ok_or(Error::DenominationMissing)?,
peers: self.peers_count.ok_or(Error::PeerMissing)?,
timeout: self.timeout.ok_or(Error::TimeoutMissing)?,
relays: self.relay.clone().map(|r| vec![r]).unwrap_or_default(),
relay: self.relay.clone().unwrap_or_default(),
fee: self.fee.clone().ok_or(Error::FeeMissing)?,
transport,
vpn_gateway: None,
};
let mut engine = sha256::Hash::engine();
engine.input(&public_key.clone().to_bytes());
Expand All @@ -1265,7 +1290,7 @@ impl<'a> JoinstrInner<'a> {
let id = sha256::Hash::from_engine(engine).to_string();

let pool = Pool {
versions: default_version(),
version: default_version(),
id,
pool_type: PoolType::Create,
public_key,
Expand Down Expand Up @@ -1406,6 +1431,8 @@ impl<'a> JoinstrInner<'a> {
S: JoinstrSigner,
N: Fn(),
{
use miniscript::bitcoin::{Psbt, Transaction, TxIn};

let name = self.client.name.clone();
log::debug!("Joinstr::register_input({name})");
let unsigned = match self.coinjoin_as_ref()?.unsigned_tx() {
Expand All @@ -1415,18 +1442,43 @@ impl<'a> JoinstrInner<'a> {
if let Some(input) = self.input.take() {
log::debug!("Joinstr::register_input({name}) signing input ...");
let signed_input = signer
.sign_input(&unsigned, input)
.sign_input(&unsigned, input.clone())
.map_err(Error::SigningFail)?;
log::debug!("Joinstr::register_input({name}) input signed!");
let msg = PoolMessage::Input(signed_input.clone());

// Build a full PSBT (1 input + all outputs) for Python compatibility
let mut tx = unsigned.clone();
tx.input.push(signed_input.txin.clone());

let mut psbt = Psbt::from_unsigned_tx(Transaction {
version: tx.version,
lock_time: tx.lock_time,
input: vec![TxIn {
previous_output: signed_input.txin.previous_output,
sequence: signed_input.txin.sequence,
..Default::default()
}],
output: tx.output,
})
.map_err(|_| Error::Coinjoin(crate::coinjoin::Error::TxToPsbt))?;

// Add witness data and UTXO info to the PSBT input
use miniscript::bitcoin::psbt;
psbt.inputs[0] = psbt::Input {
witness_utxo: Some(input.txout.clone()),
sighash_type: Some(psbt::PsbtSighashType::from_u32(0x81)),
final_script_witness: Some(signed_input.txin.witness.clone()),
..Default::default()
};

let msg = PoolMessage::Psbt(psbt);
self.pool_exists()?;
let npub = self.pool_as_ref()?.public_key;
log::debug!("Joinstr::register_input({name}) sending signed input to pool..");
log::debug!("Joinstr::register_input({name}) sending signed PSBT to pool..");
self.client.send_pool_message(&npub, msg)?;
self.inputs.push(signed_input);
notif();
log::debug!("Joinstr::register_input({name}) input sent & locally registered!");
// TODO: handle re-send if fails
Ok(())
} else {
Err(Error::InputMissing)
Expand Down
Loading
Loading