Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- GUI + CLI: When Tor is disabled, onion addresses are no longer dialed. Previously a maker's advertised onion address could be attempted without Tor, producing failed dials and pointless redial churn; such addresses are now filtered out before the Swarm attempts to dial them.
- ASB: The Hermes protocol is now enabled by default (`hermes_enabled` defaults to `true`), and the default `hermes_min_swap_amount` was lowered from `0.01` to `0.001` BTC (~50 USD at a reference price of 50,000 USD/BTC).

## [4.11.4] - 2026-06-30
Expand Down
1 change: 1 addition & 0 deletions swap-p2p/src/protocols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ pub mod quotes_cached;
pub mod redial;
pub mod rendezvous;
pub mod swap_setup;
pub mod tor_dial_filter;
pub mod transfer_proof;
pub mod wormhole;
160 changes: 160 additions & 0 deletions swap-p2p/src/protocols/tor_dial_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! A single choke point that prevents dialing onion addresses without Tor.
//!
//! [`TorDialFilter`] wraps any [`NetworkBehaviour`] and, when Tor is disabled,
//! strips `/onion3` (and `/onion`) addresses from every dial-candidate list the
//! inner behaviour produces via [`NetworkBehaviour::handle_pending_outbound_connection`].
//!
//! Why a wrapper instead of per-behaviour filters?
//!
//! libp2p assembles the address list for an address-less `DialOpts` by calling
//! `handle_pending_outbound_connection` on *every* behaviour and concatenating
//! the results. Many behaviours contribute cached peer addresses here: the
//! `redial` behaviours, `identify`, and — crucially — every libp2p
//! `request_response` behaviour keeps a per-peer address book that it populates
//! from `FromSwarm::NewExternalAddrOfPeer` and hands back as dial candidates.
//! Filtering each of them individually is fragile whack-a-mole. Wrapping the
//! top-level behaviour filters the *combined* result once, so no matter which
//! inner behaviour contributed an onion address it is dropped before the Swarm
//! ever hands it to the transport.
//!
//! It delegates everything else to the inner behaviour (including, via
//! [`Deref`], its inherent API and derived field access).

use std::ops::{Deref, DerefMut};
use std::task::{Context, Poll};

use libp2p::PeerId;
use libp2p::core::{Endpoint, Multiaddr};
use libp2p::multiaddr::Protocol;
use libp2p::swarm::{
ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent,
THandlerOutEvent, ToSwarm,
};

/// Returns true if the multiaddr contains a Tor onion (v2 or v3) component.
fn is_onion(addr: &Multiaddr) -> bool {
addr.iter()
.any(|proto| matches!(proto, Protocol::Onion(_, _) | Protocol::Onion3(_)))
}

/// Wraps a [`NetworkBehaviour`] and drops onion dial candidates when Tor is off.
///
/// When `tor_enabled` is `true` this is a transparent passthrough.
#[allow(missing_debug_implementations)]
pub struct TorDialFilter<B> {
inner: B,
tor_enabled: bool,
}

impl<B> TorDialFilter<B> {
pub fn new(inner: B, tor_enabled: bool) -> Self {
Self { inner, tor_enabled }
}
}

impl<B> Deref for TorDialFilter<B> {
type Target = B;

fn deref(&self) -> &Self::Target {
&self.inner
}
}

impl<B> DerefMut for TorDialFilter<B> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}

