From 167e0311726ca34e1ee81ce4bc2d03b30c136e17 Mon Sep 17 00:00:00 2001 From: binarybaron <86064887+binarybaron@users.noreply.github.com> Date: Thu, 19 Sep 2024 00:40:51 +0200 Subject: [PATCH] feat(gui): Allow discovery of sellers via connecting to rendezvous point (#83) We, - add a new list_sellers Tauri IPC command - we rename the Seller struct to AliceAddress to name clash --- src-gui/src/renderer/api.ts | 6 +++ .../modal/listSellers/ListSellersDialog.tsx | 35 ++++++++------- .../modal/provider/ProviderInfo.tsx | 2 +- .../components/pages/swap/SwapWidget.tsx | 4 +- src-gui/src/renderer/index.tsx | 17 +++---- src-gui/src/renderer/rpc.ts | 10 +++++ src-gui/src/store/features/providersSlice.ts | 44 ++++++++++++------- src-gui/src/utils/conversionUtils.ts | 27 ++++++++++++ src-gui/src/utils/parseUtils.ts | 36 +++++++++++++++ src-tauri/src/lib.rs | 5 ++- swap/src/cli/api/request.rs | 22 ++++++---- swap/src/cli/list_sellers.rs | 5 +++ swap/src/protocol/bob/swap.rs | 8 ++++ 13 files changed, 164 insertions(+), 57 deletions(-) diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index f07bb916f..e5054b98c 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -1,3 +1,9 @@ +// This file is responsible for making HTTP requests to the Unstoppable API and to the CoinGecko API. +// The APIs are used to: +// - fetch provider status from the public registry +// - fetch alerts to be displayed to the user +// - and to submit feedback +// - fetch currency rates from CoinGecko import { Alert, ExtendedProviderStatus } from "models/apiModel"; const API_BASE_URL = "https://api.unstoppableswap.net"; diff --git a/src-gui/src/renderer/components/modal/listSellers/ListSellersDialog.tsx b/src-gui/src/renderer/components/modal/listSellers/ListSellersDialog.tsx index 5a74e76f1..1ae89e8d7 100644 --- a/src-gui/src/renderer/components/modal/listSellers/ListSellersDialog.tsx +++ b/src-gui/src/renderer/components/modal/listSellers/ListSellersDialog.tsx @@ -11,11 +11,15 @@ import { TextField, Theme, } from "@material-ui/core"; -import { Multiaddr } from "multiaddr"; +import { ListSellersResponse } from "models/tauriModel"; import { useSnackbar } from "notistack"; import { ChangeEvent, useState } from "react"; import TruncatedText from "renderer/components/other/TruncatedText"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; +import { listSellersAtRendezvousPoint } from "renderer/rpc"; +import { discoveredProvidersByRendezvous } from "store/features/providersSlice"; +import { useAppDispatch } from "store/hooks"; +import { isValidMultiAddressWithPeerId } from "utils/parseUtils"; const PRESET_RENDEZVOUS_POINTS = [ "/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE", @@ -42,27 +46,23 @@ export default function ListSellersDialog({ const classes = useStyles(); const [rendezvousAddress, setRendezvousAddress] = useState(""); const { enqueueSnackbar } = useSnackbar(); + const dispatch = useAppDispatch(); function handleMultiAddrChange(event: ChangeEvent) { setRendezvousAddress(event.target.value); } function getMultiAddressError(): string | null { - try { - const multiAddress = new Multiaddr(rendezvousAddress); - if (!multiAddress.protoNames().includes("p2p")) { - return "The multi address must contain the peer id (/p2p/)"; - } - return null; - } catch { - return "Not a valid multi address"; - } + return isValidMultiAddressWithPeerId(rendezvousAddress) ? null : "Address is invalid or missing peer ID"; } - function handleSuccess(amountOfSellers: number) { + function handleSuccess({ sellers }: ListSellersResponse) { + dispatch(discoveredProvidersByRendezvous(sellers)); + + const discoveredSellersCount = sellers.length; let message: string; - switch (amountOfSellers) { + switch (discoveredSellersCount) { case 0: message = `No providers were discovered at the rendezvous point`; break; @@ -70,7 +70,7 @@ export default function ListSellersDialog({ message = `Discovered one provider at the rendezvous point`; break; default: - message = `Discovered ${amountOfSellers} providers at the rendezvous point`; + message = `Discovered ${discoveredSellersCount} providers at the rendezvous point`; } enqueueSnackbar(message, { @@ -119,12 +119,13 @@ export default function ListSellersDialog({ { - throw new Error("Not implemented"); - }} + onInvoke={() => listSellersAtRendezvousPoint(rendezvousAddress)} > Connect diff --git a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx index 6b5c7e28d..85538b822 100644 --- a/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx +++ b/src-gui/src/renderer/components/modal/provider/ProviderInfo.tsx @@ -39,7 +39,7 @@ export default function ProviderInfo({ {provider.multiAddr} - {provider.peerId} + {provider.peerId} Exchange rate:{" "} diff --git a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx index cd85699c1..ec7bac862 100644 --- a/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/SwapWidget.tsx @@ -177,7 +177,7 @@ function HasNoProvidersSwapWidget() { const forceShowDialog = useAppSelector((state) => state.swap.state !== null); const isPublicRegistryDown = useAppSelector((state) => isRegistryDown( - state.providers.registry.failedReconnectAttemptsSinceLastSuccess, + state.providers.registry.connectionFailsCount, ), ); const classes = useStyles(); @@ -255,7 +255,7 @@ export default function SwapWidget() { (state) => state.providers.registry.providers === null && !isRegistryDown( - state.providers.registry.failedReconnectAttemptsSinceLastSuccess, + state.providers.registry.connectionFailsCount, ), ); diff --git a/src-gui/src/renderer/index.tsx b/src-gui/src/renderer/index.tsx index 25b75c676..43ad88b65 100644 --- a/src-gui/src/renderer/index.tsx +++ b/src-gui/src/renderer/index.tsx @@ -2,7 +2,10 @@ import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; import { setAlerts } from "store/features/alertsSlice"; -import { setRegistryProviders } from "store/features/providersSlice"; +import { + registryConnectionFailed, + setRegistryProviders, +} from "store/features/providersSlice"; import { setBtcPrice, setXmrPrice } from "store/features/ratesSlice"; import logger from "../utils/logger"; import { @@ -12,18 +15,9 @@ import { fetchXmrPrice, } from "./api"; import App from "./components/App"; -import { - checkBitcoinBalance, - getAllSwapInfos, - initEventListeners, -} from "./rpc"; +import { initEventListeners } from "./rpc"; import { persistor, store } from "./store/storeRenderer"; -setInterval(() => { - checkBitcoinBalance(); - getAllSwapInfos(); -}, 30 * 1000); - const container = document.getElementById("root"); const root = createRoot(container!); root.render( @@ -44,6 +38,7 @@ async function fetchInitialData() { "Fetched providers via UnstoppableSwap HTTP API", ); } catch (e) { + store.dispatch(registryConnectionFailed()); logger.error(e, "Failed to fetch providers via UnstoppableSwap HTTP API"); } diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 340eb5169..9005a0aa2 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -8,6 +8,7 @@ import { GetLogsArgs, GetLogsResponse, GetSwapInfoResponse, + ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, ResumeSwapResponse, @@ -27,6 +28,7 @@ import { store } from "./store/storeRenderer"; import { Provider } from "models/apiModel"; import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils"; import { MoneroRecoveryResponse } from "models/rpcModel"; +import { ListSellersResponse } from "../models/tauriModel"; export async function initEventListeners() { // This operation is in-expensive @@ -144,3 +146,11 @@ export async function getLogsOfSwap( redact, }); } + +export async function listSellersAtRendezvousPoint( + rendezvousPointAddress: string, +): Promise { + return await invoke("list_sellers", { + rendezvous_point: rendezvousPointAddress, + }); +} diff --git a/src-gui/src/store/features/providersSlice.ts b/src-gui/src/store/features/providersSlice.ts index 6e00cdd22..dfcfc6763 100644 --- a/src-gui/src/store/features/providersSlice.ts +++ b/src-gui/src/store/features/providersSlice.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel"; +import { Seller } from "models/tauriModel"; import { getStubTestnetProvider } from "store/config"; +import { rendezvousSellerToProviderStatus } from "utils/conversionUtils"; import { isProviderCompatible } from "utils/multiAddrUtils"; import { sortProviderList } from "utils/sortUtils"; @@ -12,7 +14,8 @@ export interface ProvidersSlice { }; registry: { providers: ExtendedProviderStatus[] | null; - failedReconnectAttemptsSinceLastSuccess: number; + // This counts how many failed connections attempts we have counted since the last successful connection + connectionFailsCount: number; }; selectedProvider: ExtendedProviderStatus | null; } @@ -23,7 +26,7 @@ const initialState: ProvidersSlice = { }, registry: { providers: stubTestnetProvider ? [stubTestnetProvider] : null, - failedReconnectAttemptsSinceLastSuccess: 0, + connectionFailsCount: 0, }, selectedProvider: null, }; @@ -47,35 +50,42 @@ export const providersSlice = createSlice({ name: "providers", initialState, reducers: { - discoveredProvidersByRendezvous( - slice, - action: PayloadAction, - ) { - action.payload.forEach((discoveredProvider) => { + discoveredProvidersByRendezvous(slice, action: PayloadAction) { + action.payload.forEach((discoveredSeller) => { + const discoveredProviderStatus = + rendezvousSellerToProviderStatus(discoveredSeller); + + // If the seller has a status of "Unreachable" the provider is not added to the list + if (discoveredProviderStatus === null) { + return; + } + + // If the provider was already discovered via the public registry, don't add it again if ( !slice.registry.providers?.some( (prov) => - prov.peerId === discoveredProvider.peerId && - prov.multiAddr === discoveredProvider.multiAddr, + prov.peerId === discoveredProviderStatus.peerId && + prov.multiAddr === discoveredProviderStatus.multiAddr, ) ) { const indexOfExistingProvider = slice.rendezvous.providers.findIndex( (prov) => - prov.peerId === discoveredProvider.peerId && - prov.multiAddr === discoveredProvider.multiAddr, + prov.peerId === discoveredProviderStatus.peerId && + prov.multiAddr === discoveredProviderStatus.multiAddr, ); - // Avoid duplicates, replace instead + // Avoid duplicate entries, replace them instead if (indexOfExistingProvider !== -1) { slice.rendezvous.providers[indexOfExistingProvider] = - discoveredProvider; + discoveredProviderStatus; } else { - slice.rendezvous.providers.push(discoveredProvider); + slice.rendezvous.providers.push(discoveredProviderStatus); } } }); slice.rendezvous.providers = sortProviderList(slice.rendezvous.providers); + slice.selectedProvider = selectNewSelectedProvider(slice); }, setRegistryProviders( slice, @@ -90,8 +100,8 @@ export const providersSlice = createSlice({ ); slice.selectedProvider = selectNewSelectedProvider(slice); }, - increaseFailedRegistryReconnectAttemptsSinceLastSuccess(slice) { - slice.registry.failedReconnectAttemptsSinceLastSuccess += 1; + registryConnectionFailed(slice) { + slice.registry.connectionFailsCount += 1; }, setSelectedProvider( slice, @@ -110,7 +120,7 @@ export const providersSlice = createSlice({ export const { discoveredProvidersByRendezvous, setRegistryProviders, - increaseFailedRegistryReconnectAttemptsSinceLastSuccess, + registryConnectionFailed, setSelectedProvider, } = providersSlice.actions; diff --git a/src-gui/src/utils/conversionUtils.ts b/src-gui/src/utils/conversionUtils.ts index b8ebe5f5e..82f984ae1 100644 --- a/src-gui/src/utils/conversionUtils.ts +++ b/src-gui/src/utils/conversionUtils.ts @@ -1,3 +1,8 @@ +import { ProviderStatus } from "models/apiModel"; +import { Seller } from "models/tauriModel"; +import { isTestnet } from "store/config"; +import { splitPeerIdFromMultiAddress } from "./parseUtils"; + export function satsToBtc(sats: number): number { return sats / 100000000; } @@ -40,3 +45,25 @@ export function getMoneroTxExplorerUrl(txid: string, stagenet: boolean) { export function secondsToDays(seconds: number): number { return seconds / 86400; } + +// Convert the "Seller" object returned by the list sellers tauri endpoint to a "ProviderStatus" 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 rendezvousSellerToProviderStatus( + seller: Seller, +): ProviderStatus | null { + if (seller.status.type === "Unreachable") { + return null; + } + + const [multiAddr, peerId] = splitPeerIdFromMultiAddress(seller.multiaddr); + + return { + maxSwapAmount: seller.status.content.max_quantity, + minSwapAmount: seller.status.content.min_quantity, + price: seller.status.content.price, + peerId, + multiAddr, + testnet: isTestnet(), + }; +} diff --git a/src-gui/src/utils/parseUtils.ts b/src-gui/src/utils/parseUtils.ts index 6dfe93be2..d4badd8ee 100644 --- a/src-gui/src/utils/parseUtils.ts +++ b/src-gui/src/utils/parseUtils.ts @@ -1,4 +1,5 @@ import { CliLog } from "models/cliModel"; +import { Multiaddr } from "multiaddr"; /* Extract btc amount from string @@ -72,3 +73,38 @@ export function getLogsFromRawFileString(rawFileData: string): CliLog[] { export function logsToRawString(logs: (CliLog | string)[]): string { return logs.map((l) => JSON.stringify(l)).join("\n"); } + +// This function checks if a given multi address string is a valid multi address +// and contains a peer ID component. +export function isValidMultiAddressWithPeerId( + multiAddressStr: string, +): boolean { + try { + const multiAddress = new Multiaddr(multiAddressStr); + const peerId = multiAddress.getPeerId(); + + return peerId !== null; + } catch { + return false; + } +} + +// This function splits a multi address string into the multi address and peer ID components. +// It throws an error if the multi address string is invalid or does not contain a peer ID component. +export function splitPeerIdFromMultiAddress( + multiAddressStr: string, +): [multiAddress: string, peerId: string] { + const multiAddress = new Multiaddr(multiAddressStr); + + // Extract the peer ID + const peerId = multiAddress.getPeerId(); + + if (peerId) { + // Remove the peer ID component + const p2pMultiaddr = new Multiaddr("/p2p/" + peerId); + const multiAddressWithoutPeerId = multiAddress.decapsulate(p2pMultiaddr); + return [multiAddressWithoutPeerId.toString(), peerId]; + } else { + throw new Error("No peer id encapsulated in multi address"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d7fe2f4b..3866e2e05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,8 @@ use swap::cli::{ api::{ request::{ BalanceArgs, BuyXmrArgs, GetHistoryArgs, GetLogsArgs, GetSwapInfosAllArgs, - MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, + ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, + WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle}, Context, ContextBuilder, @@ -169,6 +170,7 @@ pub fn run() { get_history, monero_recovery, get_logs, + list_sellers, suspend_current_swap, is_context_available, ]) @@ -208,6 +210,7 @@ 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); // These commands require no arguments tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 26f9b178b..84e0c4b2a 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -2,7 +2,7 @@ use super::tauri_bindings::TauriHandle; use crate::bitcoin::{CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock}; use crate::cli::api::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent}; use crate::cli::api::Context; -use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus}; +use crate::cli::{list_sellers as list_sellers_impl, EventLoop, Seller, SellerStatus}; use crate::common::get_logs; use crate::libp2p_ext::MultiAddrExt; use crate::network::quote::{BidQuote, ZeroQuoteReceived}; @@ -156,8 +156,14 @@ pub struct ListSellersArgs { pub rendezvous_point: Multiaddr, } +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize)] +pub struct ListSellersResponse { + sellers: Vec, +} + impl Request for ListSellersArgs { - type Response = serde_json::Value; + type Response = ListSellersResponse; async fn request(self, ctx: Arc) -> Result { list_sellers(self, ctx).await @@ -193,7 +199,7 @@ pub struct GetSwapInfoArgs { pub struct GetSwapInfoResponse { #[typeshare(serialized_as = "string")] pub swap_id: Uuid, - pub seller: Seller, + pub seller: AliceAddress, pub completed: bool, pub start_date: String, #[typeshare(serialized_as = "string")] @@ -280,8 +286,8 @@ impl Request for GetHistoryArgs { // Additional structs #[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct Seller { +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct AliceAddress { #[typeshare(serialized_as = "string")] pub peer_id: PeerId, pub addresses: Vec, @@ -507,7 +513,7 @@ pub async fn get_swap_info( Ok(GetSwapInfoResponse { swap_id: args.swap_id, - seller: Seller { + seller: AliceAddress { peer_id, addresses: addresses.iter().map(|a| a.to_string()).collect(), }, @@ -1016,7 +1022,7 @@ pub async fn get_balance(balance: BalanceArgs, context: Arc) -> Result< pub async fn list_sellers( list_sellers: ListSellersArgs, context: Arc, -) -> Result { +) -> Result { let ListSellersArgs { rendezvous_point } = list_sellers; let rendezvous_node_peer_id = rendezvous_point .extract_peer_id() @@ -1060,7 +1066,7 @@ pub async fn list_sellers( } } - Ok(json!({ "sellers": sellers })) + Ok(ListSellersResponse { sellers }) } #[tracing::instrument(fields(method = "export_bitcoin_wallet"), skip(context))] diff --git a/swap/src/cli/list_sellers.rs b/swap/src/cli/list_sellers.rs index 85abe2636..c17dc6a0b 100644 --- a/swap/src/cli/list_sellers.rs +++ b/swap/src/cli/list_sellers.rs @@ -14,6 +14,7 @@ use serde_with::{serde_as, DisplayFromStr}; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::time::Duration; +use typeshare::typeshare; /// Returns sorted list of sellers, with [Online](Status::Online) listed first. /// @@ -60,14 +61,18 @@ pub async fn list_sellers( } #[serde_as] +#[typeshare] #[derive(Debug, Serialize, PartialEq, Eq, Hash, Ord, PartialOrd)] pub struct Seller { pub status: Status, #[serde_as(as = "DisplayFromStr")] + #[typeshare(serialized_as = "string")] pub multiaddr: Multiaddr, } +#[typeshare] #[derive(Debug, Serialize, PartialEq, Eq, Hash, Copy, Clone, Ord, PartialOrd)] +#[serde(tag = "type", content = "content")] pub enum Status { Online(BidQuote), Unreachable, diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 4ff41bc66..ef518ac76 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -398,6 +398,14 @@ async fn next_state( } ExpiredTimelocks::Cancel { .. } => { state.publish_refund_btc(bitcoin_wallet).await?; + + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcRefunded { + btc_refund_txid: state.signed_refund_transaction()?.txid(), + }, + ); + BobState::BtcRefunded(state) } ExpiredTimelocks::Punish => {