diff --git a/.gitignore b/.gitignore index dd2013c0..d499b9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ swap-orchestrator/config.toml release-build.sh cn_macos libp2p-rendezvous-node/rendezvous-data +rust-electrum-client/ +rust-libp2p/ +bdk/ diff --git a/.sqlx/query-60462ce4f45f174eb4603a2d94e67cf98eb7d6176515e6a28c4e8ce9fda6ef15.json b/.sqlx/query-60462ce4f45f174eb4603a2d94e67cf98eb7d6176515e6a28c4e8ce9fda6ef15.json new file mode 100644 index 00000000..12194afa --- /dev/null +++ b/.sqlx/query-60462ce4f45f174eb4603a2d94e67cf98eb7d6176515e6a28c4e8ce9fda6ef15.json @@ -0,0 +1,18 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT 1 as found\n FROM swap_states\n WHERE swap_id = ?\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "found", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [false] + }, + "hash": "60462ce4f45f174eb4603a2d94e67cf98eb7d6176515e6a28c4e8ce9fda6ef15" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a3494ed8..b79a7398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: Display detailed information about which peers we are connected to. +- GUI: Change the unique avatars of makers to be distinctly different from each other to allow for recognizability. +- GUI + SWAP + ASB: Massivly improved the reliability of all parts of the P2P networking stack. +- ASB: Fix bugs where we would not properly reconnect to rendezvous servers which could negatively impact peer discovery +- CLI: Removed the `list-sellers` and `buy-xmr` CLI commands. They were cumbersome to use and confusing. They will be re-added once the CLI is more flexible to allow for interactivity. + +## [3.3.4] - 2025-11-14 + ## [3.3.8] - 2025-11-30 - ASB + CONTROLLER: Add the `peer-id` command to the controller shell which can be used to obtain the Peer ID of your ASB instance. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eb3a82a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- When asked about libp2p, check if a rust-libp2p folder exists which contains the cloned rust libp2p codebase. Read through to figure out what the best response it. If its a question about best practice when implementing protocols read @rust-libp2p/protocols/ specificially. diff --git a/Cargo.lock b/Cargo.lock index 5571293a..ef097552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11341,6 +11341,7 @@ name = "swap-p2p" version = "0.1.0" dependencies = [ "anyhow", + "arti-client", "async-trait", "asynchronous-codec 0.7.0", "backoff", @@ -11349,8 +11350,10 @@ dependencies = [ "bmrng", "futures", "libp2p", + "libp2p-tor", "monero", "rand 0.8.5", + "semver", "serde", "serde_cbor", "swap-core", @@ -11360,7 +11363,9 @@ dependencies = [ "swap-serde", "thiserror 1.0.69", "tokio", + "tor-rtcompat", "tracing", + "tracing-subscriber", "typeshare", "unsigned-varint 0.8.0", "uuid", @@ -13982,11 +13987,13 @@ version = "3.3.8" dependencies = [ "anyhow", "dfx-swiss-sdk", + "libp2p", "monero-rpc-pool", "rustls 0.23.35", "serde", "serde_json", "swap", + "swap-p2p", "tauri", "tauri-build", "tauri-plugin-cli", diff --git a/Cargo.toml b/Cargo.toml index 222a11a2..d6fcf71d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ sigma_fun = { version = "0.7", default-features = false } async-trait = "0.1" # Serialization +semver = { version = "1.0" } serde_cbor = "0.11" strum = { version = "0.26" } diff --git a/justfile b/justfile index 10279976..f77297e6 100644 --- a/justfile +++ b/justfile @@ -30,7 +30,7 @@ test-ffi-address: # Start the Tauri app tauri: - cd src-tauri && cargo tauri dev --no-watch -- --verbose -- --testnet + cd src-tauri && cargo tauri dev --no-watch -- -vv -- --testnet tauri-mainnet: cd src-tauri && cargo tauri dev --no-watch -- -vv diff --git a/libp2p-rendezvous-node/src/behaviour.rs b/libp2p-rendezvous-node/src/behaviour.rs index 78206d03..62bafcbc 100644 --- a/libp2p-rendezvous-node/src/behaviour.rs +++ b/libp2p-rendezvous-node/src/behaviour.rs @@ -1,6 +1,6 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use libp2p::swarm::NetworkBehaviour; -use libp2p::{identity, Multiaddr, PeerId}; +use libp2p::{identity, PeerId}; use swap_p2p::protocols::rendezvous::{register, XmrBtcNamespace}; /// Acts as both a rendezvous server and registers at other rendezvous points @@ -13,50 +13,15 @@ pub struct Behaviour { impl Behaviour { pub fn new( identity: identity::Keypair, - rendezvous_addrs: Vec, + rendezvous_nodes: Vec, namespace: XmrBtcNamespace, - registration_ttl: Option, ) -> Result { let server = libp2p::rendezvous::server::Behaviour::new( libp2p::rendezvous::server::Config::default(), ); - let rendezvous_nodes = - build_rendezvous_nodes(rendezvous_addrs, namespace, registration_ttl)?; - let register = register::Behaviour::new(identity, rendezvous_nodes); + let register = register::Behaviour::new(identity, rendezvous_nodes, namespace.into()); Ok(Self { server, register }) } } - -/// Builds a list of RendezvousNode from multiaddrs and namespace -fn build_rendezvous_nodes( - addrs: Vec, - namespace: XmrBtcNamespace, - registration_ttl: Option, -) -> Result> { - addrs - .into_iter() - .map(|addr| { - let peer_id = extract_peer_id(&addr)?; - Ok(register::RendezvousNode::new( - &addr, - peer_id, - namespace, - registration_ttl, - )) - }) - .collect() -} - -fn extract_peer_id(addr: &Multiaddr) -> Result { - addr.iter() - .find_map(|protocol| { - if let libp2p::multiaddr::Protocol::P2p(peer_id) = protocol { - Some(peer_id) - } else { - None - } - }) - .context("No peer_id found in multiaddr") -} diff --git a/libp2p-rendezvous-node/src/main.rs b/libp2p-rendezvous-node/src/main.rs index 55a7d1ee..091a42be 100644 --- a/libp2p-rendezvous-node/src/main.rs +++ b/libp2p-rendezvous-node/src/main.rs @@ -107,24 +107,19 @@ async fn main() -> Result<()> { tracing::info!(peer=%enquirer, "Discovery served"); } SwarmEvent::Behaviour(behaviour::BehaviourEvent::Register( - register::InnerBehaviourEvent::Rendezvous(rendezvous::client::Event::Registered { - rendezvous_node, - ttl, - namespace, - }), + register::Event::Registered { peer_id }, )) => { - tracing::info!(%rendezvous_node, %namespace, ttl, "Registered at rendezvous point"); + tracing::info!(%peer_id, "Registered at rendezvous point"); } SwarmEvent::Behaviour(behaviour::BehaviourEvent::Register( - register::InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::RegisterFailed { - rendezvous_node, - namespace, - error, - }, - ), + register::Event::RegisterRequestFailed { peer_id, error }, )) => { - tracing::warn!(%rendezvous_node, %namespace, ?error, "Failed to register at rendezvous point"); + tracing::warn!(%peer_id, ?error, "Failed to register at rendezvous point"); + } + SwarmEvent::Behaviour(behaviour::BehaviourEvent::Register( + register::Event::RegisterDispatchFailed { peer_id, error }, + )) => { + tracing::warn!(%peer_id, ?error, "Failed to dispatch register request at rendezvous point"); } SwarmEvent::NewListenAddr { address, .. } => { tracing::info!(%address, "New listening address reported"); diff --git a/libp2p-rendezvous-node/src/swarm.rs b/libp2p-rendezvous-node/src/swarm.rs index daf2b4a4..65e82353 100644 --- a/libp2p-rendezvous-node/src/swarm.rs +++ b/libp2p-rendezvous-node/src/swarm.rs @@ -10,6 +10,7 @@ use libp2p::{dns, noise, Multiaddr, PeerId, Swarm, Transport}; use libp2p_tor::{AddressConversion, TorTransport}; use std::fmt; use std::path::Path; +use swap_p2p::libp2p_ext::MultiAddrVecExt; use tor_hsservice::config::OnionServiceConfigBuilder; use crate::behaviour::Behaviour; @@ -26,48 +27,23 @@ mod defaults { pub const HIDDEN_SERVICE_NUM_INTRO_POINTS: u8 = 5; pub const MULTIPLEX_TIMEOUT: Duration = Duration::from_secs(60); - - pub const REGISTRATION_TTL: Option = None; } pub fn create_swarm( identity: identity::Keypair, - rendezvous_addrs: Vec, + rendezvous_nodes: Vec, ) -> Result> { + let rendezvous_nodes = rendezvous_nodes.extract_peer_addresses(); + let rendezvous_nodes_peer_ids = rendezvous_nodes + .iter() + .map(|(peer_id, _)| *peer_id) + .collect(); + let transport = create_transport(&identity).context("Failed to create transport")?; let behaviour = Behaviour::new( identity.clone(), - rendezvous_addrs, + rendezvous_nodes_peer_ids, swap_p2p::protocols::rendezvous::XmrBtcNamespace::RendezvousPoint, - defaults::REGISTRATION_TTL, - )?; - - let swarm = SwarmBuilder::with_existing_identity(identity) - .with_tokio() - .with_other_transport(|_| transport)? - .with_behaviour(|_| behaviour)? - .with_swarm_config(|cfg| { - cfg.with_idle_connection_timeout(defaults::IDLE_CONNECTION_TIMEOUT) - }) - .build(); - - Ok(swarm) -} - -pub async fn create_swarm_with_onion( - identity: identity::Keypair, - onion_port: u16, - data_dir: &Path, - rendezvous_addrs: Vec, -) -> Result> { - let (transport, onion_address) = create_transport_with_onion(&identity, onion_port, data_dir) - .await - .context("Failed to create transport with onion")?; - let behaviour = Behaviour::new( - identity.clone(), - rendezvous_addrs, - swap_p2p::protocols::rendezvous::XmrBtcNamespace::RendezvousPoint, - defaults::REGISTRATION_TTL, )?; let mut swarm = SwarmBuilder::with_existing_identity(identity) @@ -79,6 +55,54 @@ pub async fn create_swarm_with_onion( }) .build(); + // Add all the addresses of the rendezvous nodes to the Swarm + for (peer_id, addresses) in rendezvous_nodes { + for address in addresses { + swarm.add_peer_address(peer_id.clone(), address.clone()); + } + } + + Ok(swarm) +} + +pub async fn create_swarm_with_onion( + identity: identity::Keypair, + onion_port: u16, + data_dir: &Path, + rendezvous_nodes: Vec, +) -> Result> { + let rendezvous_nodes = rendezvous_nodes.extract_peer_addresses(); + let rendezvous_nodes_peer_ids = rendezvous_nodes + .iter() + .map(|(peer_id, _)| *peer_id) + .collect(); + + let (transport, onion_address) = create_transport_with_onion(&identity, onion_port, data_dir) + .await + .context("Failed to create transport with onion")?; + + let behaviour = Behaviour::new( + identity.clone(), + rendezvous_nodes_peer_ids, + swap_p2p::protocols::rendezvous::XmrBtcNamespace::RendezvousPoint, + )?; + + let mut swarm = SwarmBuilder::with_existing_identity(identity) + .with_tokio() + .with_other_transport(|_| transport)? + .with_behaviour(|_| behaviour)? + .with_swarm_config(|cfg| { + cfg.with_idle_connection_timeout(defaults::IDLE_CONNECTION_TIMEOUT) + }) + .build(); + + // Add all the addresses of the rendezvous nodes to the Swarm + for (peer_id, addresses) in rendezvous_nodes { + for address in addresses { + swarm.add_peer_address(peer_id, address.clone()); + } + } + // Listen on the onion address swarm .listen_on(onion_address.clone()) diff --git a/src-gui/index.html b/src-gui/index.html index 6c5f2819..4a3b087a 100644 --- a/src-gui/index.html +++ b/src-gui/index.html @@ -27,6 +27,7 @@ height: 100%; margin: 0; overflow: auto; + overscroll-behavior: none; } diff --git a/src-gui/package.json b/src-gui/package.json index 316363a9..86a061a7 100644 --- a/src-gui/package.json +++ b/src-gui/package.json @@ -34,9 +34,9 @@ "@tauri-apps/plugin-shell": "^2.3.0", "@tauri-apps/plugin-store": "^2.4.0", "@tauri-apps/plugin-updater": "^2.9.0", - "boring-avatars": "^1.11.2", "dayjs": "^1.11.13", "humanize-duration": "^3.32.1", + "jdenticon": "^3.3.0", "lodash": "^4.17.21", "multiaddr": "^10.0.1", "notistack": "^3.0.1", diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index ca60c6ab..d393a472 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -23,10 +23,6 @@ import { } from "store/features/ratesSlice"; import { FiatCurrency } from "store/features/settingsSlice"; import { setAlerts } from "store/features/alertsSlice"; -import { - registryConnectionFailed, - setRegistryMakers, -} from "store/features/makersSlice"; import logger from "utils/logger"; import { setConversation } from "store/features/conversationsSlice"; @@ -185,19 +181,6 @@ export async function updateRates(): Promise { } } -/** - * Update public registry - */ -export async function updatePublicRegistry(): Promise { - try { - const providers = await fetchMakersViaHttp(); - store.dispatch(setRegistryMakers(providers)); - } catch (error) { - store.dispatch(registryConnectionFailed()); - logger.error(error, "Error fetching providers"); - } -} - /** * Fetch all alerts */ diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 40eab676..5f8af662 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -11,19 +11,17 @@ import { setBitcoinBalance } from "store/features/bitcoinWalletSlice"; import { receivedCliLog } from "store/features/logsSlice"; import { poolStatusReceived } from "store/features/poolSlice"; import { swapProgressEventReceived } from "store/features/swapSlice"; -import logger from "utils/logger"; import { - fetchAllConversations, - updateAlerts, - updatePublicRegistry, - updateRates, -} from "./api"; + quotesProgressReceived, + connectionChangeReceived, +} from "store/features/p2pSlice"; +import logger from "utils/logger"; +import { fetchAllConversations, updateAlerts, updateRates } from "./api"; import { checkContextStatus, getSwapInfo, getSwapTimelock, initializeContext, - listSellersAtRendezvousPoint, refreshApprovals, updateAllNodeStatuses, } from "./rpc"; @@ -80,15 +78,9 @@ export async function setupBackgroundTasks(): Promise { ); // Setup periodic fetch tasks - setIntervalImmediate(updatePublicRegistry, PROVIDER_UPDATE_INTERVAL); setIntervalImmediate(updateAllNodeStatuses, STATUS_UPDATE_INTERVAL); setIntervalImmediate(updateRates, UPDATE_RATE_INTERVAL); setIntervalImmediate(fetchAllConversations, FETCH_CONVERSATIONS_INTERVAL); - setIntervalImmediate( - () => - listSellersAtRendezvousPoint(store.getState().settings.rendezvousPoints), - DISCOVER_PEERS_INTERVAL, - ); setIntervalImmediate(refreshApprovals, FETCH_PENDING_APPROVALS_INTERVAL); // Fetch all alerts @@ -195,6 +187,15 @@ listen(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => { } break; + case "P2P": + if (eventData.type === "ConnectionChange") { + store.dispatch(connectionChangeReceived(eventData.content)); + } + if (eventData.type === "QuotesProgress") { + store.dispatch(quotesProgressReceived(eventData.content.peers)); + } + break; + default: exhaustiveGuard(channelName); } diff --git a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx index eb1bd2cf..9852eb5a 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -185,9 +185,9 @@ export default function SeedSelectionDialog() { const isDisabled = selectedOption === "FromSeed" ? customSeed.trim().length === 0 || - !isSeedValid || - isBlockheightInvalid || - !isPasswordValid + !isSeedValid || + isBlockheightInvalid || + !isPasswordValid : selectedOption === "FromWalletPath" ? !walletPath : selectedOption === "RandomSeed" diff --git a/src-gui/src/renderer/components/other/ClickToCopy.tsx b/src-gui/src/renderer/components/other/ClickToCopy.tsx new file mode 100644 index 00000000..3dce17d0 --- /dev/null +++ b/src-gui/src/renderer/components/other/ClickToCopy.tsx @@ -0,0 +1,42 @@ +import { Box, Tooltip } from "@mui/material"; +import { ReactNode, useState } from "react"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; + +type Props = { + content: string; + children: ReactNode; + showTooltip?: boolean; +}; + +export default function ClickToCopy({ + content, + children, + showTooltip = true, +}: Props) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const wrapper = ( + + {children} + + ); + + if (!showTooltip) { + return wrapper; + } + + return ( + + {wrapper} + + ); +} diff --git a/src-gui/src/renderer/components/other/Jdenticon.tsx b/src-gui/src/renderer/components/other/Jdenticon.tsx new file mode 100644 index 00000000..ab7a6a12 --- /dev/null +++ b/src-gui/src/renderer/components/other/Jdenticon.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef } from "react"; +import { update } from "jdenticon"; + +interface JdenticonProps { + value: string; + size: number; + className?: string; + style?: React.CSSProperties; +} + +/** + * React wrapper component for jdenticon + * Generates a unique identicon based on the provided value + */ +export default function Jdenticon({ + value, + size, + className, + style, +}: JdenticonProps) { + const svgRef = useRef(null); + + useEffect(() => { + if (svgRef.current) { + update(svgRef.current, value); + } + }, [value]); + + return ( + + ); +} diff --git a/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx b/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx deleted file mode 100644 index 0efce391..00000000 --- a/src-gui/src/renderer/components/pages/help/DiscoveryBox.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Box, Typography, styled } from "@mui/material"; -import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; -import { useSettings } from "store/hooks"; -import { Search } from "@mui/icons-material"; -import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { listSellersAtRendezvousPoint } from "renderer/rpc"; -import { useAppDispatch } from "store/hooks"; -import { discoveredMakersByRendezvous } from "store/features/makersSlice"; -import { useSnackbar } from "notistack"; - -const StyledPromiseButton = styled(PromiseInvokeButton)(({ theme }) => ({ - marginTop: theme.spacing(2), -})); - -export default function DiscoveryBox() { - const rendezvousPoints = useSettings((s) => s.rendezvousPoints); - const dispatch = useAppDispatch(); - const { enqueueSnackbar } = useSnackbar(); - - const handleDiscovery = async () => { - const { sellers } = await listSellersAtRendezvousPoint(rendezvousPoints); - dispatch(discoveredMakersByRendezvous(sellers)); - }; - - return ( - - Discover Makers - - } - mainContent={ - - By connecting to rendezvous points run by volunteers, you can discover - makers and then connect and swap with them in a decentralized manner. - You have {rendezvousPoints.length} stored rendezvous{" "} - {rendezvousPoints.length === 1 ? "point" : "points"} which we will - connect to. We will also attempt to connect to peers which you have - previously connected to. - - } - additionalContent={ - } - displayErrorSnackbar - > - Discover Makers - - } - icon={null} - loading={false} - /> - ); -} diff --git a/src-gui/src/renderer/components/pages/help/SettingsPage.tsx b/src-gui/src/renderer/components/pages/help/SettingsPage.tsx index e608167b..7fedbd87 100644 --- a/src-gui/src/renderer/components/pages/help/SettingsPage.tsx +++ b/src-gui/src/renderer/components/pages/help/SettingsPage.tsx @@ -2,7 +2,6 @@ import { Box } from "@mui/material"; import DonateInfoBox from "./DonateInfoBox"; import DaemonControlBox from "./DaemonControlBox"; import SettingsBox from "./SettingsBox"; -import DiscoveryBox from "./DiscoveryBox"; import MoneroPoolHealthBox from "./MoneroPoolHealthBox"; import { useLocation } from "react-router-dom"; import { useEffect } from "react"; @@ -30,7 +29,6 @@ export default function SettingsPage() { - ); } diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx index 6dd13161..83f476df 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRowExpanded.tsx @@ -109,7 +109,7 @@ export default function HistoryRowExpanded({ - Monero receive pool + Monero Redeem Addresses {swap.monero_receive_pool.map((pool, index) => ( diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx index 8af50ddb..de2e3646 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerDiscoveryStatus.tsx @@ -1,122 +1,412 @@ -import { Box, Typography, LinearProgress, Paper } from "@mui/material"; -import { usePendingBackgroundProcesses } from "store/hooks"; +import { + Box, + Typography, + LinearProgress, + Paper, + IconButton, + Dialog, + DialogTitle, + DialogContent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Chip, + CircularProgress, + Stack, +} from "@mui/material"; +import { Info as InfoIcon, Close as CloseIcon } from "@mui/icons-material"; +import { useEffect, useState, useMemo } from "react"; +import { useAppSelector } from "store/hooks"; +import { QuoteStatus, ConnectionStatus } from "models/tauriModel"; +import { selectPeers } from "store/selectors"; +import TorIcon from "renderer/components/icons/TorIcon"; +import TruncatedText from "renderer/components/other/TruncatedText"; +import ClickToCopy from "renderer/components/other/ClickToCopy"; +import Jdenticon from "renderer/components/other/Jdenticon"; + +type Peer = ReturnType[number]; export default function MakerDiscoveryStatus() { - const backgroundProcesses = usePendingBackgroundProcesses(); - - // Find active ListSellers processes - const listSellersProcesses = backgroundProcesses.filter( - ([, status]) => - status.componentName === "ListSellers" && - status.progress.type === "Pending", + const [dialogOpen, setDialogOpen] = useState(false); + const [everConnectedPeers, setEverConnectedPeers] = useState>( + new Set(), ); + const peers = useAppSelector(selectPeers); - const isActive = listSellersProcesses.length > 0; + // Track peers that have ever been connected + useEffect(() => { + const connectedPeerIds = peers + .filter((p) => p.connection === ConnectionStatus.Connected) + .map((p) => p.peer_id); - // Default values for inactive state - let progress = { - rendezvous_points_total: 0, - peers_discovered: 0, - rendezvous_points_connected: 0, - quotes_received: 0, - quotes_failed: 0, - }; - let progressValue = 0; - - if (isActive) { - // Use the first ListSellers process for display - const [, status] = listSellersProcesses[0]; - - // Type guard to ensure we have ListSellers progress - if ( - status.componentName === "ListSellers" && - status.progress.type === "Pending" - ) { - progress = status.progress.content; - - const totalExpected = - progress.rendezvous_points_total + progress.peers_discovered; - const totalCompleted = - progress.rendezvous_points_connected + - progress.quotes_received + - progress.quotes_failed; - progressValue = - totalExpected > 0 ? (totalCompleted / totalExpected) * 100 : 0; + if (connectedPeerIds.length > 0) { + setEverConnectedPeers((prev) => { + const updated = new Set(prev); + connectedPeerIds.forEach((id) => updated.add(id)); + return updated; + }); } - } + }, [peers]); + + const totalPeers = peers.length; + const quotesReceived = peers.filter( + (p) => p.quote === QuoteStatus.Received, + ).length; + const quotesFailed = peers.filter( + (p) => + p.quote === QuoteStatus.Failed || p.quote === QuoteStatus.NotSupported, + ).length; + const quotesInflight = peers.filter( + (p) => p.quote === QuoteStatus.Inflight, + ).length; + + const isActive = quotesInflight > 0 || totalPeers === 0; + + const totalCompleted = quotesReceived + quotesFailed; + const progressValue = + totalPeers > 0 ? (totalCompleted / totalPeers) * 100 : 0; return ( - - + setDialogOpen(true)} sx={{ - display: "flex", - flexDirection: "column", - gap: 1.5, width: "100%", + mb: 2, + p: 2, + borderColor: isActive ? "success.main" : "divider", + opacity: isActive ? 1 : 0.6, + cursor: "pointer", + transition: "background-color 0.2s", + "&:hover": { + bgcolor: "action.hover", + }, }} > - - + - {isActive - ? "Getting offers..." - : "Waiting a few seconds before refreshing offers"} - - - {progress.quotes_received} online + {isActive + ? "Getting offers..." + : "Waiting a few seconds before refreshing offers"} - - {progress.quotes_failed} offline - - - - - - + + + + + {quotesReceived} online + + + {quotesFailed} offline + + + + + + + + + setDialogOpen(false)} + peers={peers} + everConnectedPeers={everConnectedPeers} + /> + ); } + +function QuoteStatusChip({ status }: { status: QuoteStatus | null }) { + switch (status) { + case QuoteStatus.Received: + return ; + case QuoteStatus.Inflight: + return ( + } + /> + ); + case QuoteStatus.Failed: + return ; + case QuoteStatus.NotSupported: + return ; + case QuoteStatus.Nothing: + case null: + return ; + default: + return null; + } +} + +function ConnectionStatusChip({ status }: { status: ConnectionStatus | null }) { + switch (status) { + case ConnectionStatus.Connected: + return ; + case ConnectionStatus.Disconnected: + case null: + return ; + case ConnectionStatus.Dialing: + return ( + } + /> + ); + default: + return null; + } +} + +/** + * Sorts peers based on connection history, quote status (optional), and peer ID. + */ +function sortPeers( + peers: Peer[], + everConnectedPeers: Set, + checkQuoteStatus: boolean, +): Peer[] { + return [...peers].sort((a, b) => { + // 1. Put peers that have never connected at the bottom + const aEverConnected = everConnectedPeers.has(a.peer_id); + const bEverConnected = everConnectedPeers.has(b.peer_id); + + if (aEverConnected !== bEverConnected) { + return aEverConnected ? -1 : 1; + } + + // 2. Put NotSupported quotes at the bottom + if (checkQuoteStatus) { + const aNotSupported = a.quote === QuoteStatus.NotSupported; + const bNotSupported = b.quote === QuoteStatus.NotSupported; + + if (aNotSupported !== bNotSupported) { + return aNotSupported ? 1 : -1; + } + } + + // 3. Sort alphabetically by peer_id + return a.peer_id.localeCompare(b.peer_id); + }); +} + +interface PeerTableProps { + peers: Peer[]; + page: number; + rowsPerPage: number; +} + +function PeerTable({ peers, page, rowsPerPage }: PeerTableProps) { + const paginatedPeers = useMemo(() => { + const startIndex = page * rowsPerPage; + return peers.slice(startIndex, startIndex + rowsPerPage); + }, [peers, page, rowsPerPage]); + + const emptyRows = rowsPerPage - paginatedPeers.length; + + return ( + + + + + Peer ID + Address + Connection + Quote + + + + {paginatedPeers.map((entry) => ( + + + + + + + + {entry.peer_id} + + + + + + + + + + {entry.last_address ?? "--"} + + {entry.last_address?.includes("/onion3/") && ( + + )} + + + + + + + + + + + ))} + {emptyRows > 0 && + Array.from({ length: emptyRows }).map((_, index) => ( + + + + ))} + +
+
+ ); +} + +interface PeerDetailsDialogProps { + open: boolean; + onClose: () => void; + peers: Peer[]; + everConnectedPeers: Set; +} + +function PeerDetailsDialog({ + open, + onClose, + peers, + everConnectedPeers, +}: PeerDetailsDialogProps) { + const [page, setPage] = useState(0); + const rowsPerPage = 10; + + const sortedPeers = useMemo(() => { + return sortPeers(peers, everConnectedPeers, true); + }, [peers, everConnectedPeers]); + + const handleChangePage = (_event: unknown, newPage: number) => { + setPage(newPage); + }; + + return ( + + + + Peers + + + + + + + {peers.length === 0 ? ( + + + No peers discovered yet. + + + ) : ( + + + + + )} + + + ); +} \ No newline at end of file diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx index e651826a..8ee9e73c 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/deposit_and_choose_offer/MakerOfferItem.tsx @@ -1,5 +1,5 @@ import { Box, Button, Chip, Paper, Tooltip, Typography } from "@mui/material"; -import Avatar from "boring-avatars"; +import Jdenticon from "renderer/components/other/Jdenticon"; import { QuoteWithAddress } from "models/tauriModel"; import { MoneroSatsExchangeRate, @@ -42,12 +42,7 @@ export default function MakerOfferItem({ gap: 2, }} > - + (command: string): Promise { return invokeUnsafe(command) as Promise; } -export async function fetchSellersAtPresetRendezvousPoints() { - await Promise.all( - DEFAULT_RENDEZVOUS_POINTS.map(async (rendezvousPoint) => { - const response = await listSellersAtRendezvousPoint([rendezvousPoint]); - store.dispatch(discoveredMakersByRendezvous(response.sellers)); - - logger.info( - `Discovered ${response.sellers.length} sellers at rendezvous point ${rendezvousPoint} during startup fetch`, - ); - }), - ); -} - export async function checkBitcoinBalance() { // If we are already syncing, don't start a new sync if ( @@ -180,17 +160,6 @@ export async function buyXmr() { const donationPercentage = state.settings.donateToDevelopment; - // Get all available makers from the Redux store - const allMakers = [ - ...(state.makers.registry.makers || []), - ...state.makers.rendezvous.makers, - ]; - - // Convert all makers to multiaddr format - const sellers = allMakers.map((maker) => - providerToConcatenatedMultiAddr(maker), - ); - const address_pool: LabeledMoneroAddress[] = []; if (donationPercentage !== false && donationPercentage > 0) { const donation_address = isTestnet() @@ -229,9 +198,7 @@ export async function buyXmr() { }); } - await invoke("buy_xmr", { - rendezvous_points: DEFAULT_RENDEZVOUS_POINTS, - sellers, + await invoke("buy_xmr", { monero_receive_pool: address_pool, // We convert null to undefined because typescript // expects undefined if the field is optional and does not accept null here @@ -253,6 +220,12 @@ export async function initializeContext() { const useMoneroRpcPool = store.getState().settings.useMoneroRpcPool; const useMoneroTor = store.getState().settings.enableMoneroTor; + const rendezvousPoints = Array.from( + new Set([ + ...store.getState().settings.rendezvousPoints, + ...DEFAULT_RENDEZVOUS_POINTS, + ]), + ); const moneroNodeUrl = store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null; @@ -276,6 +249,7 @@ export async function initializeContext() { monero_node_config: moneroNodeConfig, use_tor: useTor, enable_monero_tor: useMoneroTor, + rendezvous_points: rendezvousPoints, }; logger.info({ tauriSettings }, "Initializing context with settings"); @@ -444,14 +418,6 @@ export async function redactLogs( return parseLogsFromString(response.text); } -export async function listSellersAtRendezvousPoint( - rendezvousPointAddresses: string[], -): Promise { - return await invoke("list_sellers", { - rendezvous_points: rendezvousPointAddresses, - }); -} - export async function getWalletDescriptor() { return await invokeNoArgs( "get_wallet_descriptor", diff --git a/src-gui/src/store/combinedReducer.ts b/src-gui/src/store/combinedReducer.ts index 3e151465..84e34c60 100644 --- a/src-gui/src/store/combinedReducer.ts +++ b/src-gui/src/store/combinedReducer.ts @@ -1,5 +1,4 @@ import alertsSlice from "./features/alertsSlice"; -import makersSlice from "./features/makersSlice"; import ratesSlice from "./features/ratesSlice"; import rpcSlice from "./features/rpcSlice"; import swapReducer from "./features/swapSlice"; @@ -10,11 +9,12 @@ import poolSlice from "./features/poolSlice"; import walletSlice from "./features/walletSlice"; import bitcoinWalletSlice from "./features/bitcoinWalletSlice"; import logsSlice from "./features/logsSlice"; +import p2pSlice from "./features/p2pSlice"; export const reducers = { swap: swapReducer, - makers: makersSlice, rpc: rpcSlice, + p2p: p2pSlice, alerts: alertsSlice, rates: ratesSlice, settings: settingsSlice, diff --git a/src-gui/src/store/features/makersSlice.ts b/src-gui/src/store/features/makersSlice.ts deleted file mode 100644 index 73b6ed19..00000000 --- a/src-gui/src/store/features/makersSlice.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { ExtendedMakerStatus, MakerStatus } from "models/apiModel"; -import { SellerStatus } from "models/tauriModel"; -import { getStubTestnetMaker } from "store/config"; -import { rendezvousSellerToMakerStatus } from "utils/conversionUtils"; -import { isMakerOutdated } from "utils/multiAddrUtils"; - -const stubTestnetMaker = getStubTestnetMaker(); - -export interface MakersSlice { - rendezvous: { - makers: (ExtendedMakerStatus | MakerStatus)[]; - }; - registry: { - makers: ExtendedMakerStatus[] | null; - // This counts how many failed connections attempts we have counted since the last successful connection - connectionFailsCount: number; - }; - selectedMaker: ExtendedMakerStatus | null; -} - -const initialState: MakersSlice = { - rendezvous: { - makers: [], - }, - registry: { - makers: stubTestnetMaker ? [stubTestnetMaker] : null, - connectionFailsCount: 0, - }, - selectedMaker: null, -}; - -export const makersSlice = createSlice({ - name: "providers", - initialState, - reducers: { - discoveredMakersByRendezvous(slice, action: PayloadAction) { - action.payload.forEach((discoveredSeller) => { - const discoveredMakerStatus = - rendezvousSellerToMakerStatus(discoveredSeller); - - // If the seller has a status of "Unreachable" the provider is not added to the list - if (discoveredMakerStatus === null) { - return; - } - - // If the provider was already discovered via the public registry, don't add it again - const indexOfExistingMaker = slice.rendezvous.makers.findIndex( - (prov) => - prov.peerId === discoveredMakerStatus.peerId && - prov.multiAddr === discoveredMakerStatus.multiAddr, - ); - - // Avoid duplicate entries, replace them instead - if (indexOfExistingMaker !== -1) { - slice.rendezvous.makers[indexOfExistingMaker] = discoveredMakerStatus; - } else { - slice.rendezvous.makers.push(discoveredMakerStatus); - } - }); - }, - setRegistryMakers(slice, action: PayloadAction) { - if (stubTestnetMaker) { - action.payload.push(stubTestnetMaker); - } - }, - registryConnectionFailed(slice) { - slice.registry.connectionFailsCount += 1; - }, - }, -}); - -export const { - discoveredMakersByRendezvous, - setRegistryMakers, - registryConnectionFailed, -} = makersSlice.actions; - -export default makersSlice.reducer; diff --git a/src-gui/src/store/features/p2pSlice.ts b/src-gui/src/store/features/p2pSlice.ts new file mode 100644 index 00000000..20a525df --- /dev/null +++ b/src-gui/src/store/features/p2pSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { + PeerQuoteProgress, + ConnectionChange, + ConnectionStatus, + QuoteStatus, +} from "models/tauriModel"; +import { exhaustiveGuard } from "utils/typescriptUtils"; + +interface P2PSlice { + connectionStatus: Record; + lastAddress: Record; + quoteStatus: Record; +} + +const initialState: P2PSlice = { + connectionStatus: {}, + lastAddress: {}, + quoteStatus: {}, +}; + +export const p2pSlice = createSlice({ + name: "p2p", + initialState, + reducers: { + quotesProgressReceived(slice, action: PayloadAction) { + action.payload.forEach((entry) => { + slice.quoteStatus[entry.peer_id] = entry.quote_status; + }); + }, + connectionChangeReceived( + slice, + action: PayloadAction<{ peer_id: string; change: ConnectionChange }>, + ) { + const { peer_id, change } = action.payload; + + switch (change.type) { + case "Connection": + slice.connectionStatus[peer_id] = change.content; + break; + case "LastAddress": + slice.lastAddress[peer_id] = change.content; + break; + default: + exhaustiveGuard(change); + } + }, + }, +}); + +export const { quotesProgressReceived, connectionChangeReceived } = + p2pSlice.actions; + +export default p2pSlice.reducer; diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index 3c4c3b65..a87665aa 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -16,7 +16,6 @@ import logger from "utils/logger"; interface State { withdrawTxId: string | null; - rendezvousDiscoveredSellers: (ExtendedMakerStatus | MakerStatus)[]; swapInfos: { [swapId: string]: GetSwapInfoResponseExt; }; @@ -58,7 +57,6 @@ const initialState: RPCSlice = { status: null, state: { withdrawTxId: null, - rendezvousDiscoveredSellers: [], swapInfos: {}, swapTimelocks: {}, moneroRecovery: null, @@ -97,12 +95,6 @@ export const rpcSlice = createSlice({ rpcSetWithdrawTxId(slice, action: PayloadAction) { slice.state.withdrawTxId = action.payload; }, - rpcSetRendezvousDiscoveredMakers( - slice, - action: PayloadAction<(ExtendedMakerStatus | MakerStatus)[]>, - ) { - slice.state.rendezvousDiscoveredSellers = action.payload; - }, rpcResetWithdrawTxId(slice) { slice.state.withdrawTxId = null; }, @@ -175,7 +167,6 @@ export const { contextInitializationFailed, rpcSetWithdrawTxId, rpcResetWithdrawTxId, - rpcSetRendezvousDiscoveredMakers, rpcSetSwapInfo, rpcSetMoneroRecoveryKeys, rpcResetMoneroRecoveryKeys, diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 77a6b46c..3a8023ed 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -153,14 +153,6 @@ export function useActiveSwapLogs() { }, [logs, swapId]); } -export function useAllMakers() { - return useAppSelector((state) => { - const registryMakers = state.makers.registry.makers || []; - const listSellersMakers = state.makers.rendezvous.makers || []; - return [...registryMakers, ...listSellersMakers]; - }); -} - /// This hook returns the all swap infos, as an array /// Excluding those who are in a state where it's better to hide them from the user export function useSaneSwapInfos() { diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index b0917d47..f3587765 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -6,7 +6,6 @@ import { checkBitcoinBalance, getBitcoinAddress, updateAllNodeStatuses, - fetchSellersAtPresetRendezvousPoints, getSwapInfo, getSwapTimelock, initializeMoneroWallet, @@ -139,17 +138,6 @@ export function createMainListeners() { await getAllSwapInfos(); await getAllSwapTimelocks(); } - - // If the database just became availiable, fetch sellers at preset rendezvous points - if ( - status.database_available && - !previousContextStatus?.database_available - ) { - logger.info( - "Database just became available, fetching sellers at preset rendezvous points...", - ); - await fetchSellersAtPresetRendezvousPoints(); - } }, }); diff --git a/src-gui/src/store/selectors.ts b/src-gui/src/store/selectors.ts index d1b1e156..71a21d7c 100644 --- a/src-gui/src/store/selectors.ts +++ b/src-gui/src/store/selectors.ts @@ -1,9 +1,14 @@ import { createSelector } from "@reduxjs/toolkit"; import { RootState } from "renderer/store/storeRenderer"; import { GetSwapInfoResponseExt } from "models/tauriModelExt"; -import { ExpiredTimelocks } from "models/tauriModel"; +import { + ConnectionStatus, + ExpiredTimelocks, + QuoteStatus, +} from "models/tauriModel"; const selectRpcState = (state: RootState) => state.rpc.state; +const selectP2pState = (state: RootState) => state.p2p; export const selectAllSwapIds = createSelector([selectRpcState], (rpcState) => Object.keys(rpcState.swapInfos), @@ -47,3 +52,19 @@ export const selectPendingApprovals = createSelector( (c) => c.request_status.state === "Pending", ), ); + +// TODO: This should be split into multiple selectors/hooks to avoid excessive re-rendering +export const selectPeers = createSelector([selectP2pState], (p2p) => { + const peerIds = new Set([ + ...Object.keys(p2p.connectionStatus), + ...Object.keys(p2p.lastAddress), + ...Object.keys(p2p.quoteStatus), + ]); + + return Array.from(peerIds).map((peerId) => ({ + peer_id: peerId, + connection: p2p.connectionStatus[peerId] ?? null, + last_address: p2p.lastAddress[peerId] ?? null, + quote: p2p.quoteStatus[peerId] ?? null, + })); +}); diff --git a/src-gui/src/utils/conversionUtils.ts b/src-gui/src/utils/conversionUtils.ts index f69db74f..e64a411d 100644 --- a/src-gui/src/utils/conversionUtils.ts +++ b/src-gui/src/utils/conversionUtils.ts @@ -1,8 +1,3 @@ -import { MakerStatus, ExtendedMakerStatus } from "models/apiModel"; -import { SellerStatus } from "models/tauriModel"; -import { isTestnet } from "store/config"; -import { splitPeerIdFromMultiAddress } from "./parseUtils"; - export function satsToBtc(sats: number): number { return sats / 100000000; } @@ -48,27 +43,6 @@ export function secondsToDays(seconds: number): number { return seconds / 86400; } -// Convert the "Seller" object returned by the list sellers tauri endpoint to a "MakerStatus" object -// which we use internally to represent the status of a provider. This provides consistency between -// the models returned by the public registry and the models used internally. -export function rendezvousSellerToMakerStatus( - seller: SellerStatus, -): ExtendedMakerStatus | null { - if (seller.type === "Unreachable") { - return null; - } - - return { - maxSwapAmount: seller.content.quote.max_quantity, - minSwapAmount: seller.content.quote.min_quantity, - price: seller.content.quote.price, - peerId: seller.content.peer_id, - multiAddr: seller.content.multiaddr, - testnet: isTestnet(), - version: seller.content.version, - }; -} - export function bytesToMb(bytes: number): number { return bytes / (1024 * 1024); } diff --git a/src-gui/src/utils/multiAddrUtils.ts b/src-gui/src/utils/multiAddrUtils.ts index 6223a260..ad34d391 100644 --- a/src-gui/src/utils/multiAddrUtils.ts +++ b/src-gui/src/utils/multiAddrUtils.ts @@ -8,12 +8,6 @@ import { isTestnet } from "store/config"; // const MIN_ASB_VERSION = "2.0.0-beta.1"; // First version with support for tx_early_refund const MIN_ASB_VERSION = "3.2.0-rc.1"; -export function providerToConcatenatedMultiAddr(provider: Maker) { - return new Multiaddr(provider.multiAddr) - .encapsulate(`/p2p/${provider.peerId}`) - .toString(); -} - export function isMakerOnCorrectNetwork( provider: ExtendedMakerStatus, ): boolean { diff --git a/src-gui/yarn.lock b/src-gui/yarn.lock index 6ce90148..86308b6c 100644 --- a/src-gui/yarn.lock +++ b/src-gui/yarn.lock @@ -1142,6 +1142,13 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93" integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA== +"@types/node@*": + version "24.10.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01" + integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== + dependencies: + undici-types "~7.16.0" + "@types/node@^22.15.29": version "22.17.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.17.2.tgz#47a93d6f4b79327da63af727e7c54e8cab8c4d33" @@ -1568,11 +1575,6 @@ bl@^1.2.1: readable-stream "^2.3.5" safe-buffer "^5.1.1" -boring-avatars@^1.11.2: - version "1.11.2" - resolved "https://registry.yarnpkg.com/boring-avatars/-/boring-avatars-1.11.2.tgz#365e0b765fb0065ca0cb2fd20c200674d0a9ded6" - integrity sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A== - brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -1659,6 +1661,13 @@ caniuse-lite@^1.0.30001735: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz#8292bb7591932ff09e9a765f12fdf5629a241ccc" integrity sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw== +canvas-renderer@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/canvas-renderer/-/canvas-renderer-2.2.1.tgz#c1d131f78a9799aca8af9679ad0a005052b65550" + integrity sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg== + dependencies: + "@types/node" "*" + chai@^5.1.2: version "5.3.2" resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.2.tgz#e2c35570b8fa23b5b7129b4114d5dc03b3fd3401" @@ -2842,6 +2851,13 @@ iterator.prototype@^1.1.4: has-symbols "^1.1.0" set-function-name "^2.0.2" +jdenticon@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/jdenticon/-/jdenticon-3.3.0.tgz#64bae9f9b3cf5c2a210e183648117afe3a89b367" + integrity sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg== + dependencies: + canvas-renderer "~2.2.0" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4084,6 +4100,11 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + update-browserslist-db@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 890b3575..db1e70ee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2.*", features = ["config-json5"] } [dependencies] monero-rpc-pool = { path = "../monero-rpc-pool" } swap = { path = "../swap", features = [ "tauri" ] } +swap-p2p = { path = "../swap-p2p" } dfx-swiss-sdk = { workspace = true } anyhow = "1" @@ -40,6 +41,9 @@ tauri-plugin-updater = "2.*" tokio = { workspace = true } tokio-util = { workspace = true } +# LibP2P +libp2p = { workspace = true } + [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-cli = "2.*" tauri-plugin-single-instance = "2.*" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index faf6e2df..d7be8149 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,7 +12,7 @@ use swap::cli::{ GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, - GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, + GetSwapInfoArgs, GetSwapInfosAllArgs, MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, @@ -22,6 +22,7 @@ use swap::cli::{ }, command::Bitcoin, }; +use swap_p2p::libp2p_ext::MultiAddrVecExt; use tauri_plugin_dialog::DialogExt; use zip::{write::SimpleFileOptions, ZipWriter}; @@ -46,7 +47,6 @@ macro_rules! generate_command_handlers { get_history, monero_recovery, get_logs, - list_sellers, suspend_current_swap, cancel_and_refund, initialize_context, @@ -168,6 +168,9 @@ pub async fn initialize_context( // Get tauri handle from the state let tauri_handle = state.handle.clone(); + // Parse rendeuvous points + let rendezvous_points = settings.rendezvous_points.extract_peer_addresses(); + // Now populate the context in the background let context_result = ContextBuilder::new(testnet) .with_bitcoin(Bitcoin { @@ -178,6 +181,7 @@ pub async fn initialize_context( .with_json(false) .with_tor(settings.use_tor) .with_enable_monero_tor(settings.enable_monero_tor) + .with_rendezvous_points(rendezvous_points) .with_tauri(tauri_handle.clone()) .build(state.context()) .await; @@ -435,7 +439,6 @@ tauri_command!(resume_swap, ResumeSwapArgs); tauri_command!(withdraw_btc, WithdrawBtcArgs); tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); -tauri_command!(list_sellers, ListSellersArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs); tauri_command!(redact, RedactArgs); tauri_command!(send_monero, SendMoneroArgs); diff --git a/swap-controller-api/src/lib.rs b/swap-controller-api/src/lib.rs index 883059a7..6790dec7 100644 --- a/swap-controller-api/src/lib.rs +++ b/swap-controller-api/src/lib.rs @@ -40,21 +40,21 @@ pub struct ActiveConnectionsResponse { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum RendezvousConnectionStatus { - Disconnected, - Dialling, Connected, + Disconnected, } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum RendezvousRegistrationStatus { - RegisterOnNextConnection, - Pending, Registered, + WillRegisterAfterDelay, + RegisterOnceConnected, + RequestInflight, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct RegistrationStatusItem { - pub address: String, + pub address: Option, pub connection: RendezvousConnectionStatus, pub registration: RendezvousRegistrationStatus, } diff --git a/swap-controller/src/main.rs b/swap-controller/src/main.rs index 82a9eb5c..6c0361bd 100644 --- a/swap-controller/src/main.rs +++ b/swap-controller/src/main.rs @@ -95,9 +95,10 @@ async fn dispatch(cmd: Cmd, client: impl AsbApiClient) -> anyhow::Result<()> { println!("No rendezvous points configured"); } else { for item in response.registrations { + let address = item.address.as_deref().unwrap_or("?"); println!( "Connection status to rendezvous point at \"{}\" is \"{:?}\". Registration status is \"{:?}\"", - item.address, item.connection, item.registration + address, item.connection, item.registration ); } } diff --git a/swap-env/src/defaults.rs b/swap-env/src/defaults.rs index 86465e62..1539a789 100644 --- a/swap-env/src/defaults.rs +++ b/swap-env/src/defaults.rs @@ -46,7 +46,6 @@ pub fn default_rendezvous_points() -> Vec { "/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw".parse().unwrap(), "/dns4/darkness.su/tcp/8888/p2p/12D3KooWFQAgVVS9t9UgL6v1sLprJVM7am5hFK7vy9iBCCoCBYmU".parse().unwrap(), "/dns4/eigen.center/tcp/8888/p2p/12D3KooWS5RaYJt4ANKMH4zczGVhNcw5W214e2DDYXnjs5Mx5zAT".parse().unwrap(), - "/dns4/swapanarchy.cfd/tcp/8888/p2p/12D3KooWRtyVpmyvwzPYXuWyakFbRKhyXGrjhq6tP7RrBofpgQGp".parse().unwrap(), "/dns4/rendezvous.observer/tcp/8888/p2p/12D3KooWMjceGXrYuGuDMGrfmJxALnSDbK4km6s1i1sJEgDTgGQa".parse().unwrap(), "/dns4/aswap.click/tcp/8888/p2p/12D3KooWQzW52mdsLHTMu1EPiz3APumG6vGwpCuyy494MAQoEa5X".parse().unwrap(), "/dns4/getxmr.st/tcp/8888/p2p/12D3KooWHHwiz6WDThPT8cEurstomg3kDSxzL2L8pwxfyX2fpxVk".parse().unwrap() @@ -141,7 +140,7 @@ impl GetDefaults for Testnet { .join("testnet") .join("config.toml"), data_dir: default_asb_data_dir()?.join("testnet"), - listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9939")?, + listen_address_tcp: Multiaddr::from_str("/ip4/0.0.0.0/tcp/9839")?, electrum_rpc_urls: default_electrum_servers_testnet(), price_ticker_ws_url: Url::parse(KRAKEN_PRICE_TICKER_WS_URL)?, bitcoin_confirmation_target: 1, diff --git a/swap-machine/src/common/mod.rs b/swap-machine/src/common/mod.rs index ff724830..89c2c49d 100644 --- a/swap-machine/src/common/mod.rs +++ b/swap-machine/src/common/mod.rs @@ -166,4 +166,5 @@ pub trait Database { &self, swap_id: Uuid, ) -> Result>; + async fn has_swap(&self, swap_id: Uuid) -> Result; } diff --git a/swap-p2p/Cargo.toml b/swap-p2p/Cargo.toml index 6936c2a1..63d2c80e 100644 --- a/swap-p2p/Cargo.toml +++ b/swap-p2p/Cargo.toml @@ -14,7 +14,7 @@ swap-serde = { path = "../swap-serde" } # Networking async-trait = { workspace = true } -libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "identify", "ping"] } +libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "ping", "identify"] } # Serialization asynchronous-codec = "0.7.0" @@ -31,6 +31,7 @@ rand = { workspace = true } # Utils anyhow = { workspace = true } backoff = { workspace = true } +semver = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true, features = ["serde"] } void = "1" @@ -43,8 +44,21 @@ tokio = { workspace = true } # Logging tracing = { workspace = true } +[[example]] +name = "fetch_quotes" +required-features = ["fetch-quotes-example"] + +[features] +fetch-quotes-example = [] +test-support = ["libp2p/noise", "libp2p/tcp", "libp2p/yamux", "libp2p/tokio", "libp2p/dns"] + [dev-dependencies] -libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "ping", "identify", "noise", "tcp", "yamux", "tokio"] } +arti-client = { workspace = true } +async-trait = { workspace = true } +libp2p = { workspace = true, features = ["serde", "request-response", "rendezvous", "cbor", "json", "ping", "identify", "noise", "tcp", "yamux", "tokio", "dns"] } +libp2p-tor = { path = "../libp2p-tor" } +tor-rtcompat = { workspace = true } +tracing-subscriber = { workspace = true } [lints] workspace = true diff --git a/swap-p2p/examples/fetch_quotes.rs b/swap-p2p/examples/fetch_quotes.rs new file mode 100644 index 00000000..23a0a5ba --- /dev/null +++ b/swap-p2p/examples/fetch_quotes.rs @@ -0,0 +1,157 @@ +use anyhow::Result; +use arti_client::{config::TorClientConfigBuilder, TorClient}; +use futures::StreamExt; +use libp2p::core::muxing::StreamMuxerBox; +use libp2p::core::transport::Boxed; +use libp2p::core::upgrade::Version; +use libp2p::multiaddr::Protocol; +use libp2p::swarm::dial_opts::DialOpts; +use libp2p::swarm::NetworkBehaviour; +use libp2p::{dns, tcp}; +use libp2p::{identify, noise, ping, request_response}; +use libp2p::{identity, yamux, Multiaddr, PeerId, SwarmBuilder, Transport}; +use libp2p_tor::{AddressConversion, TorTransport}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::Duration; +use swap_p2p::libp2p_ext::MultiAddrExt; +use swap_p2p::protocols::quote::BidQuote; +use swap_p2p::protocols::{quote, quotes_cached, rendezvous}; +use tor_rtcompat::tokio::TokioRustlsRuntime; + +const USE_TOR: bool = true; + +#[derive(NetworkBehaviour)] +struct Behaviour { + rendezvous: rendezvous::discovery::Behaviour, + ping: ping::Behaviour, + quote: quotes_cached::Behaviour, +} + +fn create_transport( + identity: &identity::Keypair, + tor_client: Option>>, +) -> Result> { + let auth_upgrade = noise::Config::new(identity)?; + let multiplex_upgrade = yamux::Config::default(); + + if let Some(tor_client) = tor_client { + let transport = TorTransport::from_client(tor_client, AddressConversion::IpAndDns) + .upgrade(Version::V1) + .authenticate(auth_upgrade) + .multiplex(multiplex_upgrade) + .timeout(Duration::from_secs(60)) + .map(|(peer, muxer), _| (peer, StreamMuxerBox::new(muxer))) + .boxed(); + Ok(transport) + } else { + // TCP with system DNS + let tcp = tcp::tokio::Transport::new(tcp::Config::default().nodelay(true)); + let tcp_dns = dns::tokio::Transport::system(tcp)?; + let transport = tcp_dns + .upgrade(Version::V1) + .authenticate(auth_upgrade) + .multiplex(multiplex_upgrade) + .timeout(Duration::from_secs(60)) + .map(|(peer, muxer), _| (peer, StreamMuxerBox::new(muxer))) + .boxed(); + Ok(transport) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new( + "info,swap_p2p=trace,fetch_quotes=trace,libp2p_request_response=trace,libp2p_swarm=debug", + )) + .try_init(); + + let identity = identity::Keypair::generate_ed25519(); + + let tor_client_opt = if USE_TOR { + let config = TorClientConfigBuilder::default().build()?; + let runtime = TokioRustlsRuntime::current()?; + let tor_client = TorClient::with_runtime(runtime) + .config(config) + .create_bootstrapped() + .await?; + Some(Arc::new(tor_client)) + } else { + None + }; + + let rendezvous_nodes = swap_env::defaults::default_rendezvous_points(); + let rendezvous_nodes_peer_ids = rendezvous_nodes + .iter() + .map(|addr| { + addr.extract_peer_id() + .expect("Rendezvous node address must contain peer ID") + }) + .collect(); + + let namespace = rendezvous::XmrBtcNamespace::Mainnet; + + let behaviour = Behaviour { + rendezvous: rendezvous::discovery::Behaviour::new( + identity.clone(), + rendezvous_nodes_peer_ids, + namespace.into(), + ), + ping: ping::Behaviour::new(ping::Config::new().with_interval(Duration::from_secs(1))), + quote: quotes_cached::Behaviour::new(identify::Config::new( + "fetch_quotes/1.0.0".to_string(), + identity.public(), + )), + }; + + let transport = create_transport(&identity, tor_client_opt)?; + + let mut swarm = SwarmBuilder::with_existing_identity(identity) + .with_tokio() + .with_other_transport(|_| transport)? + .with_behaviour(|_| behaviour)? + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60))) + .build(); + + for rendezvous_node_addr in rendezvous_nodes { + swarm.add_peer_address( + rendezvous_node_addr + .extract_peer_id() + .expect("Rendezvous node address must contain peer ID"), + rendezvous_node_addr, + ); + } + + loop { + let event = swarm.select_next_some().await; + // println!("Event: {:?}", event); + + match event { + libp2p::swarm::SwarmEvent::Behaviour(event) => match event { + BehaviourEvent::Rendezvous(event) => match event { + rendezvous::discovery::Event::DiscoveredPeer { peer_id } => {} + }, + BehaviourEvent::Quote(quotes_cached::Event::CachedQuotes { quotes }) => { + println!("================"); + println!("================"); + println!("================"); + println!("==== !!!! GOT CACHED QUOTES SNAPSHOT !!!! ===="); + println!("All quotes:"); + for (peer, addr, quote, agent_version) in quotes { + println!("- {peer} @ {addr}:"); + if let Some(version) = agent_version { + println!(" - Agent Version: {version}"); + } + println!(" - {:?}", quote); + println!("================"); + } + // panic!("Got quote from peer, stopping"); + } + _ => {} + }, + libp2p::swarm::SwarmEvent::ConnectionEstablished { peer_id, .. } => {} + _ => {} + } + } +} diff --git a/swap-p2p/src/behaviour_util.rs b/swap-p2p/src/behaviour_util.rs new file mode 100644 index 00000000..3d983967 --- /dev/null +++ b/swap-p2p/src/behaviour_util.rs @@ -0,0 +1,379 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use backoff::backoff::Backoff; +use backoff::ExponentialBackoff; +use libp2p::core::ConnectedPoint; +use libp2p::Multiaddr; +use libp2p::{ + swarm::{ConnectionId, FromSwarm}, + PeerId, +}; + +/// Used inside of a Behaviour to track connections to peers +// TODO: Track inflight dial attempts +pub struct ConnectionTracker { + connections: HashMap>, + inflight_dials: HashMap>, +} + +impl ConnectionTracker { + pub fn new() -> Self { + Self { + connections: HashMap::new(), + inflight_dials: HashMap::new(), + } + } + + pub fn is_connected(&self, peer_id: &PeerId) -> bool { + self.connections + .get(peer_id) + .map(|connections| !connections.is_empty()) + .unwrap_or(false) + } + + pub fn has_inflight_dial(&self, peer_id: &PeerId) -> bool { + self.inflight_dials + .get(peer_id) + .map(|dials| !dials.is_empty()) + .unwrap_or(false) + } + + pub fn peers(&self) -> impl Iterator { + self.connections.keys() + } + + /// Any behaviour that uses the ConnectionTracker MUST call this method on every [`NetworkBehaviour::on_swarm_event`] + /// + /// Returns the peer id if the calling of this method resulted in a change of the internal state of that peer + pub fn handle_swarm_event(&mut self, event: FromSwarm<'_>) -> Option { + match event { + FromSwarm::ConnectionEstablished(connection_established) => { + self.connections + .entry(connection_established.peer_id) + .or_insert_with(HashSet::new) + .insert(connection_established.connection_id); + + // This dial attempts is no longer inflight because it has been established + if let Some(inflight_dials) = + self.inflight_dials.get_mut(&connection_established.peer_id) + { + if inflight_dials.remove(&connection_established.connection_id) { + return Some(connection_established.peer_id); + } + } + } + FromSwarm::ConnectionClosed(connection_closed) => { + self.connections + .entry(connection_closed.peer_id) + .and_modify(|connections| { + connections.remove(&connection_closed.connection_id); + }); + + return Some(connection_closed.peer_id); + } + FromSwarm::DialFailure(dial_failure) => { + // This dial attempts is no longer inflight because it has failed + if let Some(peer_id) = dial_failure.peer_id { + if let Some(inflight_dials) = self.inflight_dials.get_mut(&peer_id) { + if inflight_dials.remove(&dial_failure.connection_id) { + return Some(peer_id); + } + } + } + } + _ => {} + } + + None + } + + /// Any behaviour that uses the ConnectionTracker MUST call this method on every [`NetworkBehaviour::handle_pending_outbound_connection`] + pub fn handle_pending_outbound_connection( + &mut self, + connection_id: ConnectionId, + maybe_peer: Option, + ) -> Option { + if let Some(peer_id) = maybe_peer { + if self + .inflight_dials + .entry(peer_id) + .or_insert_with(HashSet::new) + .insert(connection_id) + { + return Some(peer_id); + } + } + + None + } +} + +/// Used inside of a Behaviour to track exponential backoff states for each peer. +pub struct BackoffTracker { + backoffs: HashMap, + initial_interval: Duration, + max_interval: Duration, + multiplier: f64, +} + +impl BackoffTracker { + pub fn new(initial: Duration, max: Duration, multiplier: f64) -> Self { + Self { + backoffs: HashMap::new(), + initial_interval: initial, + max_interval: max, + multiplier, + } + } + + /// Get the backoff for a given peer. + pub fn get(&mut self, peer: &PeerId) -> &mut ExponentialBackoff { + self.backoffs + .entry(*peer) + .or_insert_with(|| ExponentialBackoff { + initial_interval: self.initial_interval, + current_interval: self.initial_interval, + max_interval: self.max_interval, + multiplier: self.multiplier, + // Never give up + max_elapsed_time: None, + ..ExponentialBackoff::default() + }) + } + + /// Reset the backoff state the given peer. + pub fn reset(&mut self, peer: &PeerId) { + if let Some(b) = self.backoffs.get_mut(peer) { + b.reset(); + } + } + + /// Increments the backoff for the given peer and returns the new backoff + pub fn increment(&mut self, peer: &PeerId) -> Duration { + self.get(peer) + .next_backoff() + .expect("backoff should never run out") + } +} + +/// Used inside of a Behaviour to track the last successful address for a peer +/// TODO: Track success/failure rates for each address +pub struct AddressTracker { + addresses: HashMap, +} + +impl AddressTracker { + pub fn new() -> Self { + Self { + addresses: HashMap::new(), + } + } + + /// Any behaviour that uses the AddressTracker MUST call this method on every [`NetworkBehaviour::on_swarm_event`] + /// + /// Returns the peer id if the calling of this method resulted in a change of the internal state of that peer + pub fn handle_swarm_event(&mut self, event: FromSwarm<'_>) -> Option { + match event { + // If we connected as a dialer, record the address we connected to them at + FromSwarm::ConnectionEstablished(connection_established) => { + if let ConnectedPoint::Dialer { address, .. } = connection_established.endpoint { + let old_address = self + .addresses + .insert(connection_established.peer_id, address.clone()); + + // Return the peer id if the address was changed + if old_address.as_ref() != Some(address) { + return Some(connection_established.peer_id); + } + } + } + FromSwarm::NewExternalAddrOfPeer(new_external_addr_of_peer) => { + // If we have never successfully connected to any address of the peer, we record the first announced address + if !self + .addresses + .contains_key(&new_external_addr_of_peer.peer_id) + { + self.addresses.insert( + new_external_addr_of_peer.peer_id, + new_external_addr_of_peer.addr.clone(), + ); + + // Always return the peer id because the entry was previously empty + return Some(new_external_addr_of_peer.peer_id); + } + } + _ => (), + } + + None + } + + pub fn peers(&self) -> impl Iterator { + self.addresses.keys() + } + + pub fn last_seen_address(&self, peer_id: &PeerId) -> Option { + self.addresses.get(peer_id).cloned() + } +} + +/// Extracts the semver version from a user agent string. +/// Example input: "asb/2.0.0 (xmr-btc-swap-mainnet)" +/// Returns None if the version cannot be parsed. +pub fn extract_semver_from_agent_str(agent_str: &str) -> Option { + // Split on '/' and take the second part + let version_str = agent_str.split('/').nth(1)?; + // Split on whitespace and take the first part + let version_str = version_str.split_whitespace().next()?; + // Parse the version string + semver::Version::parse(version_str).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use libp2p::core::{ConnectedPoint, Endpoint, Multiaddr}; + use libp2p::swarm::behaviour::{ + ConnectionClosed, ConnectionEstablished, DialFailure, NewExternalAddrOfPeer, + }; + use libp2p::swarm::{ConnectionId, DialError, FromSwarm}; + use libp2p::PeerId; + + #[test] + fn test_connection_tracker_basic() { + let mut tracker = ConnectionTracker::new(); + let peer_id = PeerId::random(); + let conn_id = ConnectionId::new_unchecked(1); + let endpoint = ConnectedPoint::Dialer { + address: "/ip4/127.0.0.1/tcp/1234".parse().unwrap(), + role_override: Endpoint::Dialer, + }; + + // Verify initially not connected + assert!(!tracker.is_connected(&peer_id)); + + // Simulate connection established + let event = FromSwarm::ConnectionEstablished(ConnectionEstablished { + peer_id, + connection_id: conn_id, + endpoint: &endpoint, + failed_addresses: &[], + other_established: 0, + }); + + tracker.handle_swarm_event(event); + assert!(tracker.is_connected(&peer_id)); + + // Simulate connection closed + let event = FromSwarm::ConnectionClosed(ConnectionClosed { + peer_id, + connection_id: conn_id, + endpoint: &endpoint, + remaining_established: 0, + }); + + tracker.handle_swarm_event(event); + assert!(!tracker.is_connected(&peer_id)); + } + + #[test] + fn test_connection_tracker_inflight() { + let mut tracker = ConnectionTracker::new(); + let peer_id = PeerId::random(); + let conn_id = ConnectionId::new_unchecked(1); + + // Add pending outbound + tracker.handle_pending_outbound_connection(conn_id, Some(peer_id)); + assert!(tracker.has_inflight_dial(&peer_id)); + + // Simulate dial failure + let error = DialError::Aborted; + let event = FromSwarm::DialFailure(DialFailure { + peer_id: Some(peer_id), + error: &error, + connection_id: conn_id, + }); + + tracker.handle_swarm_event(event); + assert!(!tracker.has_inflight_dial(&peer_id)); + } + + #[test] + fn test_backoff_tracker() { + let mut tracker = BackoffTracker::new(Duration::from_secs(1), Duration::from_secs(10), 2.0); + let peer_id = PeerId::random(); + + // Initial increment + let backoff1 = tracker.increment(&peer_id); + // With default randomization factor 0.5, it can be down to 0.5 * initial + assert!(backoff1 >= Duration::from_millis(500)); + + // Next increment increases + let backoff2 = tracker.increment(&peer_id); + assert!(backoff2 > backoff1); + + // Reset + tracker.reset(&peer_id); + + // After reset, it should start over + let backoff_after_reset = tracker.increment(&peer_id); + assert!(backoff_after_reset < backoff2); + } + + #[test] + fn test_address_tracker() { + let mut tracker = AddressTracker::new(); + let peer_id = PeerId::random(); + let addr: Multiaddr = "/ip4/127.0.0.1/tcp/1234".parse().unwrap(); + let endpoint = ConnectedPoint::Dialer { + address: addr.clone(), + role_override: Endpoint::Dialer, + }; + let conn_id = ConnectionId::new_unchecked(1); + + // Connection established + let event = FromSwarm::ConnectionEstablished(ConnectionEstablished { + peer_id, + connection_id: conn_id, + endpoint: &endpoint, + failed_addresses: &[], + other_established: 0, + }); + + tracker.handle_swarm_event(event); + + assert_eq!(tracker.last_seen_address(&peer_id), Some(addr.clone())); + } + + #[test] + fn test_address_tracker_external_addr() { + let mut tracker = AddressTracker::new(); + let peer_id = PeerId::random(); + let addr: Multiaddr = "/ip4/127.0.0.1/tcp/8080".parse().unwrap(); + + let event = FromSwarm::NewExternalAddrOfPeer(NewExternalAddrOfPeer { + peer_id, + addr: &addr, + }); + + tracker.handle_swarm_event(event); + assert_eq!(tracker.last_seen_address(&peer_id), Some(addr)); + } + + #[test] + fn test_extract_semver() { + let agent = "asb/2.0.0 (xmr-btc-swap-mainnet)"; + let version = extract_semver_from_agent_str(agent).unwrap(); + assert_eq!(version, semver::Version::new(2, 0, 0)); + + let invalid = "invalid"; + assert!(extract_semver_from_agent_str(invalid).is_none()); + + let agent_v3 = "asb/3.1.4-rc1 other-info"; + let version_v3 = extract_semver_from_agent_str(agent_v3).unwrap(); + assert_eq!(version_v3.major, 3); + assert_eq!(version_v3.minor, 1); + assert_eq!(version_v3.patch, 4); + } +} diff --git a/swap-p2p/src/defaults.rs b/swap-p2p/src/defaults.rs new file mode 100644 index 00000000..18c4644f --- /dev/null +++ b/swap-p2p/src/defaults.rs @@ -0,0 +1,34 @@ +use std::time::Duration; + +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +pub const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(15 * 60); // 15 minutes + +pub const BACKOFF_MULTIPLIER: f64 = 1.5; + +// Redial +pub const REDIAL_INITIAL_INTERVAL: Duration = Duration::from_secs(1); +pub const REDIAL_MAX_INTERVAL: Duration = Duration::from_secs(10); + +// Rendezvous +pub const RENDEZVOUS_REDIAL_MAX_INTERVAL: Duration = Duration::from_secs(60); + +// Rendezvous discovery +pub const DISCOVERY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); +pub const DISCOVERY_MAX_INTERVAL: Duration = Duration::from_secs(60 * 3); +pub const DISCOVERY_INTERVAL: Duration = Duration::from_secs(60); + +// Rendezvous register +pub const RENDEZVOUS_RETRY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); +pub const RENDEZVOUS_RETRY_MAX_INTERVAL: Duration = Duration::from_secs(60); + +// Quote +pub const CACHED_QUOTE_EXPIRY: Duration = Duration::from_secs(120); +pub const QUOTE_INTERVAL: Duration = Duration::from_secs(45); +pub const QUOTE_REDIAL_INTERVAL: Duration = Duration::from_secs(1); +pub const QUOTE_REDIAL_MAX_INTERVAL: Duration = Duration::from_secs(30); +pub const QUOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +// Swap setup +pub const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(120); +pub const SWAP_SETUP_KEEP_ALIVE: Duration = Duration::from_secs(30); +pub const SWAP_SETUP_CHANNEL_TIMEOUT: Duration = Duration::from_secs(60); diff --git a/swap-p2p/src/futures_util.rs b/swap-p2p/src/futures_util.rs index ca222632..e91eae50 100644 --- a/swap-p2p/src/futures_util.rs +++ b/swap-p2p/src/futures_util.rs @@ -1,66 +1,100 @@ -use libp2p::futures::future::BoxFuture; +use libp2p::futures::future::{self, AbortHandle, Abortable, BoxFuture}; use libp2p::futures::stream::{FuturesUnordered, StreamExt}; -use std::collections::HashSet; +use std::collections::HashMap; use std::hash::Hash; use std::task::{Context, Poll}; /// A collection of futures with associated keys that can be checked for presence /// before completion. /// -/// This combines a HashSet for key tracking with FuturesUnordered for efficient polling. +/// This combines a HashMap for key tracking and cancellation with FuturesUnordered for efficient polling. /// The key is provided during insertion; the future only needs to yield the value. +/// If a future with the same key is inserted, the previous one is aborted/replaced. pub struct FuturesHashSet { - keys: HashSet, - futures: FuturesUnordered>, + futures: FuturesUnordered>>, + handles: HashMap, } impl FuturesHashSet { pub fn new() -> Self { Self { - keys: HashSet::new(), futures: FuturesUnordered::new(), + handles: HashMap::new(), } } /// Check if a future with the given key is already pending pub fn contains_key(&self, key: &K) -> bool { - self.keys.contains(key) + self.handles.contains_key(key) } /// Insert a new future with the given key. - /// The future should yield V; the key will be paired with it when it completes. - /// Returns true if the key was newly inserted, false if it was already present. - /// If false is returned, the future is not added. + /// If a future with the same key already exists, it returns false and does NOT replace it. pub fn insert(&mut self, key: K, future: BoxFuture<'static, V>) -> bool { - if self.keys.insert(key.clone()) { - let key_clone = key; - let wrapped = async move { - let value = future.await; - (key_clone, value) - }; - self.futures.push(Box::pin(wrapped)); - true - } else { - false + if self.handles.contains_key(&key) { + return false; } + + let (handle, registration) = AbortHandle::new_pair(); + self.handles.insert(key.clone(), handle); + + let key_clone = key; + let wrapped = async move { + let value = future.await; + (key_clone, value) + }; + + let abortable = Abortable::new(Box::pin(wrapped), registration); + self.futures.push(Box::pin(abortable)); + true + } + + /// Removes a future with the given key, aborting it if it exists. + pub fn remove(&mut self, key: &K) -> bool { + if let Some(handle) = self.handles.remove(key) { + handle.abort(); + return true; + } + false + } + + /// Insert a new future with the given key. + /// If a future with the same key already exists, it is aborted and replaced. + /// + /// Returns true if a future was replaced, false if no future was replaced. + pub fn replace(&mut self, key: K, future: BoxFuture<'static, V>) -> bool { + let did_remove_existing = self.remove(&key); + self.insert(key, future); + + did_remove_existing } /// Poll for the next completed future. /// When a future completes, its key is automatically removed from the tracking set. pub fn poll_next_unpin(&mut self, cx: &mut Context) -> Poll> { - match self.futures.poll_next_unpin(cx) { - Poll::Ready(Some((k, v))) => { - self.keys.remove(&k); - Poll::Ready(Some((k, v))) + loop { + match self.futures.poll_next_unpin(cx) { + Poll::Ready(Some(Ok((k, v)))) => { + let did_remove_handle = self.handles.remove(&k).is_some(); + + // TODO: Make this a production assert + debug_assert!(did_remove_handle, "A future returned Ok but the key is not in handles. This should never happen."); + + // Still return the value to avoid panicking + return Poll::Ready(Some((k, v))); + } + Poll::Ready(Some(Err(future::Aborted))) => { + // Future was aborted, ignore and continue polling + continue; + } + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, } - other => other, } } pub fn len(&self) -> usize { - assert_eq!(self.keys.len(), self.futures.len()); - - self.keys.len() + self.handles.len() } } diff --git a/swap-p2p/src/lib.rs b/swap-p2p/src/lib.rs index 8a1f6d2f..54de4274 100644 --- a/swap-p2p/src/lib.rs +++ b/swap-p2p/src/lib.rs @@ -1,6 +1,12 @@ +pub mod behaviour_util; +pub mod defaults; pub mod futures_util; pub mod impl_from_rr_event; +pub mod libp2p_ext; +pub mod observe; pub mod out_event; +pub mod patches; pub mod protocols; +#[cfg(any(test, feature = "test-support"))] pub mod test; diff --git a/swap-p2p/src/libp2p_ext.rs b/swap-p2p/src/libp2p_ext.rs new file mode 100644 index 00000000..94a85184 --- /dev/null +++ b/swap-p2p/src/libp2p_ext.rs @@ -0,0 +1,80 @@ +use libp2p::multiaddr::Protocol; +use libp2p::{Multiaddr, PeerId}; +use std::collections::HashMap; + +pub trait MultiAddrExt { + fn extract_peer_id(&self) -> Option; + fn split_peer_id(&self) -> Option<(PeerId, Multiaddr)>; + fn is_local(&self) -> bool; +} + +impl MultiAddrExt for Multiaddr { + fn extract_peer_id(&self) -> Option { + match self.iter().last()? { + Protocol::P2p(peer_id) => Some(peer_id), + _ => None, + } + } + + // Takes a peer id like /ip4/192.168.178.64/tcp/9939/p2p/12D3KooWQsqsCyJ9ae1YEAJZAfoVdVFZdDdUq3yvZ92btq7hSv9f + // and returns the peer id and the original address *with* the peer id + fn split_peer_id(&self) -> Option<(PeerId, Multiaddr)> { + let peer_id = self.extract_peer_id()?; + let address = self.clone(); + Some((peer_id, address)) + } + + // Returns true if the multi address contains a local address which should not be advertised to the global internet + fn is_local(&self) -> bool { + self.iter().any(|p| match p { + Protocol::Ip4(addr) => { + addr.is_private() + || addr.is_loopback() + || addr.is_link_local() + || addr.is_unspecified() + } + Protocol::Ip6(addr) => { + addr.is_unique_local() + || addr.is_loopback() + || addr.is_unicast_link_local() + || addr.is_unspecified() + } + _ => false, + }) + } +} + +pub trait MultiAddrVecExt { + /// Takes multiaddresses where each multiaddress contains a peer id + /// and returns a vector of peer ids and their respective addresses + fn extract_peer_addresses(&self) -> Vec<(PeerId, Vec)>; +} + +impl MultiAddrVecExt for Vec { + fn extract_peer_addresses(&self) -> Vec<(PeerId, Vec)> { + let addresses = self + .iter() + .filter_map(|addr| addr.parse::().ok()) + .collect::>(); + + parse_strings_to_multiaddresses(&addresses) + } +} + +impl MultiAddrVecExt for Vec { + fn extract_peer_addresses(&self) -> Vec<(PeerId, Vec)> { + parse_strings_to_multiaddresses(self) + } +} + +pub fn parse_strings_to_multiaddresses(addresses: &[Multiaddr]) -> Vec<(PeerId, Vec)> { + let mut map: HashMap> = HashMap::new(); + + for addr in addresses { + if let Some(peer_id) = addr.extract_peer_id() { + map.entry(peer_id).or_default().push(addr.clone()); + } + } + + map.into_iter().collect() +} diff --git a/swap-p2p/src/observe.rs b/swap-p2p/src/observe.rs new file mode 100644 index 00000000..f42f55ca --- /dev/null +++ b/swap-p2p/src/observe.rs @@ -0,0 +1,163 @@ +//! A network behaviour that observes our connections to peers and emits events which can +//! then be used to be (among other things) emitted to the UI to display which peers we are connected to. +use libp2p::{ + swarm::{NetworkBehaviour, ToSwarm}, + Multiaddr, PeerId, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::VecDeque, task::Poll}; +use typeshare::typeshare; + +use crate::behaviour_util::{AddressTracker, ConnectionTracker}; + +pub struct Behaviour { + connections: ConnectionTracker, + addresses: AddressTracker, + + /// Queue of events to be sent to the swarm + to_swarm: VecDeque, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct Event { + #[typeshare(serialized_as = "string")] + pub peer_id: PeerId, + pub update: ConnectionChange, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +#[serde(tag = "type", content = "content")] +pub enum ConnectionChange { + /// Emitted when the connection status of a peer changes + Connection(ConnectionStatus), + /// Emitted when the address changes that we display to the user + LastAddress(#[typeshare(serialized_as = "string")] Multiaddr), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare] +pub enum ConnectionStatus { + Connected, + Disconnected, + Dialing, +} + +impl Behaviour { + pub fn new() -> Self { + Self { + connections: ConnectionTracker::new(), + addresses: AddressTracker::new(), + to_swarm: VecDeque::new(), + } + } + + // TODO: We could extract this into the trackers. They could have an internal queue which we can pop in our poll function. + fn emit_connection_status(&mut self, peer_id: PeerId) { + let status = if self.connections.has_inflight_dial(&peer_id) { + ConnectionStatus::Dialing + } else if self.connections.is_connected(&peer_id) { + ConnectionStatus::Connected + } else { + ConnectionStatus::Disconnected + }; + + self.to_swarm.push_back(Event { + peer_id, + update: ConnectionChange::Connection(status), + }); + } + + fn emit_last_seen_address(&mut self, peer_id: PeerId) { + let Some(address) = self.addresses.last_seen_address(&peer_id) else { + return; + }; + + self.to_swarm.push_back(Event { + peer_id, + update: ConnectionChange::LastAddress(address), + }); + } +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = libp2p::swarm::dummy::ConnectionHandler; + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + _connection_id: libp2p::swarm::ConnectionId, + _peer: libp2p::PeerId, + _local_addr: &libp2p::Multiaddr, + _remote_addr: &libp2p::Multiaddr, + ) -> Result, libp2p::swarm::ConnectionDenied> { + Ok(libp2p::swarm::dummy::ConnectionHandler) + } + + fn handle_established_outbound_connection( + &mut self, + _connection_id: libp2p::swarm::ConnectionId, + _peer: libp2p::PeerId, + _addr: &libp2p::Multiaddr, + _role_override: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + Ok(libp2p::swarm::dummy::ConnectionHandler) + } + + fn on_swarm_event(&mut self, event: libp2p::swarm::FromSwarm) { + if let Some(peer_id) = self.connections.handle_swarm_event(event) { + self.emit_connection_status(peer_id); + } + + if let Some(peer_id) = self.addresses.handle_swarm_event(event) { + self.emit_last_seen_address(peer_id); + } + } + + fn on_connection_handler_event( + &mut self, + _peer_id: libp2p::PeerId, + _connection_id: libp2p::swarm::ConnectionId, + _event: libp2p::swarm::THandlerOutEvent, + ) { + unreachable!("No event will be produced by a dummy handler."); + } + + fn poll( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> Poll>> { + if let Some(event) = self.to_swarm.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } + + fn handle_pending_inbound_connection( + &mut self, + _connection_id: libp2p::swarm::ConnectionId, + _local_addr: &Multiaddr, + _remote_addr: &Multiaddr, + ) -> Result<(), libp2p::swarm::ConnectionDenied> { + Ok(()) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + maybe_peer: Option, + _addresses: &[Multiaddr], + _effective_role: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + if let Some(peer_id) = self + .connections + .handle_pending_outbound_connection(connection_id, maybe_peer) + { + self.emit_connection_status(peer_id); + } + + Ok(std::vec![]) + } +} diff --git a/swap-p2p/src/out_event/alice.rs b/swap-p2p/src/out_event/alice.rs index 9fd8e46b..ada6c611 100644 --- a/swap-p2p/src/out_event/alice.rs +++ b/swap-p2p/src/out_event/alice.rs @@ -7,6 +7,7 @@ use libp2p::{ }; use uuid::Uuid; +use crate::protocols::rendezvous; use crate::protocols::{ cooperative_xmr_redeem_after_punish, encrypted_signature, quote::BidQuote, swap_setup, }; @@ -45,7 +46,7 @@ pub enum OutEvent { swap_id: Uuid, peer: PeerId, }, - Rendezvous(libp2p::rendezvous::client::Event), + Rendezvous(rendezvous::register::Event), OutboundRequestResponseFailure { peer: PeerId, error: OutboundFailure, @@ -96,21 +97,8 @@ impl From for OutEvent { } } -impl From for OutEvent { - fn from(e: libp2p::rendezvous::client::Event) -> Self { - OutEvent::Rendezvous(e) - } -} - -impl From for OutEvent { - fn from(e: crate::protocols::rendezvous::register::InnerBehaviourEvent) -> Self { - match e { - crate::protocols::rendezvous::register::InnerBehaviourEvent::Rendezvous(ev) => { - OutEvent::from(ev) - } - crate::protocols::rendezvous::register::InnerBehaviourEvent::Redial(_) => { - OutEvent::Other - } - } +impl From for OutEvent { + fn from(event: rendezvous::register::Event) -> Self { + OutEvent::Rendezvous(event) } } diff --git a/swap-p2p/src/out_event/bob.rs b/swap-p2p/src/out_event/bob.rs index 69fab49d..b255cd74 100644 --- a/swap-p2p/src/out_event/bob.rs +++ b/swap-p2p/src/out_event/bob.rs @@ -3,14 +3,15 @@ use libp2p::{ request_response::{ InboundFailure, InboundRequestId, OutboundFailure, OutboundRequestId, ResponseChannel, }, - PeerId, + Multiaddr, PeerId, }; -use crate::protocols::redial; +use crate::observe; use crate::protocols::{ cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason, quote::BidQuote, - transfer_proof, + quotes_cached::QuoteStatus, transfer_proof, }; +use crate::protocols::{redial, rendezvous}; #[derive(Debug)] pub enum OutEvent { @@ -18,6 +19,13 @@ pub enum OutEvent { id: OutboundRequestId, response: BidQuote, }, + CachedQuotes { + quotes: Vec<(PeerId, Multiaddr, BidQuote, Option)>, + }, + CachedQuotesProgress { + peers: Vec<(PeerId, QuoteStatus)>, + }, + Observe(observe::Event), SwapSetupCompleted { peer: PeerId, swap_id: uuid::Uuid, @@ -93,6 +101,18 @@ impl From for OutEvent { } } +impl From for OutEvent { + fn from(_: rendezvous::discovery::Event) -> Self { + OutEvent::Other + } +} + +impl From for OutEvent { + fn from(event: observe::Event) -> Self { + OutEvent::Observe(event) + } +} + impl From<()> for OutEvent { fn from(_: ()) -> Self { OutEvent::Other diff --git a/swap-p2p/src/patches.rs b/swap-p2p/src/patches.rs new file mode 100644 index 00000000..5529e6b3 --- /dev/null +++ b/swap-p2p/src/patches.rs @@ -0,0 +1 @@ +pub mod identify; diff --git a/swap-p2p/src/patches/identify.rs b/swap-p2p/src/patches/identify.rs new file mode 100644 index 00000000..33fae381 --- /dev/null +++ b/swap-p2p/src/patches/identify.rs @@ -0,0 +1,136 @@ +use std::task::Poll; + +use libp2p::identify; +use libp2p::PeerId; + +use crate::libp2p_ext::MultiAddrExt; + +/// This wraps libp2p::identify::Behaviour, and: +/// 1. Blocks Identify from sharing local addresses with other peers +/// 2. Blocks Identify from sharing addresses of other peers with the Swarm +/// +/// This helps with: +/// 1. privacy (by avoiding to share local addresses with other peers) +/// 2. preventing the Swarm from trying to dial addresses that we probably cannot reach anyway +/// +/// TODO: Add a clipply rule to forbid the normal identify behaviour from being used in the codebase +pub struct Behaviour { + inner: identify::Behaviour, +} + +impl Behaviour { + pub fn new(config: identify::Config) -> Self { + Self { + inner: identify::Behaviour::new(config), + } + } +} + +impl libp2p::swarm::NetworkBehaviour for Behaviour { + type ConnectionHandler = + ::ConnectionHandler; + type ToSwarm = ::ToSwarm; + + fn handle_established_inbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + peer: PeerId, + local_addr: &libp2p::Multiaddr, + remote_addr: &libp2p::Multiaddr, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + peer: PeerId, + addr: &libp2p::Multiaddr, + role_override: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + fn on_swarm_event(&mut self, event: libp2p::swarm::FromSwarm) { + match event { + libp2p::swarm::FromSwarm::NewListenAddr(new_listen_addr) + if new_listen_addr.addr.is_local() => + { + tracing::trace!(?new_listen_addr, "Blocking attempt by Swarm to tell Identify to share local address with other peers (FromSwarm::NewListenAddr)"); + } + libp2p::swarm::FromSwarm::NewExternalAddrCandidate(new_external_addr_candidate) + if new_external_addr_candidate.addr.is_local() => + { + tracing::trace!(?new_external_addr_candidate, "Blocking attempt by Swarm to tell Identify to share a local address with the Swarm (FromSwarm::NewExternalAddrCandidate)"); + } + libp2p::swarm::FromSwarm::NewExternalAddrCandidate(new_external_addr_candidate) + if new_external_addr_candidate.addr.is_local() => + { + tracing::trace!(?new_external_addr_candidate, "Blocking attempt by Swarm to tell Identify to share a local address of another peer (FromSwarm::NewExternalAddrCandidate)"); + } + other => self.inner.on_swarm_event(other), + } + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: libp2p::swarm::ConnectionId, + event: libp2p::swarm::THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event); + } + + fn poll( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll>> + { + while let Poll::Ready(event) = self.inner.poll(cx) { + match event { + // We ignore the private addresses that other peers tell us through Identify + libp2p::swarm::ToSwarm::NewExternalAddrOfPeer { peer_id, address } + if address.is_local() => + { + tracing::trace!(?peer_id, ?address, "Blocking attempt by Identify to share a local address of another peer with the Swarm"); + continue; + } + _ => return Poll::Ready(event), + } + } + + Poll::Pending + } + + fn handle_pending_inbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + local_addr: &libp2p::Multiaddr, + remote_addr: &libp2p::Multiaddr, + ) -> Result<(), libp2p::swarm::ConnectionDenied> { + self.inner + .handle_pending_inbound_connection(connection_id, local_addr, remote_addr) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + maybe_peer: Option, + addresses: &[libp2p::Multiaddr], + effective_role: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + ) + } +} diff --git a/swap-p2p/src/protocols.rs b/swap-p2p/src/protocols.rs index 9207dcdd..de3bf7e3 100644 --- a/swap-p2p/src/protocols.rs +++ b/swap-p2p/src/protocols.rs @@ -1,6 +1,9 @@ pub mod cooperative_xmr_redeem_after_punish; pub mod encrypted_signature; +pub mod notice; pub mod quote; +pub mod quotes; +pub mod quotes_cached; pub mod redial; pub mod rendezvous; pub mod swap_setup; diff --git a/swap-p2p/src/protocols/cooperative_xmr_redeem_after_punish.rs b/swap-p2p/src/protocols/cooperative_xmr_redeem_after_punish.rs index 534126ff..f806e028 100644 --- a/swap-p2p/src/protocols/cooperative_xmr_redeem_after_punish.rs +++ b/swap-p2p/src/protocols/cooperative_xmr_redeem_after_punish.rs @@ -2,7 +2,6 @@ use crate::out_event; use libp2p::request_response::ProtocolSupport; use libp2p::{request_response, PeerId, StreamProtocol}; use serde::{Deserialize, Serialize}; -use std::time::Duration; use swap_core::monero::{Scalar, TransferProof}; use uuid::Uuid; @@ -55,7 +54,8 @@ pub fn alice() -> Behaviour { StreamProtocol::new(CooperativeXmrRedeemProtocol.as_ref()), ProtocolSupport::Inbound, )], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } @@ -65,7 +65,8 @@ pub fn bob() -> Behaviour { StreamProtocol::new(CooperativeXmrRedeemProtocol.as_ref()), ProtocolSupport::Outbound, )], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } diff --git a/swap-p2p/src/protocols/encrypted_signature.rs b/swap-p2p/src/protocols/encrypted_signature.rs index 77ec5a66..31b0973b 100644 --- a/swap-p2p/src/protocols/encrypted_signature.rs +++ b/swap-p2p/src/protocols/encrypted_signature.rs @@ -2,7 +2,6 @@ use crate::out_event; use libp2p::request_response::{self}; use libp2p::{PeerId, StreamProtocol}; use serde::{Deserialize, Serialize}; -use std::time::Duration; use uuid::Uuid; const PROTOCOL: &str = "/comit/xmr/btc/encrypted_signature/1.0.0"; @@ -32,7 +31,8 @@ pub fn alice() -> Behaviour { StreamProtocol::new(EncryptedSignatureProtocol.as_ref()), request_response::ProtocolSupport::Inbound, )], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } @@ -42,7 +42,8 @@ pub fn bob() -> Behaviour { StreamProtocol::new(EncryptedSignatureProtocol.as_ref()), request_response::ProtocolSupport::Outbound, )], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } diff --git a/swap-p2p/src/protocols/notice.rs b/swap-p2p/src/protocols/notice.rs new file mode 100644 index 00000000..de500ff1 --- /dev/null +++ b/swap-p2p/src/protocols/notice.rs @@ -0,0 +1,162 @@ +//! A behaviour that emits a Event to the Swarm when it notices that a specific peer supports a specific protocol. +use std::collections::VecDeque; + +use libp2p::{ + core::upgrade, + swarm::{handler::ProtocolsChange, ConnectionHandler, NetworkBehaviour, SubstreamProtocol}, + PeerId, StreamProtocol, +}; + +pub struct Behaviour { + interesting_protocol: StreamProtocol, + to_swarm: VecDeque, +} + +impl Behaviour { + pub fn new(interesting_protocol: StreamProtocol) -> Self { + Self { + interesting_protocol, + to_swarm: VecDeque::new(), + } + } +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = NoticeProtocolSupportConnectionHandler; + + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + _connection_id: libp2p::swarm::ConnectionId, + _peer: libp2p::PeerId, + _local_addr: &libp2p::Multiaddr, + _remote_addr: &libp2p::Multiaddr, + ) -> Result, libp2p::swarm::ConnectionDenied> { + Ok(NoticeProtocolSupportConnectionHandler::new( + self.interesting_protocol.clone(), + )) + } + + fn handle_established_outbound_connection( + &mut self, + _connection_id: libp2p::swarm::ConnectionId, + _peer: libp2p::PeerId, + _addr: &libp2p::Multiaddr, + _role_override: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + Ok(NoticeProtocolSupportConnectionHandler::new( + self.interesting_protocol.clone(), + )) + } + + fn on_swarm_event(&mut self, _event: libp2p::swarm::FromSwarm) { + // nothing to do here + } + + fn on_connection_handler_event( + &mut self, + peer_id: libp2p::PeerId, + _connection_id: libp2p::swarm::ConnectionId, + _event: libp2p::swarm::THandlerOutEvent, + ) { + self.to_swarm + .push_back(Event::SupportsProtocol { peer: peer_id }); + } + + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll>> + { + if let Some(event) = self.to_swarm.pop_front() { + return std::task::Poll::Ready(libp2p::swarm::ToSwarm::GenerateEvent(event)); + } + + std::task::Poll::Pending + } +} + +pub struct NoticeProtocolSupportConnectionHandler { + interesting_protocol: StreamProtocol, + to_behaviour: VecDeque, +} + +impl NoticeProtocolSupportConnectionHandler { + fn new(interesting_protocol: StreamProtocol) -> Self { + Self { + interesting_protocol, + to_behaviour: VecDeque::new(), + } + } +} + +#[derive(Debug)] +pub enum Event { + SupportsProtocol { peer: PeerId }, +} + +#[derive(Debug)] +pub enum ToBehaviour { + SupportsProtocol, +} + +impl ConnectionHandler for NoticeProtocolSupportConnectionHandler { + type FromBehaviour = (); + type ToBehaviour = ToBehaviour; + + type InboundProtocol = upgrade::DeniedUpgrade; + type OutboundProtocol = upgrade::DeniedUpgrade; + + type InboundOpenInfo = (); + type OutboundOpenInfo = (); + + fn listen_protocol( + &self, + ) -> libp2p::swarm::SubstreamProtocol { + SubstreamProtocol::new(upgrade::DeniedUpgrade, ()) + } + + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll< + libp2p::swarm::ConnectionHandlerEvent< + Self::OutboundProtocol, + Self::OutboundOpenInfo, + Self::ToBehaviour, + >, + > { + if let Some(to_behaviour) = self.to_behaviour.pop_front() { + return std::task::Poll::Ready(libp2p::swarm::ConnectionHandlerEvent::NotifyBehaviour( + to_behaviour, + )); + } + + std::task::Poll::Pending + } + + fn on_behaviour_event(&mut self, _event: Self::FromBehaviour) { + unreachable!("This connection handler should not receive events"); + } + + fn on_connection_event( + &mut self, + event: libp2p::swarm::handler::ConnectionEvent< + Self::InboundProtocol, + Self::OutboundProtocol, + Self::InboundOpenInfo, + Self::OutboundOpenInfo, + >, + ) { + if let libp2p::swarm::handler::ConnectionEvent::RemoteProtocolsChange(protocols) = event { + if let ProtocolsChange::Added(protocols) = protocols { + for protocol in protocols { + if protocol == &self.interesting_protocol { + self.to_behaviour.push_back(ToBehaviour::SupportsProtocol); + } + } + } + } + } +} diff --git a/swap-p2p/src/protocols/quote.rs b/swap-p2p/src/protocols/quote.rs index a7fe31f8..a2547e85 100644 --- a/swap-p2p/src/protocols/quote.rs +++ b/swap-p2p/src/protocols/quote.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use crate::out_event; use libp2p::request_response::{self, ProtocolSupport}; use libp2p::{PeerId, StreamProtocol}; @@ -7,7 +5,7 @@ use serde::{Deserialize, Serialize}; use swap_core::bitcoin; use typeshare::typeshare; -const PROTOCOL: &str = "/comit/xmr/btc/bid-quote/1.0.0"; +pub(crate) const PROTOCOL: &str = "/comit/xmr/btc/bid-quote/1.0.0"; pub type OutEvent = request_response::Event<(), BidQuote>; pub type Message = request_response::Message<(), BidQuote>; @@ -58,10 +56,11 @@ pub struct ZeroQuoteReceived; /// /// The ASB is always listening and only supports inbound connections, i.e. /// handing out quotes. -pub fn asb() -> Behaviour { +pub fn alice() -> Behaviour { Behaviour::new( vec![(StreamProtocol::new(PROTOCOL), ProtocolSupport::Inbound)], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::QUOTE_REQUEST_TIMEOUT), ) } @@ -69,10 +68,11 @@ pub fn asb() -> Behaviour { /// /// The CLI is always dialing and only supports outbound connections, i.e. /// requesting quotes. -pub fn cli() -> Behaviour { +pub fn bob() -> Behaviour { Behaviour::new( vec![(StreamProtocol::new(PROTOCOL), ProtocolSupport::Outbound)], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::QUOTE_REQUEST_TIMEOUT), ) } diff --git a/swap-p2p/src/protocols/quotes.rs b/swap-p2p/src/protocols/quotes.rs new file mode 100644 index 00000000..80fb36ad --- /dev/null +++ b/swap-p2p/src/protocols/quotes.rs @@ -0,0 +1,502 @@ +use libp2p::{ + core::Endpoint, + identify, + request_response::{self, OutboundFailure}, + swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandlerInEvent, + THandlerOutEvent, ToSwarm, + }, + Multiaddr, PeerId, StreamProtocol, +}; + +use crate::{ + behaviour_util::{extract_semver_from_agent_str, BackoffTracker, ConnectionTracker}, + futures_util::FuturesHashSet, + patches, + protocols::{ + notice, + quote::{self, BidQuote}, + redial, + }, +}; +use std::{ + collections::{HashSet, VecDeque}, + task::Poll, + time::Duration, +}; + +// We initially assume all peers support our protocol. +pub struct Behaviour { + inner: InnerBehaviour, + + /// Track connected peers + connection_tracker: ConnectionTracker, + + /// Peers which have explictly told us that they do not support our protocol + does_not_support: HashSet, + + /// Peers to dispatch a quote request to as soon as we are connected to them + to_dispatch: VecDeque, + /// Peers to request a quote from once the future resolves + to_request: FuturesHashSet, + + /// Store backoffs for each peer + backoff: BackoffTracker, + + // Queue of events to be sent to the swarm + to_swarm: VecDeque, +} + +impl Behaviour { + pub fn new(identify_config: identify::Config) -> Self { + Self { + inner: InnerBehaviour { + quote: quote::bob(), + notice: notice::Behaviour::new(StreamProtocol::new(quote::PROTOCOL)), + redial: redial::Behaviour::new( + "quotes", + crate::defaults::QUOTE_REDIAL_INTERVAL, + crate::defaults::QUOTE_REDIAL_MAX_INTERVAL, + ), + identify: patches::identify::Behaviour::new(identify_config), + }, + connection_tracker: ConnectionTracker::new(), + does_not_support: HashSet::new(), + to_dispatch: VecDeque::new(), + to_request: FuturesHashSet::new(), + backoff: BackoffTracker::new( + crate::defaults::QUOTE_REDIAL_INTERVAL, + crate::defaults::QUOTE_REDIAL_MAX_INTERVAL, + crate::defaults::BACKOFF_MULTIPLIER, + ), + to_swarm: VecDeque::new(), + } + } + + fn is_connected(&self, peer_id: &PeerId) -> bool { + self.connection_tracker.is_connected(peer_id) + } + + fn schedule_quote_request_after(&mut self, peer: PeerId, duration: Duration) -> Duration { + // TODO: Handle if there already was a future for this peer + self.to_request + .insert(peer, Box::pin(tokio::time::sleep(duration))); + duration + } + + fn schedule_quote_request_with_backoff(&mut self, peer: PeerId) -> Duration { + let duration = self.backoff.get(&peer).current_interval; + + self.schedule_quote_request_after(peer, duration) + } + + // Called whenever we hear about a new peer, this can be called multiple times for the same peer + fn handle_discovered_peer(&mut self, peer: PeerId) { + // Instruct to redial unless we know that the peer does not support the protocol + if !self.does_not_support.contains(&peer) { + self.inner.redial.add_peer(peer); + } + } + + fn handle_does_not_support_protocol(&mut self, peer: PeerId) { + tracing::trace!(%peer, "Peer does not support the quote protocol"); + + self.does_not_support.insert(peer); + self.inner.redial.remove_peer(&peer); + self.to_swarm + .push_back(Event::DoesNotSupportProtocol { peer }); + } +} + +#[derive(NetworkBehaviour)] +pub struct InnerBehaviour { + quote: quote::Behaviour, + notice: notice::Behaviour, + redial: redial::Behaviour, + identify: patches::identify::Behaviour, +} + +#[derive(Debug)] +pub enum Event { + QuoteReceived { + peer: PeerId, + quote: BidQuote, + }, + QuoteInflight { + peer: PeerId, + }, + QuoteFailed { + peer: PeerId, + }, + VersionReceived { + peer: PeerId, + version: semver::Version, + }, + DoesNotSupportProtocol { + peer: PeerId, + }, +} + +impl libp2p::swarm::NetworkBehaviour for Behaviour { + type ConnectionHandler = ::ConnectionHandler; + type ToSwarm = Event; + + fn poll( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> Poll>> { + while let Poll::Ready(Some((peer, ()))) = self.to_request.poll_next_unpin(cx) { + self.to_dispatch.push_back(peer); + } + + // Only dispatch to connected peers, keep non-connected ones in queue + // Take ownership of the queue to avoid borrow checker issues + let to_dispatch = std::mem::take(&mut self.to_dispatch); + self.to_dispatch = to_dispatch + .into_iter() + .filter(|peer| { + if self.is_connected(peer) { + let outbound_request_id = self.inner.quote.send_request(peer, ()); + tracing::trace!( + %peer, + %outbound_request_id, + "Dispatching outgoing quote request to peer" + ); + + self.to_swarm + .push_back(Event::QuoteInflight { peer: *peer }); + + false + } else { + true + } + }) + .collect(); + + while let Poll::Ready(ready_to_swarm) = self.inner.poll(cx) { + match ready_to_swarm { + ToSwarm::GenerateEvent(event) => { + match event { + InnerBehaviourEvent::Notice(notice::Event::SupportsProtocol { peer }) => { + self.does_not_support.remove(&peer); + } + InnerBehaviourEvent::Quote(request_response::Event::Message { + peer, + message, + }) => { + if let request_response::Message::Response { response, .. } = message { + self.to_swarm.push_back(Event::QuoteReceived { + peer, + quote: response, + }); + + // We got a successful response, so we reset the backoff + self.backoff.reset(&peer); + + // Schedule a new quote request after backoff + self.schedule_quote_request_after( + peer, + crate::defaults::QUOTE_INTERVAL, + ); + } + } + InnerBehaviourEvent::Quote(request_response::Event::OutboundFailure { + peer, + request_id, + error, + }) => { + // We got an outbound failure, so we increment the backoff + self.backoff.increment(&peer); + + if let OutboundFailure::UnsupportedProtocols = error { + self.handle_does_not_support_protocol(peer); + + // Only schedule a new quote request if we are not sure that the peer does not support the protocol + } else if !self.does_not_support.contains(&peer) { + // We schedule a new quote request + let next_request_in = + self.schedule_quote_request_with_backoff(peer); + + tracing::trace!(%peer, %request_id, %error, next_request_in = %next_request_in.as_secs(), "Queuing quote request to peer after outbound failure"); + + self.to_swarm.push_back(Event::QuoteFailed { peer }); + } + } + InnerBehaviourEvent::Identify(identify::Event::Received { + peer_id, + info, + }) => match extract_semver_from_agent_str(info.agent_version.as_str()) { + Some(version) => { + tracing::trace!(%peer_id, %version, "Received version from peer via identify"); + + self.to_swarm.push_back(Event::VersionReceived { + peer: peer_id, + version, + }); + } + None => { + tracing::warn!(%peer_id, ?info, "Received identify info from peer but failed to extract semver version"); + } + }, + _other => { + // TODO: Do we need to handle other events? + } + } + } + _ => { + return Poll::Ready(ready_to_swarm.map_out(|_| { + unreachable!("we handle all generate events in the arm above") + })); + } + } + } + + while let Some(event) = self.to_swarm.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } + + fn on_swarm_event(&mut self, event: FromSwarm<'_>) { + self.connection_tracker.handle_swarm_event(event); + + match event { + FromSwarm::ConnectionEstablished(connection_established) => { + // When we connected to a peer where we are not certain that they do not support the protocol, we schedule a quote request + if !self + .does_not_support + .contains(&connection_established.peer_id) + { + self.schedule_quote_request_with_backoff(connection_established.peer_id); + } + } + FromSwarm::NewExternalAddrOfPeer(event) => { + self.handle_discovered_peer(event.peer_id); + } + _ => {} + } + + self.inner.on_swarm_event(event) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + maybe_peer: Option, + addresses: &[Multiaddr], + effective_role: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.connection_tracker + .handle_pending_outbound_connection(connection_id, maybe_peer); + + self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + ) + } + + fn handle_established_inbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + addr: &Multiaddr, + role_override: Endpoint, + ) -> Result, ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event) + } + + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::{new_swarm, SwarmExt}; + use futures::StreamExt; + use libp2p::swarm::{Swarm, SwarmEvent}; + use tokio::task::JoinHandle; + + #[tokio::test] + async fn receive_quote_from_alice() { + // Create the swarm for Bob + let mut bob = + new_swarm(|identity| Behaviour::new(identify_config(identity, "quotes", "1.0.0"))); + + // Create the swarm for Alice + // Let her listen on a random memory address + // Let her respond to requests + let alice = new_swarm(|_| quote::alice()); + let (alice_peer_id, alice_addr, alice_handle) = serve_quotes(alice).await; + + // Tell Bob about Alice's address + // This should be enough to get the `quotes` behaviour to dial Alice + bob.add_peer_address(alice_peer_id, alice_addr); + + let bob_handle = tokio::spawn(async move { + loop { + if let SwarmEvent::Behaviour(Event::QuoteReceived { peer, quote }) = + bob.select_next_some().await + { + if peer == alice_peer_id && quote == quote::BidQuote::ZERO { + return; + } + } + } + }); + + tokio::select! { + _ = bob_handle => {} + _ = tokio::time::sleep(Duration::from_secs(10)) => { + panic!("Test timed out"); + } + } + + alice_handle.abort(); + } + + #[tokio::test] + async fn receive_does_not_support_protocol_from_alice() { + // Create the swarm for Bob + let mut bob = + new_swarm(|identity| Behaviour::new(identify_config(identity, "quotes", "1.0.0"))); + + // Use quote::bob() so Alice doesn't support inbound requests + let mut alice = new_swarm(|_| quote::bob()); + let alice_peer_id = *alice.local_peer_id(); + let alice_addr = alice.listen_on_random_memory_address().await; + + // Let Alice run but she doesn't need to do anything + let alice_handle = tokio::spawn(async move { + loop { + alice.select_next_some().await; + } + }); + + // Tell Bob about Alice's address + bob.add_peer_address(alice_peer_id, alice_addr); + + // Wait for DoesNotSupportProtocol + let timeout = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(timeout); + + loop { + tokio::select! { + event = bob.select_next_some() => { + if let SwarmEvent::Behaviour(Event::DoesNotSupportProtocol { peer }) = event { + if peer == alice_peer_id { + break; + } + } + } + _ = &mut timeout => { + panic!("Timeout waiting for DoesNotSupportProtocol"); + } + } + } + + // Kill Alice which should kill the connection + // Bob should not attempt to redial Alice because he has noticed that she does not support the protocol + alice_handle.abort(); + + let mut connection_closed = false; + let timeout = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(timeout); + + loop { + tokio::select! { + event = bob.select_next_some() => { + match event { + SwarmEvent::ConnectionClosed { peer_id, .. } if peer_id == alice_peer_id => { + connection_closed = true; + } + SwarmEvent::OutgoingConnectionError { peer_id: Some(peer_id), .. } if peer_id == alice_peer_id => { + panic!("Bob attempted to redial Alice!"); + } + _ => {} + } + } + _ = &mut timeout => { + break; + } + } + } + + assert!( + connection_closed, + "Bob should have noticed connection closed" + ); + } + + /// Ensures that Alice responds with a zero quote when requested. + async fn serve_quotes( + mut alice: Swarm, + ) -> (PeerId, Multiaddr, JoinHandle<()>) { + let alice_peer_id = *alice.local_peer_id(); + let alice_addr = alice.listen_on_random_memory_address().await; + + let alice_handle = tokio::spawn(async move { + loop { + match alice.select_next_some().await { + SwarmEvent::Behaviour(libp2p::request_response::Event::Message { + message: libp2p::request_response::Message::Request { channel, .. }, + .. + }) => { + alice + .behaviour_mut() + .send_response(channel, quote::BidQuote::ZERO) + .unwrap(); + } + _ => {} + } + } + }); + + (alice_peer_id, alice_addr, alice_handle) + } + + fn identify_config( + identity: libp2p::identity::Keypair, + protocol: &str, + version: &str, + ) -> identify::Config { + identify::Config::new(format!("/quotes/test/{}", protocol), identity.public()) + .with_agent_version(format!("{} ({})", protocol, version)) + } +} diff --git a/swap-p2p/src/protocols/quotes_cached.rs b/swap-p2p/src/protocols/quotes_cached.rs new file mode 100644 index 00000000..7e9e6d83 --- /dev/null +++ b/swap-p2p/src/protocols/quotes_cached.rs @@ -0,0 +1,263 @@ +use crate::behaviour_util::{AddressTracker}; +use crate::futures_util::FuturesHashSet; +use crate::out_event; +use crate::protocols::quote::BidQuote; +use crate::protocols::quotes; +use libp2p::identify; +use libp2p::swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, +}; +use libp2p::{Multiaddr, PeerId}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::task::{Context, Poll}; +use typeshare::typeshare; + +pub struct Behaviour { + inner: quotes::Behaviour, + + /// For each peer, cache the address we last connected to them at + // TODO: Technically this is not required. The UI gets the address from the observe behaviour. + address_tracker: AddressTracker, + + /// For every peer track the last semver version we received from them + // TODO: Maybe let these expire after a certain time? + versions: HashMap, + + // Caches quotes + // TODO: Maybe move the identify logic from quotes to quotes_cached? + cache: HashMap, + quote_status: HashMap, + expiry: FuturesHashSet, + + // Queue of events to be sent to the swarm + to_swarm: VecDeque, +} + +impl Behaviour { + pub fn new(identify_config: identify::Config) -> Self { + Self { + inner: quotes::Behaviour::new(identify_config), + address_tracker: AddressTracker::new(), + versions: HashMap::new(), + cache: HashMap::new(), + quote_status: HashMap::new(), + expiry: FuturesHashSet::new(), + to_swarm: VecDeque::new(), + } + } + + fn emit_cached_quotes(&mut self) { + // Attach the address we last connected to the peer at to the quote + // + // Ignores those peers where we don't have an address cached + let quotes: Vec<(PeerId, Multiaddr, BidQuote, Option)> = self + .cache + .iter() + .filter_map(|(peer_id, quote)| { + self.address_tracker.last_seen_address(peer_id).map(|addr| { + let version = self.versions.get(peer_id).cloned(); + + (*peer_id, addr, quote.clone(), version) + }) + }) + .collect(); + + // There is no way to receive a quote from a peer without ever hearing about any address from them + debug_assert_eq!(quotes.len(), self.cache.len()); + + self.to_swarm.push_back(Event::CachedQuotes { quotes }); + } + + fn emit_progress(&mut self) { + let mut peers = Vec::new(); + + let all_peers: HashSet = self + .address_tracker + .peers() + .chain(self.quote_status.keys()) + .cloned() + .collect(); + + for peer in all_peers { + let quote = self + .quote_status + .get(&peer) + .cloned() + .unwrap_or(QuoteStatus::Nothing); + + peers.push((peer, quote)); + } + + self.to_swarm.push_back(Event::Progress { peers }); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare] +pub enum QuoteStatus { + Received, + NotSupported, + Inflight, + Failed, + Nothing, +} + +#[derive(Debug)] +pub enum Event { + CachedQuotes { + // Peer ID, Address, Quote, Version + quotes: Vec<(PeerId, Multiaddr, BidQuote, Option)>, + }, + Progress { + peers: Vec<(PeerId, QuoteStatus)>, + }, +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = ::ConnectionHandler; + type ToSwarm = Event; + + fn poll( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + while let Poll::Ready(Some((peer, ()))) = self.expiry.poll_next_unpin(cx) { + self.cache.remove(&peer); + self.emit_cached_quotes(); + } + + while let Poll::Ready(event) = self.inner.poll(cx) { + match event { + ToSwarm::GenerateEvent(event) => { + match event { + quotes::Event::QuoteReceived { peer, quote } => { + self.cache.insert(peer, quote); + self.quote_status.insert(peer, QuoteStatus::Received); + self.expiry.replace( + peer, + Box::pin(tokio::time::sleep(crate::defaults::CACHED_QUOTE_EXPIRY)), + ); + + self.emit_cached_quotes(); + self.emit_progress(); + } + quotes::Event::QuoteInflight { peer } => { + self.quote_status.insert(peer, QuoteStatus::Inflight); + self.emit_progress(); + } + quotes::Event::QuoteFailed { peer } => { + self.quote_status.insert(peer, QuoteStatus::Failed); + self.emit_progress(); + } + quotes::Event::VersionReceived { peer, version } => { + self.versions.insert(peer, version); + + // TODO: Only emit if the version is different from the cached one? + self.emit_cached_quotes(); + self.emit_progress(); + } + quotes::Event::DoesNotSupportProtocol { peer } => { + self.quote_status.insert(peer, QuoteStatus::NotSupported); + self.emit_progress(); + } + } + } + _ => { + return Poll::Ready(event.map_out(|_| { + unreachable!("we handle all generate events in the arm above") + })); + } + } + } + + if let Some(event) = self.to_swarm.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } + + fn handle_established_inbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + addr: &Multiaddr, + role_override: libp2p::core::Endpoint, + ) -> Result, ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + 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_pending_outbound_connection( + &mut self, + connection_id: ConnectionId, + maybe_peer: Option, + addresses: &[Multiaddr], + effective_role: libp2p::core::Endpoint, + ) -> Result, ConnectionDenied> { + self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + ) + } + + fn on_swarm_event(&mut self, event: FromSwarm<'_>) { + self.address_tracker.handle_swarm_event(event); + self.emit_progress(); // TODO: this will emit quite frequently + self.inner.on_swarm_event(event); + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event); + } +} + +impl From for out_event::bob::OutEvent { + fn from(event: Event) -> Self { + match event { + Event::CachedQuotes { quotes } => Self::CachedQuotes { quotes }, + Event::Progress { peers } => Self::CachedQuotesProgress { peers }, + } + } +} + +impl From for out_event::alice::OutEvent { + fn from(_: Event) -> Self { + unreachable!("Alice should not use the cached quotes behaviour"); + } +} diff --git a/swap-p2p/src/protocols/redial.rs b/swap-p2p/src/protocols/redial.rs index 77739862..292bd14b 100644 --- a/swap-p2p/src/protocols/redial.rs +++ b/swap-p2p/src/protocols/redial.rs @@ -1,7 +1,6 @@ +use crate::behaviour_util::{BackoffTracker, ConnectionTracker}; use crate::futures_util::FuturesHashSet; use crate::out_event; -use backoff::backoff::Backoff; -use backoff::ExponentialBackoff; use libp2p::core::Multiaddr; use libp2p::swarm::dial_opts::{DialOpts, PeerCondition}; use libp2p::swarm::{DialError, FromSwarm, NetworkBehaviour, ToSwarm}; @@ -18,27 +17,30 @@ use void::Void; /// Note: Make sure that when using this as an inner behaviour for a `NetworkBehaviour` that you /// call all the NetworkBehaviour methods (including `handle_pending_outbound_connection`) to ensure /// that the addresses are cached correctly. -/// TODO: Allow removing peers from the set after we are done with them. pub struct Behaviour { + /// An identifier for this redial behaviour instance (for logging/tracing). + name: &'static str, + /// The peers we are interested in. peers: HashSet, + + connections: ConnectionTracker, + /// Store address for all peers (even those we are not interested in) /// because we might be interested in them later on // TODO: Sort these by how often we were able to connect to them + // TODO: Use the behaviour_util::AddressTracker instead addresses: HashMap>, + /// Tracks sleep timers for each peer waiting to redial. /// Futures in here yield the PeerId and when a Future completes we dial that peer to_dial: FuturesHashSet, + /// Tracks the current backoff state for each peer. - backoff: HashMap, - /// Initial interval for backoff. - initial_interval: Duration, - /// Maximum interval for backoff. - max_interval: Duration, + backoff: BackoffTracker, + /// A queue of events to be sent to the swarm. to_swarm: VecDeque>, - /// An identifier for this redial behaviour instance (for logging/tracing). - name: &'static str, } impl Behaviour { @@ -47,9 +49,12 @@ impl Behaviour { peers: HashSet::default(), addresses: HashMap::default(), to_dial: FuturesHashSet::new(), - backoff: HashMap::new(), - initial_interval: interval, - max_interval, + connections: ConnectionTracker::new(), + backoff: BackoffTracker::new( + interval, + max_interval, + crate::defaults::BACKOFF_MULTIPLIER, + ), to_swarm: VecDeque::new(), name, } @@ -64,12 +69,25 @@ impl Behaviour { if newly_added { self.schedule_redial(&peer, Duration::ZERO); - tracing::trace!("Added a new peer to the set of peers we want to contineously redial"); + tracing::trace!("Started tracking peer"); } newly_added } + /// Removes a peer from the set of peers to track. Returns true if the peer was removed. + #[tracing::instrument(level = "trace", name = "redial::remove_peer", skip(self, peer), fields(redial_type = %self.name, peer = %peer))] + pub fn remove_peer(&mut self, peer: &PeerId) -> bool { + if self.peers.remove(peer) { + self.to_dial.remove(peer); + + tracing::trace!("Stopped tracking peer"); + return true; + } + + false + } + /// Adds a peer to the set of peers to track with a specific address. Returns true if the peer was newly added. #[tracing::instrument(level = "trace", name = "redial::add_peer_with_address", skip(self, peer, address), fields(redial_type = %self.name, peer = %peer, address = %address))] pub fn add_peer_with_address(&mut self, peer: PeerId, address: Multiaddr) -> bool { @@ -79,31 +97,20 @@ impl Behaviour { if newly_added { self.schedule_redial(&peer, Duration::ZERO); - tracing::trace!(?address, "Added a new peer to the set of peers we want to contineously redial with a specific address"); + tracing::trace!( + ?address, + "Started tracking peer and added a specific address" + ); } self.to_swarm.push_back(ToSwarm::NewExternalAddrOfPeer { peer_id: peer, address: address.clone(), }); - self.insert_address(&peer, address); newly_added } - fn get_backoff(&mut self, peer: &PeerId) -> &mut ExponentialBackoff { - self.backoff.entry(*peer).or_insert_with(|| { - ExponentialBackoff { - initial_interval: self.initial_interval, - current_interval: self.initial_interval, - max_interval: self.max_interval, - // We never give up on re-dialling - max_elapsed_time: None, - ..ExponentialBackoff::default() - } - }) - } - #[tracing::instrument(level = "trace", name = "redial::schedule_redial", skip(self, peer, override_next_dial_in), fields(redial_type = %self.name, peer = %peer))] fn schedule_redial( &mut self, @@ -118,11 +125,10 @@ impl Behaviour { // How long should we wait before we redial the peer? // If an override is provided, use that, otherwise use the backoff - let next_dial_in = override_next_dial_in.into().unwrap_or_else(|| { - self.get_backoff(peer) - .next_backoff() - .expect("redial backoff should never run out of attempts") - }); + // TODO: Instead only increment on errors + let next_dial_in = override_next_dial_in + .into() + .unwrap_or_else(|| self.backoff.increment(peer)); let did_queue_new_dial = self.to_dial.insert( peer.clone(), @@ -153,16 +159,18 @@ impl Behaviour { self.to_dial.contains_key(peer) } - pub fn insert_address(&mut self, peer: &PeerId, address: Multiaddr) { + pub fn insert_address(&mut self, peer: &PeerId, address: Multiaddr) -> bool { self.addresses .entry(peer.clone()) .or_default() - .insert(address); + .insert(address) } } #[derive(Debug)] pub enum Event { + // TODO: This should emit useful events like Connected, Disconnected, etc. for the peers we are interested in. + // This could prevent having to use the ConnectionTracker in parent behaviours (essentiually duplicating the code here) ScheduledRedial { peer: PeerId, next_dial_in: Duration, @@ -175,13 +183,15 @@ impl NetworkBehaviour for Behaviour { #[tracing::instrument(level = "trace", name = "redial::on_swarm_event", skip(self, event), fields(redial_type = %self.name))] fn on_swarm_event(&mut self, event: FromSwarm<'_>) { + self.connections.handle_swarm_event(event); + let peer_to_redial = match event { // Check if we discovered a new address for some peer FromSwarm::NewExternalAddrOfPeer(event) => { // TOOD: Ensure that if the address contains a peer id it matches the peer id in the event - self.insert_address(&event.peer_id, event.addr.clone()); - - tracing::trace!(peer = %event.peer_id, address = %event.addr, "Cached an address for a peer"); + if self.insert_address(&event.peer_id, event.addr.clone()) { + tracing::trace!(peer = %event.peer_id, address = %event.addr, "Cached an address for a peer"); + } None } @@ -189,14 +199,20 @@ impl NetworkBehaviour for Behaviour { // - a failed dial // - a closed connection // - // We will then schedule a redial for the peer - FromSwarm::ConnectionClosed(event) if self.peers.contains(&event.peer_id) => { - tracing::trace!(peer = %event.peer_id, "A connection was closed for a peer we want to contineously redial. We will schedule a redial."); + // We will then schedule a redial for the peer. We only do this if we are not already connected to the peer. + FromSwarm::ConnectionClosed(event) + if self.peers.contains(&event.peer_id) + && !self.connections.is_connected(&event.peer_id) => + { + tracing::trace!(peer = %event.peer_id, "Connection closed. We will schedule a redial for this peer."); Some(event.peer_id) } FromSwarm::DialFailure(event) => match event.peer_id { - Some(peer_id) if self.peers.contains(&peer_id) => { + Some(peer_id) + if self.peers.contains(&peer_id) + && !self.connections.is_connected(&peer_id) => + { match event.error { DialError::DialPeerConditionFalse(_) => { // TODO: Can this lead to a condition where we will not redial the peer ever again? I don't think so... @@ -205,11 +221,11 @@ impl NetworkBehaviour for Behaviour { // We always dial with `PeerCondition::DisconnectedAndNotDialing`. // If we not disconnected, we don't need to redial. // If we are already dialing, another event will be emitted if that dial fails. - tracing::trace!(peer = %peer_id, dial_error = ?event.error, "A dial failure occurred for a peer we want to contineously redial, but this was due to a dial condition failure. We are not treating this as a failure. We will not schedule a redial."); + // tracing::trace!(peer = %peer_id, dial_error = ?event.error, "A dial failure occurred for a peer we want to contineously redial, but this was due to a dial condition failure. We are not treating this as a failure. We will not schedule a redial."); None } _ => { - tracing::trace!(peer = %peer_id, dial_error = ?event.error, "A dial failure occurred for a peer we want to contineously redial. We will schedule a redial."); + tracing::trace!(peer = %peer_id, dial_error = ?event.error, "Dial failure occurred. We will schedule a redial for this peer."); Some(peer_id) } } @@ -223,8 +239,6 @@ impl NetworkBehaviour for Behaviour { // We will then reset the backoff state for the peer let peer_to_reset = match event { FromSwarm::ConnectionEstablished(e) if self.peers.contains(&e.peer_id) => { - tracing::trace!(peer = %e.peer_id, "A connection was established for a peer we want to contineously redial, resetting backoff state"); - Some(e.peer_id) } _ => None, @@ -232,9 +246,7 @@ impl NetworkBehaviour for Behaviour { // Reset the backoff state for the peer if needed if let Some(peer) = peer_to_reset { - if let Some(backoff) = self.backoff.get_mut(&peer) { - backoff.reset(); - } + self.backoff.reset(&peer); } // Schedule a redial if needed @@ -253,9 +265,6 @@ impl NetworkBehaviour for Behaviour { // Check if any peer's sleep timer has completed // If it has, dial that peer if let Poll::Ready(Some((peer, _))) = self.to_dial.poll_next_unpin(cx) { - tracing::trace!(peer = %peer, "Instructing swarm to redial a peer we want to contineously redial after the sleep timer completed"); - - // Actually dial the peer return Poll::Ready(ToSwarm::Dial { opts: DialOpts::peer_id(peer) .condition(PeerCondition::DisconnectedAndNotDialing) @@ -295,23 +304,32 @@ impl NetworkBehaviour for Behaviour { Ok(Self::ConnectionHandler {}) } - #[tracing::instrument(level = "trace", name = "redial::handle_pending_outbound_connection", skip(self, _connection_id, _addresses, maybe_peer, _effective_role), fields(redial_type = %self.name))] + #[tracing::instrument(level = "trace", name = "redial::handle_pending_outbound_connection", skip(self, connection_id, _addresses, maybe_peer, _effective_role), fields(redial_type = %self.name))] fn handle_pending_outbound_connection( &mut self, - _connection_id: libp2p::swarm::ConnectionId, + connection_id: libp2p::swarm::ConnectionId, maybe_peer: Option, _addresses: &[Multiaddr], _effective_role: libp2p::core::Endpoint, ) -> Result, libp2p::swarm::ConnectionDenied> { + self.connections + .handle_pending_outbound_connection(connection_id, maybe_peer); + // If we don't know the peer id, we cannot contribute any addresses let Some(peer_id) = maybe_peer else { return Ok(vec![]); }; - // TODO: Uncomment this if we only want to contribute addresses for peers we are instructed to redial - // if !self.peers.contains(&peer_id) { - // return Ok(vec![]); - // } + // We only want to contribute addresses for peers we are instructed to redial + if !self.peers.contains(&peer_id) { + return Ok(vec![]); + } + + // Cancel all pending dials for this peer in this behaviour + // Another Behaviour already schedules a dial before we could + if self.to_dial.remove(&peer_id) { + tracing::trace!(peer = %peer_id, "Cancelled a pending dial for a peer because something else already scheduled a dial"); + } // Check if we have any addresses cached for the peer // TODO: Sort these by how often we were able to connect to them @@ -321,7 +339,7 @@ impl NetworkBehaviour for Behaviour { .map(|addrs| addrs.iter().cloned().collect()) .unwrap_or_default(); - tracing::trace!(peer = %peer_id, contributed_addresses = ?addresses, "Contributing our cached addresses for a peer to the dial attempt"); + // tracing::trace!(peer = %peer_id, contributed_addresses = ?addresses, "Contributing our cached addresses for a peer to the dial attempt"); Ok(addresses) } diff --git a/swap-p2p/src/protocols/rendezvous.rs b/swap-p2p/src/protocols/rendezvous.rs index f4b1a0c0..2b6cdb97 100644 --- a/swap-p2p/src/protocols/rendezvous.rs +++ b/swap-p2p/src/protocols/rendezvous.rs @@ -1,5 +1,5 @@ use libp2p::rendezvous::Namespace; -use std::{fmt, time::Duration}; +use std::fmt; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum XmrBtcNamespace { @@ -32,9 +32,6 @@ impl From for Namespace { } } -const REDIAL_INITIAL_INTERVAL: Duration = Duration::from_secs(1); -const REDIAL_MAX_INTERVAL: Duration = Duration::from_secs(60); - impl XmrBtcNamespace { pub fn from_is_testnet(testnet: bool) -> XmrBtcNamespace { if testnet { @@ -46,530 +43,109 @@ impl XmrBtcNamespace { } /// A behaviour that periodically re-registers at multiple rendezvous points as a client -pub mod register { - use crate::protocols::redial; +pub mod register; +/// A behaviour that periodically discovers other peers at a given rendezvous point +/// +/// The behaviour also internally attempts to dial any newly discovered peers +/// It uses the `redial` behaviour internally to do this +pub mod discovery; + +#[cfg(test)] +mod tests { use super::*; - use backoff::backoff::Backoff; - use backoff::ExponentialBackoff; - use futures::FutureExt; - use libp2p::rendezvous::client::RegisterError; - use libp2p::swarm::{ - ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, - THandlerOutEvent, ToSwarm, - }; - use libp2p::{identity, rendezvous, Multiaddr, PeerId}; - use std::collections::HashMap; - use std::pin::Pin; - use std::task::{Context, Poll}; + use crate::test::{new_swarm, SwarmExt}; + use futures::StreamExt; + use libp2p::rendezvous; + use libp2p::swarm::SwarmEvent; + use libp2p::{Multiaddr, PeerId}; use std::time::Duration; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum ConnectionStatus { - Disconnected, - Connected, - } + #[tokio::test] + async fn register_and_discover_together() { + // Create rendezvous node + let (rendezvous_peer_id, rendezvous_addr, rendezvous_handle) = + spawn_rendezvous_node().await; - enum RegistrationStatus { - RegisterOnNextConnection, - Pending, - Registered { - re_register_in: Pin>, - }, - } - - pub struct Behaviour { - inner: InnerBehaviour, - rendezvous_nodes: Vec, - backoffs: HashMap, - } - - #[derive(NetworkBehaviour)] - pub struct InnerBehaviour { - rendezvous: rendezvous::client::Behaviour, - redial: redial::Behaviour, - } - - // Provide a read-only snapshot of rendezvous registrations - impl Behaviour { - /// Returns a snapshot of registration and connection status for all configured rendezvous nodes. - pub fn registrations(&self) -> Vec { - self.rendezvous_nodes - .iter() - .map(|n| RegistrationReport { - address: n.address.clone(), - connection: n.connection_status, - registration: match &n.registration_status { - RegistrationStatus::RegisterOnNextConnection => { - RegistrationStatusReport::RegisterOnNextConnection - } - RegistrationStatus::Pending => RegistrationStatusReport::Pending, - RegistrationStatus::Registered { .. } => { - RegistrationStatusReport::Registered - } - }, - }) - .collect() - } - } - - /// Public representation of a rendezvous node registration status - /// The raw `RegistrationStatus` cannot be exposed because it is not serializable - #[derive(Debug, Clone)] - pub struct RegistrationReport { - pub address: Multiaddr, - pub connection: ConnectionStatus, - pub registration: RegistrationStatusReport, - } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum RegistrationStatusReport { - RegisterOnNextConnection, - Pending, - Registered, - } - - /// A node running the rendezvous server protocol. - pub struct RendezvousNode { - pub address: Multiaddr, - connection_status: ConnectionStatus, - pub peer_id: PeerId, - registration_status: RegistrationStatus, - pub registration_ttl: Option, - pub namespace: XmrBtcNamespace, - } - - impl RendezvousNode { - pub fn new( - address: &Multiaddr, - peer_id: PeerId, - namespace: XmrBtcNamespace, - registration_ttl: Option, - ) -> Self { - Self { - address: address.to_owned(), - connection_status: ConnectionStatus::Disconnected, - namespace, - peer_id, - registration_status: RegistrationStatus::RegisterOnNextConnection, - registration_ttl, - } - } - - fn set_connection(&mut self, status: ConnectionStatus) { - self.connection_status = status; - } - - fn set_registration(&mut self, status: RegistrationStatus) { - self.registration_status = status; - } - } - - impl Behaviour { - const RENDEZVOUS_RETRY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); - const RENDEZVOUS_RETRY_MAX_INTERVAL: Duration = Duration::from_secs(60); - const REDIAL_IDENTIFIER: &str = "rendezvous-server-for-register"; - - pub fn new(identity: identity::Keypair, rendezvous_nodes: Vec) -> Self { - let our_peer_id = identity.public().to_peer_id(); - let rendezvous_nodes: Vec = rendezvous_nodes - .into_iter() - .filter(|node| node.peer_id != our_peer_id) - .collect(); - - let mut backoffs = HashMap::new(); - - let mut redial = redial::Behaviour::new( - Self::REDIAL_IDENTIFIER, - REDIAL_INITIAL_INTERVAL, - REDIAL_MAX_INTERVAL, - ); - - // Initialize backoff for each rendezvous node - for node in &rendezvous_nodes { - redial.add_peer_with_address(node.peer_id, node.address.clone()); - - backoffs.insert( - node.peer_id, - ExponentialBackoff { - // Never give up - max_elapsed_time: None, - // We retry aggressively. We begin with 50ms and increase by 10% per retry. - multiplier: 1.1f64, - initial_interval: Self::RENDEZVOUS_RETRY_INITIAL_INTERVAL, - current_interval: Self::RENDEZVOUS_RETRY_INITIAL_INTERVAL, - max_interval: Self::RENDEZVOUS_RETRY_MAX_INTERVAL, - ..ExponentialBackoff::default() - }, - ); - } - - Self { - inner: InnerBehaviour { - rendezvous: rendezvous::client::Behaviour::new(identity), - redial, - }, - rendezvous_nodes, - backoffs, - } - } - - /// Registers the rendezvous node at the given index. - /// Also sets the registration status to [`RegistrationStatus::Pending`]. - pub fn register(&mut self, node_index: usize) -> Result<(), RegisterError> { - let node = &mut self.rendezvous_nodes[node_index]; - node.set_registration(RegistrationStatus::Pending); - let (namespace, peer_id, ttl) = - (node.namespace.into(), node.peer_id, node.registration_ttl); - self.inner.rendezvous.register(namespace, peer_id, ttl) - } - } - - impl NetworkBehaviour for Behaviour { - type ConnectionHandler = ::ConnectionHandler; - type ToSwarm = InnerBehaviourEvent; - - fn on_swarm_event(&mut self, event: FromSwarm<'_>) { - match event { - FromSwarm::ConnectionEstablished(connection) => { - let peer_id = connection.peer_id; - - // Find the rendezvous node that matches the peer id, else do nothing. - if let Some(index) = self - .rendezvous_nodes - .iter_mut() - .position(|node| node.peer_id == peer_id) - { - let rendezvous_node = &mut self.rendezvous_nodes[index]; - rendezvous_node.set_connection(ConnectionStatus::Connected); - - // Reset backoff on successful connection - if let Some(backoff) = self.backoffs.get_mut(&peer_id) { - backoff.reset(); - } - - if let RegistrationStatus::RegisterOnNextConnection = - rendezvous_node.registration_status - { - let _ = self.register(index).inspect_err(|err| { - tracing::error!( - error=%err, - rendezvous_node=%peer_id, - "Failed to register with rendezvous node"); - }); - } - } - } - FromSwarm::ConnectionClosed(connection) => { - let peer_id = connection.peer_id; - - // Update the connection status of the rendezvous node that disconnected. - if let Some(node) = self - .rendezvous_nodes - .iter_mut() - .find(|node| node.peer_id == peer_id) - { - node.set_connection(ConnectionStatus::Disconnected); - } - } - FromSwarm::DialFailure(dial_failure) => { - // Update the connection status of the rendezvous node that failed to connect. - if let Some(peer_id) = dial_failure.peer_id { - if let Some(node) = self - .rendezvous_nodes - .iter_mut() - .find(|node| node.peer_id == peer_id) - { - node.set_connection(ConnectionStatus::Disconnected); - } - } - } - _ => {} - } - self.inner.on_swarm_event(event); - } - - fn poll( - &mut self, - cx: &mut Context<'_>, - ) -> Poll>> { - // Check the status of each rendezvous node - for i in 0..self.rendezvous_nodes.len() { - let connection_status = self.rendezvous_nodes[i].connection_status.clone(); - match &mut self.rendezvous_nodes[i].registration_status { - RegistrationStatus::RegisterOnNextConnection => match connection_status { - ConnectionStatus::Disconnected => {} - ConnectionStatus::Connected => { - let _ = self.register(i); - } - }, - RegistrationStatus::Registered { re_register_in } => { - if let Poll::Ready(()) = re_register_in.poll_unpin(cx) { - match connection_status { - ConnectionStatus::Connected => { - let _ = self.register(i).inspect_err(|err| { - tracing::error!( - error=%err, - rendezvous_node=%self.rendezvous_nodes[i].peer_id, - "Failed to register with rendezvous node"); - }); - } - ConnectionStatus::Disconnected => { - self.rendezvous_nodes[i].set_registration( - RegistrationStatus::RegisterOnNextConnection, - ); - } - } - } - } - RegistrationStatus::Pending => {} - } - } - - let inner_poll = self.inner.poll(cx); - - // Reset the timer for the specific rendezvous node if we successfully registered - if let Poll::Ready(ToSwarm::GenerateEvent(InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::Registered { - ttl, - rendezvous_node, - .. - }, - ))) = &inner_poll - { - if let Some(i) = self - .rendezvous_nodes - .iter() - .position(|n| &n.peer_id == rendezvous_node) - { - let half_of_ttl = Duration::from_secs(*ttl) / 2; - let re_register_in = Box::pin(tokio::time::sleep(half_of_ttl)); - let status = RegistrationStatus::Registered { re_register_in }; - self.rendezvous_nodes[i].set_registration(status); - } - } - - inner_poll - } - - fn on_connection_handler_event( - &mut self, - peer_id: PeerId, - connection_id: ConnectionId, - event: THandlerOutEvent, - ) { - self.inner - .on_connection_handler_event(peer_id, connection_id, event) - } - - fn handle_established_inbound_connection( - &mut self, - connection_id: ConnectionId, - peer: PeerId, - local_addr: &Multiaddr, - remote_addr: &Multiaddr, - ) -> Result, ConnectionDenied> { - self.inner.handle_established_inbound_connection( - connection_id, - peer, - local_addr, - remote_addr, + // Create peer that registers at the rendezvous node + let mut registrar = new_swarm(|identity| { + register::Behaviour::new( + identity, + vec![rendezvous_peer_id], + XmrBtcNamespace::Testnet.into(), ) - } + }); + registrar.add_peer_address(rendezvous_peer_id, rendezvous_addr.clone()); + registrar.listen_on_random_memory_address().await; + let registrar_id = *registrar.local_peer_id(); - fn handle_established_outbound_connection( - &mut self, - connection_id: ConnectionId, - peer: PeerId, - addr: &Multiaddr, - role_override: libp2p::core::Endpoint, - ) -> Result, ConnectionDenied> { - self.inner.handle_established_outbound_connection( - connection_id, - peer, - addr, - role_override, + // Create peer that discovers the + let mut discoverer = new_swarm(|identity| { + discovery::Behaviour::new( + identity, + vec![rendezvous_peer_id], + XmrBtcNamespace::Testnet.into(), ) - } + }); + discoverer.add_peer_address(rendezvous_peer_id, rendezvous_addr); - fn handle_pending_outbound_connection( - &mut self, - connection_id: ConnectionId, - maybe_peer: Option, - addresses: &[Multiaddr], - effective_role: libp2p::core::Endpoint, - ) -> std::result::Result, ConnectionDenied> { - self.inner.handle_pending_outbound_connection( - connection_id, - maybe_peer, - addresses, - effective_role, - ) - } + let registrar_task = tokio::spawn(async move { + loop { + registrar.next().await; + } + }); - 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) - } + // Now wait until discovery wrapper discovers registrar and dials it. + let discovery_task = tokio::spawn(async move { + let mut saw_discovery = false; + let mut saw_address = false; + + loop { + match discoverer.select_next_some().await { + SwarmEvent::Behaviour(discovery::Event::DiscoveredPeer { peer_id }) + if peer_id == registrar_id => + { + saw_discovery = true; + } + SwarmEvent::NewExternalAddrOfPeer { peer_id, .. } + if peer_id == registrar_id => + { + saw_address = true; + } + _ => {} + } + + if saw_discovery && saw_address { + break; + } + } + }); + + tokio::time::timeout(Duration::from_secs(10), discovery_task) + .await + .expect("discovery and direct connection to registrar timed out") + .unwrap(); + + registrar_task.abort(); + rendezvous_handle.abort(); } - #[cfg(test)] - mod tests { - use super::*; - use crate::test::{new_swarm, SwarmExt}; - use futures::StreamExt; - use libp2p::rendezvous; - use libp2p::swarm::SwarmEvent; - use std::collections::HashMap; + /// Spawns a rendezvous server that continuously processes events + async fn spawn_rendezvous_node() -> (PeerId, Multiaddr, tokio::task::JoinHandle<()>) { + let mut rendezvous_node = new_swarm(|_| { + rendezvous::server::Behaviour::new( + rendezvous::server::Config::default().with_min_ttl(2), + ) + }); + let address = rendezvous_node.listen_on_random_memory_address().await; + let peer_id = *rendezvous_node.local_peer_id(); - #[tokio::test] - async fn given_no_initial_connection_when_constructed_asb_connects_and_registers_with_rendezvous_node( - ) { - let mut rendezvous_node = new_swarm(|_| { - rendezvous::server::Behaviour::new(rendezvous::server::Config::default()) - }); - let address = rendezvous_node.listen_on_random_memory_address().await; - let rendezvous_point = RendezvousNode::new( - &address, - rendezvous_node.local_peer_id().to_owned(), - XmrBtcNamespace::Testnet, - None, - ); - - let mut asb = - new_swarm(|identity| super::Behaviour::new(identity, vec![rendezvous_point])); - asb.listen_on_random_memory_address().await; - - tokio::spawn(async move { - loop { - rendezvous_node.next().await; - } - }); - let asb_registered = tokio::spawn(async move { - loop { - if let SwarmEvent::Behaviour(InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::Registered { .. }, - )) = asb.select_next_some().await - { - break; - } - } - }); - - tokio::time::timeout(Duration::from_secs(10), asb_registered) - .await - .unwrap() - .unwrap(); - } - - #[tokio::test] - async fn asb_automatically_re_registers() { - let mut rendezvous_node = new_swarm(|_| { - rendezvous::server::Behaviour::new( - rendezvous::server::Config::default().with_min_ttl(2), - ) - }); - let address = rendezvous_node.listen_on_random_memory_address().await; - let rendezvous_point = RendezvousNode::new( - &address, - rendezvous_node.local_peer_id().to_owned(), - XmrBtcNamespace::Testnet, - Some(5), - ); - - let mut asb = - new_swarm(|identity| super::Behaviour::new(identity, vec![rendezvous_point])); - asb.listen_on_random_memory_address().await; - - tokio::spawn(async move { - loop { - rendezvous_node.next().await; - } - }); - let asb_registered_three_times = tokio::spawn(async move { - let mut number_of_registrations = 0; - - loop { - if let SwarmEvent::Behaviour(InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::Registered { .. }, - )) = asb.select_next_some().await - { - number_of_registrations += 1 - } - - if number_of_registrations == 3 { - break; - } - } - }); - - tokio::time::timeout(Duration::from_secs(30), asb_registered_three_times) - .await - .unwrap() - .unwrap(); - } - - #[tokio::test] - async fn asb_registers_multiple() { - let registration_ttl = Some(10); - let mut rendezvous_nodes = Vec::new(); - let mut registrations = HashMap::new(); - - // Register with 5 rendezvous nodes - for _ in 0..5 { - let mut rendezvous = new_swarm(|_| { - rendezvous::server::Behaviour::new( - rendezvous::server::Config::default().with_min_ttl(2), - ) - }); - let address = rendezvous.listen_on_random_memory_address().await; - let id = *rendezvous.local_peer_id(); - registrations.insert(id, 0); - rendezvous_nodes.push(RendezvousNode::new( - &address, - *rendezvous.local_peer_id(), - XmrBtcNamespace::Testnet, - registration_ttl, - )); - tokio::spawn(async move { - loop { - rendezvous.next().await; - } - }); + let handle = tokio::spawn(async move { + loop { + rendezvous_node.next().await; } + }); - let mut asb = - new_swarm(|identity| register::Behaviour::new(identity, rendezvous_nodes)); - asb.listen_on_random_memory_address().await; // this adds an external address - - let handle = tokio::spawn(async move { - loop { - if let SwarmEvent::Behaviour(InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::Registered { - rendezvous_node, .. - }, - )) = asb.select_next_some().await - { - registrations - .entry(rendezvous_node) - .and_modify(|counter| *counter += 1); - } - - if registrations.iter().all(|(_, &count)| count >= 4) { - break; - } - } - }); - - tokio::time::timeout(Duration::from_secs(30), handle) - .await - .unwrap() - .unwrap(); - } + (peer_id, address, handle) } } diff --git a/swap-p2p/src/protocols/rendezvous/discovery.rs b/swap-p2p/src/protocols/rendezvous/discovery.rs new file mode 100644 index 00000000..adc1a5d2 --- /dev/null +++ b/swap-p2p/src/protocols/rendezvous/discovery.rs @@ -0,0 +1,289 @@ +use futures::future::{self}; +use futures::FutureExt; +use libp2p::{ + identity, rendezvous, + swarm::{NetworkBehaviour, THandlerInEvent, ToSwarm}, + Multiaddr, PeerId, +}; +use std::{ + collections::{HashSet, VecDeque}, + task::Poll, +}; + +use crate::{ + behaviour_util::{BackoffTracker, ConnectionTracker}, + futures_util::FuturesHashSet, + protocols::redial, +}; + +pub struct Behaviour { + inner: InnerBehaviour, + namespace: rendezvous::Namespace, + + // Set of all (peer_id, address) pairs that we have discovered over the lifetime of the behaviour + // This is also used to avoid notifying the swarm about the same pair multiple times + discovered: HashSet<(PeerId, Multiaddr)>, + + // Track all the connections internally + connection_tracker: ConnectionTracker, + + // Backoff for each rendezvous node for discovery + backoff: BackoffTracker, + + // Queue of discovery requests to send to a rendezvous node + // once the future resolves, the peer id is removed from the queue and a discovery request is sent + pending_to_discover: FuturesHashSet, + + // Queue of peers to send a request to as soon as we are connected to them + to_discover: VecDeque, + + // Queue of events to be sent to the swarm + to_swarm: VecDeque>>, +} + +// This could use notice to recursively discover other rendezvous nodes +#[derive(NetworkBehaviour)] +pub struct InnerBehaviour { + rendezvous: libp2p::rendezvous::client::Behaviour, + redial: redial::Behaviour, +} + +#[derive(Debug)] +pub enum Event { + DiscoveredPeer { peer_id: PeerId }, +} + +impl Behaviour { + pub fn new( + identity: identity::Keypair, + rendezvous_nodes: Vec, + namespace: rendezvous::Namespace, + ) -> Self { + let mut redial = redial::Behaviour::new( + "rendezvous-discovery", + crate::defaults::REDIAL_INITIAL_INTERVAL, + crate::defaults::REDIAL_MAX_INTERVAL, + ); + let rendezvous = libp2p::rendezvous::client::Behaviour::new(identity); + + let backoff = BackoffTracker::new( + crate::defaults::DISCOVERY_INITIAL_INTERVAL, + crate::defaults::DISCOVERY_MAX_INTERVAL, + crate::defaults::BACKOFF_MULTIPLIER, + ); + let mut to_discover = FuturesHashSet::new(); + + // Initialize backoff for each rendezvous node + for node in &rendezvous_nodes { + // We initially schedule a discovery request for each rendezvous node + to_discover.insert(node.clone(), future::ready(()).boxed()); + + // We instruct the redial behaviour to dial rendezvous nodes periodically + redial.add_peer(node.clone()); + } + + Self { + inner: InnerBehaviour { rendezvous, redial }, + discovered: HashSet::new(), + backoff, + pending_to_discover: to_discover, + to_discover: VecDeque::new(), + connection_tracker: ConnectionTracker::new(), + namespace, + to_swarm: VecDeque::new(), + } + } +} + +impl NetworkBehaviour for Behaviour { + // We use a dummy connection handler here as we don't need low level connection handling + // This is handled by the inner behaviour + type ConnectionHandler = ::ConnectionHandler; + + type ToSwarm = Event; + + fn poll( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll>> + { + // Check if a backoff timer for a peer has resolved + while let Poll::Ready(Some((peer_id, _))) = self.pending_to_discover.poll_next_unpin(cx) { + // Request is ready to be dispatched + self.to_discover.push_back(peer_id); + } + + // Take ownership of the queue to avoid borrow checker issues + let to_discover = std::mem::take(&mut self.to_discover); + self.to_discover = to_discover + .into_iter() + .filter(|peer| { + // If we are not connected to the peer, keep it in the queue + if !self.connection_tracker.is_connected(peer) { + return true; + } + + // If we are connected to the peer, send a discovery request + self.inner.rendezvous.discover( + Some(self.namespace.clone()), + None, + None, + peer.clone(), + ); + + false + }) + .collect(); + + while let Poll::Ready(event) = self.inner.poll(cx) { + match event { + ToSwarm::GenerateEvent(InnerBehaviourEvent::Rendezvous( + libp2p::rendezvous::client::Event::Discovered { + rendezvous_node, + registrations, + .. + }, + )) => { + tracing::trace!( + ?rendezvous_node, + num_registrations = %registrations.len(), + "Discovered peers at rendezvous node" + ); + + for registration in registrations { + for address in registration.record.addresses() { + let peer_id = registration.record.peer_id(); + + if self.discovered.insert((peer_id, address.clone())) { + self.to_swarm.push_back(ToSwarm::NewExternalAddrOfPeer { + peer_id, + address: address.clone(), + }); + self.to_swarm.push_back(ToSwarm::GenerateEvent( + Event::DiscoveredPeer { peer_id }, + )); + + tracing::trace!( + ?rendezvous_node, + ?peer_id, + ?address, + "Discovered peer at rendezvous node" + ); + } + + self.pending_to_discover.insert( + rendezvous_node, + tokio::time::sleep(crate::defaults::DISCOVERY_INTERVAL).boxed(), + ); + } + } + continue; + } + ToSwarm::GenerateEvent(InnerBehaviourEvent::Rendezvous( + libp2p::rendezvous::client::Event::DiscoverFailed { + rendezvous_node, + error, + namespace: _, + }, + )) => { + let backoff = self.backoff.increment(&rendezvous_node); + + self.pending_to_discover + .insert(rendezvous_node, tokio::time::sleep(backoff).boxed()); + + tracing::error!( + ?rendezvous_node, + ?error, + seconds_until_next_discovery_attempt = %backoff.as_secs(), + "Failed to discover peers at rendezvous node, scheduling retry after backoff" + ); + continue; + } + ToSwarm::GenerateEvent(_) => { + // TODO: Do anything with these? + } + other => { + return Poll::Ready(other.map_out(|_| { + unreachable!("we handled all generated events in the arm above") + })) + } + } + } + + // Check if we have any events to send to the swarm + if let Some(event) = self.to_swarm.pop_front() { + return Poll::Ready(event); + } + + Poll::Pending + } + + fn on_swarm_event(&mut self, event: libp2p::swarm::FromSwarm) { + self.connection_tracker.handle_swarm_event(event); + self.inner.on_swarm_event(event); + } + + fn handle_established_inbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + peer: PeerId, + local_addr: &libp2p::Multiaddr, + remote_addr: &libp2p::Multiaddr, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + peer: PeerId, + addr: &libp2p::Multiaddr, + role_override: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: libp2p::swarm::ConnectionId, + event: libp2p::swarm::THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event); + } + + fn handle_pending_inbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result<(), libp2p::swarm::ConnectionDenied> { + self.inner + .handle_pending_inbound_connection(connection_id, local_addr, remote_addr) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: libp2p::swarm::ConnectionId, + maybe_peer: Option, + addresses: &[Multiaddr], + effective_role: libp2p::core::Endpoint, + ) -> Result, libp2p::swarm::ConnectionDenied> { + self.connection_tracker + .handle_pending_outbound_connection(connection_id, maybe_peer); + + self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + ) + } +} diff --git a/swap-p2p/src/protocols/rendezvous/register.rs b/swap-p2p/src/protocols/rendezvous/register.rs new file mode 100644 index 00000000..e7ecf161 --- /dev/null +++ b/swap-p2p/src/protocols/rendezvous/register.rs @@ -0,0 +1,445 @@ +use crate::behaviour_util::{AddressTracker, BackoffTracker, ConnectionTracker}; +use crate::futures_util::FuturesHashSet; +use crate::protocols::redial; +use futures::{future, FutureExt}; +use libp2p::rendezvous::client::RegisterError; +use libp2p::rendezvous::ErrorCode; +use libp2p::swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, +}; +use libp2p::{identity, rendezvous, Multiaddr, PeerId}; +use std::collections::{HashSet, VecDeque}; +use std::task::{Context, Poll}; +use std::time::Duration; + +// TODO: This could use notice to recursively discover other rendezvous nodes +// TODO: Get the tests working again +pub struct Behaviour { + inner: InnerBehaviour, + + rendezvous_nodes: Vec, + namespace: rendezvous::Namespace, + + backoffs: BackoffTracker, + connections: ConnectionTracker, + address: AddressTracker, + + // Set of all peers that we think we are registered at + registered: HashSet, + + // Register at these as soon as we are connected to them + to_dispatch: VecDeque, + + // Move these into `to_dispatch` once the future resolves + pending_to_dispatch: FuturesHashSet, + + to_swarm: VecDeque, +} + +#[derive(NetworkBehaviour)] +pub struct InnerBehaviour { + rendezvous: rendezvous::client::Behaviour, + redial: redial::Behaviour, +} + +#[derive(Debug)] +pub enum Event { + Registered { + peer_id: PeerId, + }, + RegisterDispatchFailed { + peer_id: PeerId, + error: RegisterError, + }, + RegisterRequestFailed { + peer_id: PeerId, + error: ErrorCode, + }, +} + +pub mod public { + use libp2p::{Multiaddr, PeerId}; + + #[derive(Debug, Clone)] + pub struct RendezvousNodeStatus { + pub peer_id: PeerId, + pub address: Option, + pub is_connected: bool, + pub registration: RegistrationStatus, + } + + #[derive(Debug, Clone)] + pub enum RegistrationStatus { + Registered, + WillRegisterAfterDelay, + RegisterOnceConnected, + RequestInflight, + } +} + +impl Behaviour { + const REDIAL_IDENTIFIER: &str = "rendezvous-register"; + + pub fn new( + identity: identity::Keypair, + rendezvous_nodes: Vec, + namespace: rendezvous::Namespace, + ) -> Self { + let backoffs = BackoffTracker::new( + crate::defaults::RENDEZVOUS_RETRY_INITIAL_INTERVAL, + crate::defaults::RENDEZVOUS_RETRY_MAX_INTERVAL, + crate::defaults::BACKOFF_MULTIPLIER, + ); + + let mut redial = redial::Behaviour::new( + Self::REDIAL_IDENTIFIER, + crate::defaults::REDIAL_INITIAL_INTERVAL, + crate::defaults::REDIAL_MAX_INTERVAL, + ); + + let mut pending_to_dispatch = FuturesHashSet::new(); + + for &peer_id in &rendezvous_nodes { + // We want to redial all of the nodes periodically because we only dispatch requests once we are connected + redial.add_peer(peer_id.clone()); + + // Schedule an intitial register + pending_to_dispatch.insert(peer_id, Box::pin(future::ready(()))); + } + + Self { + inner: InnerBehaviour { + rendezvous: rendezvous::client::Behaviour::new(identity), + redial, + }, + connections: ConnectionTracker::new(), + address: AddressTracker::new(), + registered: HashSet::new(), + rendezvous_nodes, + backoffs, + to_dispatch: VecDeque::new(), + pending_to_dispatch, + namespace, + to_swarm: VecDeque::new(), + } + } + + pub fn status(&self) -> Vec { + self.rendezvous_nodes + .iter() + .map(|peer_id| { + let registration = { + if self.registered.contains(peer_id) { + public::RegistrationStatus::Registered + } else if self.to_dispatch.contains(peer_id) { + public::RegistrationStatus::RegisterOnceConnected + } else { + public::RegistrationStatus::WillRegisterAfterDelay + } + }; + + public::RendezvousNodeStatus { + peer_id: peer_id.clone(), + address: self.address.last_seen_address(peer_id), + is_connected: self.connections.is_connected(peer_id), + registration, + } + }) + .collect() + } + + pub fn schedule_re_register_replace(&mut self, peer_id: PeerId) -> Duration { + let backoff = self.backoffs.get(&peer_id).current_interval; + + // We replace any existing timeout + self.pending_to_dispatch + .replace(peer_id, Box::pin(tokio::time::sleep(backoff).boxed())); + + backoff + } +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = ::ConnectionHandler; + type ToSwarm = Event; + + fn on_swarm_event(&mut self, event: FromSwarm<'_>) { + self.connections.handle_swarm_event(event); + self.address.handle_swarm_event(event); + self.inner.on_swarm_event(event); + + if let FromSwarm::ConnectionClosed(connection_closed) = event { + // We disconnected from a node where we were registered, so we schedule a re-register + if self.registered.contains(&connection_closed.peer_id) { + let backoff = self.schedule_re_register_replace(connection_closed.peer_id); + + tracing::info!( + ?connection_closed.peer_id, + seconds_until_next_registration_attempt = %backoff.as_secs(), + "Disconnected from rendezvous node, scheduling re-register after backoff" + ); + } + } + } + + fn poll( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + // Check if any of the futures resolved + while let Poll::Ready(Some((peer_id, _))) = self.pending_to_dispatch.poll_next_unpin(cx) { + self.to_dispatch.push_back(peer_id); + + // We assume that if we have queued a register to be dispatched, then we are not registed anymore + // because we only queue a register if we failed to register or the ttl expired + self.registered.remove(&peer_id); + } + + // Take ownership of the queue to avoid borrow checker issues + let to_dispatch = std::mem::take(&mut self.to_dispatch); + self.to_dispatch = to_dispatch + .into_iter() + .filter(|peer| { + if !self.connections.is_connected(peer) { + return true; + } + + // If we are connected to the peer, register with them + if let Err(err) = self.inner.rendezvous.register(self.namespace.clone(), peer.clone(), None) { + // We failed to dispatch the register, so we backoff + self.backoffs.increment(&peer); + + // Schedule a re-register + let backoff = self.schedule_re_register_replace(*peer); + + tracing::error!( + ?peer, + ?err, + seconds_until_next_registration_attempt = %backoff.as_secs(), + "Failed to dispatch register at rendezvous node, scheduling retry after backoff" + ); + + // Inform swarm + self.to_swarm.push_back(Event::RegisterDispatchFailed { peer_id: peer.clone(), error: err }); + } + + false + }) + .collect(); + + while let Poll::Ready(event) = self.inner.poll(cx) { + match event { + ToSwarm::GenerateEvent(InnerBehaviourEvent::Rendezvous( + rendezvous::client::Event::Registered { + ttl, + rendezvous_node, + .. + }, + )) => { + // We successfully registered, so we reset the backoff + self.backoffs.reset(&rendezvous_node); + + self.registered.insert(rendezvous_node.clone()); + + // Schedule a re-registration after half of the TTL + let half_of_ttl = Duration::from_secs(ttl) / 2; + self.pending_to_dispatch.insert( + rendezvous_node.clone(), + tokio::time::sleep(half_of_ttl).boxed(), + ); + + // Inform swarm + self.to_swarm.push_back(Event::Registered { + peer_id: rendezvous_node, + }); + + tracing::info!( + ?rendezvous_node, + re_register_after_seconds = %half_of_ttl.as_secs(), + "Registered with rendezvous node" + ); + } + ToSwarm::GenerateEvent(InnerBehaviourEvent::Rendezvous( + rendezvous::client::Event::RegisterFailed { + rendezvous_node, + namespace: _, + error, + }, + )) => { + // We failed to register, so we backoff + let backoff = self.backoffs.increment(&rendezvous_node); + + // Inform swarm + self.to_swarm.push_back(Event::RegisterRequestFailed { + peer_id: rendezvous_node, + error, + }); + + tracing::error!( + ?rendezvous_node, + ?error, + seconds_until_next_registration_attempt = %backoff.as_secs(), + "Failed to register with rendezvous node, scheduling retry after backoff" + ); + + // Schedule a retry after the backoff + self.pending_to_dispatch + .insert(rendezvous_node.clone(), tokio::time::sleep(backoff).boxed()); + } + ToSwarm::GenerateEvent(_) => { + // swallow all other generated events by the inner swarm + // TODO: Do something with these + } + other => { + return Poll::Ready(other.map_out(|_| { + unreachable!("we handled all generated events in the arm above") + })) + } + } + } + + while let Some(event) = self.to_swarm.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + self.inner + .on_connection_handler_event(peer_id, connection_id, event) + } + + fn handle_established_inbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + local_addr: &Multiaddr, + remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + self.inner.handle_established_inbound_connection( + connection_id, + peer, + local_addr, + remote_addr, + ) + } + + fn handle_established_outbound_connection( + &mut self, + connection_id: ConnectionId, + peer: PeerId, + addr: &Multiaddr, + role_override: libp2p::core::Endpoint, + ) -> Result, ConnectionDenied> { + self.inner + .handle_established_outbound_connection(connection_id, peer, addr, role_override) + } + + fn handle_pending_outbound_connection( + &mut self, + connection_id: ConnectionId, + maybe_peer: Option, + addresses: &[Multiaddr], + effective_role: libp2p::core::Endpoint, + ) -> std::result::Result, ConnectionDenied> { + self.connections + .handle_pending_outbound_connection(connection_id, maybe_peer); + + self.inner.handle_pending_outbound_connection( + connection_id, + maybe_peer, + addresses, + effective_role, + ) + } + + 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) + } +} + +// TODO: Add a test that ensures we re-register after some time +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::rendezvous::XmrBtcNamespace; + use crate::test::{new_swarm, SwarmExt}; + use futures::StreamExt; + use libp2p::rendezvous; + use libp2p::swarm::SwarmEvent; + + #[tokio::test] + async fn registers_once_at_two_rendezvous_nodes() { + let (rendezvous_peer_id1, rendezvous_addr1, _) = spawn_rendezvous_server().await; + let (rendezvous_peer_id2, rendezvous_addr2, _) = spawn_rendezvous_server().await; + + let mut asb = new_swarm(|identity| { + super::Behaviour::new( + identity, + vec![rendezvous_peer_id1, rendezvous_peer_id2], + XmrBtcNamespace::Testnet.into(), + ) + }); + asb.add_peer_address(rendezvous_peer_id1, rendezvous_addr1); + asb.add_peer_address(rendezvous_peer_id2, rendezvous_addr2); + + // We need to listen on address because otherwise we cannot advertise an address at the rendezvous point + asb.listen_on_random_memory_address().await; + + let mut registered = HashSet::new(); + + let asb_registered_three_times = tokio::spawn(async move { + loop { + if let SwarmEvent::Behaviour(Event::Registered { peer_id }) = + asb.select_next_some().await + { + assert!(peer_id == rendezvous_peer_id1 || peer_id == rendezvous_peer_id2); + registered.insert(peer_id); + } + + if registered.contains(&rendezvous_peer_id1) + && registered.contains(&rendezvous_peer_id2) + { + break; + } + } + }); + + tokio::time::timeout(Duration::from_secs(5), asb_registered_three_times) + .await + .unwrap() + .unwrap(); + } + + /// Spawns a rendezvous server that continuously processes events + async fn spawn_rendezvous_server() -> (PeerId, Multiaddr, tokio::task::JoinHandle<()>) { + let mut rendezvous_node = new_swarm(|_| { + rendezvous::server::Behaviour::new( + rendezvous::server::Config::default().with_min_ttl(2), + ) + }); + let address = rendezvous_node.listen_on_random_memory_address().await; + let peer_id = *rendezvous_node.local_peer_id(); + + let handle = tokio::spawn(async move { + loop { + rendezvous_node.next().await; + } + }); + + (peer_id, address, handle) + } +} diff --git a/swap-p2p/src/protocols/swap_setup/alice.rs b/swap-p2p/src/protocols/swap_setup/alice.rs index 42a8bf4c..014a5350 100644 --- a/swap-p2p/src/protocols/swap_setup/alice.rs +++ b/swap-p2p/src/protocols/swap_setup/alice.rs @@ -4,9 +4,11 @@ use crate::protocols::swap_setup::{ protocol, BlockchainNetwork, SpotPriceError, SpotPriceRequest, SpotPriceResponse, }; use anyhow::{anyhow, Context, Result}; -use futures::future::{BoxFuture, OptionFuture}; +use futures::future::BoxFuture; +use futures::stream::FuturesUnordered; use futures::AsyncWriteExt; use futures::FutureExt; +use futures::StreamExt; use libp2p::core::upgrade; use libp2p::swarm::handler::ConnectionEvent; use libp2p::swarm::{ConnectionHandler, ConnectionId}; @@ -15,7 +17,7 @@ use libp2p::{Multiaddr, PeerId}; use std::collections::VecDeque; use std::fmt::Debug; use std::task::Poll; -use std::time::{Duration, Instant}; +use std::time::Duration; use swap_core::bitcoin; use swap_env::env; use swap_feed::LatestRate; @@ -218,10 +220,8 @@ where } } -type InboundStream = BoxFuture<'static, Result<(Uuid, State3)>>; - pub struct Handler { - inbound_stream: OptionFuture, + inbound_streams: FuturesUnordered>>, events: VecDeque, min_buy: bitcoin::Amount, @@ -233,10 +233,6 @@ pub struct Handler { // This is the timeout for the negotiation phase where Alice and Bob exchange messages negotiation_timeout: Duration, - - // If set to None, we will keep the connection alive indefinitely - // If set to Some, we will keep the connection alive until the given instant - keep_alive_until: Option, } impl Handler { @@ -248,15 +244,14 @@ impl Handler { resume_only: bool, ) -> Self { Self { - inbound_stream: OptionFuture::from(None), + inbound_streams: FuturesUnordered::new(), events: Default::default(), min_buy, max_buy, env_config, latest_rate, resume_only, - negotiation_timeout: Duration::from_secs(120), - keep_alive_until: Some(Instant::now() + Duration::from_secs(30)), + negotiation_timeout: crate::defaults::NEGOTIATION_TIMEOUT, } } } @@ -295,14 +290,13 @@ where ) { match event { ConnectionEvent::FullyNegotiatedInbound(substream) => { - self.keep_alive_until = None; - let mut substream = substream.protocol; - let (sender, receiver) = bmrng::channel_with_timeout::< - bitcoin::Amount, - WalletSnapshot, - >(1, Duration::from_secs(60)); + let (sender, receiver) = + bmrng::channel_with_timeout::( + 1, + crate::defaults::SWAP_SETUP_CHANNEL_TIMEOUT, + ); let resume_only = self.resume_only; let min_buy = self.min_buy; @@ -448,14 +442,14 @@ where }); let max_seconds = self.negotiation_timeout.as_secs(); - self.inbound_stream = OptionFuture::from(Some( + self.inbound_streams.push( async move { protocol.await.with_context(|| { format!("Failed to complete execution setup within {}s", max_seconds) })? } .boxed(), - )); + ); self.events.push_back(HandlerOutEvent::Initiated(receiver)); } @@ -474,12 +468,7 @@ where } fn connection_keep_alive(&self) -> bool { - // If keep_alive_until is None, we keep the connection alive indefinitely - // If keep_alive_until is Some, we keep the connection alive until the given instant - match self.keep_alive_until { - None => true, - Some(keep_alive_until) => Instant::now() < keep_alive_until, - } + !self.inbound_streams.is_empty() } #[allow(clippy::type_complexity)] @@ -495,10 +484,9 @@ where return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour(event)); } - if let Some(result) = futures::ready!(self.inbound_stream.poll_unpin(cx)) { - self.inbound_stream = OptionFuture::from(None); - + if let Poll::Ready(Some(result)) = self.inbound_streams.poll_next_unpin(cx) { // Notify the behaviour that the negotiation phase has been completed + // (either successfully or with an error) return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour( HandlerOutEvent::Completed(result), )); diff --git a/swap-p2p/src/protocols/swap_setup/bob.rs b/swap-p2p/src/protocols/swap_setup/bob.rs index 279eb0e7..3e9284ad 100644 --- a/swap-p2p/src/protocols/swap_setup/bob.rs +++ b/swap-p2p/src/protocols/swap_setup/bob.rs @@ -1,12 +1,11 @@ +use crate::futures_util::FuturesHashSet; use crate::out_event; use crate::protocols::swap_setup::{ protocol, BlockchainNetwork, SpotPriceError, SpotPriceResponse, }; use anyhow::{Context, Result}; use bitcoin_wallet::BitcoinWallet; -use futures::future::{BoxFuture, OptionFuture}; use futures::AsyncWriteExt; -use futures::FutureExt; use libp2p::core::upgrade; use libp2p::swarm::behaviour::ConnectionEstablished; use libp2p::swarm::dial_opts::{DialOpts, PeerCondition}; @@ -28,6 +27,8 @@ use uuid::Uuid; use super::{read_cbor_message, write_cbor_message, SpotPriceRequest}; +// TODO: This should use redial::Behaviour to keep connections alive for peers with queued requests +// TODO: Do not use swap_id as key inside the ConnectionHandler, use another key #[allow(missing_debug_implementations)] pub struct Behaviour { env_config: env::Config, @@ -35,20 +36,18 @@ pub struct Behaviour { // Queue of swap setup request that haven't been assigned to a connection handler yet // (peer_id, swap_id, new_swap) - new_swaps: VecDeque<(PeerId, Uuid, NewSwap)>, + new_swaps: VecDeque<(PeerId, NewSwap)>, - // Maintains the list of connections handlers for a specific peer - // - // 0. List of connection handlers that are still active but haven't been assigned a swap setup request yet - // 1. List of connection handlers that have died. Once their death is acknowledged / processed, they are removed from the list - connection_handlers: HashMap, VecDeque)>, + // Maintains the set of all alive connections handlers for a specific peer + connection_handlers: HashMap>, + connection_handler_deaths: VecDeque<(PeerId, ConnectionId)>, // Queue of completed swaps that we have assigned a connection handler to but where we haven't notified the ConnectionHandler yet // We notify the ConnectionHandler by emitting a ConnectionHandlerEvent::NotifyBehaviour event assigned_unnotified_swaps: VecDeque<(ConnectionId, PeerId, Uuid, NewSwap)>, // Maintains the list of requests that we have sent to a connection handler but haven't yet received a response - inflight_requests: HashMap, + inflight_requests: HashMap>, // Queue of swap setup results that we want to notify the Swarm about to_swarm: VecDeque, @@ -67,44 +66,26 @@ impl Behaviour { assigned_unnotified_swaps: VecDeque::default(), inflight_requests: HashMap::default(), connection_handlers: HashMap::default(), + connection_handler_deaths: VecDeque::default(), to_dial: VecDeque::default(), } } - pub async fn start(&mut self, alice_peer_id: PeerId, swap: NewSwap) { + pub fn queue_new_swap(&mut self, alice_peer_id: PeerId, swap: NewSwap) { tracing::trace!( %alice_peer_id, ?swap, "Queuing new swap setup request inside the Behaviour", ); - // TODO: This is a bit redundant because we already have the swap_id in the NewSwap struct - self.new_swaps - .push_back((alice_peer_id, swap.swap_id, swap)); + self.new_swaps.push_back((alice_peer_id, swap)); self.to_dial.push_back(alice_peer_id); } // Returns a mutable reference to the queues of the connection handlers for a specific peer - fn connection_handlers_mut( - &mut self, - peer_id: PeerId, - ) -> &mut (VecDeque, VecDeque) { + fn connection_handlers_mut(&mut self, peer_id: PeerId) -> &mut HashSet { self.connection_handlers.entry(peer_id).or_default() } - - // Returns a mutable reference to the queues of the connection handlers for a specific peer - fn alive_connection_handlers_mut(&mut self, peer_id: PeerId) -> &mut VecDeque { - &mut self.connection_handlers_mut(peer_id).0 - } - - // Returns a mutable reference to the queues of the connection handlers for a specific peer - fn dead_connection_handlers_mut(&mut self, peer_id: PeerId) -> &mut VecDeque { - &mut self.connection_handlers_mut(peer_id).1 - } - - fn known_peers(&self) -> HashSet { - self.connection_handlers.keys().copied().collect() - } } impl NetworkBehaviour for Behaviour { @@ -118,6 +99,13 @@ impl NetworkBehaviour for Behaviour { _local_addr: &Multiaddr, _remote_addr: &Multiaddr, ) -> Result, ConnectionDenied> { + // This should never be called as Bob does not support inbound substreams + // TODO: Can this still be called somehow by libp2p? Can we forbid this? + debug_assert!( + false, + "Bob does not listen so he should never get an inbound connection" + ); + Ok(Handler::new(self.env_config, self.bitcoin_wallet.clone())) } @@ -146,8 +134,7 @@ impl NetworkBehaviour for Behaviour { "A new connection handler has been established", ); - self.alive_connection_handlers_mut(peer_id) - .push_back(connection_id); + self.connection_handlers_mut(peer_id).insert(connection_id); } FromSwarm::ConnectionClosed(ConnectionClosed { peer_id, @@ -157,11 +144,11 @@ impl NetworkBehaviour for Behaviour { tracing::trace!( peer = %peer_id, connection_id = %connection_id, - "A swap setup connection handler has died", + "A connection handler has died", ); - self.dead_connection_handlers_mut(peer_id) - .push_back(connection_id); + self.connection_handler_deaths + .push_back((peer_id, connection_id)); } _ => {} } @@ -173,14 +160,21 @@ impl NetworkBehaviour for Behaviour { connection_id: libp2p::swarm::ConnectionId, result: THandlerOutEvent, ) { - if let Some((swap_id, peer)) = self.inflight_requests.remove(&connection_id) { - assert_eq!(peer, event_peer_id); + let (handler_swap_id, result) = result; + if self + .inflight_requests + .get_mut(&connection_id) + .map(|swap_ids| swap_ids.remove(&(handler_swap_id, event_peer_id))) + .unwrap_or(false) + { self.to_swarm.push_back(SwapSetupResult { - peer, - swap_id, + peer: event_peer_id, + swap_id: handler_swap_id, result, }); + } else { + debug_assert!(false, "Received a swap setup result from a connection handler for which we have no inflight request stored"); } } @@ -200,6 +194,7 @@ impl NetworkBehaviour for Behaviour { // Forward any peers that we want to dial to the Swarm if let Some(peer) = self.to_dial.pop_front() { + // TODO: We need to redial here!! tracing::trace!( peer = %peer, "Instructing swarm to dial a new connection handler for a swap setup request", @@ -212,59 +207,49 @@ impl NetworkBehaviour for Behaviour { }); } - // Remove any unused already dead connection handlers that were never assigned a request - for peer in self.known_peers() { - let (alive_connection_handlers, dead_connection_handlers) = - self.connection_handlers_mut(peer); - - // Create sets for efficient lookup - let alive_set: HashSet<_> = alive_connection_handlers.iter().copied().collect(); - let dead_set: HashSet<_> = dead_connection_handlers.iter().copied().collect(); - - // Remove from alive any handlers that are also in dead - alive_connection_handlers.retain(|id| !dead_set.contains(id)); - - // Remove from dead any handlers that were in alive (the overlap we just processed) - dead_connection_handlers.retain(|id| !alive_set.contains(id)); - } - - // Go through our new_swaps and try to assign a request to a connection handler - // - // If we find a connection handler for the peer, it will be removed from new_swaps - // If we don't find a connection handler for the peer, it will remain in new_swaps - { - let new_swaps = &mut self.new_swaps; - let connection_handlers = &mut self.connection_handlers; - let assigned_unnotified_swaps = &mut self.assigned_unnotified_swaps; - - let mut remaining = std::collections::VecDeque::new(); - for (peer, swap_id, new_swap) in new_swaps.drain(..) { - if let Some(connection_id) = - connection_handlers.entry(peer).or_default().0.pop_front() - { - assigned_unnotified_swaps.push_back((connection_id, peer, swap_id, new_swap)); - } else { - remaining.push_back((peer, swap_id, new_swap)); - } - } - - *new_swaps = remaining; - } - - // If a connection handler died which had an assigned swap setup request, - // we need to notify the swarm that the request failed - for peer_id in self.known_peers() { - while let Some(connection_id) = self.dead_connection_handlers_mut(peer_id).pop_front() { - if let Some((swap_id, _)) = self.inflight_requests.remove(&connection_id) { + // Check for dead connection handlers + // Important: This must be done at the top of the function to avoid assigning new swaps to dead connection handlers + while let Some((peer_id, connection_id)) = self.connection_handler_deaths.pop_front() { + // Did the connection handler have any assigned swap setup request? + // If it did, we need to notify the swarm that the request failed + if let Some(swap_ids) = self.inflight_requests.remove(&connection_id) { + for (swap_id, peer_id) in swap_ids { self.to_swarm.push_back(SwapSetupResult { peer: peer_id, swap_id, - result: Err(anyhow::anyhow!("Connection handler for peer {} has died after we notified it of the swap setup request", peer_id)), + result: Err(anyhow::anyhow!("Connection handler for peer died after we notified it of the swap setup request")), }); } } + + // After handling inflight request, remove the connection handler from the list + self.connection_handlers + .get_mut(&peer_id) + .map(|connection_ids| connection_ids.remove(&connection_id)); } + self.new_swaps.retain(|(peer, new_swap)| { + // Check if we have any open connection handlers for this peer + if let Some(connection_ids) = self.connection_handlers.get(&peer) { + // Choose the first one and assign it to the new swap + if let Some(connection_id) = connection_ids.iter().next() { + // TODO: Double swap_id is useless + self.assigned_unnotified_swaps.push_back(( + *connection_id, + peer.clone(), + new_swap.swap_id.clone(), + new_swap.clone(), + )); + + // Remove the swap from queue + return false; + } + } + + // Keep in queue as we didn't find a connection handler for this peer + true + }); + // Iterate through our assigned_unnotified_swaps queue (with popping) if let Some((connection_id, peer_id, swap_id, new_swap)) = self.assigned_unnotified_swaps.pop_front() @@ -276,66 +261,61 @@ impl NetworkBehaviour for Behaviour { "Dispatching swap setup request from Behaviour to a specific connection handler", ); - // Check if the connection handler is still alive - if let Some(dead_connection_handler) = self - .dead_connection_handlers_mut(peer_id) - .iter() - .position(|id| *id == connection_id) - { - self.dead_connection_handlers_mut(peer_id) - .remove(dead_connection_handler); + // ConnectionHandler must still be alive + // If it wasn't we'd have removed it from the list at the start of poll(..) + tracing::trace!( + peer = %peer_id, + swap_id = %swap_id, + ?new_swap, + "Notifying connection handler of the swap setup request. We are assuming it is still alive.", + ); - self.to_swarm.push_back(SwapSetupResult { - peer: peer_id, - swap_id, - result: Err(anyhow::anyhow!("Connection handler for peer {} has died before we could notify it of the swap setup request", peer_id)), - }); - } else { - // ConnectionHandler must still be alive, notify it of the swap setup request - tracing::trace!( - peer = %peer_id, - swap_id = %swap_id, - ?new_swap, - "Notifying connection handler of the swap setup request. We are assuming it is still alive.", - ); + self.inflight_requests + .entry(connection_id) + .or_default() + .insert((swap_id, peer_id)); - self.inflight_requests - .insert(connection_id, (swap_id, peer_id)); - - return Poll::Ready(ToSwarm::NotifyHandler { - peer_id, - handler: libp2p::swarm::NotifyHandler::One(connection_id), - event: new_swap, - }); - } + return Poll::Ready(ToSwarm::NotifyHandler { + peer_id, + handler: libp2p::swarm::NotifyHandler::One(connection_id), + event: new_swap, + }); } Poll::Pending } } -type OutboundStream = BoxFuture<'static, Result>; - pub struct Handler { - outbound_stream: OptionFuture, + // Configuration env_config: env::Config, timeout: Duration, - new_swaps: VecDeque, bitcoin_wallet: Arc, - keep_alive: bool, + + // Queue of swap setup requests that do not have an inflight substream negotiation + new_swaps: VecDeque, + + // When we have instructed the Behaviour to start a new outbound substream, we store the swap id here + // Eventually we will either get a fully negotiated outbound substream or a dial upgrade error + inflight_substream_negotiations: HashSet, + + // Inflight swap setup requests that we have a fully negotiated outbound substream for + outbound_streams: FuturesHashSet>, + + // Queue of swap setup results that we want to notify the Behaviour about + to_behaviour: VecDeque<(Uuid, Result)>, } impl Handler { fn new(env_config: env::Config, bitcoin_wallet: Arc) -> Self { Self { env_config, - outbound_stream: OptionFuture::from(None), - timeout: Duration::from_secs(120), - new_swaps: VecDeque::default(), + timeout: crate::defaults::NEGOTIATION_TIMEOUT, bitcoin_wallet, - // TODO: This will keep ALL connections alive indefinitely - // which is not optimal - keep_alive: true, + new_swaps: VecDeque::default(), + outbound_streams: FuturesHashSet::default(), + to_behaviour: VecDeque::default(), + inflight_substream_negotiations: HashSet::default(), } } } @@ -359,7 +339,7 @@ pub struct SwapSetupResult { impl ConnectionHandler for Handler { type FromBehaviour = NewSwap; - type ToBehaviour = Result; + type ToBehaviour = (Uuid, Result); type InboundProtocol = upgrade::DeniedUpgrade; type OutboundProtocol = protocol::SwapSetup; type InboundOpenInfo = (); @@ -381,24 +361,25 @@ impl ConnectionHandler for Handler { >, ) { match event { - libp2p::swarm::handler::ConnectionEvent::FullyNegotiatedInbound(_) => { - // TODO: Maybe warn here as Bob does not support inbound substreams? - } - libp2p::swarm::handler::ConnectionEvent::FullyNegotiatedOutbound(outbound) => { - let mut substream = outbound.protocol; - let new_swap_request = outbound.info; + libp2p::swarm::handler::ConnectionEvent::FullyNegotiatedOutbound( + libp2p::swarm::handler::FullyNegotiatedOutbound { + protocol: mut substream, + info, + }, + ) => { + let swap_id = info.swap_id; + + // We got the substream, so its no longer inflight + self.inflight_substream_negotiations.remove(&swap_id); let bitcoin_wallet = self.bitcoin_wallet.clone(); let env_config = self.env_config; + // This runs runs the actual negotiation protocol + // It is wrapped in a timeout to protect against the case where the peer does not respond let protocol = tokio::time::timeout(self.timeout, async move { - let result = run_swap_setup( - &mut substream, - new_swap_request, - env_config, - bitcoin_wallet, - ) - .await; + let result = + run_swap_setup(&mut substream, info, env_config, bitcoin_wallet).await; result.map_err(|err: anyhow::Error| { tracing::error!(?err, "Error occurred during swap setup protocol"); @@ -408,40 +389,59 @@ impl ConnectionHandler for Handler { let max_seconds = self.timeout.as_secs(); - self.outbound_stream = OptionFuture::from(Some(Box::pin(async move { - protocol.await.map_err(|_| Error::Timeout { - seconds: max_seconds, - })? - }) - as OutboundStream)); + let did_replace_existing_future = self.outbound_streams.replace( + swap_id, + Box::pin(async move { + protocol.await.map_err(|_| Error::Timeout { + seconds: max_seconds, + })? + }), + ); + + // In poll(..), we ensure that we never dispatch multiple concurrent swap setup requests for the same swap on the same ConnectionHandler + // This invariant should therefore never be violated + // TODO: Is this truly true? + assert!(!did_replace_existing_future, "Replacing an existing inflight swap setup request is not allowed. We should have checked for this invariant before instructing the Behaviour to start a substream."); } - libp2p::swarm::handler::ConnectionEvent::AddressChange(address_change) => { - tracing::trace!( - ?address_change, - "Connection address changed during swap setup" + libp2p::swarm::handler::ConnectionEvent::DialUpgradeError( + libp2p::swarm::handler::DialUpgradeError { info, error }, + ) => { + // We failed to get a fully negotiated outbound substream, so its no longer inflight + self.inflight_substream_negotiations.remove(&info.swap_id); + + tracing::error!(%error, "Dial upgrade error during swap setup substream negotiation. Propagating error back to the Behaviour"); + + self.to_behaviour.push_back(( + info.swap_id, + Err(anyhow::Error::from(error) + .context("Dial upgrade error during swap setup. The peer may not support the swap setup protocol.")), + )); + } + libp2p::swarm::handler::ConnectionEvent::ListenUpgradeError(_) + | libp2p::swarm::handler::ConnectionEvent::FullyNegotiatedInbound(_) => { + // This should never be called as Bob does not support inbound substreams + // TODO: Maybe warn here as Bob does not support inbound substreams? + debug_assert!( + false, + "Bob does not support inbound substreams for the swap setup protocol" ); } - libp2p::swarm::handler::ConnectionEvent::DialUpgradeError(dial_upgrade_error) => { - tracing::trace!(error = %dial_upgrade_error.error, "Dial upgrade error during swap setup"); - } - libp2p::swarm::handler::ConnectionEvent::ListenUpgradeError(listen_upgrade_error) => { - tracing::trace!( - ?listen_upgrade_error, - "Listen upgrade error during swap setup" - ); - } - _ => { - // We ignore the rest of events - } + _ => {} } } fn on_behaviour_event(&mut self, new_swap: Self::FromBehaviour) { + tracing::trace!( + swap_id = %new_swap.swap_id, + "Received a new swap setup request from the Behaviour", + ); + self.new_swaps.push_back(new_swap); } fn connection_keep_alive(&self) -> bool { - self.keep_alive + // Keep alive as long as there are queued swaps our inflight requests + !self.new_swaps.is_empty() || self.outbound_streams.len() > 0 } fn poll( @@ -452,38 +452,67 @@ impl ConnectionHandler for Handler { > { // Check if there is a new swap to be started on this connection // Has the Behaviour assigned us a new swap to be started on this connection? - if let Some(new_swap) = self.new_swaps.pop_front() { + while let Some(new_swap) = self.new_swaps.pop_front() { + // Check if we already have an inflight request for this swap + // We disallow multiple concurrent swap setup requests for the same swap on the same ConnectionHandler + if self.outbound_streams.contains_key(&new_swap.swap_id) { + tracing::error!( + swap_id = %new_swap.swap_id, + "Received a new swap setup request for a swap id that we already have an inflight request for. Ignoring request. The upstream behaviour may encounter bugs if its internal logic does not handle this correctly.", + ); + + // TODO: Potentially make this a production assert + debug_assert!(false, "Multiple concurrent swap setup requests with the same swap id are not allowed."); + + continue; + } + + // We disallow multiple concurrent substream negotiations for the same swap on the same ConnectionHandler + if self + .inflight_substream_negotiations + .contains(&new_swap.swap_id) + { + tracing::error!( + swap_id = %new_swap.swap_id, + "Received a new swap setup request for a swap id that we already have an inflight substream negotiation for. Ignoring. The upstream behaviour may encounter bugs if its internal logic does not handle this correctly.", + ); + + // TODO: Potentially make this a production assert + debug_assert!(false, "Multiple concurrent substream negotiations for the same swap id are not allowed."); + + continue; + } + tracing::trace!( ?new_swap.swap_id, "Instructing swarm to start a new outbound substream as part of swap setup", ); - // Keep the connection alive because we want to use it - self.keep_alive = true; - // We instruct the swarm to start a new outbound substream + self.inflight_substream_negotiations + .insert(new_swap.swap_id); + return Poll::Ready(ConnectionHandlerEvent::OutboundSubstreamRequest { protocol: SubstreamProtocol::new(protocol::new(), new_swap), }); } // Check if the outbound stream has completed - if let Poll::Ready(Some(result)) = self.outbound_stream.poll_unpin(cx) { - self.outbound_stream = None.into(); + while let Poll::Ready(Some((swap_id, result))) = self.outbound_streams.poll_next_unpin(cx) { + self.to_behaviour + .push_back((swap_id, result.map_err(anyhow::Error::from))); + } - // Once the outbound stream is completed, we no longer keep the connection alive - self.keep_alive = false; - - // We notify the swarm that the swap setup is completed / failed - return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour( - result.map_err(anyhow::Error::from).into(), - )); + // Notify the Behaviour about any swap setup results + if let Some(result) = self.to_behaviour.pop_front() { + return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour(result)); } Poll::Pending } } +// TODO: This is protocol and should be moved to another crate (probably swap-machine, swap-core or swap) async fn run_swap_setup( mut substream: &mut libp2p::swarm::Stream, new_swap_request: NewSwap, @@ -624,7 +653,7 @@ pub enum Error { asb: BlockchainNetwork, }, - #[error("Failed to complete swap setup within {seconds}s")] + #[error("Failed to complete swap setup back-and-forth within {seconds}s")] Timeout { seconds: u64 }, /// Something went wrong during the swap setup protocol that is not covered by the other errors @@ -666,3 +695,9 @@ impl From for out_event::bob::OutEvent { } } } + +// TODO: Tests +// - Case where Alice does not support the protocol at all +// - Case where Connection dies before the swap setup is started +// - Case where Connection dies during the swap setup protocol +// TODO: Extract actualy protocol logic into a callback of sorts or some type of event/state system diff --git a/swap-p2p/src/protocols/transfer_proof.rs b/swap-p2p/src/protocols/transfer_proof.rs index 9e820315..e2a1947c 100644 --- a/swap-p2p/src/protocols/transfer_proof.rs +++ b/swap-p2p/src/protocols/transfer_proof.rs @@ -1,7 +1,6 @@ use libp2p::request_response::{self, ProtocolSupport}; use libp2p::{PeerId, StreamProtocol}; use serde::{Deserialize, Serialize}; -use std::time::Duration; use uuid::Uuid; use crate::out_event; @@ -30,14 +29,16 @@ pub struct Request { pub fn alice() -> Behaviour { Behaviour::new( vec![(StreamProtocol::new(PROTOCOL), ProtocolSupport::Outbound)], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } pub fn bob() -> Behaviour { Behaviour::new( vec![(StreamProtocol::new(PROTOCOL), ProtocolSupport::Inbound)], - request_response::Config::default().with_request_timeout(Duration::from_secs(60)), + request_response::Config::default() + .with_request_timeout(crate::defaults::DEFAULT_REQUEST_TIMEOUT), ) } diff --git a/swap-p2p/src/test.rs b/swap-p2p/src/test.rs index d2bfaa91..dbd48dcc 100644 --- a/swap-p2p/src/test.rs +++ b/swap-p2p/src/test.rs @@ -34,7 +34,7 @@ where .map(|(peer, muxer), _| (peer, StreamMuxerBox::new(muxer))) .boxed(); - const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 2); // 2 hours + const IDLE_CONNECTION_TIMEOUT: Duration = crate::defaults::IDLE_CONNECTION_TIMEOUT; SwarmBuilder::with_existing_identity(identity) .with_tokio() @@ -52,17 +52,6 @@ fn get_rand_memory_address() -> Multiaddr { format!("/memory/{}", address_port).parse().unwrap() } -async fn get_local_tcp_address() -> Multiaddr { - let random_port = { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - listener.local_addr().unwrap().port() - }; - - format!("/ip4/127.0.0.1/tcp/{}", random_port) - .parse() - .unwrap() -} - /// An extension trait for [`Swarm`] that makes it easier to set up a network of /// [`Swarm`]s for tests. #[async_trait] @@ -77,10 +66,6 @@ pub trait SwarmExt { /// Listens on a random memory address, polling the [`Swarm`] until the /// transport is ready to accept connections. async fn listen_on_random_memory_address(&mut self) -> Multiaddr; - - /// Listens on a TCP port for localhost only, polling the [`Swarm`] until - /// the transport is ready to accept connections. - async fn listen_on_tcp_localhost(&mut self) -> Multiaddr; } #[async_trait] @@ -158,15 +143,6 @@ where multiaddr } - - async fn listen_on_tcp_localhost(&mut self) -> Multiaddr { - let multiaddr = get_local_tcp_address().await; - - self.listen_on(multiaddr.clone()).unwrap(); - block_until_listening_on(self, &multiaddr).await; - - multiaddr - } } async fn block_until_listening_on(swarm: &mut Swarm, multiaddr: &Multiaddr) diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 5c406cf3..8079265d 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -78,7 +78,7 @@ regex = "1.10" reqwest = { workspace = true, features = ["http2", "rustls-tls-native-roots", "stream", "socks"] } rust_decimal = { version = "1", features = ["serde-float"] } rust_decimal_macros = "1" -semver = "1.0" +semver = { workspace = true } structopt = "0.3" swap-controller-api = { path = "../swap-controller-api" } swap-core = { path = "../swap-core" } @@ -145,6 +145,7 @@ monero-harness = { path = "../monero-harness" } proptest = "1" serde_cbor = "0.11" serial_test = "3.1" +swap-p2p = { path = "../swap-p2p", features = ["test-support"] } tempfile = "3" testcontainers = "0.15" diff --git a/swap/src/asb/event_loop.rs b/swap/src/asb/event_loop.rs index 63a8da19..a14ebd83 100644 --- a/swap/src/asb/event_loop.rs +++ b/swap/src/asb/event_loop.rs @@ -258,7 +258,9 @@ where let _ = responder.respond(wallet_snapshot); } SwarmEvent::Behaviour(OutEvent::SwapSetupCompleted{peer_id, swap_id, state3}) => { - self.handle_execution_setup_done(peer_id, swap_id, state3).await; + if let Err(error) = self.handle_execution_setup_done(peer_id, swap_id, state3).await { + tracing::error!(%swap_id, ?error, "Failed to handle execution setup done"); + } } SwarmEvent::Behaviour(OutEvent::SwapDeclined { peer, error }) => { tracing::warn!(%peer, "Ignoring spot price request: {}", error); @@ -424,11 +426,14 @@ where tracing::info!(swap_id = %swap_id, peer = %peer, "Fullfilled cooperative XMR redeem request"); } - SwarmEvent::Behaviour(OutEvent::Rendezvous(libp2p::rendezvous::client::Event::Registered { rendezvous_node, ttl, namespace })) => { - tracing::trace!("Successfully registered with rendezvous node: {} with namespace: {} and TTL: {:?}", rendezvous_node, namespace, ttl); + SwarmEvent::Behaviour(OutEvent::Rendezvous(swap_p2p::protocols::rendezvous::register::Event::Registered { peer_id })) => { + tracing::trace!("Successfully registered with rendezvous node: {}", peer_id); } - SwarmEvent::Behaviour(OutEvent::Rendezvous(libp2p::rendezvous::client::Event::RegisterFailed { rendezvous_node, namespace, error })) => { - tracing::trace!("Registration with rendezvous node {} failed for namespace {}: {:?}", rendezvous_node, namespace, error); + SwarmEvent::Behaviour(OutEvent::Rendezvous(swap_p2p::protocols::rendezvous::register::Event::RegisterRequestFailed { peer_id, error })) => { + tracing::trace!("Registration with rendezvous node {} failed: {:?}", peer_id, error); + } + SwarmEvent::Behaviour(OutEvent::Rendezvous(swap_p2p::protocols::rendezvous::register::Event::RegisterDispatchFailed { peer_id, error })) => { + tracing::trace!("Failed to dispatch registration to rendezvous node {}: {:?}", peer_id, error); } SwarmEvent::Behaviour(OutEvent::OutboundRequestResponseFailure {peer, error, request_id, protocol}) => { tracing::error!( @@ -529,7 +534,7 @@ where .behaviour() .rendezvous .as_ref() - .map(|b| b.registrations()) + .map(|b| b.status()) .unwrap_or_default(); // If rendezvous behaviour is disabled we report empty list let _ = respond_to.send(registrations); @@ -611,7 +616,17 @@ where bob_peer_id: PeerId, swap_id: Uuid, state3: State3, - ) { + ) -> Result<()> { + if self + .db + .has_swap(swap_id) + .await + .context("Failed to check if UUID is already in use")? + { + // TODO: We should ideally check this during swap setup, not after + return Err(anyhow::anyhow!("UUID is already in use")); + } + let handle = self.new_handle(bob_peer_id, swap_id); let initial_state = AliceState::Started { @@ -629,16 +644,16 @@ where developer_tip: self.developer_tip.clone(), }; - match self.db.insert_peer_id(swap_id, bob_peer_id).await { - Ok(_) => { - if let Err(error) = self.swap_sender.send(swap).await { - tracing::warn!(%swap_id, "Failed to start swap: {:?}", error); - } - } - Err(error) => { - tracing::warn!(%swap_id, "Unable to save peer-id in database: {}", error); - } - } + self.db + .insert_peer_id(swap_id, bob_peer_id) + .await + .context("Failed to save peer-id in database")?; + self.swap_sender + .send(swap) + .await + .context("Failed to send message to spawn swap state machine")?; + + Ok(()) } /// Create a new [`EventLoopHandle`] that is scoped for communication with @@ -841,7 +856,9 @@ mod service { respond_to: oneshot::Sender, }, GetRegistrationStatus { - respond_to: oneshot::Sender>, + respond_to: oneshot::Sender< + Vec, + >, }, } @@ -879,7 +896,8 @@ mod service { /// Get the registration status at configured rendezvous points pub async fn get_registration_status( &self, - ) -> anyhow::Result> { + ) -> anyhow::Result> + { let (tx, rx) = oneshot::channel(); self.sender .send(EventLoopRequest::GetRegistrationStatus { respond_to: tx }) diff --git a/swap/src/asb/network.rs b/swap/src/asb/network.rs index a22f63cd..2cdaaf9a 100644 --- a/swap/src/asb/network.rs +++ b/swap/src/asb/network.rs @@ -93,9 +93,9 @@ pub mod transport { pub mod behaviour { use libp2p::{identify, identity, ping, swarm::behaviour::toggle::Toggle}; - use swap_p2p::out_event::alice::OutEvent; + use swap_p2p::{out_event::alice::OutEvent, patches}; - use super::{rendezvous::register, *}; + use super::*; /// A `NetworkBehaviour` that represents an XMR/BTC swap node as Alice. #[derive(NetworkBehaviour)] @@ -111,7 +111,7 @@ pub mod behaviour { pub transfer_proof: transfer_proof::Behaviour, pub cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::Behaviour, pub encrypted_signature: encrypted_signature::Behaviour, - pub identify: identify::Behaviour, + pub identify: patches::identify::Behaviour, /// Ping behaviour that ensures that the underlying network connection /// is still alive. If the ping fails a connection close event @@ -130,7 +130,7 @@ pub mod behaviour { resume_only: bool, env_config: env::Config, identify_params: (identity::Keypair, XmrBtcNamespace), - rendezvous_nodes: Vec, + rendezvous_nodes: Vec, ) -> Self { let (identity, namespace) = identify_params; let agent_version = format!("asb/{} ({})", env!("CARGO_PKG_VERSION"), namespace); @@ -147,12 +147,13 @@ pub mod behaviour { Some(rendezvous::register::Behaviour::new( identity, rendezvous_nodes, + namespace.into(), )) }; Self { rendezvous: Toggle::from(behaviour), - quote: quote::asb(), + quote: quote::alice(), swap_setup: alice::Behaviour::new( min_buy, max_buy, @@ -164,7 +165,7 @@ pub mod behaviour { encrypted_signature: encrypted_signature::alice(), cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::alice(), ping: ping::Behaviour::new(pingConfig), - identify: identify::Behaviour::new(identifyConfig), + identify: patches::identify::Behaviour::new(identifyConfig), } } } diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index 21a658e8..31cf5b5a 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -184,23 +184,23 @@ impl AsbApiServer for RpcImpl { let registrations = regs .into_iter() .map(|r| RegistrationStatusItem { - address: r.address.to_string(), - connection: match r.connection { - crate::asb::register::ConnectionStatus::Disconnected => { - RendezvousConnectionStatus::Disconnected - } - crate::asb::register::ConnectionStatus::Connected => { - RendezvousConnectionStatus::Connected - } + address: r.address.map(|a| a.to_string()), + connection: if r.is_connected { + RendezvousConnectionStatus::Connected + } else { + RendezvousConnectionStatus::Disconnected }, registration: match r.registration { - crate::asb::register::RegistrationStatusReport::RegisterOnNextConnection => { - RendezvousRegistrationStatus::RegisterOnNextConnection + crate::network::rendezvous::register::public::RegistrationStatus::RegisterOnceConnected => { + RendezvousRegistrationStatus::RegisterOnceConnected } - crate::asb::register::RegistrationStatusReport::Pending => { - RendezvousRegistrationStatus::Pending + crate::network::rendezvous::register::public::RegistrationStatus::WillRegisterAfterDelay => { + RendezvousRegistrationStatus::WillRegisterAfterDelay } - crate::asb::register::RegistrationStatusReport::Registered => { + crate::network::rendezvous::register::public::RegistrationStatus::RequestInflight => { + RendezvousRegistrationStatus::RequestInflight + } + crate::network::rendezvous::register::public::RegistrationStatus::Registered => { RendezvousRegistrationStatus::Registered } }, diff --git a/swap/src/bin/swap.rs b/swap/src/bin/swap.rs index 5b83fe83..8364abb3 100644 --- a/swap/src/bin/swap.rs +++ b/swap/src/bin/swap.rs @@ -44,8 +44,8 @@ mod tests { use libp2p::PeerId; use std::sync::{Arc, Mutex}; use std::time::Duration; - use swap::cli::api::request::{determine_btc_to_swap, QuoteFetchFuture}; - use swap::cli::{QuoteWithAddress, SellerStatus}; + use swap::cli::api::request::determine_btc_to_swap; + use swap::cli::QuoteWithAddress; use swap::network::quote::BidQuote; use swap::tracing_ext::capture_logs; use tracing::level_filters::LevelFilter; @@ -398,15 +398,18 @@ mod tests { } } - fn quote_with_max(btc: f64) -> impl Fn() -> QuoteFetchFuture { + fn quote_with_max(btc: f64) -> ::tokio::sync::watch::Receiver> { quote_minmax(None, Some(btc)) } - fn quote_with_min(btc: f64) -> impl Fn() -> QuoteFetchFuture { + fn quote_with_min(btc: f64) -> ::tokio::sync::watch::Receiver> { quote_minmax(Some(btc), None) } - fn quote_minmax(min: Option, max: Option) -> impl Fn() -> QuoteFetchFuture { + fn quote_minmax( + min: Option, + max: Option, + ) -> ::tokio::sync::watch::Receiver> { let max_quantity = max .map(|m| Amount::from_btc(m).unwrap()) .unwrap_or(Amount::MAX_MONEY); @@ -414,23 +417,17 @@ mod tests { .map(|m| Amount::from_btc(m).unwrap()) .unwrap_or(Amount::ZERO); - move || { - async move { - let (_, rx) = - ::tokio::sync::watch::channel(vec![SellerStatus::Online(QuoteWithAddress { - multiaddr: "/ip4/127.0.0.1/tcp/5678".parse().unwrap(), - peer_id: PeerId::random(), - quote: BidQuote { - price: Amount::from_btc(0.001).unwrap(), - max_quantity, - min_quantity, - }, - version: "1.0.0".parse().unwrap(), - })]); - Ok((tokio::task::spawn(async {}), rx)) - } - .boxed() - } + let (_, rx) = ::tokio::sync::watch::channel(vec![QuoteWithAddress { + peer_id: PeerId::random(), + multiaddr: "/ip4/127.0.0.1/tcp/5678".parse().unwrap(), + quote: BidQuote { + price: Amount::from_btc(0.001).unwrap(), + max_quantity, + min_quantity, + }, + version: Some("1.0.0".parse().unwrap()), + }]); + rx } async fn get_dummy_address() -> Result { diff --git a/swap/src/cli.rs b/swap/src/cli.rs index 5a73b3d0..49fea258 100644 --- a/swap/src/cli.rs +++ b/swap/src/cli.rs @@ -1,277 +1,14 @@ pub mod api; -mod behaviour; pub mod cancel_and_refund; pub mod command; -mod event_loop; -mod list_sellers; pub mod transport; pub mod watcher; +mod behaviour; +mod event_loop; +mod list_sellers; + pub use behaviour::{Behaviour, OutEvent}; pub use cancel_and_refund::{cancel, cancel_and_refund, refund}; pub use event_loop::{EventLoop, EventLoopHandle, SwapEventLoopHandle}; -pub use list_sellers::{list_sellers, QuoteWithAddress, SellerStatus}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::list_sellers::{QuoteWithAddress, SellerStatus}; - use crate::network::quote; - use crate::network::quote::BidQuote; - use crate::network::rendezvous::XmrBtcNamespace; - use futures::StreamExt; - use libp2p::core::Endpoint; - use libp2p::multiaddr::Protocol; - use libp2p::swarm::{ - ConnectionDenied, ConnectionId, FromSwarm, THandlerInEvent, THandlerOutEvent, ToSwarm, - }; - use libp2p::{identity, rendezvous, request_response, Multiaddr, PeerId}; - use semver::Version; - use std::collections::HashSet; - use std::task::Poll; - use std::time::Duration; - use swap_p2p::protocols::rendezvous::register::{InnerBehaviourEvent, RendezvousNode}; - use swap_p2p::test::{new_swarm, SwarmExt}; - - // Test-only struct for compatibility - #[derive(Debug, Clone, PartialEq, Eq, Hash)] - struct Seller { - multiaddr: Multiaddr, - status: SellerStatus, - } - - #[tokio::test] - #[ignore] - // Due to an issue with the libp2p rendezvous library - // This needs to be fixed upstream and was - // introduced in our codebase by a libp2p refactor which bumped the version of libp2p: - // - // - The new bumped rendezvous client works, and can connect to an old rendezvous server - // - The new rendezvous has an issue, which is why these test (use the new mock server) - // do not work - // - // Ignore this test for now . This works in production :) - async fn list_sellers_should_report_all_registered_asbs_with_a_quote() { - let namespace = XmrBtcNamespace::Mainnet; - let (rendezvous_address, rendezvous_peer_id) = setup_rendezvous_point().await; - let expected_seller_1 = setup_asb(rendezvous_peer_id, &rendezvous_address, namespace).await; - let expected_seller_2 = setup_asb(rendezvous_peer_id, &rendezvous_address, namespace).await; - - let list_sellers = list_sellers( - vec![(rendezvous_peer_id, rendezvous_address)], - namespace, - None, - identity::Keypair::generate_ed25519(), - None, - None, - ); - let sellers = tokio::time::timeout(Duration::from_secs(15), list_sellers) - .await - .unwrap() - .unwrap(); - - // Convert SellerStatus to test Seller struct - let actual_sellers: Vec = sellers - .into_iter() - .map(|status| Seller { - multiaddr: match &status { - SellerStatus::Online(quote_with_addr) => quote_with_addr.multiaddr.clone(), - SellerStatus::Unreachable(_) => "/ip4/0.0.0.0/tcp/0".parse().unwrap(), // placeholder - }, - status, - }) - .collect(); - - assert_eq!( - HashSet::::from_iter(actual_sellers), - HashSet::::from_iter([expected_seller_1, expected_seller_2]) - ) - } - - async fn setup_rendezvous_point() -> (Multiaddr, PeerId) { - let mut rendezvous_node = new_swarm(|_| RendezvousPointBehaviour::default()); - let rendezvous_address = rendezvous_node.listen_on_tcp_localhost().await; - let rendezvous_peer_id = *rendezvous_node.local_peer_id(); - - tokio::spawn(async move { - loop { - rendezvous_node.next().await; - } - }); - - (rendezvous_address, rendezvous_peer_id) - } - - async fn setup_asb( - rendezvous_peer_id: PeerId, - rendezvous_address: &Multiaddr, - namespace: XmrBtcNamespace, - ) -> Seller { - let static_quote = BidQuote { - price: bitcoin::Amount::from_sat(1337), - min_quantity: bitcoin::Amount::from_sat(42), - max_quantity: bitcoin::Amount::from_sat(9001), - }; - - let mut asb = new_swarm(|identity| { - let rendezvous_node = - RendezvousNode::new(rendezvous_address, rendezvous_peer_id, namespace, None); - let rendezvous = crate::asb::register::Behaviour::new(identity, vec![rendezvous_node]); - - StaticQuoteAsbBehaviour { - inner: StaticQuoteAsbBehaviourInner { - rendezvous, - quote: quote::asb(), - }, - static_quote, - registered: false, - } - }); - - let asb_address = asb.listen_on_tcp_localhost().await; - asb.add_external_address(asb_address.clone()); - - let asb_peer_id = *asb.local_peer_id(); - - // avoid race condition where `list_sellers` tries to discover before we are - // registered block this function until we are registered - while !asb.behaviour().registered { - asb.next().await; - } - - tokio::spawn(async move { - loop { - asb.next().await; - } - }); - - let full_address = asb_address.with(Protocol::P2p(asb_peer_id)); - Seller { - multiaddr: full_address.clone(), - status: SellerStatus::Online(QuoteWithAddress { - multiaddr: full_address, - peer_id: asb_peer_id, - quote: static_quote, - version: Version::parse("1.0.0").unwrap(), - }), - } - } - - #[derive(libp2p::swarm::NetworkBehaviour)] - struct StaticQuoteAsbBehaviourInner { - rendezvous: crate::asb::register::Behaviour, - quote: quote::Behaviour, - } - - struct StaticQuoteAsbBehaviour { - inner: StaticQuoteAsbBehaviourInner, - static_quote: BidQuote, - registered: bool, - } - - impl libp2p::swarm::NetworkBehaviour for StaticQuoteAsbBehaviour { - type ConnectionHandler = - ::ConnectionHandler; - type ToSwarm = ::ToSwarm; - - fn handle_established_inbound_connection( - &mut self, - connection_id: ConnectionId, - peer: PeerId, - local_addr: &Multiaddr, - remote_addr: &Multiaddr, - ) -> Result, ConnectionDenied> { - self.inner.handle_established_inbound_connection( - connection_id, - peer, - local_addr, - remote_addr, - ) - } - - fn handle_established_outbound_connection( - &mut self, - connection_id: ConnectionId, - peer: PeerId, - addr: &Multiaddr, - role_override: Endpoint, - ) -> Result, 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.inner - .on_connection_handler_event(peer_id, connection_id, event); - } - - fn poll( - &mut self, - cx: &mut std::task::Context<'_>, - ) -> Poll>> { - match self.inner.poll(cx) { - Poll::Ready(ToSwarm::GenerateEvent(event)) => match event { - StaticQuoteAsbBehaviourInnerEvent::Rendezvous(rendezvous_event) => { - if let InnerBehaviourEvent::Rendezvous( - rendezvous::client::Event::Registered { .. }, - ) = &rendezvous_event - { - self.registered = true; - } - - Poll::Ready(ToSwarm::GenerateEvent( - StaticQuoteAsbBehaviourInnerEvent::Rendezvous(rendezvous_event), - )) - } - StaticQuoteAsbBehaviourInnerEvent::Quote(quote_event) => { - if let request_response::Event::Message { - message: quote::Message::Request { channel, .. }, - .. - } = quote_event - { - self.inner - .quote - .send_response(channel, self.static_quote) - .unwrap(); - - return Poll::Pending; - } - - Poll::Ready(ToSwarm::GenerateEvent( - StaticQuoteAsbBehaviourInnerEvent::Quote(quote_event), - )) - } - }, - other => other, - } - } - } - - #[derive(libp2p::swarm::NetworkBehaviour)] - struct RendezvousPointBehaviour { - rendezvous: rendezvous::server::Behaviour, - } - - impl Default for RendezvousPointBehaviour { - fn default() -> Self { - RendezvousPointBehaviour { - rendezvous: rendezvous::server::Behaviour::new( - rendezvous::server::Config::default(), - ), - } - } - } -} +pub use list_sellers::QuoteWithAddress; diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 42c6a676..f1ea9e6c 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -13,6 +13,7 @@ use crate::{bitcoin, common, monero}; use anyhow::{bail, Context as AnyContext, Error, Result}; use arti_client::TorClient; use futures::future::try_join_all; +use libp2p::{Multiaddr, PeerId}; use std::fmt; use std::future::Future; use std::path::{Path, PathBuf}; @@ -210,6 +211,21 @@ pub use swap_lock::{PendingTaskList, SwapLock}; mod context { use super::*; + use crate::cli::EventLoopHandle; + + /// Combined state for the EventLoop and its background task + /// These must always exist together + pub struct EventLoopState { + pub handle: EventLoopHandle, + #[allow(dead_code)] + pub task: JoinHandle<()>, + } + + impl EventLoopState { + pub fn new(handle: EventLoopHandle, task: JoinHandle<()>) -> Self { + Self { handle, task } + } + } /// Holds shared data for different parts of the CLI. /// @@ -229,6 +245,7 @@ mod context { pub(super) tor_client: Arc>>>>, #[allow(dead_code)] pub(super) monero_rpc_pool_handle: Arc>>>, + pub(super) event_loop_state: Arc>>, } impl Context { @@ -252,6 +269,7 @@ mod context { monero_manager: Arc::new(RwLock::new(None)), tor_client: Arc::new(RwLock::new(None)), monero_rpc_pool_handle: Arc::new(RwLock::new(None)), + event_loop_state: Arc::new(RwLock::new(None)), } } @@ -309,6 +327,16 @@ mod context { .context("Tor client not initialized") } + /// Get the event loop handle, returning an error if not initialized + pub async fn try_get_event_loop_handle(&self) -> Result { + self.event_loop_state + .read() + .await + .as_ref() + .map(|state| state.handle.clone()) + .context("Event loop not initialized") + } + pub async fn for_harness( seed: Seed, env_config: EnvConfig, @@ -331,6 +359,7 @@ mod context { tauri_handle: None, tor_client: Arc::new(RwLock::new(None)), monero_rpc_pool_handle: Arc::new(RwLock::new(None)), + event_loop_state: Arc::new(RwLock::new(None)), } } @@ -405,6 +434,7 @@ pub use context::Context; mod builder { use super::*; + use crate::cli::api::context::EventLoopState; /// A conveniant builder struct for [`Context`]. #[must_use = "ContextBuilder must be built to be useful"] @@ -417,6 +447,7 @@ mod builder { tor: bool, enable_monero_tor: bool, tauri_handle: Option, + rendezvous_points: Vec<(PeerId, Vec)>, } impl ContextBuilder { @@ -440,6 +471,7 @@ mod builder { tor: false, enable_monero_tor: false, tauri_handle: None, + rendezvous_points: Vec::new(), } } @@ -492,6 +524,14 @@ mod builder { self } + pub fn with_rendezvous_points( + mut self, + rendezvous_points: Vec<(PeerId, Vec)>, + ) -> Self { + self.rendezvous_points = rendezvous_points; + self + } + /// Initializes the context by populating it with all configured components. /// /// Context fields are set as early as possible for availability to other parts of the system. @@ -671,6 +711,17 @@ mod builder { let wallet_database = Some(Arc::new(wallet_database)); + // Initialize config + *context.config.write().await = Some(Config { + namespace: XmrBtcNamespace::from_is_testnet(self.is_testnet), + env_config, + seed: seed.clone().into(), + json: self.json, + is_testnet: self.is_testnet, + data_dir: data_dir.clone(), + log_dir: log_dir.clone(), + }); + // Initialize Monero wallet manager async { let manager = Arc::new( @@ -693,17 +744,6 @@ mod builder { } .await?; - // Initialize config - *context.config.write().await = Some(Config { - namespace: XmrBtcNamespace::from_is_testnet(self.is_testnet), - env_config, - seed: seed.clone().into(), - json: self.json, - is_testnet: self.is_testnet, - data_dir: data_dir.clone(), - log_dir: log_dir.clone(), - }); - // Initialize swap database let db = async { let database_progress_handle = self @@ -778,6 +818,57 @@ mod builder { } } + // Initialize persistent EventLoop + if let Some(wallet) = bitcoin_wallet.clone() { + let namespace = XmrBtcNamespace::from_is_testnet(self.is_testnet); + let tor_client_for_swarm = unbootstrapped_tor_client.clone(); + let db_for_swarm = db.clone(); + + let rendezvous_points = self.rendezvous_points; + let rendezvous_peer_ids: Vec = + rendezvous_points.iter().map(|(p, _)| *p).collect(); + + let behaviour = crate::cli::Behaviour::new( + env_config, + wallet.clone(), + seed.derive_libp2p_identity(), + namespace, + rendezvous_peer_ids, + ); + + let mut swarm = crate::network::swarm::cli( + seed.derive_libp2p_identity(), + tor_client_for_swarm, + behaviour, + ) + .await?; + + // Add addresses of rendezvous points to the swarm + for (peer_id, addrs) in rendezvous_points { + for addr in addrs { + swarm.add_peer_address(peer_id, addr); + } + } + + // Add addresses of peers that we have previously connected to to the swarm + for (peer_id, addrs) in db.get_all_peer_addresses().await.context( + "Failed to retrieve peer addresses from database to insert into swarm", + )? { + for addr in addrs { + swarm.add_peer_address(peer_id, addr); + } + } + + let (event_loop, event_loop_handle) = + crate::cli::EventLoop::new(swarm, db_for_swarm, self.tauri_handle.clone())?; + + let event_loop_task = tokio::spawn(event_loop.run()); + + // TODO: We need to emit this to the frontend so that certain button can be disabled if the event loop is not yet running + *context.event_loop_state.write().await = + Some(EventLoopState::new(event_loop_handle, event_loop_task)); + } + // Wait for Tor client to fully bootstrap bootstrap_tor_client_task.await?; diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 95f92cbf..63730613 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -5,28 +5,23 @@ use crate::cli::api::tauri_bindings::{ TauriSwapProgressEvent, }; use crate::cli::api::Context; -use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller}; -use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus}; +use crate::cli::list_sellers::QuoteWithAddress; use crate::common::{get_logs, redact}; -use crate::libp2p_ext::MultiAddrExt; use crate::monero::wallet_rpc::MoneroDaemon; use crate::monero::MoneroAddressPool; use crate::network::quote::BidQuote; -use crate::network::rendezvous::XmrBtcNamespace; -use crate::network::swarm; use crate::protocol::bob::{self, BobState, Swap}; -use crate::protocol::{Database, State}; +use crate::protocol::State; use crate::{cli, monero}; use ::bitcoin::address::NetworkUnchecked; use ::bitcoin::Txid; use ::monero::Network; use anyhow::{bail, Context as AnyContext, Result}; -use arti_client::TorClient; use futures::future::BoxFuture; use futures::stream::FuturesUnordered; use futures::StreamExt; use libp2p::core::Multiaddr; -use libp2p::{identity, PeerId}; +use libp2p::PeerId; use monero_seed::{Language, Seed as MoneroSeed}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; @@ -41,7 +36,6 @@ use swap_core::bitcoin; use swap_core::bitcoin::{CancelTimelock, ExpiredTimelocks, PunishTimelock}; use thiserror::Error; use tokio_util::task::AbortOnDropHandle; -use tor_rtcompat::tokio::TokioRustlsRuntime; use tracing::debug_span; use tracing::error; use tracing::Instrument; @@ -69,25 +63,13 @@ fn get_swap_tracing_span(swap_id: Uuid) -> Span { #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct BuyXmrArgs { - #[typeshare(serialized_as = "Vec")] - pub rendezvous_points: Vec, - #[typeshare(serialized_as = "Vec")] - pub sellers: Vec, #[typeshare(serialized_as = "Option")] pub bitcoin_change_address: Option>, pub monero_receive_pool: MoneroAddressPool, } -#[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct BuyXmrResponse { - #[typeshare(serialized_as = "string")] - pub swap_id: Uuid, - pub quote: BidQuote, -} - impl Request for BuyXmrArgs { - type Response = BuyXmrResponse; + type Response = (); async fn request(self, ctx: Arc) -> Result { let swap_id = Uuid::new_v4(); @@ -184,30 +166,6 @@ impl Request for WithdrawBtcArgs { } } -// ListSellers -#[typeshare] -#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct ListSellersArgs { - /// The rendezvous points to search for sellers - /// The address must contain a peer ID - #[typeshare(serialized_as = "Vec")] - pub rendezvous_points: Vec, -} - -#[typeshare] -#[derive(Debug, Eq, PartialEq, Serialize)] -pub struct ListSellersResponse { - sellers: Vec, -} - -impl Request for ListSellersArgs { - type Response = ListSellersResponse; - - async fn request(self, ctx: Arc) -> Result { - list_sellers(self, ctx).await - } -} - // GetSwapInfo #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -982,12 +940,10 @@ pub async fn buy_xmr( buy_xmr: BuyXmrArgs, swap_id: Uuid, context: Arc, -) -> Result { +) -> Result<(), anyhow::Error> { let _span = get_swap_tracing_span(swap_id); let BuyXmrArgs { - rendezvous_points, - sellers, bitcoin_change_address, monero_receive_pool, } = buy_xmr; @@ -1019,14 +975,14 @@ pub async fn buy_xmr( let monero_wallet = context.try_get_monero_manager().await?; let env_config = config.env_config; - let seed = config.seed.clone().context("Could not get seed")?; // Prepare variables for the quote fetching process - let identity = seed.derive_libp2p_identity(); - let namespace = config.namespace; - let tor_client = context.tor_client.read().await.clone(); let tauri_handle = context.tauri_handle.clone(); + // Get the existing event loop handle from context + let mut event_loop_handle = context.try_get_event_loop_handle().await?; + let quotes_rx = event_loop_handle.cached_quotes(); + // Wait for the user to approve a seller and to deposit coins // Calling determine_btc_to_swap let address_len = bitcoin_wallet.new_address().await?.script_pubkey().len(); @@ -1035,13 +991,8 @@ pub async fn buy_xmr( // Clone variables before moving them into closures let bitcoin_change_address_for_spawn = bitcoin_change_address.clone(); - let rendezvous_points_clone = rendezvous_points.clone(); - let sellers_clone = sellers.clone(); - let db_for_fetch = db.clone(); - let tor_client_for_swarm = tor_client.clone(); // Clone tauri_handle for different closures - let tauri_handle_for_fetch = tauri_handle.clone(); let tauri_handle_for_determine = tauri_handle.clone(); let tauri_handle_for_selection = tauri_handle.clone(); let tauri_handle_for_suspension = tauri_handle.clone(); @@ -1050,29 +1001,9 @@ pub async fn buy_xmr( // because we need to be able to cancel the determine_btc_to_swap(..) context.swap_lock.acquire_swap_lock(swap_id).await?; - let (seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee) = tokio::select! { + let select_offer_result = tokio::select! { result = determine_btc_to_swap( - move || { - let rendezvous_points = rendezvous_points_clone.clone(); - let sellers = sellers_clone.clone(); - let namespace = namespace; - let identity = identity.clone(); - let db = db_for_fetch.clone(); - let tor_client = tor_client.clone(); - let tauri_handle = tauri_handle_for_fetch.clone(); - - Box::pin(async move { - fetch_quotes_task( - rendezvous_points, - namespace, - sellers, - identity, - Some(db), - tor_client, - tauri_handle, - ).await - }) - }, + quotes_rx, bitcoin_wallet.new_address(), { let wallet = Arc::clone(&bitcoin_wallet_for_closures); @@ -1110,51 +1041,44 @@ pub async fn buy_xmr( }) as Box> + Send> }, ) => { - result? + Some(result?) } _ = context.swap_lock.listen_for_swap_force_suspension() => { context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); + if let Some(handle) = tauri_handle_for_suspension { handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); } - bail!("Shutdown signal received"); + + None }, }; + let Some((seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee)) = + select_offer_result + else { + return Ok(()); + }; + // Insert the peer_id into the database db.insert_peer_id(swap_id, seller_peer_id).await?; db.insert_address(seller_peer_id, seller_multiaddr.clone()) .await?; - let behaviour = cli::Behaviour::new( - env_config, - bitcoin_wallet.clone(), - (seed.derive_libp2p_identity(), namespace), - ); - - let mut swarm = swarm::cli( - seed.derive_libp2p_identity(), - tor_client_for_swarm, - behaviour, - ) - .await?; - - swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone()); - db.insert_monero_address_pool(swap_id, monero_receive_pool.clone()) .await?; - tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized"); + // Add the seller's address to the swarm + event_loop_handle + .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) + .await?; tauri_handle.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::ReceivedQuote(quote.clone()), ); - let (event_loop, mut event_loop_handle) = EventLoop::new(swarm, db.clone())?; - let event_loop = tokio::spawn(event_loop.run().in_current_span()); - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); context.tasks.clone().spawn(async move { @@ -1169,16 +1093,6 @@ pub async fn buy_xmr( bail!("Shutdown signal received"); }, - event_loop_result = event_loop => { - match event_loop_result { - Ok(_) => { - tracing::debug!(%swap_id, "EventLoop completed") - } - Err(error) => { - tracing::error!(%swap_id, "EventLoop failed: {:#}", error) - } - } - }, swap_result = async { let swap_event_loop_handle = event_loop_handle.swap_handle(seller_peer_id, swap_id).await?; let swap = Swap::new( @@ -1219,7 +1133,7 @@ pub async fn buy_xmr( Ok::<_, anyhow::Error>(()) }.in_current_span()).await; - Ok(BuyXmrResponse { swap_id, quote }) + Ok(()) } #[tracing::instrument(fields(method = "resume_swap"), skip(context))] @@ -1233,34 +1147,18 @@ pub async fn resume_swap( let config = context.try_get_config().await?; let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let monero_manager = context.try_get_monero_manager().await?; - let tor_client = context.tor_client.read().await.clone(); let seller_peer_id = db.get_peer_id(swap_id).await?; let seller_addresses = db.get_addresses(seller_peer_id).await?; - let seed = config - .seed - .as_ref() - .context("Could not get seed")? - .derive_libp2p_identity(); + let mut event_loop_handle = context.try_get_event_loop_handle().await?; - let behaviour = cli::Behaviour::new( - config.env_config, - bitcoin_wallet.clone(), - (seed.clone(), config.namespace), - ); - let mut swarm = swarm::cli(seed.clone(), tor_client, behaviour).await?; - let our_peer_id = swarm.local_peer_id(); - - tracing::debug!(peer_id = %our_peer_id, "Network layer initialized"); - - // Fetch the seller's addresses from the database and add them to the swarm for seller_address in seller_addresses { - swarm.add_peer_address(seller_peer_id, seller_address); + event_loop_handle + .queue_peer_address(seller_peer_id, seller_address) + .await?; } - let (event_loop, mut event_loop_handle) = EventLoop::new(swarm, db.clone())?; - let monero_receive_pool = db.get_monero_address_pool(swap_id).await?; let tauri_handle = context.tauri_handle.clone(); @@ -1286,7 +1184,6 @@ pub async fn resume_swap( context.tasks.clone().spawn( async move { - let handle = tokio::spawn(event_loop.run().in_current_span()); tokio::select! { biased; _ = context.swap_lock.listen_for_swap_force_suspension() => { @@ -1298,16 +1195,6 @@ pub async fn resume_swap( bail!("Shutdown signal received"); }, - event_loop_result = handle => { - match event_loop_result { - Ok(_) => { - tracing::debug!(%swap_id, "EventLoop completed during swap resume") - } - Err(error) => { - tracing::error!(%swap_id, "EventLoop failed during swap resume: {:#}", error) - } - } - }, swap_result = bob::run(swap) => { match swap_result { Ok(state) => { @@ -1477,75 +1364,6 @@ pub async fn get_balance(balance: BalanceArgs, context: Arc) -> Result< }) } -#[tracing::instrument(fields(method = "list_sellers"), skip(context))] -pub async fn list_sellers( - list_sellers: ListSellersArgs, - context: Arc, -) -> Result { - let ListSellersArgs { rendezvous_points } = list_sellers; - let rendezvous_nodes: Vec<_> = rendezvous_points - .iter() - .filter_map(|rendezvous_point| rendezvous_point.split_peer_id()) - .collect(); - - let config = context.try_get_config().await?; - let db = context.try_get_db().await?; - let tor_client = context.tor_client.read().await.clone(); - let tauri_handle = context.tauri_handle.clone(); - - let identity = config - .seed - .as_ref() - .context("Cannot extract seed")? - .derive_libp2p_identity(); - - let sellers = list_sellers_impl( - rendezvous_nodes, - config.namespace, - tor_client, - identity, - Some(db.clone()), - tauri_handle, - ) - .await?; - - for seller in &sellers { - match seller { - SellerStatus::Online(QuoteWithAddress { - quote, - multiaddr, - peer_id, - version, - }) => { - tracing::trace!( - status = "Online", - price = %quote.price.to_string(), - min_quantity = %quote.min_quantity.to_string(), - max_quantity = %quote.max_quantity.to_string(), - address = %multiaddr.clone().to_string(), - peer_id = %peer_id, - version = %version, - "Fetched peer status" - ); - - // Add the peer as known to the database - // This'll allow us to later request a quote again - // without having to re-discover the peer at the rendezvous point - db.insert_address(*peer_id, multiaddr.clone()).await?; - } - SellerStatus::Unreachable(UnreachableSeller { peer_id }) => { - tracing::trace!( - status = "Unreachable", - peer_id = %peer_id.to_string(), - "Fetched peer status" - ); - } - } - } - - Ok(ListSellersResponse { sellers }) -} - #[tracing::instrument(fields(method = "export_bitcoin_wallet"), skip(context))] pub async fn export_bitcoin_wallet(context: Arc) -> Result { let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; @@ -1600,49 +1418,6 @@ pub async fn get_current_swap(context: Arc) -> Result, - namespace: XmrBtcNamespace, - sellers: Vec, - identity: identity::Keypair, - db: Option>, - tor_client: Option>>, - tauri_handle: Option, -) -> Result<( - tokio::task::JoinHandle<()>, - ::tokio::sync::watch::Receiver>, -)> { - let (tx, rx) = ::tokio::sync::watch::channel(Vec::new()); - - let rendezvous_nodes: Vec<_> = rendezvous_points - .iter() - .filter_map(|addr| addr.split_peer_id()) - .collect(); - - let fetch_fn = list_sellers_init( - rendezvous_nodes, - namespace, - tor_client, - identity, - db, - tauri_handle, - Some(tx.clone()), - sellers, - ) - .await?; - - let handle = tokio::task::spawn(async move { - loop { - let sellers = fetch_fn().await; - let _ = tx.send(sellers); - - tokio::time::sleep(std::time::Duration::from_secs(90)).await; - } - }); - - Ok((handle, rx)) -} - // TODO: Let this take a refresh interval as an argument pub async fn refresh_wallet_task( max_giveable_fn: FMG, @@ -1679,17 +1454,9 @@ where Ok((handle, rx)) } -pub type QuoteFetchFuture = BoxFuture< - 'static, - Result<( - tokio::task::JoinHandle<()>, - ::tokio::sync::watch::Receiver>, - )>, ->; - #[allow(clippy::too_many_arguments)] pub async fn determine_btc_to_swap( - quote_fetch_tasks: impl Fn() -> QuoteFetchFuture, + mut quotes_rx: ::tokio::sync::watch::Receiver>, // TODO: Shouldn't this be a function? get_new_address: impl Future>, balance: FB, @@ -1714,17 +1481,12 @@ where FS: Fn() -> TS + Send + 'static, { // Start background tasks with watch channels - let (quote_fetch_handle, mut quotes_rx): ( - _, - ::tokio::sync::watch::Receiver>, - ) = quote_fetch_tasks().await?; let (wallet_refresh_handle, mut balance_rx): ( _, ::tokio::sync::watch::Receiver<(bitcoin::Amount, bitcoin::Amount)>, ) = refresh_wallet_task(max_giveable_fn, balance, sync).await?; // Get the abort handles to kill the background tasks when we exit the function - let quote_fetch_abort_handle = AbortOnDropHandle::new(quote_fetch_handle); let wallet_refresh_abort_handle = AbortOnDropHandle::new(wallet_refresh_handle); let mut pending_approvals = FuturesUnordered::new(); @@ -1736,14 +1498,6 @@ where let quotes = quotes_rx.borrow().clone(); let (balance, max_giveable) = *balance_rx.borrow(); - let success_quotes = quotes - .iter() - .filter_map(|quote| match quote { - SellerStatus::Online(quote_with_address) => Some(quote_with_address.clone()), - SellerStatus::Unreachable(_) => None, - }) - .collect::>(); - // Emit a Tauri event event_emitter.emit_swap_progress_event( swap_id, @@ -1751,12 +1505,12 @@ where deposit_address: deposit_address.clone(), max_giveable: max_giveable, min_bitcoin_lock_tx_fee: balance - max_giveable, - known_quotes: success_quotes.clone(), + known_quotes: quotes.clone(), }, ); // Iterate through quotes and find ones that match the balance and max_giveable - let matching_quotes = success_quotes + let matching_quotes = quotes .iter() .filter_map(|quote_with_address| { let quote = quote_with_address.quote; @@ -1814,15 +1568,6 @@ where }); } - tracing::trace!( - swap_id = ?swap_id, - pending_approvals = ?pending_approvals.len(), - balance = ?balance, - max_giveable = ?max_giveable, - quotes = ?quotes, - "Waiting for user to select an offer" - ); - // Listen for approvals, balance changes, or quote changes let result: Option<( Multiaddr, @@ -1854,7 +1599,6 @@ where // If user accepted an offer, return it to start the swap if let Some((multiaddr, peer_id, quote, tx_lock_amount, tx_lock_fee)) = result { - quote_fetch_abort_handle.abort(); wallet_refresh_abort_handle.abort(); return Ok((multiaddr, peer_id, quote, tx_lock_amount, tx_lock_fee)); diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index c9cc2ad6..2de84cd6 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -8,6 +8,7 @@ use crate::{monero, network::quote::BidQuote}; use anyhow::{anyhow, bail, Context, Result}; use async_trait::async_trait; use bitcoin::Txid; +use libp2p::{Multiaddr, PeerId}; use monero_rpc_pool::pool::PoolStatus; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -18,6 +19,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use strum::Display; use swap_core::bitcoin; use swap_core::bitcoin::ExpiredTimelocks; +use swap_p2p::observe; +use swap_p2p::protocols::quotes_cached::QuoteStatus; use tokio::sync::oneshot; use typeshare::typeshare; use uuid::Uuid; @@ -37,6 +40,29 @@ pub enum TauriEvent { BackgroundProgress(TauriBackgroundProgressWrapper), PoolStatusUpdate(PoolStatus), MoneroWalletUpdate(MoneroWalletUpdate), + P2P(TauriP2PEvent), +} + +#[typeshare] +#[derive(Clone, Serialize)] +#[serde(tag = "type", content = "content")] +pub enum TauriP2PEvent { + ConnectionChange { + #[typeshare(serialized_as = "string")] + peer_id: PeerId, + change: observe::ConnectionChange, + }, + QuotesProgress { + peers: Vec, + }, +} + +#[typeshare] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PeerQuoteProgress { + #[typeshare(serialized_as = "string")] + pub peer_id: PeerId, + pub quote_status: QuoteStatus, } #[typeshare] @@ -506,6 +532,27 @@ pub trait TauriEmitter { self.emit_unified_event(TauriEvent::PoolStatusUpdate(status)); } + fn emit_peer_connection_change(&self, peer_id: PeerId, change: observe::ConnectionChange) { + self.emit_unified_event(TauriEvent::P2P(TauriP2PEvent::ConnectionChange { + peer_id, + change, + })); + } + + fn emit_quotes_progress(&self, peers: Vec<(PeerId, QuoteStatus)>) { + let entries = peers + .into_iter() + .map(|(peer_id, quote_status)| PeerQuoteProgress { + peer_id, + quote_status, + }) + .collect(); + + self.emit_unified_event(TauriEvent::P2P(TauriP2PEvent::QuotesProgress { + peers: entries, + })); + } + /// Create a new background progress handle for tracking a specific type of progress fn new_background_process( &self, @@ -1022,6 +1069,8 @@ pub struct TauriSettings { pub use_tor: bool, /// Whether to route Monero wallet traffic through Tor pub enable_monero_tor: bool, + /// The list of rendezvous points to connect to + pub rendezvous_points: Vec, } #[typeshare] diff --git a/swap/src/cli/behaviour.rs b/swap/src/cli/behaviour.rs index ccc207fd..661f0e02 100644 --- a/swap/src/cli/behaviour.rs +++ b/swap/src/cli/behaviour.rs @@ -1,15 +1,17 @@ use crate::network::rendezvous::XmrBtcNamespace; use crate::network::swap_setup::bob; use crate::network::{ - cooperative_xmr_redeem_after_punish, encrypted_signature, quote, redial, transfer_proof, + cooperative_xmr_redeem_after_punish, encrypted_signature, quote, quotes_cached, redial, + rendezvous, transfer_proof, }; use anyhow::Result; use bitcoin_wallet::BitcoinWallet; use libp2p::swarm::NetworkBehaviour; -use libp2p::{identify, identity, ping}; +use libp2p::{identify, identity, ping, PeerId}; use std::sync::Arc; use std::time::Duration; use swap_env::env; +use swap_p2p::observe; pub use swap_p2p::out_event::bob::OutEvent; const PROTOCOL_VERSION: &str = "/comit/xmr/btc/1.0.0"; @@ -22,13 +24,23 @@ const MAX_REDIAL_INTERVAL: Duration = Duration::from_secs(30); #[behaviour(to_swarm = "OutEvent")] #[allow(missing_debug_implementations)] pub struct Behaviour { - pub quote: quote::Behaviour, + /// Fetch a quote from a specifc peer, usually before starting a swap + pub direct_quote: quote::Behaviour, + /// Periodically request quotes from any peers that might offer them + pub quotes: quotes_cached::Behaviour, + /// Periodically discover peers via rendezvous nodes + pub discovery: rendezvous::discovery::Behaviour, + /// Observes connection status and emits events + pub observe: observe::Behaviour, + + /// Requires to actually do swaps pub swap_setup: bob::Behaviour, pub transfer_proof: transfer_proof::Behaviour, pub cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::Behaviour, pub encrypted_signature: encrypted_signature::Behaviour, + + /// Allows us to keep connections to specific peers alive pub redial: redial::Behaviour, - pub identify: identify::Behaviour, /// Ping behaviour that ensures that the underlying network connection is /// still alive. If the ping fails a connection close event will be @@ -40,30 +52,37 @@ impl Behaviour { pub fn new( env_config: env::Config, bitcoin_wallet: Arc, - identify_params: (identity::Keypair, XmrBtcNamespace), + identity: identity::Keypair, + namespace: XmrBtcNamespace, + rendezvous_nodes: Vec, ) -> Self { - let agentVersion = format!("cli/{} ({})", env!("CARGO_PKG_VERSION"), identify_params.1); - - let identifyConfig = - identify::Config::new(PROTOCOL_VERSION.to_string(), identify_params.0.public()) - .with_agent_version(agentVersion); + let identifyConfig = identify::Config::new(PROTOCOL_VERSION.to_string(), identity.public()) + .with_agent_version(agent_version(namespace)); let pingConfig = ping::Config::new().with_timeout(Duration::from_secs(60)); Self { - quote: quote::cli(), + direct_quote: quote::bob(), + quotes: quotes_cached::Behaviour::new(identifyConfig), + + discovery: rendezvous::discovery::Behaviour::new( + identity, + rendezvous_nodes, + namespace.into(), + ), + observe: observe::Behaviour::new(), + swap_setup: bob::Behaviour::new(env_config, bitcoin_wallet), transfer_proof: transfer_proof::bob(), encrypted_signature: encrypted_signature::bob(), cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::bob(), - redial: redial::Behaviour::new( - // This redial behaviour is responsible for redialing all Alice peers during swaps - "multi-alice-redialer", - INITIAL_REDIAL_INTERVAL, - MAX_REDIAL_INTERVAL, - ), + + redial: redial::Behaviour::new("makers", INITIAL_REDIAL_INTERVAL, MAX_REDIAL_INTERVAL), ping: ping::Behaviour::new(pingConfig), - identify: identify::Behaviour::new(identifyConfig), } } } + +fn agent_version(namespace: XmrBtcNamespace) -> String { + format!("cli/{} ({})", env!("CARGO_PKG_VERSION"), namespace) +} diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index 74eb1492..cf448b38 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -1,6 +1,6 @@ use crate::cli::api::request::{ - BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetConfigArgs, - GetHistoryArgs, ListSellersArgs, MoneroRecoveryArgs, Request, ResumeSwapArgs, WithdrawBtcArgs, + BalanceArgs, CancelAndRefundArgs, ExportBitcoinWalletArgs, GetConfigArgs, + GetHistoryArgs, MoneroRecoveryArgs, Request, ResumeSwapArgs, WithdrawBtcArgs, }; use crate::cli::api::Context; use crate::monero::{self, MoneroAddressPool}; @@ -83,44 +83,6 @@ async fn apply_defaults( (json, is_testnet, data): JsonTestnetData, ) -> Result<()> { match cli_cmd { - CliCommand::BuyXmr { - seller: Seller { seller }, - bitcoin, - bitcoin_change_address, - monero, - monero_receive_address, - tor, - } => { - let monero_receive_pool: MoneroAddressPool = - swap_serde::monero::address::validate_is_testnet( - monero_receive_address, - is_testnet, - )? - .into(); - - let bitcoin_change_address = bitcoin_change_address - .map(|address| bitcoin_address::validate(address, is_testnet)) - .transpose()? - .map(|address| address.into_unchecked()); - - ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) - .with_bitcoin(bitcoin) - .with_monero(monero) - .with_data_dir(data) - .with_json(json) - .build(context.clone()) - .await?; - - BuyXmrArgs { - rendezvous_points: vec![], - sellers: vec![seller], - bitcoin_change_address, - monero_receive_pool, - } - .request(context) - .await?; - } CliCommand::History => { ContextBuilder::new(is_testnet) .with_data_dir(data) @@ -218,23 +180,6 @@ async fn apply_defaults( CancelAndRefundArgs { swap_id }.request(context).await?; } - CliCommand::ListSellers { - rendezvous_point, - tor, - } => { - ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) - .with_data_dir(data) - .with_json(json) - .build(context.clone()) - .await?; - - ListSellersArgs { - rendezvous_points: vec![rendezvous_point], - } - .request(context) - .await?; - } CliCommand::ExportBitcoinWallet { bitcoin } => { ContextBuilder::new(is_testnet) .with_bitcoin(bitcoin) @@ -296,33 +241,34 @@ struct Arguments { #[derive(structopt::StructOpt, Debug, PartialEq)] enum CliCommand { - /// Start a BTC for XMR swap - BuyXmr { - #[structopt(flatten)] - seller: Seller, + // TODO: Add this back once our CLI is more flexible + // Start a BTC for XMR swap + // BuyXmr { + // #[structopt(flatten)] + // seller: Seller, - #[structopt(flatten)] - bitcoin: Bitcoin, + // #[structopt(flatten)] + // bitcoin: Bitcoin, - #[structopt( - long = "change-address", - help = "The bitcoin address where any form of change or excess funds should be sent to. If omitted they will be sent to the internal wallet.", - parse(try_from_str = bitcoin_address::parse) - )] - bitcoin_change_address: Option>, + // #[structopt( + // long = "change-address", + // help = "The bitcoin address where any form of change or excess funds should be sent to. If omitted they will be sent to the internal wallet.", + // parse(try_from_str = bitcoin_address::parse) + // )] + // bitcoin_change_address: Option>, - #[structopt(flatten)] - monero: Monero, + // #[structopt(flatten)] + // monero: Monero, - #[structopt(long = "receive-address", - help = "The monero address where you would like to receive monero", - parse(try_from_str = swap_serde::monero::address::parse) - )] - monero_receive_address: monero::Address, + // #[structopt(long = "receive-address", + // help = "The monero address where you would like to receive monero", + // parse(try_from_str = swap_serde::monero::address::parse) + // )] + // monero_receive_address: monero::Address, - #[structopt(flatten)] - tor: Tor, - }, + // #[structopt(flatten)] + // tor: Tor, + // }, /// Show a list of past, ongoing and completed swaps History, /// Output all logging messages that have been issued. @@ -391,17 +337,6 @@ enum CliCommand { #[structopt(flatten)] bitcoin: Bitcoin, }, - /// Discover and list sellers (i.e. ASB providers) - ListSellers { - #[structopt( - long, - help = "Address of the rendezvous point you want to use to discover ASBs" - )] - rendezvous_point: Multiaddr, - - #[structopt(flatten)] - tor: Tor, - }, /// Print the internal bitcoin wallet descriptor ExportBitcoinWallet { #[structopt(flatten)] diff --git a/swap/src/cli/event_loop.rs b/swap/src/cli/event_loop.rs index 66045f12..c388c4fc 100644 --- a/swap/src/cli/event_loop.rs +++ b/swap/src/cli/event_loop.rs @@ -1,8 +1,11 @@ +use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle}; use crate::cli::behaviour::{Behaviour, OutEvent}; +use crate::cli::list_sellers::QuoteWithAddress; use crate::monero; use crate::network::cooperative_xmr_redeem_after_punish::{self, Request, Response}; use crate::network::encrypted_signature; use crate::network::quote::BidQuote; +use crate::network::quotes_cached::QuoteStatus; use crate::network::swap_setup::bob::NewSwap; use crate::protocol::bob::swap::has_already_processed_transfer_proof; use crate::protocol::bob::{BobState, State2}; @@ -18,7 +21,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use swap_core::bitcoin::EncryptedSignature; +use swap_p2p::observe; use swap_p2p::protocols::redial; +use tracing::Instrument; use uuid::Uuid; // Timeout for the execution setup protocol within the event loop. @@ -53,6 +58,7 @@ pub struct EventLoop { Uuid, PeerId, bmrng::unbounded::UnboundedRequestSender, + tracing::Span, ), (), >, @@ -61,27 +67,32 @@ pub struct EventLoop { ( PeerId, bmrng::unbounded::UnboundedRequestSender, + tracing::Span, ), >, // These streams represents outgoing requests that we have to make (queues) // // Requests are keyed by the PeerId because they do not correspond to an existing swap yet - quote_requests: - bmrng::unbounded::UnboundedRequestReceiverStream>, + quote_requests: bmrng::unbounded::UnboundedRequestReceiverStream< + (PeerId, tracing::Span), + Result, + >, // TODO: technically NewSwap.swap_id already contains the id of the swap - execution_setup_requests: - bmrng::unbounded::UnboundedRequestReceiverStream<(PeerId, NewSwap), Result>, + execution_setup_requests: bmrng::unbounded::UnboundedRequestReceiverStream< + (PeerId, NewSwap, tracing::Span), + Result, + >, // These streams represents outgoing requests that we have to make (queues) // // Requests are keyed by the swap_id because they correspond to a specific swap cooperative_xmr_redeem_requests: bmrng::unbounded::UnboundedRequestReceiverStream< - (PeerId, Uuid), + (PeerId, Uuid, tracing::Span), Result, >, encrypted_signatures_requests: bmrng::unbounded::UnboundedRequestReceiverStream< - (PeerId, Uuid, EncryptedSignature), + (PeerId, Uuid, EncryptedSignature, tracing::Span), Result<(), OutboundFailure>, >, @@ -91,19 +102,33 @@ pub struct EventLoop { // response. inflight_quote_requests: HashMap< OutboundRequestId, - bmrng::unbounded::UnboundedResponder>, + ( + bmrng::unbounded::UnboundedResponder>, + tracing::Span, + ), >, inflight_encrypted_signature_requests: HashMap< OutboundRequestId, - bmrng::unbounded::UnboundedResponder>, + ( + bmrng::unbounded::UnboundedResponder>, + tracing::Span, + ), + >, + inflight_swap_setup: HashMap< + (PeerId, Uuid), + ( + bmrng::unbounded::UnboundedResponder>, + tracing::Span, + ), >, - inflight_swap_setup: - HashMap<(PeerId, Uuid), bmrng::unbounded::UnboundedResponder>>, inflight_cooperative_xmr_redeem_requests: HashMap< OutboundRequestId, - bmrng::unbounded::UnboundedResponder< - Result, - >, + ( + bmrng::unbounded::UnboundedResponder< + Result, + >, + tracing::Span, + ), >, /// The future representing the successful handling of an incoming transfer proof (by the state machine) @@ -113,12 +138,20 @@ pub struct EventLoop { /// /// The future will yield the swap_id and the response channel which are used to send an acknowledgement to Alice. pending_transfer_proof_acks: FuturesUnordered)>>, + + /// Queue for adding peer addresses to the swarm + add_peer_address_requests: + bmrng::unbounded::UnboundedRequestReceiverStream<(PeerId, libp2p::Multiaddr), ()>, + + cached_quotes_sender: tokio::sync::watch::Sender>, + tauri_handle: Option, } impl EventLoop { pub fn new( swarm: Swarm, db: Arc, + tauri_handle: Option, ) -> Result<(Self, EventLoopHandle)> { // We still use a timeout here because we trust our own implementation of the swap setup protocol less than the libp2p library let (execution_setup_sender, execution_setup_receiver) = @@ -132,6 +165,11 @@ impl EventLoop { bmrng::unbounded::channel(); let (queued_transfer_proof_sender, queued_transfer_proof_receiver) = bmrng::unbounded::channel(); + let (add_peer_address_sender, add_peer_address_receiver) = bmrng::unbounded::channel(); + + // TODO: We should probably differentiate between empty and none + let (cached_quotes_sender, cached_quotes_receiver) = + tokio::sync::watch::channel(Vec::new()); let event_loop = EventLoop { swarm, @@ -147,6 +185,9 @@ impl EventLoop { inflight_encrypted_signature_requests: HashMap::default(), inflight_cooperative_xmr_redeem_requests: HashMap::default(), pending_transfer_proof_acks: FuturesUnordered::new(), + add_peer_address_requests: add_peer_address_receiver.into(), + cached_quotes_sender, + tauri_handle, }; let handle = EventLoopHandle { @@ -155,6 +196,8 @@ impl EventLoop { cooperative_xmr_redeem_sender, quote_sender, queued_transfer_proof_sender, + add_peer_address_sender, + cached_quotes_receiver, }; Ok((event_loop, handle)) @@ -167,22 +210,26 @@ impl EventLoop { swarm_event = self.swarm.select_next_some() => { match swarm_event { SwarmEvent::Behaviour(OutEvent::QuoteReceived { id, response }) => { - tracing::trace!( - %id, - "Received quote" - ); + if let Some((responder, span)) = self.inflight_quote_requests.remove(&id) { + let _span_guard = span.enter(); + + tracing::trace!( + %id, + "Received quote" + ); - if let Some(responder) = self.inflight_quote_requests.remove(&id) { let _ = responder.respond(Ok(response)); } } SwarmEvent::Behaviour(OutEvent::SwapSetupCompleted { peer, swap_id, result }) => { - tracing::trace!( - %peer, - "Processing swap setup completion" - ); + if let Some((responder, span)) = self.inflight_swap_setup.remove(&(peer, swap_id)) { + let _span_guard = span.enter(); + + tracing::trace!( + %peer, + "Processing swap setup completion" + ); - if let Some(responder) = self.inflight_swap_setup.remove(&(peer, swap_id)) { let _ = responder.respond(*result); } } @@ -196,7 +243,7 @@ impl EventLoop { let swap_id = msg.swap_id; // Check if we have a registered handler for this swap - if let Some((expected_peer_id, sender)) = self.registered_swap_handlers.get(&swap_id) { + if let Some((expected_peer_id, sender, _)) = self.registered_swap_handlers.get(&swap_id) { // Ensure the transfer proof is coming from the expected peer if peer != *expected_peer_id { tracing::warn!( @@ -267,38 +314,53 @@ impl EventLoop { } } SwarmEvent::Behaviour(OutEvent::EncryptedSignatureAcknowledged { id }) => { - if let Some(responder) = self.inflight_encrypted_signature_requests.remove(&id) { + if let Some((responder, span)) = self.inflight_encrypted_signature_requests.remove(&id) { + let _span_guard = span.enter(); let _ = responder.respond(Ok(())); } } SwarmEvent::Behaviour(OutEvent::CooperativeXmrRedeemFulfilled { id, swap_id, s_a, lock_transfer_proof }) => { - if let Some(responder) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) { + if let Some((responder, span)) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) { + let _span_guard = span.enter(); let _ = responder.respond(Ok(Response::Fullfilled { s_a, swap_id, lock_transfer_proof })); } } SwarmEvent::Behaviour(OutEvent::CooperativeXmrRedeemRejected { id, swap_id, reason }) => { - if let Some(responder) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) { + if let Some((responder, span)) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) { + let _span_guard = span.enter(); let _ = responder.respond(Ok(Response::Rejected { reason, swap_id })); } } SwarmEvent::Behaviour(OutEvent::Failure { peer, error }) => { + let span = self.get_peer_span(peer); + let _span_guard = span.enter(); tracing::warn!(%peer, err = ?error, "Communication error"); return; } - SwarmEvent::ConnectionEstablished { peer_id: _, endpoint, .. } => { - tracing::info!(peer_id = %endpoint.get_remote_address(), "Connected to peer"); + SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => { + let span = self.get_peer_span(peer_id); + let _span_guard = span.enter(); + tracing::info!(%peer_id, peer_addr = %endpoint.get_remote_address(), "Connected to peer"); } SwarmEvent::Dialing { peer_id: Some(peer_id), connection_id } => { + let span = self.get_peer_span(peer_id); + let _span_guard = span.enter(); tracing::debug!(%peer_id, %connection_id, "Dialing peer"); } - SwarmEvent::ConnectionClosed { peer_id: _, endpoint, num_established, cause: Some(error), connection_id } if num_established == 0 => { - tracing::warn!(peer_id = %endpoint.get_remote_address(), cause = ?error, %connection_id, "Lost connection to peer"); + SwarmEvent::ConnectionClosed { peer_id, num_established, cause: Some(error), connection_id, endpoint } if num_established == 0 => { + let span = self.get_peer_span(peer_id); + let _span_guard = span.enter(); + tracing::warn!(%peer_id, peer_addr = %endpoint.get_remote_address(), cause = ?error, %connection_id, "Lost connection to peer"); } SwarmEvent::ConnectionClosed { peer_id, num_established, cause: None, .. } if num_established == 0 => { // no error means the disconnection was requested + let span = self.get_peer_span(peer_id); + let _span_guard = span.enter(); tracing::info!(%peer_id, "Successfully closed connection to peer"); } SwarmEvent::OutgoingConnectionError { peer_id: Some(peer_id), error, connection_id } => { + let span = self.get_peer_span(peer_id); + let _span_guard = span.enter(); tracing::warn!(%peer_id, %connection_id, ?error, "Outgoing connection error to peer"); } SwarmEvent::Behaviour(OutEvent::OutboundRequestResponseFailure {peer, error, request_id, protocol}) => { @@ -313,19 +375,22 @@ impl EventLoop { // We will remove the responder from the inflight requests and respond with an error // Check for encrypted signature requests - if let Some(responder) = self.inflight_encrypted_signature_requests.remove(&request_id) { + if let Some((responder, span)) = self.inflight_encrypted_signature_requests.remove(&request_id) { + let _span_guard = span.enter(); let _ = responder.respond(Err(error)); continue; } // Check for quote requests - if let Some(responder) = self.inflight_quote_requests.remove(&request_id) { + if let Some((responder, span)) = self.inflight_quote_requests.remove(&request_id) { + let _span_guard = span.enter(); let _ = responder.respond(Err(error)); continue; } // Check for cooperative xmr redeem requests - if let Some(responder) = self.inflight_cooperative_xmr_redeem_requests.remove(&request_id) { + if let Some((responder, span)) = self.inflight_cooperative_xmr_redeem_requests.remove(&request_id) { + let _span_guard = span.enter(); let _ = responder.respond(Err(error)); continue; } @@ -345,14 +410,40 @@ impl EventLoop { "Scheduled redial for peer" ); } + SwarmEvent::Behaviour(OutEvent::CachedQuotes { quotes }) => { + tracing::trace!( + ?quotes, + "Received cached quotes" + ); + + let quotes_with_addresses: Vec = quotes + .into_iter() + .map(|(peer_id, multiaddr, quote, version)| QuoteWithAddress { + peer_id, + multiaddr, + quote, + version, + }) + .collect(); + + let _ = self.cached_quotes_sender.send(quotes_with_addresses); + } + SwarmEvent::Behaviour(OutEvent::CachedQuotesProgress { peers }) => { + self.tauri_handle.emit_quotes_progress(peers.clone()); + } + SwarmEvent::Behaviour(OutEvent::Observe(event)) => { + self.tauri_handle.emit_peer_connection_change(event.peer_id, event.update); + } _ => {} } }, // Handle to-be-sent outgoing requests for all our network protocols. - Some((peer_id, responder)) = self.quote_requests.next().fuse() => { - let outbound_request_id = self.swarm.behaviour_mut().quote.send_request(&peer_id, ()); - self.inflight_quote_requests.insert(outbound_request_id, responder); + Some(((peer_id, span), responder)) = self.quote_requests.next().fuse() => { + let _span_guard = span.enter(); + + let outbound_request_id = self.swarm.behaviour_mut().direct_quote.send_request(&peer_id, ()); + self.inflight_quote_requests.insert(outbound_request_id, (responder, span.clone())); tracing::trace!( %peer_id, @@ -360,14 +451,16 @@ impl EventLoop { "Dispatching outgoing quote request" ); }, - Some(((peer_id, swap_id, tx_redeem_encsig), responder)) = self.encrypted_signatures_requests.next().fuse() => { + Some(((peer_id, swap_id, tx_redeem_encsig, span), responder)) = self.encrypted_signatures_requests.next().fuse() => { + let _span_guard = span.enter(); + let request = encrypted_signature::Request { swap_id, tx_redeem_encsig }; let outbound_request_id = self.swarm.behaviour_mut().encrypted_signature.send_request(&peer_id, request); - self.inflight_encrypted_signature_requests.insert(outbound_request_id, responder); + self.inflight_encrypted_signature_requests.insert(outbound_request_id, (responder, span.clone())); tracing::trace!( %peer_id, @@ -376,11 +469,13 @@ impl EventLoop { "Dispatching outgoing encrypted signature" ); }, - Some(((peer_id, swap_id), responder)) = self.cooperative_xmr_redeem_requests.next().fuse() => { + Some(((peer_id, swap_id, span), responder)) = self.cooperative_xmr_redeem_requests.next().fuse() => { + let _span_guard = span.enter(); + let outbound_request_id = self.swarm.behaviour_mut().cooperative_xmr_redeem.send_request(&peer_id, Request { swap_id }); - self.inflight_cooperative_xmr_redeem_requests.insert(outbound_request_id, responder); + self.inflight_cooperative_xmr_redeem_requests.insert(outbound_request_id, (responder, span.clone())); tracing::trace!( %peer_id, @@ -392,11 +487,26 @@ impl EventLoop { // Instruct the swap setup behaviour to do a swap setup request // The behaviour will instruct the swarm to dial Alice, so we don't need to check if we are connected - Some(((alice_peer_id, swap), responder)) = self.execution_setup_requests.next().fuse() => { + Some(((alice_peer_id, swap, span), responder)) = self.execution_setup_requests.next().fuse() => { + let _span_guard = span.enter(); let swap_id = swap.swap_id.clone(); - self.swarm.behaviour_mut().swap_setup.start(alice_peer_id, swap).await; - self.inflight_swap_setup.insert((alice_peer_id, swap_id), responder); + // Check if we have an inflight swap setup request for this swap already + if self.inflight_swap_setup.contains_key(&(alice_peer_id, swap_id)) { + tracing::warn!( + %alice_peer_id, + %swap_id, + "We already have an inflight swap setup request for this swap. We will not instruct the Behaviour to start a new one. Responding with an error to the caller immediately.", + ); + + // Immediately respond with an error to the caller + let _ = responder.respond(Err(anyhow::anyhow!("We already have an inflight swap setup request for this swap"))); + + continue; + } + + self.swarm.behaviour_mut().swap_setup.queue_new_swap(alice_peer_id, swap); + self.inflight_swap_setup.insert((alice_peer_id, swap_id), (responder, span.clone())); tracing::trace!( %alice_peer_id, @@ -419,11 +529,12 @@ impl EventLoop { } }, - Some(((swap_id, peer_id, sender), responder)) = self.queued_swap_handlers.next().fuse() => { + Some(((swap_id, peer_id, sender, span), responder)) = self.queued_swap_handlers.next().fuse() => { + let _guard = span.enter(); tracing::trace!(%swap_id, %peer_id, "Registering swap handle for a swap internally inside the event loop"); // This registers the swap_id -> peer_id and swap_id -> transfer_proof_sender - self.registered_swap_handlers.insert(swap_id, (peer_id, sender)); + self.registered_swap_handlers.insert(swap_id, (peer_id, sender, span.clone())); // Instruct the swarm to contineously redial the peer // TODO: We must remove it again once the swap is complete, otherwise we will redial indefinitely @@ -432,9 +543,26 @@ impl EventLoop { // Acknowledge the registration let _ = responder.respond(()); }, + + Some(((peer_id, addr), responder)) = self.add_peer_address_requests.next().fuse() => { + tracing::trace!(%peer_id, %addr, "Adding peer address to swarm"); + self.swarm.add_peer_address(peer_id, addr); + let _ = responder.respond(()); + }, } } } + + fn get_peer_span(&self, peer_id: PeerId) -> tracing::Span { + let span = tracing::debug_span!("peer_context", %peer_id); + + for (peer, _, s) in self.registered_swap_handlers.values() { + if *peer == peer_id { + span.follows_from(s); + } + } + span + } } #[derive(Debug, Clone)] @@ -444,14 +572,14 @@ pub struct EventLoopHandle { /// 2. Return the resulting State2 if successful /// 3. Return an anyhow error if the request fails execution_setup_sender: - bmrng::unbounded::UnboundedRequestSender<(PeerId, NewSwap), Result>, + bmrng::unbounded::UnboundedRequestSender<(PeerId, NewSwap, tracing::Span), Result>, /// When a (PeerId, Uuid, EncryptedSignature) tuple is sent into this channel, the EventLoop will: /// 1. Send the encrypted signature to the specified peer over the network /// 2. Return Ok(()) if the peer acknowledges receipt, or /// 3. Return an OutboundFailure error if the request fails encrypted_signature_sender: bmrng::unbounded::UnboundedRequestSender< - (PeerId, Uuid, EncryptedSignature), + (PeerId, Uuid, EncryptedSignature, tracing::Span), Result<(), OutboundFailure>, >, @@ -459,8 +587,10 @@ pub struct EventLoopHandle { /// 1. Request a price quote from the specified peer /// 2. Return the quote if successful /// 3. Return an OutboundFailure error if the request fails - quote_sender: - bmrng::unbounded::UnboundedRequestSender>, + quote_sender: bmrng::unbounded::UnboundedRequestSender< + (PeerId, tracing::Span), + Result, + >, /// When a (PeerId, Uuid) tuple is sent into this channel, the EventLoop will: /// 1. Request the specified peer's cooperation in redeeming the Monero for the given swap @@ -468,7 +598,7 @@ pub struct EventLoopHandle { /// The Fullfilled object contains the keys required to redeem the Monero /// 3. Return an OutboundFailure error if the network request fails cooperative_xmr_redeem_sender: bmrng::unbounded::UnboundedRequestSender< - (PeerId, Uuid), + (PeerId, Uuid, tracing::Span), Result, >, @@ -477,12 +607,37 @@ pub struct EventLoopHandle { Uuid, PeerId, bmrng::unbounded::UnboundedRequestSender, + tracing::Span, ), (), >, + + /// Channel for adding peer addresses to the swarm + add_peer_address_sender: + bmrng::unbounded::UnboundedRequestSender<(PeerId, libp2p::Multiaddr), ()>, + + // TODO: Extract the Vec<_> into its own struct (QuotesBatch?) + cached_quotes_receiver: tokio::sync::watch::Receiver>, } impl EventLoopHandle { + pub fn cached_quotes(&self) -> tokio::sync::watch::Receiver> { + self.cached_quotes_receiver.clone() + } + + /// Adds a peer address to the swarm + pub async fn queue_peer_address( + &mut self, + peer_id: PeerId, + addr: libp2p::Multiaddr, + ) -> Result<()> { + self.add_peer_address_sender + .send((peer_id, addr)) + .context("Failed to queue peer address into event loop")?; + + Ok(()) + } + /// Creates a SwapEventLoopHandle for a specific swap /// This registers the swap's transfer proof receiver with the event loop pub async fn swap_handle( @@ -494,14 +649,15 @@ impl EventLoopHandle { // // The sender is stored in the `EventLoop`. The receiver is stored in the `SwapEventLoopHandle`. let (transfer_proof_sender, transfer_proof_receiver) = bmrng::unbounded_channel(); + let span = tracing::Span::current(); // Register this sender in the `EventLoop` // It is put into the queue and then later moved into `registered_transfer_proof_senders` // // We use `send(...) instead of send_receive(...)` because the event loop needs to be running for this to respond self.queued_transfer_proof_sender - .send((swap_id, peer_id, transfer_proof_sender)) - .context("Failed to register transfer proof sender with event loop")?; + .send((swap_id, peer_id, transfer_proof_sender, span)) + .context("Failed to queue transfer proof sender into event loop")?; Ok(SwapEventLoopHandle { handle: self.clone(), @@ -515,13 +671,14 @@ impl EventLoopHandle { /// /// This will retry until the maximum elapsed time is reached. It is therefore fallible. pub async fn setup_swap(&mut self, peer_id: PeerId, swap: NewSwap) -> Result { + let span = tracing::Span::current(); tracing::debug!(swap = ?swap, %peer_id, "Sending swap setup request"); let backoff = retry::give_up_eventually(RETRY_MAX_INTERVAL, EXECUTION_SETUP_MAX_ELAPSED_TIME); backoff::future::retry_notify(backoff, || async { - match self.execution_setup_sender.send_receive((peer_id, swap.clone())).await { + match self.execution_setup_sender.send_receive((peer_id, swap.clone(), span.clone())).await { Ok(Ok(state2)) => { Ok(state2) } @@ -562,8 +719,9 @@ impl EventLoopHandle { REQUEST_RESPONSE_PROTOCOL_RETRY_MAX_ELASPED_TIME, ); + let span = tracing::Span::current(); backoff::future::retry_notify(backoff, || async { - match self.quote_sender.send_receive(peer_id).await { + match self.quote_sender.send_receive((peer_id, span.clone())).await { Ok(Ok(quote)) => Ok(quote), Ok(Err(err)) => { Err(backoff::Error::transient(anyhow!(err).context("A network error occurred while requesting a quote"))) @@ -591,6 +749,7 @@ impl EventLoopHandle { peer_id: PeerId, swap_id: Uuid, ) -> Result { + let span = tracing::Span::current(); tracing::debug!(%peer_id, %swap_id, "Requesting cooperative XMR redeem"); // We want to give up eventually here @@ -600,7 +759,7 @@ impl EventLoopHandle { ); backoff::future::retry_notify(backoff, || async { - match self.cooperative_xmr_redeem_sender.send_receive((peer_id, swap_id)).await { + match self.cooperative_xmr_redeem_sender.send_receive((peer_id, swap_id, span.clone())).await { Ok(Ok(response)) => Ok(response), Ok(Err(err)) => { Err(backoff::Error::transient(anyhow!(err).context("A network error occurred while requesting cooperative XMR redeem"))) @@ -629,13 +788,14 @@ impl EventLoopHandle { swap_id: Uuid, tx_redeem_encsig: EncryptedSignature, ) -> () { + let span = tracing::Span::current(); tracing::debug!(%peer_id, %swap_id, "Sending encrypted signature"); // We will retry indefinitely until we succeed let backoff = retry::never_give_up(RETRY_MAX_INTERVAL); backoff::future::retry_notify(backoff, || async { - match self.encrypted_signature_sender.send_receive((peer_id, swap_id, tx_redeem_encsig.clone())).await { + match self.encrypted_signature_sender.send_receive((peer_id, swap_id, tx_redeem_encsig.clone(), span.clone())).await { Ok(Ok(_)) => Ok(()), Ok(Err(err)) => { Err(backoff::Error::transient(anyhow!(err).context("A network error occurred while sending the encrypted signature"))) diff --git a/swap/src/cli/list_sellers.rs b/swap/src/cli/list_sellers.rs index 489b915a..61e243b2 100644 --- a/swap/src/cli/list_sellers.rs +++ b/swap/src/cli/list_sellers.rs @@ -1,165 +1,11 @@ -use crate::cli::api::tauri_bindings::{ - ListSellersProgress, TauriBackgroundProgress, TauriBackgroundProgressHandle, TauriEmitter, - TauriHandle, -}; -use crate::libp2p_ext::MultiAddrExt; use crate::network::quote::BidQuote; -use crate::network::rendezvous::XmrBtcNamespace; -use crate::network::{quote, swarm}; -use crate::protocol::Database; -use anyhow::Result; -use arti_client::TorClient; -use futures::StreamExt; -use libp2p::identify; -use libp2p::multiaddr::Protocol; -use libp2p::request_response; -use libp2p::swarm::dial_opts::DialOpts; -use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; -use libp2p::{identity, ping, rendezvous, Multiaddr, PeerId, Swarm}; +use libp2p::{Multiaddr, PeerId}; use semver::Version; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::Duration; -use tor_rtcompat::tokio::TokioRustlsRuntime; use typeshare::typeshare; -/// Builds an identify config for the CLI with appropriate protocol and agent versions. -/// This allows peers to identify our client version and protocol compatibility. -fn build_identify_config(identity: identity::Keypair) -> identify::Config { - let protocol_version = "/comit/xmr/btc/1.0.0".to_string(); - let agent_version = format!("cli/{}", env!("CARGO_PKG_VERSION")); - identify::Config::new(protocol_version, identity.public()).with_agent_version(agent_version) -} - -/// Returns a function that when called will return sorted list of sellers, with [Online](Status::Online) listed first. -/// -/// First uses the rendezvous node to discover peers in the given namespace, -/// then fetches a quote from each peer that was discovered. If fetching a quote -/// from a discovered peer fails the seller's status will be -/// [Unreachable](Status::Unreachable). -/// -/// If a database is provided, it will be used to get the list of peers that -/// have already been discovered previously and attempt to fetch a quote from them. -pub async fn list_sellers_init( - rendezvous_points: Vec<(PeerId, Multiaddr)>, - namespace: XmrBtcNamespace, - maybe_tor_client: Option>>, - identity: identity::Keypair, - db: Option>, - tauri_handle: Option, - sender: Option<::tokio::sync::watch::Sender>>, - sellers: Vec, -) -> Result< - impl Fn() -> std::pin::Pin< - Box> + Send + 'static>, - > + Send - + Sync - + 'static, -> { - // Capture variables needed to build an EventLoop on each invocation - let rendezvous_points_clone = rendezvous_points.clone(); - let namespace_clone = namespace; - let maybe_tor_client_clone = maybe_tor_client.clone(); - let identity_clone = identity.clone(); - let db_clone = db.clone(); - let tauri_handle_clone = tauri_handle.clone(); - let sellers_clone = sellers.clone(); - - Ok(move || { - // Clone captured values inside the closure to avoid moving them and thus implement `Fn` - let rendezvous_points = rendezvous_points_clone.clone(); - let namespace = namespace_clone; - let maybe_tor_client = maybe_tor_client_clone.clone(); - let identity = identity_clone.clone(); - let db = db_clone.clone(); - let tauri_handle = tauri_handle_clone.clone(); - let sender = sender.clone(); - let sellers = sellers_clone.clone(); - - Box::pin(async move { - // Build a fresh swarm and event loop for every call so the closure can be invoked multiple times. - let behaviour = Behaviour { - rendezvous: rendezvous::client::Behaviour::new(identity.clone()), - quote: quote::cli(), - ping: ping::Behaviour::new( - ping::Config::new().with_timeout(Duration::from_secs(60)), - ), - identify: identify::Behaviour::new(build_identify_config(identity.clone())), - }; - - // TODO: Dont use unwrap - let swarm = swarm::cli(identity, maybe_tor_client, behaviour) - .await - .unwrap(); - - // Get peers from the database, add them to the dial queue - let mut external_dial_queue = match db { - Some(db) => match db.get_all_peer_addresses().await { - Ok(peers) => VecDeque::from(peers), - Err(err) => { - tracing::trace!(%err, "Failed to get peers from database for list_sellers"); - VecDeque::new() - } - }, - None => VecDeque::new(), - }; - - // Get peers the user has manually passed in, add them to the dial queue - for seller_addr in sellers { - if let Some((peer_id, multiaddr)) = seller_addr.split_peer_id() { - external_dial_queue.push_back((peer_id, vec![multiaddr])); - } - } - - let event_loop = EventLoop::new( - swarm, - rendezvous_points, - namespace, - external_dial_queue, - tauri_handle, - ); - - event_loop.run(sender).await - }) - as std::pin::Pin< - Box> + Send + 'static>, - > - }) -} - -/// Returns sorted list of sellers, with [Online](Status::Online) listed first. -/// -/// First uses the rendezvous node to discover peers in the given namespace, -/// then fetches a quote from each peer that was discovered. If fetching a quote -/// from a discovered peer fails the seller's status will be -/// [Unreachable](Status::Unreachable). -/// -/// If a database is provided, it will be used to get the list of peers that -/// have already been discovered previously and attempt to fetch a quote from them. -pub async fn list_sellers( - rendezvous_points: Vec<(PeerId, Multiaddr)>, - namespace: XmrBtcNamespace, - maybe_tor_client: Option>>, - identity: identity::Keypair, - db: Option>, - tauri_handle: Option, -) -> Result> { - let fetch_fn = list_sellers_init( - rendezvous_points, - namespace, - maybe_tor_client, - identity, - db, - tauri_handle, - None, - Vec::new(), - ) - .await?; - Ok(fetch_fn().await) -} - +// TODO: Move these types into swap-p2p? #[serde_as] #[typeshare] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] @@ -177,1446 +23,7 @@ pub struct QuoteWithAddress { pub quote: BidQuote, /// The version of the seller's agent - #[serde_as(as = "DisplayFromStr")] + #[serde_as(as = "Option")] #[typeshare(serialized_as = "string")] - pub version: Version, -} - -#[typeshare] -#[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] -pub struct UnreachableSeller { - /// The peer id of the seller - #[typeshare(serialized_as = "string")] - pub peer_id: PeerId, -} - -#[typeshare] -#[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] -#[serde(tag = "type", content = "content")] -pub enum SellerStatus { - Online(QuoteWithAddress), - Unreachable(UnreachableSeller), -} - -#[allow(unused)] -#[derive(Debug)] -enum OutEvent { - Rendezvous(rendezvous::client::Event), - Quote(quote::OutEvent), - Ping(ping::Event), - Identify(Box), -} - -#[derive(NetworkBehaviour)] -#[behaviour(event_process = false)] -#[behaviour(out_event = "OutEvent")] -struct Behaviour { - rendezvous: rendezvous::client::Behaviour, - quote: quote::Behaviour, - ping: ping::Behaviour, - identify: identify::Behaviour, -} - -#[derive(Debug, Clone)] -enum PeerState { - /// Initial state with just the peer ID - Initial { peer_id: PeerId }, - /// We have received a reachable address - HasAddress { - peer_id: PeerId, - reachable_addresses: Vec, - }, - /// We have received the version - HasVersion { - peer_id: PeerId, - version: Version, - reachable_addresses: Vec, - }, - /// We have received the quote - HasQuote { - peer_id: PeerId, - quote: BidQuote, - reachable_addresses: Vec, - }, - /// We have received both address and version - HasAddressAndVersion { - peer_id: PeerId, - version: Version, - reachable_addresses: Vec, - }, - /// We have received both address and quote - HasAddressAndQuote { - peer_id: PeerId, - quote: BidQuote, - reachable_addresses: Vec, - }, - /// We have received both version and quote - HasVersionAndQuote { - peer_id: PeerId, - version: Version, - quote: BidQuote, - reachable_addresses: Vec, - }, - /// We have received all three: address, version, and quote - Complete { - peer_id: PeerId, - version: Version, - quote: BidQuote, - reachable_addresses: Vec, - }, - /// The peer failed with an error - Failed { - peer_id: PeerId, - error_message: String, - reachable_addresses: Vec, - }, -} - -/// Extracts the semver version from a user agent string. -/// Example input: "asb/2.0.0 (xmr-btc-swap-mainnet)" -/// Returns None if the version cannot be parsed. -fn extract_semver_from_agent_str(agent_str: &str) -> Option { - // Split on '/' and take the second part - let version_str = agent_str.split('/').nth(1)?; - // Split on whitespace and take the first part - let version_str = version_str.split_whitespace().next()?; - // Parse the version string - Version::parse(version_str).ok() -} - -impl PeerState { - fn new(peer_id: PeerId) -> Self { - Self::Initial { peer_id } - } - - fn add_reachable_address(self, address: Multiaddr) -> Self { - let reachable_addresses = self.get_reachable_addresses(); - let mut new_reachable_addresses = reachable_addresses.clone(); - - if !new_reachable_addresses.contains(&address) { - new_reachable_addresses.push(address); - } - - match self { - Self::Initial { peer_id } => Self::HasAddress { - peer_id, - reachable_addresses: new_reachable_addresses, - }, - Self::HasVersion { - peer_id, version, .. - } => Self::HasAddressAndVersion { - peer_id, - version, - reachable_addresses: new_reachable_addresses, - }, - Self::HasQuote { peer_id, quote, .. } => Self::HasAddressAndQuote { - peer_id, - quote, - reachable_addresses: new_reachable_addresses, - }, - Self::HasVersionAndQuote { - peer_id, - version, - quote, - .. - } => Self::Complete { - peer_id, - version, - quote, - reachable_addresses: new_reachable_addresses, - }, - Self::HasAddress { peer_id, .. } => Self::HasAddress { - peer_id, - reachable_addresses: new_reachable_addresses, - }, - Self::HasAddressAndVersion { - peer_id, version, .. - } => Self::HasAddressAndVersion { - peer_id, - version, - reachable_addresses: new_reachable_addresses, - }, - Self::HasAddressAndQuote { peer_id, quote, .. } => Self::HasAddressAndQuote { - peer_id, - quote, - reachable_addresses: new_reachable_addresses, - }, - Self::Complete { - peer_id, - version, - quote, - .. - } => Self::Complete { - peer_id, - version, - quote, - reachable_addresses: new_reachable_addresses, - }, - Self::Failed { - peer_id, - error_message, - .. - } => Self::Failed { - peer_id, - error_message, - reachable_addresses: new_reachable_addresses, - }, - } - } - - fn apply_quote(self, quote_result: Result) -> Self { - match (self, quote_result) { - (state, Ok(quote)) => { - let reachable_addresses = state.get_reachable_addresses(); - match state { - Self::Initial { peer_id } => Self::HasQuote { - peer_id, - quote, - reachable_addresses, - }, - Self::HasAddress { peer_id, .. } => Self::HasAddressAndQuote { - peer_id, - quote, - reachable_addresses, - }, - Self::HasVersion { - peer_id, version, .. - } => Self::HasVersionAndQuote { - peer_id, - version, - quote, - reachable_addresses, - }, - Self::HasAddressAndVersion { - peer_id, version, .. - } => Self::Complete { - peer_id, - version, - quote, - reachable_addresses, - }, - Self::HasQuote { .. } - | Self::HasAddressAndQuote { .. } - | Self::HasVersionAndQuote { .. } - | Self::Complete { .. } => state, - Self::Failed { .. } => state, - } - } - (state, Err(error)) => { - let reachable_addresses = state.get_reachable_addresses(); - Self::Failed { - peer_id: state.get_peer_id(), - error_message: error.to_string(), - reachable_addresses, - } - } - } - } - - fn apply_version(self, version: String) -> Self { - let reachable_addresses = self.get_reachable_addresses(); - - match extract_semver_from_agent_str(version.as_str()) { - Some(version) => match self { - Self::Initial { peer_id } => Self::HasVersion { - peer_id, - version, - reachable_addresses, - }, - Self::HasAddress { peer_id, .. } => Self::HasAddressAndVersion { - peer_id, - version, - reachable_addresses, - }, - Self::HasQuote { peer_id, quote, .. } => Self::HasVersionAndQuote { - peer_id, - version, - quote, - reachable_addresses, - }, - Self::HasAddressAndQuote { peer_id, quote, .. } => Self::Complete { - peer_id, - version, - quote, - reachable_addresses, - }, - Self::HasVersion { .. } - | Self::HasAddressAndVersion { .. } - | Self::HasVersionAndQuote { .. } - | Self::Complete { .. } => self, - Self::Failed { .. } => self, - }, - None => self.mark_failed(format!( - "Failed to parse version from user agent: {}", - version - )), - } - } - - fn mark_failed(self, error_message: String) -> Self { - let reachable_addresses = self.get_reachable_addresses(); - Self::Failed { - peer_id: self.get_peer_id(), - error_message, - reachable_addresses, - } - } - - fn get_peer_id(&self) -> PeerId { - match self { - Self::Initial { peer_id } - | Self::HasAddress { peer_id, .. } - | Self::HasVersion { peer_id, .. } - | Self::HasQuote { peer_id, .. } - | Self::HasAddressAndVersion { peer_id, .. } - | Self::HasAddressAndQuote { peer_id, .. } - | Self::HasVersionAndQuote { peer_id, .. } - | Self::Complete { peer_id, .. } - | Self::Failed { peer_id, .. } => *peer_id, - } - } - - fn get_reachable_addresses(&self) -> Vec { - match self { - Self::Initial { .. } => Vec::new(), - Self::HasAddress { - reachable_addresses, - .. - } - | Self::HasVersion { - reachable_addresses, - .. - } - | Self::HasQuote { - reachable_addresses, - .. - } - | Self::HasAddressAndVersion { - reachable_addresses, - .. - } - | Self::HasAddressAndQuote { - reachable_addresses, - .. - } - | Self::HasVersionAndQuote { - reachable_addresses, - .. - } - | Self::Complete { - reachable_addresses, - .. - } - | Self::Failed { - reachable_addresses, - .. - } => reachable_addresses.clone(), - } - } - - fn is_pending(&self) -> bool { - !matches!(self, Self::Complete { .. } | Self::Failed { .. }) - } -} - -#[derive(Debug)] -enum RendezvousPointStatus { - Dialed, // We have initiated dialing but do not know if it succeeded or not - Failed, // We have initiated dialing but we failed to connect OR failed to discover - Success, // We have connected to the rendezvous point and discovered peers -} - -impl RendezvousPointStatus { - // A rendezvous point has been "completed" if it is either successfully dialed or failed - fn is_complete(&self) -> bool { - matches!( - self, - RendezvousPointStatus::Success | RendezvousPointStatus::Failed - ) - } -} - -struct EventLoop { - swarm: Swarm, - - /// The namespace to discover peers in - namespace: XmrBtcNamespace, - - /// List to store which rendezvous points we have either dialed / failed to dial - rendezvous_points_status: HashMap, - - /// The rendezvous points to dial - rendezvous_points: Vec<(PeerId, Multiaddr)>, - - /// The addresses of peers that have been discovered and are reachable - reachable_asb_address: HashMap, - - /// The state of each peer - /// The state contains a mini state machine - peer_states: HashMap, - - /// The queue of peers to dial - /// When we discover a peer we add it is then dialed by the event loop - to_request_quote: VecDeque<(PeerId, Vec)>, - - /// Background progress handle for UI updates - progress_handle: Option>, -} - -impl EventLoop { - fn new( - swarm: Swarm, - rendezvous_points: Vec<(PeerId, Multiaddr)>, - namespace: XmrBtcNamespace, - dial_queue: VecDeque<(PeerId, Vec)>, - tauri_handle: Option, - ) -> Self { - let progress_handle = - tauri_handle.new_background_process(TauriBackgroundProgress::ListSellers); - - Self { - swarm, - rendezvous_points_status: Default::default(), - rendezvous_points, - namespace, - reachable_asb_address: Default::default(), - peer_states: Default::default(), - to_request_quote: dial_queue, - progress_handle: Some(progress_handle), - } - } - - fn is_rendezvous_point(&self, peer_id: &PeerId) -> bool { - self.rendezvous_points - .iter() - .any(|(rendezvous_peer_id, _)| rendezvous_peer_id == peer_id) - } - - fn get_rendezvous_point(&self, peer_id: &PeerId) -> Option { - self.rendezvous_points - .iter() - .find(|(rendezvous_peer_id, _)| rendezvous_peer_id == peer_id) - .map(|(_, multiaddr)| multiaddr.clone()) - } - - fn get_progress(&self) -> ListSellersProgress { - let rendezvous_connected = self - .rendezvous_points_status - .values() - .filter(|status| matches!(status, RendezvousPointStatus::Success)) - .count() - .try_into() - .unwrap_or(u32::MAX); - - let rendezvous_failed = self - .rendezvous_points_status - .values() - .filter(|status| matches!(status, RendezvousPointStatus::Failed)) - .count() - .try_into() - .unwrap_or(u32::MAX); - - let quotes_received = self - .peer_states - .values() - .filter(|state| matches!(state, PeerState::Complete { .. })) - .count() - .try_into() - .unwrap_or(u32::MAX); - - let quotes_failed = self - .peer_states - .values() - .filter(|state| matches!(state, PeerState::Failed { .. })) - .count() - .try_into() - .unwrap_or(u32::MAX); - - ListSellersProgress { - rendezvous_points_connected: rendezvous_connected, - rendezvous_points_total: self.rendezvous_points.len().try_into().unwrap_or(u32::MAX), - rendezvous_points_failed: rendezvous_failed, - peers_discovered: self.peer_states.len().try_into().unwrap_or(u32::MAX), - quotes_received, - quotes_failed, - } - } - - fn emit_progress(&self) { - if let Some(ref progress_handle) = self.progress_handle { - progress_handle.update(self.get_progress()); - } - } - - fn ensure_multiaddr_has_p2p_suffix(&self, peer_id: PeerId, multiaddr: Multiaddr) -> Multiaddr { - let p2p_suffix = Protocol::P2p(peer_id); - - // If the multiaddr does not end with the p2p suffix, we add it - if !multiaddr.ends_with(&Multiaddr::empty().with(p2p_suffix.clone())) { - multiaddr.clone().with(p2p_suffix) - } else { - // If the multiaddr already ends with the p2p suffix, we return it as is - multiaddr.clone() - } - } - - fn build_current_sellers(&self) -> Vec { - let mut sellers: Vec = self - .peer_states - .values() - .filter_map(|peer_state| match peer_state { - PeerState::Complete { - peer_id, - version, - quote, - reachable_addresses, - } => Some(SellerStatus::Online(QuoteWithAddress { - peer_id: *peer_id, - multiaddr: reachable_addresses[0].clone(), - quote: *quote, - version: version.clone(), - })), - PeerState::Failed { peer_id, .. } => { - Some(SellerStatus::Unreachable(UnreachableSeller { - peer_id: *peer_id, - })) - } - _ => None, // Skip pending states for partial updates - }) - .collect(); - - sellers.sort(); - sellers - } - - fn emit_partial_update( - &self, - sender: &Option<::tokio::sync::watch::Sender>>, - ) { - if let Some(sender) = sender { - let current_sellers = self.build_current_sellers(); - let _ = sender.send(current_sellers); - } - } - - async fn run( - mut self, - sender: Option<::tokio::sync::watch::Sender>>, - ) -> Vec { - // Dial all rendezvous points initially - for (peer_id, multiaddr) in &self.rendezvous_points { - let dial_opts = DialOpts::peer_id(*peer_id) - .addresses(vec![multiaddr.clone()]) - .extend_addresses_through_behaviour() - .build(); - - self.rendezvous_points_status - .insert(*peer_id, RendezvousPointStatus::Dialed); - - if let Err(e) = self.swarm.dial(dial_opts) { - tracing::trace!(%peer_id, %multiaddr, error = %e, "Failed to dial rendezvous point"); - - self.rendezvous_points_status - .insert(*peer_id, RendezvousPointStatus::Failed); - } - } - - loop { - self.emit_progress(); - - tokio::select! { - Some((peer_id, multiaddresses)) = async { self.to_request_quote.pop_front() } => { - // We do not allow an overlap of rendezvous points and quote requests - // because if we do we cannot distinguish between a quote request and a rendezvous point later on - // because we are missing state information to - if self.is_rendezvous_point(&peer_id) { - continue; - } - - // If we already have an entry for this peer, we skip it - // We probably discovered a peer at a rendezvous point which we already have an entry for locally - if self.peer_states.contains_key(&peer_id) { - continue; - } - - // Initialize peer state - self.peer_states.insert(peer_id, PeerState::new(peer_id)); - - // Add all known addresses of this peer to the swarm - for multiaddr in multiaddresses { - self.swarm.add_peer_address(peer_id, multiaddr); - } - - // Request a quote from the peer - let _request_id = self.swarm.behaviour_mut().quote.send_request(&peer_id, ()); - } - swarm_event = self.swarm.select_next_some() => { - match swarm_event { - SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } => { - if self.is_rendezvous_point(&peer_id) { - tracing::trace!( - "Connected to rendezvous point, discovering nodes in '{}' namespace ...", - self.namespace - ); - - let namespace = rendezvous::Namespace::new(self.namespace.to_string()).expect("our namespace to be a correct string"); - - self.swarm.behaviour_mut().rendezvous.discover( - Some(namespace), - None, - None, - peer_id, - ); - } else { - let address = endpoint.get_remote_address(); - tracing::trace!(%peer_id, %address, "Connection established to peer for list-sellers"); - self.reachable_asb_address.insert(peer_id, address.clone()); - - // Update the peer state with the reachable address - if let Some(state) = self.peer_states.remove(&peer_id) { - let new_state = state.add_reachable_address(address.clone()); - self.peer_states.insert(peer_id, new_state); - } - } - } - SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => { - if let Some(peer_id) = peer_id { - if let Some(rendezvous_point) = self.get_rendezvous_point(&peer_id) { - tracing::trace!( - %peer_id, - %rendezvous_point, - "Failed to connect to rendezvous point: {}", - error - ); - - // Update the status of the rendezvous point to failed - self.rendezvous_points_status.insert(peer_id, RendezvousPointStatus::Failed); - } else { - tracing::trace!( - %peer_id, - "Failed to connect to peer: {}", - error - ); - - if let Some(state) = self.peer_states.remove(&peer_id) { - let failed_state = state.mark_failed(format!("Failed to connect to peer: {}", error)); - self.peer_states.insert(peer_id, failed_state); - } - } - } else { - tracing::trace!("Failed to connect (no peer id): {}", error); - } - } - SwarmEvent::Behaviour(OutEvent::Rendezvous( - libp2p::rendezvous::client::Event::Discovered { registrations, rendezvous_node, .. }, - )) => { - tracing::trace!(%rendezvous_node, num_peers = %registrations.len(), "Discovered peers at rendezvous point"); - - for registration in registrations { - let peer = registration.record.peer_id(); - let addresses = registration.record.addresses().iter().map(|addr| self.ensure_multiaddr_has_p2p_suffix(peer, addr.clone())).collect::>(); - - self.to_request_quote.push_back((peer, addresses)); - } - - // Update the status of the rendezvous point to success - self.rendezvous_points_status.insert(rendezvous_node, RendezvousPointStatus::Success); - } - SwarmEvent::Behaviour(OutEvent::Rendezvous( - libp2p::rendezvous::client::Event::DiscoverFailed { rendezvous_node, .. }, - )) => { - self.rendezvous_points_status.insert(rendezvous_node, RendezvousPointStatus::Failed); - } - SwarmEvent::Behaviour(OutEvent::Quote(quote_response)) => { - match quote_response { - request_response::Event::Message { peer, message } => { - match message { - request_response::Message::Response { response, .. } => { - if let Some(state) = self.peer_states.remove(&peer) { - let new_state = state.apply_quote(Ok(response)); - self.peer_states.insert(peer, new_state); - } else { - tracing::trace!(%peer, "Received bid quote from unexpected peer, this record will be removed!"); - } - } - request_response::Message::Request { .. } => unreachable!("we only request quotes, not respond") - } - } - request_response::Event::OutboundFailure { peer, error, .. } => { - if self.is_rendezvous_point(&peer) { - tracing::trace!(%peer, "Outbound failure when communicating with rendezvous node: {:#}", error); - - // Update the status of the rendezvous point to failed - self.rendezvous_points_status.insert(peer, RendezvousPointStatus::Failed); - } else if let Some(state) = self.peer_states.remove(&peer) { - let failed_state = state.apply_quote(Err(anyhow::anyhow!("Quote request failed: {}", error))); - self.peer_states.insert(peer, failed_state); - } - } - request_response::Event::InboundFailure { peer, error, .. } => { - if self.is_rendezvous_point(&peer) { - tracing::trace!(%peer, "Inbound failure when communicating with rendezvous node: {:#}", error); - - // Update the status of the rendezvous point to failed - self.rendezvous_points_status.insert(peer, RendezvousPointStatus::Failed); - } else if let Some(state) = self.peer_states.remove(&peer) { - let failed_state = state.mark_failed(format!("Inbound failure: {}", error)); - self.peer_states.insert(peer, failed_state); - } - }, - request_response::Event::ResponseSent { .. } => unreachable!() - } - } - SwarmEvent::Behaviour(OutEvent::Identify(event)) => { - match *event { - identify::Event::Received { peer_id, info } => { - if let Some(state) = self.peer_states.remove(&peer_id) { - let new_state = state.apply_version(info.agent_version); - self.peer_states.insert(peer_id, new_state); - } - } - identify::Event::Error { peer_id, error } => { - tracing::trace!(%peer_id, error = %error, "Error when identifying peer"); - - if let Some(state) = self.peer_states.remove(&peer_id) { - let failed_state = state.mark_failed(format!("Error when identifying peer: {}", error)); - self.peer_states.insert(peer_id, failed_state); - } - } - _ => {} - } - } - _ => {} - } - } - } - - // We are finished if both of these conditions are true - // 1. All rendezvous points have been successfully dialed or failed to dial / discover at namespace - // 2. We don't have any pending quote requests - // 3. We received quotes OR failed to from all peers we have requested quotes from - - // Check if all peer ids from rendezvous_points are present in rendezvous_points_status - // Check if every entry in rendezvous_points_status is "complete" - let all_rendezvous_points_requests_complete = - self.rendezvous_points.iter().all(|(peer_id, _)| { - self.rendezvous_points_status - .get(peer_id) - .map(|status| status.is_complete()) - .unwrap_or(false) - }); - - // Check if to_request_quote is empty - let all_quotes_fetched = self.to_request_quote.is_empty(); - - // If we have pending request to rendezvous points or quote requests, we continue - if !all_rendezvous_points_requests_complete || !all_quotes_fetched { - // Emit partial update with any completed quotes we have so far - self.emit_partial_update(&sender); - continue; - } - - let all_quotes_fetched = self - .peer_states - .values() - .map(|peer_state| match peer_state { - state if state.is_pending() => Err(StillPending {}), - PeerState::Complete { - peer_id, - version, - quote, - reachable_addresses, - } => Ok(SellerStatus::Online(QuoteWithAddress { - peer_id: *peer_id, - multiaddr: reachable_addresses[0].clone(), - quote: *quote, - version: version.clone(), - })), - PeerState::Failed { - peer_id, - error_message, - .. - } => { - tracing::trace!(%peer_id, error = %error_message, "Peer failed"); - - Ok(SellerStatus::Unreachable(UnreachableSeller { - peer_id: *peer_id, - })) - } - _ => unreachable!("All cases should be covered above"), - }) - .collect::, _>>(); - - match all_quotes_fetched { - Ok(mut sellers) => { - sellers.sort(); - if let Some(ref progress_handle) = self.progress_handle { - progress_handle.finish(); - } - break sellers; - } - Err(StillPending {}) => continue, - } - } - } -} - -#[derive(Debug)] -struct StillPending {} - -impl From for OutEvent { - fn from(event: rendezvous::client::Event) -> Self { - OutEvent::Rendezvous(event) - } -} - -impl From for OutEvent { - fn from(event: quote::OutEvent) -> Self { - OutEvent::Quote(event) - } -} - -impl From for OutEvent { - fn from(event: identify::Event) -> Self { - OutEvent::Identify(Box::new(event)) - } -} - -impl From for OutEvent { - fn from(event: ping::Event) -> Self { - OutEvent::Ping(event) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::anyhow; - - // Helper function to create a test multiaddr - fn test_multiaddr() -> Multiaddr { - "/ip4/127.0.0.1/tcp/8080".parse().unwrap() - } - - // Helper function to create a test PeerId - fn test_peer_id() -> PeerId { - PeerId::random() - } - - // Helper function to create a test BidQuote - fn test_bid_quote() -> BidQuote { - BidQuote { - price: bitcoin::Amount::from_sat(50000), - min_quantity: bitcoin::Amount::from_sat(1000), - max_quantity: bitcoin::Amount::from_sat(100000), - } - } - - // Helper function to create a test Version - fn test_version() -> Version { - Version::parse("1.2.3").unwrap() - } - - mod extract_semver_tests { - use super::*; - - #[test] - fn extract_semver_from_asb_agent_string() { - assert_eq!( - extract_semver_from_agent_str("asb/2.0.0 (xmr-btc-swap-mainnet)"), - Some(Version::parse("2.0.0").unwrap()) - ); - } - - #[test] - fn extract_semver_from_cli_agent_string() { - assert_eq!( - extract_semver_from_agent_str("cli/1.5.2"), - Some(Version::parse("1.5.2").unwrap()) - ); - } - - #[test] - fn extract_semver_with_prerelease() { - assert_eq!( - extract_semver_from_agent_str("asb/2.1.0-beta.2.1 (xmr-btc-swap-testnet)"), - Some(Version::parse("2.1.0-beta.2.1").unwrap()) - ); - } - - #[test] - fn extract_semver_invalid_format() { - assert_eq!(extract_semver_from_agent_str("invalid-format"), None); - } - - #[test] - fn extract_semver_no_slash() { - assert_eq!(extract_semver_from_agent_str("asb-2.0.0"), None); - } - - #[test] - fn extract_semver_invalid_version() { - assert_eq!(extract_semver_from_agent_str("asb/invalid.version"), None); - } - - #[test] - fn extract_semver_empty_version() { - assert_eq!(extract_semver_from_agent_str("asb/"), None); - } - } - - mod peer_state_tests { - use super::*; - - #[test] - fn new_peer_state_starts_as_initial() { - let peer_id = test_peer_id(); - let state = PeerState::new(peer_id); - - assert!(matches!(state, PeerState::Initial { .. })); - assert_eq!(state.get_peer_id(), peer_id); - assert_eq!(state.get_reachable_addresses(), Vec::::new()); - assert!(state.is_pending()); - } - - #[test] - fn initial_add_address_transitions_to_has_address() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let state = PeerState::new(peer_id); - - let new_state = state.add_reachable_address(address.clone()); - - match &new_state { - PeerState::HasAddress { - peer_id: p, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected HasAddress state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn initial_apply_quote_transitions_to_has_quote() { - let peer_id = test_peer_id(); - let quote = test_bid_quote(); - let state = PeerState::new(peer_id); - - let new_state = state.apply_quote(Ok(quote)); - - match &new_state { - PeerState::HasQuote { - peer_id: p, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, Vec::::new()); - } - _ => panic!("Expected HasQuote state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn initial_apply_version_transitions_to_has_version() { - let peer_id = test_peer_id(); - let version_str = "asb/1.2.3".to_string(); - let state = PeerState::new(peer_id); - - let new_state = state.apply_version(version_str); - - match &new_state { - PeerState::HasVersion { - peer_id: p, - version, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*version, Version::parse("1.2.3").unwrap()); - assert_eq!(*reachable_addresses, Vec::::new()); - } - _ => panic!("Expected HasVersion state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn has_address_apply_quote_transitions_to_has_address_and_quote() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let quote = test_bid_quote(); - - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.apply_quote(Ok(quote)); - - match &new_state { - PeerState::HasAddressAndQuote { - peer_id: p, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected HasAddressAndQuote state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn has_address_apply_version_transitions_to_has_address_and_version() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let version_str = "cli/2.1.0".to_string(); - - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.apply_version(version_str); - - match &new_state { - PeerState::HasAddressAndVersion { - peer_id: p, - version, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*version, Version::parse("2.1.0").unwrap()); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected HasAddressAndVersion state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn has_version_apply_quote_transitions_to_has_version_and_quote() { - let peer_id = test_peer_id(); - let version = test_version(); - let quote = test_bid_quote(); - - let state = PeerState::HasVersion { - peer_id, - version: version.clone(), - reachable_addresses: vec![], - }; - - let new_state = state.apply_quote(Ok(quote)); - - match &new_state { - PeerState::HasVersionAndQuote { - peer_id: p, - version: v, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*v, version); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, Vec::::new()); - } - _ => panic!("Expected HasVersionAndQuote state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn has_quote_apply_version_transitions_to_has_version_and_quote() { - let peer_id = test_peer_id(); - let quote = test_bid_quote(); - let version_str = "asb/3.0.0".to_string(); - - let state = PeerState::HasQuote { - peer_id, - quote, - reachable_addresses: vec![], - }; - - let new_state = state.apply_version(version_str); - - match &new_state { - PeerState::HasVersionAndQuote { - peer_id: p, - version, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*version, Version::parse("3.0.0").unwrap()); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, Vec::::new()); - } - _ => panic!("Expected HasVersionAndQuote state"), - } - assert!(new_state.is_pending()); - } - - #[test] - fn has_address_and_version_apply_quote_transitions_to_complete() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let version = test_version(); - let quote = test_bid_quote(); - - let state = PeerState::HasAddressAndVersion { - peer_id, - version: version.clone(), - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.apply_quote(Ok(quote)); - - match &new_state { - PeerState::Complete { - peer_id: p, - version: v, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*v, version); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected Complete state"), - } - assert!(!new_state.is_pending()); - } - - #[test] - fn has_address_and_quote_apply_version_transitions_to_complete() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let quote = test_bid_quote(); - let version_str = "cli/1.0.0".to_string(); - - let state = PeerState::HasAddressAndQuote { - peer_id, - quote, - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.apply_version(version_str); - - match &new_state { - PeerState::Complete { - peer_id: p, - version, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*version, Version::parse("1.0.0").unwrap()); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected Complete state"), - } - assert!(!new_state.is_pending()); - } - - #[test] - fn has_version_and_quote_add_address_transitions_to_complete() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let version = test_version(); - let quote = test_bid_quote(); - - let state = PeerState::HasVersionAndQuote { - peer_id, - version: version.clone(), - quote, - reachable_addresses: vec![], - }; - - let new_state = state.add_reachable_address(address.clone()); - - match &new_state { - PeerState::Complete { - peer_id: p, - version: v, - quote: q, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(*v, version); - assert_eq!(*q, quote); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected Complete state"), - } - assert!(!new_state.is_pending()); - } - - #[test] - fn apply_failed_quote_transitions_to_failed() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let error = anyhow!("Network error"); - - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.apply_quote(Err(error)); - - match &new_state { - PeerState::Failed { - peer_id: p, - error_message, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert_eq!(error_message, "Network error"); - assert_eq!(*reachable_addresses, vec![address]); - } - _ => panic!("Expected Failed state"), - } - assert!(!new_state.is_pending()); - } - - #[test] - fn apply_invalid_version_transitions_to_failed() { - let peer_id = test_peer_id(); - let invalid_version = "invalid-version-string".to_string(); - - let state = PeerState::new(peer_id); - let new_state = state.apply_version(invalid_version.clone()); - - match &new_state { - PeerState::Failed { - peer_id: p, - error_message, - reachable_addresses, - } => { - assert_eq!(*p, peer_id); - assert!(error_message.contains("Failed to parse version")); - assert!(error_message.contains(&invalid_version)); - assert_eq!(*reachable_addresses, Vec::::new()); - } - _ => panic!("Expected Failed state"), - } - assert!(!new_state.is_pending()); - } - - #[test] - fn mark_failed_from_any_state() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let error_message = "Connection timeout".to_string(); - - // Test from Initial - let state = PeerState::new(peer_id); - let failed = state.mark_failed(error_message.clone()); - assert!(matches!(failed, PeerState::Failed { .. })); - assert!(!failed.is_pending()); - - // Test from HasAddress - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address.clone()], - }; - let failed = state.mark_failed(error_message.clone()); - match failed { - PeerState::Failed { - peer_id: p, - error_message: msg, - reachable_addresses, - } => { - assert_eq!(p, peer_id); - assert_eq!(msg, error_message); - assert_eq!(reachable_addresses, vec![address.clone()]); - } - _ => panic!("Expected Failed state"), - } - - // Test from Complete - let state = PeerState::Complete { - peer_id, - version: test_version(), - quote: test_bid_quote(), - reachable_addresses: vec![address.clone()], - }; - let failed = state.mark_failed(error_message.clone()); - assert!(matches!(failed, PeerState::Failed { .. })); - } - - #[test] - fn add_duplicate_address_does_not_duplicate() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address.clone()], - }; - - let new_state = state.add_reachable_address(address.clone()); - - match new_state { - PeerState::HasAddress { - reachable_addresses, - .. - } => { - assert_eq!(reachable_addresses.len(), 1); - assert_eq!(reachable_addresses[0], address); - } - _ => panic!("Expected HasAddress state"), - } - } - - #[test] - fn add_multiple_addresses() { - let peer_id = test_peer_id(); - let address1 = test_multiaddr(); - let address2: Multiaddr = "/ip4/192.168.1.1/tcp/9090".parse().unwrap(); - - let state = PeerState::HasAddress { - peer_id, - reachable_addresses: vec![address1.clone()], - }; - - let new_state = state.add_reachable_address(address2.clone()); - - match &new_state { - PeerState::HasAddress { - reachable_addresses, - .. - } => { - assert_eq!(reachable_addresses.len(), 2); - assert!(reachable_addresses.contains(&address1)); - assert!(reachable_addresses.contains(&address2)); - } - _ => panic!("Expected HasAddress state"), - } - } - - #[test] - fn operations_on_complete_state_are_idempotent() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let version = test_version(); - let quote = test_bid_quote(); - - let state = PeerState::Complete { - peer_id, - version: version.clone(), - quote, - reachable_addresses: vec![address.clone()], - }; - - // Apply quote again - should remain unchanged - let new_quote = BidQuote { - price: bitcoin::Amount::from_sat(99999), - min_quantity: bitcoin::Amount::from_sat(1), - max_quantity: bitcoin::Amount::from_sat(1000), - }; - let new_state = state.apply_quote(Ok(new_quote)); - - match &new_state { - PeerState::Complete { quote: q, .. } => { - assert_eq!(*q, quote); // Original quote, not new_quote - } - _ => panic!("Expected Complete state to remain unchanged"), - } - - // Apply version again - should remain unchanged - let new_state = new_state.apply_version("asb/9.9.9".to_string()); - match &new_state { - PeerState::Complete { version: v, .. } => { - assert_eq!(*v, version); // Original version - } - _ => panic!("Expected Complete state to remain unchanged"), - } - } - - #[test] - fn operations_on_failed_state_are_idempotent() { - let peer_id = test_peer_id(); - let address = test_multiaddr(); - let error_message = "Original error".to_string(); - - let state = PeerState::Failed { - peer_id, - error_message: error_message.clone(), - reachable_addresses: vec![address.clone()], - }; - - // Apply quote - should remain failed - let new_state = state.apply_quote(Ok(test_bid_quote())); - match &new_state { - PeerState::Failed { - error_message: msg, .. - } => { - assert_eq!(msg, &error_message); - } - _ => panic!("Expected Failed state to remain unchanged"), - } - - // Apply version - should remain failed - let new_state = new_state.apply_version("asb/1.0.0".to_string()); - match &new_state { - PeerState::Failed { - error_message: msg, .. - } => { - assert_eq!(msg, &error_message); - } - _ => panic!("Expected Failed state to remain unchanged"), - } - } - } - - mod rendezvous_point_status_tests { - use super::*; - - #[test] - fn rendezvous_point_status_completion() { - assert!(!RendezvousPointStatus::Dialed.is_complete()); - assert!(RendezvousPointStatus::Failed.is_complete()); - assert!(RendezvousPointStatus::Success.is_complete()); - } - } - - #[test] - fn sellers_sort_with_unreachable_coming_last() { - let mut list = vec![ - SellerStatus::Unreachable(UnreachableSeller { - peer_id: PeerId::random(), - }), - SellerStatus::Unreachable(UnreachableSeller { - peer_id: PeerId::random(), - }), - SellerStatus::Online(QuoteWithAddress { - multiaddr: "/ip4/127.0.0.1/tcp/5678".parse().unwrap(), - peer_id: PeerId::random(), - quote: BidQuote { - price: Default::default(), - min_quantity: Default::default(), - max_quantity: Default::default(), - }, - version: Version::parse("1.0.0").unwrap(), // Fixed: Use valid semver - }), - ]; - - list.sort(); - - // Check that online sellers come first - assert!(matches!(list[0], SellerStatus::Online(_))); - assert!(matches!(list[1], SellerStatus::Unreachable(_))); - assert!(matches!(list[2], SellerStatus::Unreachable(_))); - } + pub version: Option, } diff --git a/swap/src/common/tracing_util.rs b/swap/src/common/tracing_util.rs index 7db59d41..b29a6972 100644 --- a/swap/src/common/tracing_util.rs +++ b/swap/src/common/tracing_util.rs @@ -217,6 +217,7 @@ mod crates { "libp2p_yamux", "libp2p_tor", "libp2p_tcp", + // TODO: Maybe add "swap_p2p" here too? ]; pub const OUR_CRATES: &[&str] = &[ diff --git a/swap/src/database/sqlite.rs b/swap/src/database/sqlite.rs index ec2aa479..42186334 100644 --- a/swap/src/database/sqlite.rs +++ b/swap/src/database/sqlite.rs @@ -472,6 +472,24 @@ impl Database for SqliteDatabase { Ok(Some(proof)) } + + async fn has_swap(&self, swap_id: Uuid) -> Result { + let swap_id = swap_id.to_string(); + + let row = sqlx::query!( + r#" + SELECT 1 as found + FROM swap_states + WHERE swap_id = ? + LIMIT 1 + "#, + swap_id + ) + .fetch_optional(&self.pool) + .await?; + + Ok(row.is_some()) + } } #[cfg(test)] diff --git a/swap/src/lib.rs b/swap/src/lib.rs index 4d15f662..faf567bc 100644 --- a/swap/src/lib.rs +++ b/swap/src/lib.rs @@ -21,7 +21,6 @@ pub mod bitcoin; pub mod cli; pub mod common; pub mod database; -pub mod libp2p_ext; pub mod monero; pub mod network; pub mod protocol; diff --git a/swap/src/libp2p_ext.rs b/swap/src/libp2p_ext.rs deleted file mode 100644 index ae9496f0..00000000 --- a/swap/src/libp2p_ext.rs +++ /dev/null @@ -1,24 +0,0 @@ -use libp2p::multiaddr::Protocol; -use libp2p::{Multiaddr, PeerId}; - -pub trait MultiAddrExt { - fn extract_peer_id(&self) -> Option; - fn split_peer_id(&self) -> Option<(PeerId, Multiaddr)>; -} - -impl MultiAddrExt for Multiaddr { - fn extract_peer_id(&self) -> Option { - match self.iter().last()? { - Protocol::P2p(peer_id) => Some(peer_id), - _ => None, - } - } - - // Takes a peer id like /ip4/192.168.178.64/tcp/9939/p2p/12D3KooWQsqsCyJ9ae1YEAJZAfoVdVFZdDdUq3yvZ92btq7hSv9f - // and returns the peer id and the original address *with* the peer id - fn split_peer_id(&self) -> Option<(PeerId, Multiaddr)> { - let peer_id = self.extract_peer_id()?; - let address = self.clone(); - Some((peer_id, address)) - } -} diff --git a/swap/src/network.rs b/swap/src/network.rs index 1e1f585c..ffc2f3da 100644 --- a/swap/src/network.rs +++ b/swap/src/network.rs @@ -1,6 +1,8 @@ pub use swap_p2p::protocols::cooperative_xmr_redeem_after_punish; pub use swap_p2p::protocols::encrypted_signature; pub use swap_p2p::protocols::quote; +pub use swap_p2p::protocols::quotes; +pub use swap_p2p::protocols::quotes_cached; pub use swap_p2p::protocols::redial; pub use swap_p2p::protocols::rendezvous; pub use swap_p2p::protocols::swap_setup; @@ -9,4 +11,5 @@ pub use swap_p2p::protocols::transfer_proof; pub mod swarm; pub mod transport; +#[cfg(test)] pub use swap_p2p::test; diff --git a/swap/src/network/swarm.rs b/swap/src/network/swarm.rs index 439b1559..71fd79b9 100644 --- a/swap/src/network/swarm.rs +++ b/swap/src/network/swarm.rs @@ -1,21 +1,22 @@ -use crate::asb::{register, LatestRate}; -use crate::libp2p_ext::MultiAddrExt; +use crate::asb::LatestRate; use crate::network::rendezvous::XmrBtcNamespace; use crate::seed::Seed; use crate::{asb, cli}; use anyhow::Result; use arti_client::TorClient; use libp2p::swarm::NetworkBehaviour; -use libp2p::SwarmBuilder; use libp2p::{identity, Multiaddr, Swarm}; +use libp2p::{PeerId, SwarmBuilder}; use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; use swap_core::bitcoin; use swap_env::env; +use swap_p2p::libp2p_ext::MultiAddrExt; use tor_rtcompat::tokio::TokioRustlsRuntime; -const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60 * 60 * 2); // 2 hours +// We keep connections open for 15 minutes +const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60 * 15); #[allow(clippy::too_many_arguments)] pub fn asb( @@ -36,14 +37,11 @@ where { let identity = seed.derive_libp2p_identity(); - let rendezvous_nodes = rendezvous_addrs + let rendezvous_nodes: Vec = rendezvous_addrs .iter() .map(|addr| { - let peer_id = addr - .extract_peer_id() - .expect("Rendezvous node address must contain peer ID"); - - register::RendezvousNode::new(addr, peer_id, namespace, None) + addr.extract_peer_id() + .expect("Rendezvous node address must contain peer ID") }) .collect(); @@ -64,13 +62,20 @@ where num_intro_points, )?; - let swarm = SwarmBuilder::with_existing_identity(identity) + let mut swarm = SwarmBuilder::with_existing_identity(identity) .with_tokio() .with_other_transport(|_| transport)? .with_behaviour(|_| behaviour)? .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT)) .build(); + for addr in rendezvous_addrs { + let peer_id = addr + .extract_peer_id() + .expect("Rendezvous node address must contain peer ID"); + swarm.add_peer_address(peer_id, addr.clone()); + } + Ok((swarm, onion_addresses)) } diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index 98fa1d7e..bc2c0b47 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -103,7 +103,6 @@ pub async fn setup_test( .await .main_address() .await - .unwrap() .into(); let developer_tip_monero_wallet_subaddress = developer_tip_monero_wallet @@ -385,7 +384,7 @@ async fn init_test_wallets( let xmr_wallet = wallets.main_wallet().await; tracing::info!( - address = %xmr_wallet.main_address().await.unwrap(), + address = %xmr_wallet.main_address().await, "Initialized monero wallet" ); @@ -535,12 +534,7 @@ impl BobParams { pub async fn get_change_receive_addresses(&self) -> (bitcoin::Address, monero::Address) { ( self.bitcoin_wallet.new_address().await.unwrap(), - self.monero_wallet - .main_wallet() - .await - .main_address() - .await - .unwrap(), + self.monero_wallet.main_wallet().await.main_address().await, ) } @@ -569,7 +563,6 @@ impl BobParams { .await .main_address() .await - .unwrap() .into(), ) .await @@ -610,7 +603,6 @@ impl BobParams { .await .main_address() .await - .unwrap() .into(), self.bitcoin_wallet.new_address().await?, btc_amount, @@ -629,7 +621,9 @@ impl BobParams { let behaviour = cli::Behaviour::new( self.env_config, self.bitcoin_wallet.clone(), - (identity.clone(), XmrBtcNamespace::Testnet), + identity.clone(), + XmrBtcNamespace::Testnet, + Vec::new(), ); let mut swarm = swarm::cli(identity.clone(), None, behaviour).await?; swarm.add_peer_address(self.alice_peer_id, self.alice_address.clone());