feat: Reliable Peer Discovery (#408)

* feat(swap): Allow discovery at multiple rendezvous points, request quotes from locally stored peers

- Ensure uniqueness of the peer_addresses table (no duplicate entries)
- Add peer to local database even if we just request a quote, and no swap (call to list_sellers)
- Refactor list_sellers to take multiple rendezvous points
- Allow db to be passed into list_sellers, if so request quote from all locally stored peers

* feat: editable list of rendezvous points in settings, new maker box on help page

* Recover old commits

* fix small compile errors due to rebase

* amend

* fixes

* fix(gui): Do not display "Core components are loading..." spinner

* fix(gui): Prefer makers with m.minSwapAmount > 0 BTC

* feat(cli, gui): Fetch version of maker

* feat: display progress bar
This commit is contained in:
Mohan 2025-06-15 14:47:39 +02:00 committed by GitHub
parent 686947e8dc
commit 4702bd5bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1869 additions and 512 deletions

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- GUI: Improved peer discovery: We can now connect to multiple rendezvous points at once. We also cache peers we have previously connected to locally and will attempt to connect to them again in the future, even if they aren't registered with a rendezvous point anymore.
## [2.0.3] - 2025-06-12
## [2.0.2] - 2025-06-12

1
Cargo.lock generated
View file

@ -9296,6 +9296,7 @@ dependencies = [
"rust_decimal",
"rust_decimal_macros",
"rustls 0.23.27",
"semver",
"serde",
"serde_cbor",
"serde_json",

View file

@ -20,6 +20,7 @@ import {
checkContextAvailability,
getSwapInfo,
initializeContext,
listSellersAtRendezvousPoint,
updateAllNodeStatuses,
} from "./rpc";
import { store } from "./store/storeRenderer";
@ -30,6 +31,9 @@ const TAURI_UNIFIED_EVENT_CHANNEL_NAME = "tauri-unified-event";
// Update the public registry every 5 minutes
const PROVIDER_UPDATE_INTERVAL = 5 * 60 * 1_000;
// Discover peers every 5 minutes
const DISCOVER_PEERS_INTERVAL = 5 * 60 * 1_000;
// Update node statuses every 2 minutes
const STATUS_UPDATE_INTERVAL = 2 * 60 * 1_000;
@ -50,6 +54,11 @@ export async function setupBackgroundTasks(): Promise<void> {
setIntervalImmediate(updateAllNodeStatuses, STATUS_UPDATE_INTERVAL);
setIntervalImmediate(updateRates, UPDATE_RATE_INTERVAL);
setIntervalImmediate(fetchAllConversations, FETCH_CONVERSATIONS_INTERVAL);
setIntervalImmediate(
() =>
listSellersAtRendezvousPoint(store.getState().settings.rendezvousPoints),
DISCOVER_PEERS_INTERVAL,
);
// Fetch all alerts
updateAlerts();

View file

@ -1,4 +1,4 @@
import { Box, Button, LinearProgress, Badge } from "@mui/material";
import { Box, Button, LinearProgress, Badge, Typography } from "@mui/material";
import { Alert } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useAppSelector, usePendingBackgroundProcesses } from "store/hooks";
@ -159,6 +159,35 @@ function PartialInitStatus({
</>
</LoadingSpinnerAlert>
);
case "ListSellers": {
const 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;
const progressValue =
totalExpected > 0 ? (totalCompleted / totalExpected) * 100 : 0;
return (
<AlertWithLinearProgress
title={
<>
Discovering peers
<Box display="flex" justifyContent="space-between">
<Box color="success.main">
{progress.quotes_received} online
</Box>
<Box color="error.main">{progress.quotes_failed} offline</Box>
</Box>
</>
}
progress={progressValue}
count={totalOfType}
/>
);
}
default:
return exhaustiveGuard(status);
}
@ -181,11 +210,7 @@ export default function DaemonStatusAlert() {
switch (contextStatus) {
case TauriContextStatusEvent.Initializing:
return (
<LoadingSpinnerAlert severity="warning">
Core components are loading
</LoadingSpinnerAlert>
);
return null;
case TauriContextStatusEvent.Available:
return <Alert severity="success">The daemon is running</Alert>;
case TauriContextStatusEvent.Failed:

View file

@ -1,124 +0,0 @@
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from "@mui/material";
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,
PRESET_RENDEZVOUS_POINTS,
} from "renderer/rpc";
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
import { useAppDispatch } from "store/hooks";
import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
type ListSellersDialogProps = {
open: boolean;
onClose: () => void;
};
export default function ListSellersDialog({
open,
onClose,
}: ListSellersDialogProps) {
const [rendezvousAddress, setRendezvousAddress] = useState("");
const { enqueueSnackbar } = useSnackbar();
const dispatch = useAppDispatch();
function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
setRendezvousAddress(event.target.value);
}
function getMultiAddressError(): string | null {
return isValidMultiAddressWithPeerId(rendezvousAddress)
? null
: "Address is invalid or missing peer ID";
}
function handleSuccess({ sellers }: ListSellersResponse) {
dispatch(discoveredMakersByRendezvous(sellers));
const discoveredSellersCount = sellers.length;
let message: string;
switch (discoveredSellersCount) {
case 0:
message = `No makers were discovered at the rendezvous point`;
break;
case 1:
message = `Discovered one maker at the rendezvous point`;
break;
default:
message = `Discovered ${discoveredSellersCount} makers at the rendezvous point`;
}
enqueueSnackbar(message, {
variant: "success",
autoHideDuration: 5000,
});
onClose();
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Discover makers</DialogTitle>
<DialogContent dividers>
<DialogContentText>
The rendezvous protocol provides a way to discover makers (trading
partners) without relying on one singular centralized institution. By
manually connecting to a rendezvous point run by a volunteer, you can
discover makers and then connect and swap with them.
</DialogContentText>
<TextField
autoFocus
margin="dense"
label="Rendezvous point"
fullWidth
helperText={
getMultiAddressError() || "Multiaddress of the rendezvous point"
}
value={rendezvousAddress}
onChange={handleMultiAddrChange}
placeholder="/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE"
error={!!getMultiAddressError()}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{PRESET_RENDEZVOUS_POINTS.map((rAddress) => (
<Chip
key={rAddress}
clickable
label={<TruncatedText limit={30}>{rAddress}</TruncatedText>}
onClick={() => setRendezvousAddress(rAddress)}
/>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
variant="contained"
disabled={
// We disable the button if the multiaddress is invalid
getMultiAddressError() !== null
}
color="primary"
onSuccess={handleSuccess}
onInvoke={() => listSellersAtRendezvousPoint(rendezvousAddress)}
>
Connect
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -86,14 +86,12 @@ export default function MakerInfo({ maker }: { maker: ExtendedMakerStatus }) {
<Chip label={`${Math.round(maker.uptime * 100)}% uptime`} />
</Tooltip>
)}
{maker.age ? (
{maker.age && (
<Chip
label={`Went online ${Math.round(secondsToDays(maker.age))} ${
maker.age === 1 ? "day" : "days"
} ago`}
/>
) : (
<Chip label="Discovered via rendezvous point" />
)}
{maker.recommended === true && (
<Tooltip title="This maker has shown to be exceptionally reliable">
@ -105,6 +103,11 @@ export default function MakerInfo({ maker }: { maker: ExtendedMakerStatus }) {
<Chip label="Outdated" icon={<WarningIcon />} color="primary" />
</Tooltip>
)}
{maker.version && (
<Tooltip title="The version of the maker's software">
<Chip label={`v${maker.version}`} />
</Tooltip>
)}
<MakerMarkupChip maker={maker} />
</Box>
</Box>

View file

@ -15,7 +15,6 @@ import { ExtendedMakerStatus } from "models/apiModel";
import { useState } from "react";
import { setSelectedMaker } from "store/features/makersSlice";
import { useAllMakers, useAppDispatch } from "store/hooks";
import ListSellersDialog from "../listSellers/ListSellersDialog";
import MakerInfo from "./MakerInfo";
import MakerSubmitDialog from "./MakerSubmitDialog";
@ -50,30 +49,6 @@ export function MakerSubmitDialogOpenButton() {
);
}
export function ListSellersDialogOpenButton() {
const [open, setOpen] = useState(false);
return (
<ListItemButton
autoFocus
onClick={() => {
// Prevents background from being clicked and reopening dialog
if (!open) {
setOpen(true);
}
}}
>
<ListSellersDialog open={open} onClose={() => setOpen(false)} />
<ListItemAvatar>
<Avatar>
<SearchIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Discover makers by connecting to a rendezvous point" />
</ListItemButton>
);
}
export default function MakerListDialog({
open,
onClose,
@ -99,7 +74,6 @@ export default function MakerListDialog({
<MakerInfo maker={maker} key={maker.peerId} />
</ListItemButton>
))}
<ListSellersDialogOpenButton />
<MakerSubmitDialogOpenButton />
</List>
</DialogContent>

View file

@ -0,0 +1,58 @@
import { Box, Typography, styled } from "@mui/material";
import InfoBox from "renderer/components/modal/swap/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 (
<InfoBox
title={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Discover Makers
</Box>
}
mainContent={
<Typography variant="subtitle2">
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.
</Typography>
}
additionalContent={
<StyledPromiseButton
variant="contained"
color="primary"
onInvoke={handleDiscovery}
disabled={rendezvousPoints.length === 0}
startIcon={<Search />}
displayErrorSnackbar
>
Discover Makers
</StyledPromiseButton>
}
icon={null}
loading={false}
/>
);
}

View file

@ -21,19 +21,20 @@ import {
Switch,
SelectChangeEvent,
} from "@mui/material";
import {
removeNode,
resetSettings,
setFetchFiatPrices,
setFiatCurrency,
} from "store/features/settingsSlice";
import {
addNode,
addRendezvousPoint,
Blockchain,
FiatCurrency,
moveUpNode,
Network,
removeNode,
removeRendezvousPoint,
resetSettings,
setFetchFiatPrices,
setFiatCurrency,
setTheme,
setTorEnabled,
} from "store/features/settingsSlice";
import { useAppDispatch, useNodes, useSettings } from "store/hooks";
import ValidatedTextField from "renderer/components/other/ValidatedTextField";
@ -49,8 +50,8 @@ import {
} from "@mui/icons-material";
import { getNetwork } from "store/config";
import { currencySymbol } from "utils/formatUtils";
import { setTorEnabled } from "store/features/settingsSlice";
import InfoBox from "renderer/components/modal/swap/InfoBox";
import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700";
const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081";
@ -85,6 +86,7 @@ export default function SettingsBox() {
<MoneroNodeUrlSetting />
<FetchFiatPricesSetting />
<ThemeSetting />
<RendezvousPointsSetting />
</TableBody>
</Table>
</TableContainer>
@ -590,3 +592,121 @@ export function TorSettings() {
</TableRow>
);
}
/**
* A setting that allows you to manage rendezvous points for maker discovery
*/
function RendezvousPointsSetting() {
const [tableVisible, setTableVisible] = useState(false);
const rendezvousPoints = useSettings((s) => s.rendezvousPoints);
const dispatch = useAppDispatch();
const [newPoint, setNewPoint] = useState("");
const onAddNewPoint = () => {
dispatch(addRendezvousPoint(newPoint));
setNewPoint("");
};
const onRemovePoint = (point: string) => {
dispatch(removeRendezvousPoint(point));
};
return (
<TableRow>
<TableCell>
<SettingLabel
label="Rendezvous Points"
tooltip="These are the points where makers can be discovered. Add custom rendezvous points here to expand your maker discovery options."
/>
</TableCell>
<TableCell>
<IconButton onClick={() => setTableVisible(true)}>
<Edit />
</IconButton>
{tableVisible && (
<Dialog
open={true}
onClose={() => setTableVisible(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Rendezvous Points</DialogTitle>
<DialogContent>
<Typography variant="subtitle2">
Add or remove rendezvous points where makers can be discovered.
These points help you find trading partners in a decentralized
way.
</Typography>
<TableContainer
component={Paper}
style={{ marginTop: "1rem" }}
elevation={0}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell style={{ width: "85%" }}>
Rendezvous Point
</TableCell>
<TableCell style={{ width: "15%" }} align="right">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rendezvousPoints.map((point, index) => (
<TableRow key={index}>
<TableCell style={{ wordBreak: "break-all" }}>
<Typography variant="overline">{point}</Typography>
</TableCell>
<TableCell align="right">
<Tooltip title="Remove this rendezvous point">
<IconButton onClick={() => onRemovePoint(point)}>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell>
<ValidatedTextField
label="Add new rendezvous point"
value={newPoint}
onValidatedChange={setNewPoint}
placeholder="/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE"
fullWidth
isValid={isValidMultiAddressWithPeerId}
variant="outlined"
noErrorWhenEmpty
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Add this rendezvous point">
<IconButton
onClick={onAddNewPoint}
disabled={
!isValidMultiAddressWithPeerId(newPoint) ||
newPoint.length === 0
}
>
<Add />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={() => setTableVisible(false)} size="large">
Close
</Button>
</DialogActions>
</Dialog>
)}
</TableCell>
</TableRow>
);
}

View file

@ -4,6 +4,7 @@ import DonateInfoBox from "./DonateInfoBox";
import DaemonControlBox from "./DaemonControlBox";
import SettingsBox from "./SettingsBox";
import ExportDataBox from "./ExportDataBox";
import DiscoveryBox from "./DiscoveryBox";
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
@ -27,6 +28,7 @@ export default function SettingsPage() {
}}
>
<SettingsBox />
<DiscoveryBox />
<ExportDataBox />
<DaemonControlBox />
<DonateInfoBox />

View file

@ -14,10 +14,7 @@ import { ExtendedMakerStatus } from "models/apiModel";
import { ChangeEvent, useEffect, useState } from "react";
import { useAppSelector } from "store/hooks";
import { satsToBtc } from "utils/conversionUtils";
import {
ListSellersDialogOpenButton,
MakerSubmitDialogOpenButton,
} from "../../modal/provider/MakerListDialog";
import { MakerSubmitDialogOpenButton } from "../../modal/provider/MakerListDialog";
import MakerSelect from "../../modal/provider/MakerSelect";
import SwapDialog from "../../modal/swap/SwapDialog";
@ -162,7 +159,7 @@ function HasNoMakersSwapWidget() {
</ul>
</Typography>
<Box>
<ListSellersDialogOpenButton />
<MakerSubmitDialogOpenButton />
</Box>
</Box>
</Alert>
@ -180,7 +177,6 @@ function HasNoMakersSwapWidget() {
</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<MakerSubmitDialogOpenButton />
<ListSellersDialogOpenButton />
</Box>
</Box>
</Alert>

View file

@ -43,13 +43,13 @@ import { CliLog } from "models/cliModel";
import { logsToRawString, parseLogsFromString } from "utils/parseUtils";
export const PRESET_RENDEZVOUS_POINTS = [
"/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE",
"/dnsaddr/xxmr.cheap/p2p/12D3KooWMk3QyPS8D1d1vpHZoY7y2MnXdPE5yV6iyPvyuj4zcdxT",
];
export async function fetchSellersAtPresetRendezvousPoints() {
await Promise.all(
PRESET_RENDEZVOUS_POINTS.map(async (rendezvousPoint) => {
const response = await listSellersAtRendezvousPoint(rendezvousPoint);
const response = await listSellersAtRendezvousPoint([rendezvousPoint]);
store.dispatch(discoveredMakersByRendezvous(response.sellers));
logger.info(
@ -206,10 +206,10 @@ export async function redactLogs(
}
export async function listSellersAtRendezvousPoint(
rendezvousPointAddress: string,
rendezvousPointAddresses: string[],
): Promise<ListSellersResponse> {
return await invoke<ListSellersArgs, ListSellersResponse>("list_sellers", {
rendezvous_point: rendezvousPointAddress,
rendezvous_points: rendezvousPointAddresses,
});
}

View file

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedMakerStatus, MakerStatus } from "models/apiModel";
import { Seller } from "models/tauriModel";
import { SellerStatus } from "models/tauriModel";
import { getStubTestnetMaker } from "store/config";
import { rendezvousSellerToMakerStatus } from "utils/conversionUtils";
import { isMakerOutdated } from "utils/multiAddrUtils";
@ -60,7 +60,7 @@ export const makersSlice = createSlice({
name: "providers",
initialState,
reducers: {
discoveredMakersByRendezvous(slice, action: PayloadAction<Seller[]>) {
discoveredMakersByRendezvous(slice, action: PayloadAction<SellerStatus[]>) {
action.payload.forEach((discoveredSeller) => {
const discoveredMakerStatus =
rendezvousSellerToMakerStatus(discoveredSeller);

View file

@ -1,6 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Theme } from "renderer/components/theme";
const DEFAULT_RENDEZVOUS_POINTS = [
"/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE",
"/dns4/discover2.unstoppableswap.net/tcp/8888/p2p/12D3KooWGRvf7qVQDrNR5nfYD6rKrbgeTi9x8RrbdxbmsPvxL4mw",
"/dns4/darkness.su/tcp/8888/p2p/12D3KooWFQAgVVS9t9UgL6v1sLprJVM7am5hFK7vy9iBCCoCBYmU",
];
export interface SettingsState {
/// This is an ordered list of node urls for each network and blockchain
nodes: Record<Network, Record<Blockchain, string[]>>;
@ -12,6 +18,8 @@ export interface SettingsState {
/// Whether to enable Tor for p2p connections
enableTor: boolean;
userHasSeenIntroduction: boolean;
/// List of rendezvous points
rendezvousPoints: string[];
}
export enum FiatCurrency {
@ -112,6 +120,7 @@ const initialState: SettingsState = {
fiatCurrency: FiatCurrency.Usd,
enableTor: true,
userHasSeenIntroduction: false,
rendezvousPoints: DEFAULT_RENDEZVOUS_POINTS,
};
const alertsSlice = createSlice({
@ -147,6 +156,14 @@ const alertsSlice = createSlice({
setFiatCurrency(slice, action: PayloadAction<FiatCurrency>) {
slice.fiatCurrency = action.payload;
},
addRendezvousPoint(slice, action: PayloadAction<string>) {
slice.rendezvousPoints.push(action.payload);
},
removeRendezvousPoint(slice, action: PayloadAction<string>) {
slice.rendezvousPoints = slice.rendezvousPoints.filter(
(point) => point !== action.payload,
);
},
addNode(
slice,
action: PayloadAction<{
@ -202,6 +219,8 @@ export const {
setFiatCurrency,
setTorEnabled,
setUserHasSeenIntroduction,
addRendezvousPoint,
removeRendezvousPoint,
} = alertsSlice.actions;
export default alertsSlice.reducer;

View file

@ -1,5 +1,5 @@
import { MakerStatus } from "models/apiModel";
import { Seller } from "models/tauriModel";
import { MakerStatus, ExtendedMakerStatus } from "models/apiModel";
import { SellerStatus } from "models/tauriModel";
import { isTestnet } from "store/config";
import { splitPeerIdFromMultiAddress } from "./parseUtils";
@ -48,21 +48,20 @@ export function secondsToDays(seconds: number): number {
// 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: Seller,
): MakerStatus | null {
if (seller.status.type === "Unreachable") {
seller: SellerStatus,
): ExtendedMakerStatus | null {
if (seller.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,
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,
};
}

View file

@ -19,6 +19,8 @@ export function sortMakerList(list: ExtendedMakerStatus[]) {
(m) => (m.relevancy == null ? 1 : 0),
// Prefer makers with a higher relevancy score
(m) => -(m.relevancy ?? 0),
// Prefer makers with a minimum quantity > 0
(m) => ((m.minSwapAmount ?? 0) > 0 ? 0 : 1),
// Prefer makers with a lower price
(m) => m.price,
],

View file

@ -1033,7 +1033,7 @@
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-updater@>=2.7.1":
"@tauri-apps/plugin-updater@2.7.1":
version "2.7.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-updater/-/plugin-updater-2.7.1.tgz#82adcfd06cdd4bc6d64343c8934b700c9174913a"
integrity sha512-1OPqEY/z7NDVSeTEMIhD2ss/vXWdpfZ5Th2Mk0KtPR/RA6FKuOTDGZQhxoyYBk0pcZJ+nNZUbl/IujDCLBApjA==

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n insert into peer_addresses (\n peer_id,\n address\n ) values (?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "0ab84c094964968e96a3f2bf590d9ae92227d057386921e0e57165b887de3c75"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n insert or ignore into peer_addresses (\n peer_id,\n address\n ) values (?, ?);\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "50ef34b4efabe650d40096a390d9240b9a7cd62878dfaa6805563cfc21284cd5"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT peer_id, address FROM peer_addresses",
"describe": {
"columns": [
{
"name": "peer_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "address",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
}

View file

@ -55,6 +55,7 @@ reqwest = { version = "0.12", features = ["http2", "rustls-tls-native-roots", "s
rust_decimal = { version = "1", features = ["serde-float"] }
rust_decimal_macros = "1"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
semver = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_cbor = "0.11"
serde_json = "1"

View file

@ -0,0 +1,18 @@
-- SQLite doesn't support adding constraints via ALTER TABLE
-- We need to recreate the table with the constraint
CREATE TABLE peer_addresses_new (
peer_id TEXT NOT NULL,
address TEXT NOT NULL,
UNIQUE(peer_id, address)
);
-- Copy existing data, ensuring only unique combinations are inserted
INSERT INTO peer_addresses_new
SELECT DISTINCT peer_id, address
FROM peer_addresses;
-- Drop the old table
DROP TABLE peer_addresses;
-- Rename the new table to the original name
ALTER TABLE peer_addresses_new RENAME TO peer_addresses;

View file

@ -263,15 +263,15 @@ where
let idx = self.next.load(Ordering::SeqCst);
// Get client for this index
let client = self.get_or_init_client_sync(idx).map_err(|e| {
let client = self.get_or_init_client_sync(idx).map_err(|err| {
trace!(
server_url = self.urls[idx],
attempt = errors.len(),
error = ?e,
error = ?err,
"Client initialization failed, switching to next client"
);
errors.push(e);
errors.push(err);
BackoffError::transient(())
})?;

View file

@ -10,14 +10,14 @@ pub mod watcher;
pub use behaviour::{Behaviour, OutEvent};
pub use cancel_and_refund::{cancel, cancel_and_refund, refund};
pub use event_loop::{EventLoop, EventLoopHandle};
pub use list_sellers::{list_sellers, Seller, Status as SellerStatus};
pub use list_sellers::{list_sellers, SellerStatus};
#[cfg(test)]
mod tests {
use super::*;
use crate::asb;
use crate::asb::rendezvous::RendezvousNode;
use crate::cli::list_sellers::{Seller, Status};
use crate::cli::list_sellers::{QuoteWithAddress, SellerStatus};
use crate::network::quote;
use crate::network::quote::BidQuote;
use crate::network::rendezvous::XmrBtcNamespace;
@ -29,11 +29,18 @@ mod tests {
ConnectionDenied, ConnectionId, FromSwarm, THandlerInEvent, THandlerOutEvent, ToSwarm,
};
use libp2p::{identity, rendezvous, request_response, Multiaddr, PeerId};
use semver::Version;
use std::collections::HashSet;
use std::iter::FromIterator;
use std::task::Poll;
use std::time::Duration;
// 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
@ -52,19 +59,32 @@ mod tests {
let expected_seller_2 = setup_asb(rendezvous_peer_id, &rendezvous_address, namespace).await;
let list_sellers = list_sellers(
rendezvous_peer_id,
rendezvous_address,
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<Seller> = 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::<Seller>::from_iter(sellers),
HashSet::<Seller>::from_iter(actual_sellers),
HashSet::<Seller>::from_iter([expected_seller_1, expected_seller_2])
)
}
@ -126,9 +146,15 @@ mod tests {
}
});
let full_address = asb_address.with(Protocol::P2p(asb_peer_id));
Seller {
multiaddr: asb_address.with(Protocol::P2p(asb_peer_id)),
status: Status::Online(static_quote),
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(),
}),
}
}

View file

@ -2,7 +2,8 @@ use super::tauri_bindings::TauriHandle;
use crate::bitcoin::{wallet, 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, Seller, SellerStatus};
use crate::cli::list_sellers::{QuoteWithAddress, UnreachableSeller};
use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus};
use crate::common::{get_logs, redact};
use crate::libp2p_ext::MultiAddrExt;
use crate::monero::wallet_rpc::MoneroDaemon;
@ -172,14 +173,16 @@ impl Request for WithdrawBtcArgs {
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ListSellersArgs {
#[typeshare(serialized_as = "string")]
pub rendezvous_point: Multiaddr,
/// The rendezvous points to search for sellers
/// The address must contain a peer ID
#[typeshare(serialized_as = "Vec<string>")]
pub rendezvous_points: Vec<Multiaddr>,
}
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize)]
pub struct ListSellersResponse {
sellers: Vec<Seller>,
sellers: Vec<SellerStatus>,
}
impl Request for ListSellersArgs {
@ -1079,10 +1082,11 @@ pub async fn list_sellers(
list_sellers: ListSellersArgs,
context: Arc<Context>,
) -> Result<ListSellersResponse> {
let ListSellersArgs { rendezvous_point } = list_sellers;
let rendezvous_node_peer_id = rendezvous_point
.extract_peer_id()
.context("Rendezvous node address must contain peer ID")?;
let ListSellersArgs { rendezvous_points } = list_sellers;
let rendezvous_nodes: Vec<_> = rendezvous_points
.iter()
.filter_map(|rendezvous_point| rendezvous_point.split_peer_id())
.collect();
let identity = context
.config
@ -1092,30 +1096,46 @@ pub async fn list_sellers(
.derive_libp2p_identity();
let sellers = list_sellers_impl(
rendezvous_node_peer_id,
rendezvous_point,
rendezvous_nodes,
context.config.namespace,
context.tor_client.clone(),
identity,
Some(context.db.clone()),
context.tauri_handle(),
)
.await?;
for seller in &sellers {
match seller.status {
SellerStatus::Online(quote) => {
tracing::info!(
match seller {
SellerStatus::Online(QuoteWithAddress {
quote,
multiaddr,
peer_id,
version,
}) => {
tracing::debug!(
status = "Online",
price = %quote.price.to_string(),
min_quantity = %quote.min_quantity.to_string(),
max_quantity = %quote.max_quantity.to_string(),
status = "Online",
address = %seller.multiaddr.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
context
.db
.insert_address(*peer_id, multiaddr.clone())
.await?;
}
SellerStatus::Unreachable => {
tracing::info!(
SellerStatus::Unreachable(UnreachableSeller { peer_id }) => {
tracing::debug!(
status = "Unreachable",
address = %seller.multiaddr.to_string(),
peer_id = %peer_id.to_string(),
"Fetched peer status"
);
}

View file

@ -546,6 +546,7 @@ pub enum TauriBackgroundProgress {
SyncingBitcoinWallet(PendingCompleted<TauriBitcoinSyncProgress>),
FullScanningBitcoinWallet(PendingCompleted<TauriBitcoinFullScanProgress>),
BackgroundRefund(PendingCompleted<BackgroundRefundProgress>),
ListSellers(PendingCompleted<ListSellersProgress>),
}
#[typeshare]
@ -707,3 +708,14 @@ pub struct TauriSettings {
/// Whether to initialize and use a tor client.
pub use_tor: bool,
}
#[typeshare]
#[derive(Debug, Serialize, Clone)]
pub struct ListSellersProgress {
pub rendezvous_points_connected: u32,
pub rendezvous_points_total: u32,
pub rendezvous_points_failed: u32,
pub peers_discovered: u32,
pub quotes_received: u32,
pub quotes_failed: u32,
}

View file

@ -247,7 +247,9 @@ where
.await?,
);
ListSellersArgs { rendezvous_point }
ListSellersArgs {
rendezvous_points: vec![rendezvous_point],
}
.request(context.clone())
.await?;

File diff suppressed because it is too large Load diff

View file

@ -154,7 +154,7 @@ impl Database for SqliteDatabase {
sqlx::query!(
r#"
insert into peer_addresses (
insert or ignore into peer_addresses (
peer_id,
address
) values (?, ?);
@ -193,6 +193,42 @@ impl Database for SqliteDatabase {
addresses
}
async fn get_all_peer_addresses(&self) -> Result<Vec<(PeerId, Vec<Multiaddr>)>> {
let rows = sqlx::query!("SELECT peer_id, address FROM peer_addresses")
.fetch_all(&self.pool)
.await?;
let mut peer_map: std::collections::HashMap<PeerId, Vec<Multiaddr>> =
std::collections::HashMap::new();
for row in rows.iter() {
match (
PeerId::from_str(&row.peer_id),
Multiaddr::from_str(&row.address),
) {
(Ok(peer_id), Ok(multiaddr)) => {
peer_map.entry(peer_id).or_default().push(multiaddr);
}
(Err(e), _) => {
tracing::warn!(
peer_id = %row.peer_id,
error = %e,
"Failed to parse peer ID, skipping entry"
);
}
(_, Err(e)) => {
tracing::warn!(
address = %row.address,
error = %e,
"Failed to parse multiaddr, skipping entry"
);
}
}
}
Ok(peer_map.into_iter().collect())
}
async fn get_swap_start_date(&self, swap_id: Uuid) -> Result<String> {
let swap_id = swap_id.to_string();

View file

@ -3,6 +3,7 @@ use libp2p::{Multiaddr, PeerId};
pub trait MultiAddrExt {
fn extract_peer_id(&self) -> Option<PeerId>;
fn split_peer_id(&self) -> Option<(PeerId, Multiaddr)>;
}
impl MultiAddrExt for Multiaddr {
@ -12,4 +13,12 @@ impl MultiAddrExt for Multiaddr {
_ => 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))
}
}

View file

@ -144,6 +144,7 @@ pub trait Database {
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>>;
async fn insert_address(&self, peer_id: PeerId, address: Multiaddr) -> Result<()>;
async fn get_addresses(&self, peer_id: PeerId) -> Result<Vec<Multiaddr>>;
async fn get_all_peer_addresses(&self) -> Result<Vec<(PeerId, Vec<Multiaddr>)>>;
async fn get_swap_start_date(&self, swap_id: Uuid) -> Result<String>;
async fn insert_latest_state(&self, swap_id: Uuid, state: State) -> Result<()>;
async fn get_state(&self, swap_id: Uuid) -> Result<State>;