Skip to content

Commit

Permalink
feat(gui): Allow discovery of sellers via connecting to rendezvous p…
Browse files Browse the repository at this point in the history
…oint (#83)

We,
- add a new list_sellers Tauri IPC command
- we rename the Seller struct to AliceAddress to name clash
  • Loading branch information
binarybaron authored Sep 18, 2024
1 parent beccd23 commit 167e031
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 57 deletions.
6 changes: 6 additions & 0 deletions src-gui/src/renderer/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -42,35 +46,31 @@ export default function ListSellersDialog({
const classes = useStyles();
const [rendezvousAddress, setRendezvousAddress] = useState("");
const { enqueueSnackbar } = useSnackbar();
const dispatch = useAppDispatch();

function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
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;
case 1:
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, {
Expand Down Expand Up @@ -119,12 +119,13 @@ export default function ListSellersDialog({
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
variant="contained"
disabled={!(rendezvousAddress && !getMultiAddressError())}
disabled={
// We disable the button if the multiaddress is invalid
getMultiAddressError() !== null
}
color="primary"
onSuccess={handleSuccess}
onInvoke={() => {
throw new Error("Not implemented");
}}
onInvoke={() => listSellersAtRendezvousPoint(rendezvousAddress)}
>
Connect
</PromiseInvokeButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function ProviderInfo({
{provider.multiAddr}
</Typography>
<Typography color="textSecondary" gutterBottom>
<TruncatedText limit={12}>{provider.peerId}</TruncatedText>
<TruncatedText>{provider.peerId}</TruncatedText>
</Typography>
<Typography variant="caption">
Exchange rate:{" "}
Expand Down
4 changes: 2 additions & 2 deletions src-gui/src/renderer/components/pages/swap/SwapWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -255,7 +255,7 @@ export default function SwapWidget() {
(state) =>
state.providers.registry.providers === null &&
!isRegistryDown(
state.providers.registry.failedReconnectAttemptsSinceLastSuccess,
state.providers.registry.connectionFailsCount,
),
);

Expand Down
17 changes: 6 additions & 11 deletions src-gui/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -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");
}

Expand Down
10 changes: 10 additions & 0 deletions src-gui/src/renderer/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
GetLogsArgs,
GetLogsResponse,
GetSwapInfoResponse,
ListSellersArgs,
MoneroRecoveryArgs,
ResumeSwapArgs,
ResumeSwapResponse,
Expand All @@ -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
Expand Down Expand Up @@ -144,3 +146,11 @@ export async function getLogsOfSwap(
redact,
});
}

export async function listSellersAtRendezvousPoint(
rendezvousPointAddress: string,
): Promise<ListSellersResponse> {
return await invoke<ListSellersArgs, ListSellersResponse>("list_sellers", {
rendezvous_point: rendezvousPointAddress,
});
}
44 changes: 27 additions & 17 deletions src-gui/src/store/features/providersSlice.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
Expand All @@ -23,7 +26,7 @@ const initialState: ProvidersSlice = {
},
registry: {
providers: stubTestnetProvider ? [stubTestnetProvider] : null,
failedReconnectAttemptsSinceLastSuccess: 0,
connectionFailsCount: 0,
},
selectedProvider: null,
};
Expand All @@ -47,35 +50,42 @@ export const providersSlice = createSlice({
name: "providers",
initialState,
reducers: {
discoveredProvidersByRendezvous(
slice,
action: PayloadAction<ProviderStatus[]>,
) {
action.payload.forEach((discoveredProvider) => {
discoveredProvidersByRendezvous(slice, action: PayloadAction<Seller[]>) {
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,
Expand All @@ -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,
Expand All @@ -110,7 +120,7 @@ export const providersSlice = createSlice({
export const {
discoveredProvidersByRendezvous,
setRegistryProviders,
increaseFailedRegistryReconnectAttemptsSinceLastSuccess,
registryConnectionFailed,
setSelectedProvider,
} = providersSlice.actions;

Expand Down
27 changes: 27 additions & 0 deletions src-gui/src/utils/conversionUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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(),
};
}
36 changes: 36 additions & 0 deletions src-gui/src/utils/parseUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CliLog } from "models/cliModel";
import { Multiaddr } from "multiaddr";

/*
Extract btc amount from string
Expand Down Expand Up @@ -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");
}
}
5 changes: 4 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -169,6 +170,7 @@ pub fn run() {
get_history,
monero_recovery,
get_logs,
list_sellers,
suspend_current_swap,
is_context_available,
])
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 167e031

Please sign in to comment.