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

@ -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==