impl<B> NetworkBehaviour for TorDialFilter<B>
where
B: NetworkBehaviour,
{
type ConnectionHandler = B::ConnectionHandler;
type ToSwarm = B::ToSwarm;

fn handle_pending_inbound_connection(
&mut self,
connection_id: ConnectionId,
local_addr: &Multiaddr,
remote_addr: &Multiaddr,
) -> Result<(), ConnectionDenied> {
self.inner
.handle_pending_inbound_connection(connection_id, local_addr, remote_addr)
}

fn handle_established_inbound_connection(
&mut self,
connection_id: ConnectionId,
peer: PeerId,
local_addr: &Multiaddr,
remote_addr: &Multiaddr,
) -> Result<THandler<Self>, ConnectionDenied> {
self.inner.handle_established_inbound_connection(
connection_id,
peer,
local_addr,
remote_addr,
)
}

fn handle_pending_outbound_connection(
&mut self,
connection_id: ConnectionId,
maybe_peer: Option<PeerId>,
addresses: &[Multiaddr],
effective_role: Endpoint,
) -> Result<Vec<Multiaddr>, ConnectionDenied> {
let candidates = self.inner.handle_pending_outbound_connection(
connection_id,
maybe_peer,
addresses,
effective_role,
)?;

// With Tor enabled every address is dialable, so don't touch anything.
if self.tor_enabled {
return Ok(candidates);
}

// Tor is off: drop onion addresses so the Swarm never even attempts to
// dial them (an onion dial without Tor can only fail with
// `MultiaddrNotSupported` and trigger pointless redial churn).
Ok(candidates
.into_iter()
.filter(|addr| !is_onion(addr))
.collect())
}

fn handle_established_outbound_connection(
&mut self,
connection_id: ConnectionId,
peer: PeerId,
addr: &Multiaddr,
role_override: Endpoint,
) -> Result<THandler<Self>, ConnectionDenied> {
self.inner
.handle_established_outbound_connection(connection_id, peer, addr, role_override)
}

fn on_swarm_event(&mut self, event: FromSwarm) {
self.inner.on_swarm_event(event)
}

fn on_connection_handler_event(
&mut self,
peer_id: PeerId,
connection_id: ConnectionId,
event: THandlerOutEvent<Self>,
) {
self.inner
.on_connection_handler_event(peer_id, connection_id, event)
}

fn poll(
&mut self,
cx: &mut Context<'_>,
) -> Poll<ToSwarm<Self::ToSwarm, THandlerInEvent<Self>>> {
self.inner.poll(cx)
}
}
5 changes: 3 additions & 2 deletions swap/src/cli/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use std::sync::Arc;
use std::time::Duration;
use swap_core::bitcoin::EncryptedSignature;
use swap_p2p::protocols::redial;
use swap_p2p::protocols::tor_dial_filter::TorDialFilter;
use uuid::Uuid;

// Timeout for the execution setup protocol within the event loop.
Expand All @@ -41,7 +42,7 @@ static RETRY_MAX_INTERVAL: Duration = Duration::from_secs(5);

#[allow(missing_debug_implementations)]
pub struct EventLoop {
swarm: libp2p::Swarm<Behaviour>,
swarm: libp2p::Swarm<TorDialFilter<Behaviour>>,
db: Arc<dyn Database + Send + Sync>,

// When a new `SwapEventLoopHandle` is created:
Expand Down Expand Up @@ -153,7 +154,7 @@ pub struct EventLoop {

impl EventLoop {
pub fn new(
swarm: Swarm<Behaviour>,
swarm: Swarm<TorDialFilter<Behaviour>>,
db: Arc<dyn Database + Send + Sync>,
tauri_handle: Option<TauriHandle>,
tor_priority_tracker: Option<TorDialPriorityTracker>,
Expand Down
13 changes: 12 additions & 1 deletion swap/src/network/swarm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use swap_core::bitcoin;
use swap_env::env;
use swap_p2p::libp2p_ext::MultiAddrExt;
use swap_p2p::protocols::metered::RequestResponseMetrics;
use swap_p2p::protocols::tor_dial_filter::TorDialFilter;
use tor_hsservice::RunningOnionService;
use tor_rtcompat::tokio::TokioRustlsRuntime;

Expand Down Expand Up @@ -136,12 +137,22 @@ pub async fn cli<T>(
identity: identity::Keypair,
maybe_tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
behaviour: T,
) -> Result<(Swarm<T>, Option<TorDialPriorityTracker>)>
) -> Result<(Swarm<TorDialFilter<T>>, Option<TorDialPriorityTracker>)>
where
T: NetworkBehaviour,
{
// Onion addresses can only be dialed through Tor. Derive the flag from the
// very same `maybe_tor_client` that decides whether the transport can dial
// onion addresses, so the dial filter and the transport can never disagree.
let tor_enabled = maybe_tor_client.is_some();

let (transport, tor_priority_tracker) = cli::transport::new(&identity, maybe_tor_client)?;

// Single choke point: with Tor disabled, strip onion addresses from every
// dial-candidate list before the Swarm ever attempts to dial them,
// regardless of which inner behaviour contributed the address.
let behaviour = TorDialFilter::new(behaviour, tor_enabled);

let swarm = SwarmBuilder::with_existing_identity(identity)
.with_tokio()
.with_other_transport(|_| transport)?
Expand Down