feat: Maker avatar (#205)

- GUI: Changed terminology from "swap providers" to "makers"
- GUI: For each maker, we now display a unique deterministically generated avatar derived from the maker's public key
This commit is contained in:
binarybaron 2024-11-25 20:15:09 +01:00 committed by GitHub
parent 23d22b5792
commit b2e74df37e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 511 additions and 429 deletions

View file

@ -29,6 +29,7 @@
"@tauri-apps/plugin-updater": "^2.0.0",
"@types/react-redux": "^7.1.34",
"humanize-duration": "^3.32.1",
"jdenticon": "^3.3.0",
"lodash": "^4.17.21",
"multiaddr": "^10.0.1",
"notistack": "^3.0.1",

View file

@ -1,4 +1,4 @@
export interface ExtendedProviderStatus extends ProviderStatus {
export interface ExtendedMakerStatus extends MakerStatus {
uptime?: number;
age?: number;
relevancy?: number;
@ -6,15 +6,15 @@ export interface ExtendedProviderStatus extends ProviderStatus {
recommended?: boolean;
}
export interface ProviderStatus extends ProviderQuote, Provider {}
export interface MakerStatus extends MakerQuote, Maker { }
export interface ProviderQuote {
export interface MakerQuote {
price: number;
minSwapAmount: number;
maxSwapAmount: number;
}
export interface Provider {
export interface Maker {
multiAddr: string;
testnet: boolean;
peerId: string;

View file

@ -5,21 +5,21 @@
// - and to submit feedback
// - fetch currency rates from CoinGecko
import { Alert, ExtendedProviderStatus } from "models/apiModel";
import { Alert, ExtendedMakerStatus } from "models/apiModel";
import { store } from "./store/storeRenderer";
import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice";
import { FiatCurrency } from "store/features/settingsSlice";
import { setAlerts } from "store/features/alertsSlice";
import { registryConnectionFailed, setRegistryProviders } from "store/features/providersSlice";
import { registryConnectionFailed, setRegistryMakers } from "store/features/makersSlice";
import logger from "utils/logger";
const PUBLIC_REGISTRY_API_BASE_URL = "https://api.unstoppableswap.net";
async function fetchProvidersViaHttp(): Promise<
ExtendedProviderStatus[]
async function fetchMakersViaHttp(): Promise<
ExtendedMakerStatus[]
> {
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/list`);
return (await response.json()) as ExtendedProviderStatus[];
return (await response.json()) as ExtendedMakerStatus[];
}
async function fetchAlertsViaHttp(): Promise<Alert[]> {
@ -114,8 +114,8 @@ export async function updateRates(): Promise<void> {
*/
export async function updatePublicRegistry(): Promise<void> {
try {
const providers = await fetchProvidersViaHttp();
store.dispatch(setRegistryProviders(providers));
const providers = await fetchMakersViaHttp();
store.dispatch(setRegistryMakers(providers));
} catch (error) {
store.dispatch(registryConnectionFailed());
logger.error(error, "Error fetching providers");

View file

@ -27,7 +27,7 @@ export default function RemainingFundsWillBeUsedAlert() {
>
The remaining funds of <SatsAmount amount={balance} /> in the wallet
will be used for the next swap. If the remaining funds exceed the
minimum swap amount of the provider, a swap will be initiated
minimum swap amount of the maker, a swap will be initiated
instantaneously.
</Alert>
</Box>

View file

@ -0,0 +1,29 @@
import React, { useEffect, useRef } from 'react';
import * as jdenticon from 'jdenticon';
interface IdentIconProps {
value: string;
size?: number | string;
className?: string;
}
function IdentIcon({ value, size = 40, className = '' }: IdentIconProps) {
const iconRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (iconRef.current) {
jdenticon.update(iconRef.current, value);
}
}, [value]);
return (
<svg
ref={iconRef}
width={size}
height={size}
className={className}
data-jdenticon-value={value} />
);
}
export default IdentIcon;

View file

@ -17,7 +17,7 @@ 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 { discoveredProvidersByRendezvous } from "store/features/providersSlice";
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
import { useAppDispatch } from "store/hooks";
import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
@ -54,20 +54,20 @@ export default function ListSellersDialog({
}
function handleSuccess({ sellers }: ListSellersResponse) {
dispatch(discoveredProvidersByRendezvous(sellers));
dispatch(discoveredMakersByRendezvous(sellers));
const discoveredSellersCount = sellers.length;
let message: string;
switch (discoveredSellersCount) {
case 0:
message = `No providers were discovered at the rendezvous point`;
message = `No makers were discovered at the rendezvous point`;
break;
case 1:
message = `Discovered one provider at the rendezvous point`;
message = `Discovered one maker at the rendezvous point`;
break;
default:
message = `Discovered ${discoveredSellersCount} providers at the rendezvous point`;
message = `Discovered ${discoveredSellersCount} makers at the rendezvous point`;
}
enqueueSnackbar(message, {
@ -80,13 +80,13 @@ export default function ListSellersDialog({
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Discover swap providers</DialogTitle>
<DialogTitle>Discover makers</DialogTitle>
<DialogContent dividers>
<DialogContentText>
The rendezvous protocol provides a way to discover providers (trading
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 providers and then connect and swap with them.
discover makers and then connect and swap with them.
</DialogContentText>
<TextField
autoFocus

View file

@ -0,0 +1,127 @@
import { Box, Chip, makeStyles, Paper, Tooltip, Typography } from "@material-ui/core";
import { VerifiedUser } from "@material-ui/icons";
import { ExtendedMakerStatus } from "models/apiModel";
import TruncatedText from "renderer/components/other/TruncatedText";
import {
MoneroBitcoinExchangeRate,
SatsAmount,
} from "renderer/components/other/Units";
import { satsToBtc, secondsToDays } from "utils/conversionUtils";
import { isMakerOutdated } from 'utils/multiAddrUtils';
import WarningIcon from '@material-ui/icons/Warning';
import { useAppSelector } from "store/hooks";
import IdentIcon from "renderer/components/icons/IdentIcon";
const useStyles = makeStyles((theme) => ({
content: {
flex: 1,
"& *": {
lineBreak: "anywhere",
},
display: "flex",
flexDirection: "column",
gap: theme.spacing(1),
},
chipsOuter: {
display: "flex",
flexWrap: "wrap",
gap: theme.spacing(0.5),
},
quoteOuter: {
display: "flex",
flexDirection: "column",
},
peerIdContainer: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
}));
/**
* A chip that displays the markup of the maker's exchange rate compared to the market rate.
*/
function MakerMarkupChip({ maker }: { maker: ExtendedMakerStatus }) {
const marketExchangeRate = useAppSelector(s => s.rates?.xmrBtcRate);
if (marketExchangeRate === null)
return null;
const makerExchangeRate = satsToBtc(maker.price);
/** The markup of the exchange rate compared to the market rate in percent */
const markup = (makerExchangeRate - marketExchangeRate) / marketExchangeRate * 100;
return (
<Tooltip title="The markup this maker charges compared to centralized markets. A lower markup means that you get more Monero for your Bitcoin.">
<Chip label={`Markup ${markup.toFixed(2)}%`} />
</Tooltip>
);
}
export default function MakerInfo({
maker,
}: {
maker: ExtendedMakerStatus;
}) {
const classes = useStyles();
const isOutdated = isMakerOutdated(maker);
return (
<Box className={classes.content}>
<Box className={classes.peerIdContainer}>
<Tooltip title={"This avatar is deterministically derived from the public key of the maker"} arrow>
<Box className={classes.peerIdContainer}>
<IdentIcon value={maker.peerId} size={"3rem"} />
</Box>
</Tooltip>
<Box>
<Typography variant="subtitle1">
<TruncatedText limit={16} truncateMiddle>{maker.peerId}</TruncatedText>
</Typography>
<Typography color="textSecondary" variant="body2">
{maker.multiAddr}
</Typography>
</Box>
</Box>
<Box className={classes.quoteOuter}>
<Typography variant="caption">
Exchange rate:{" "}
<MoneroBitcoinExchangeRate rate={satsToBtc(maker.price)} />
</Typography>
<Typography variant="caption">
Minimum amount: <SatsAmount amount={maker.minSwapAmount} />
</Typography>
<Typography variant="caption">
Maximum amount: <SatsAmount amount={maker.maxSwapAmount} />
</Typography>
</Box>
<Box className={classes.chipsOuter}>
{maker.testnet && <Chip label="Testnet" />}
{maker.uptime && (
<Tooltip title="A high uptime (>90%) indicates reliability. Makers with very low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label={`${Math.round(maker.uptime * 100)}% uptime`} />
</Tooltip>
)}
{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">
<Chip label="Recommended" icon={<VerifiedUser />} color="primary" />
</Tooltip>
)}
{isOutdated && (
<Tooltip title="This maker is running an older version of the software. Outdated makers may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label="Outdated" icon={<WarningIcon />} color="primary" />
</Tooltip>
)}
<MakerMarkupChip maker={maker} />
</Box>
</Box >
);
}

View file

@ -13,13 +13,13 @@ import {
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import SearchIcon from "@material-ui/icons/Search";
import { ExtendedProviderStatus } from "models/apiModel";
import { ExtendedMakerStatus } from "models/apiModel";
import { useState } from "react";
import { setSelectedProvider } from "store/features/providersSlice";
import { useAllProviders, useAppDispatch } from "store/hooks";
import { setSelectedMaker } from "store/features/makersSlice";
import { useAllMakers, useAppDispatch } from "store/hooks";
import ListSellersDialog from "../listSellers/ListSellersDialog";
import ProviderInfo from "./ProviderInfo";
import ProviderSubmitDialog from "./ProviderSubmitDialog";
import MakerInfo from "./MakerInfo";
import MakerSubmitDialog from "./MakerSubmitDialog";
const useStyles = makeStyles({
dialogContent: {
@ -27,12 +27,12 @@ const useStyles = makeStyles({
},
});
type ProviderSelectDialogProps = {
type MakerSelectDialogProps = {
open: boolean;
onClose: () => void;
};
export function ProviderSubmitDialogOpenButton() {
export function MakerSubmitDialogOpenButton() {
const [open, setOpen] = useState(false);
return (
@ -46,13 +46,13 @@ export function ProviderSubmitDialogOpenButton() {
}
}}
>
<ProviderSubmitDialog open={open} onClose={() => setOpen(false)} />
<MakerSubmitDialog open={open} onClose={() => setOpen(false)} />
<ListItemAvatar>
<Avatar>
<AddIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Add a new provider to public registry" />
<ListItemText primary="Add a new maker to public registry" />
</ListItem>
);
}
@ -77,41 +77,41 @@ export function ListSellersDialogOpenButton() {
<SearchIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Discover providers by connecting to a rendezvous point" />
<ListItemText primary="Discover makers by connecting to a rendezvous point" />
</ListItem>
);
}
export default function ProviderListDialog({
export default function MakerListDialog({
open,
onClose,
}: ProviderSelectDialogProps) {
}: MakerSelectDialogProps) {
const classes = useStyles();
const providers = useAllProviders();
const makers = useAllMakers();
const dispatch = useAppDispatch();
function handleProviderChange(provider: ExtendedProviderStatus) {
dispatch(setSelectedProvider(provider));
function handleMakerChange(maker: ExtendedMakerStatus) {
dispatch(setSelectedMaker(maker));
onClose();
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Select a swap provider</DialogTitle>
<DialogTitle>Select a maker</DialogTitle>
<DialogContent className={classes.dialogContent} dividers>
<List>
{providers.map((provider) => (
{makers.map((maker) => (
<ListItem
button
onClick={() => handleProviderChange(provider)}
key={provider.peerId}
onClick={() => handleMakerChange(maker)}
key={maker.peerId}
>
<ProviderInfo provider={provider} key={provider.peerId} />
<MakerInfo maker={maker} key={maker.peerId} />
</ListItem>
))}
<ListSellersDialogOpenButton />
<ProviderSubmitDialogOpenButton />
<MakerSubmitDialogOpenButton />
</List>
</DialogContent>

View file

@ -8,8 +8,8 @@ import {
import ArrowForwardIosIcon from "@material-ui/icons/ArrowForwardIos";
import { useState } from "react";
import { useAppSelector } from "store/hooks";
import ProviderInfo from "./ProviderInfo";
import ProviderListDialog from "./ProviderListDialog";
import MakerInfo from "./MakerInfo";
import MakerListDialog from "./MakerListDialog";
const useStyles = makeStyles({
inner: {
@ -17,23 +17,23 @@ const useStyles = makeStyles({
width: "100%",
height: "100%",
},
providerCard: {
makerCard: {
width: "100%",
},
providerCardContent: {
makerCardContent: {
display: "flex",
alignItems: "center",
},
});
export default function ProviderSelect() {
export default function MakerSelect() {
const classes = useStyles();
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
const selectedMaker = useAppSelector(
(state) => state.makers.selectedMaker,
);
if (!selectedProvider) return <>No provider selected</>;
if (!selectedMaker) return <>No maker selected</>;
function handleSelectDialogClose() {
setSelectDialogOpen(false);
@ -45,13 +45,13 @@ export default function ProviderSelect() {
return (
<Box>
<ProviderListDialog
<MakerListDialog
open={selectDialogOpen}
onClose={handleSelectDialogClose}
/>
<Card variant="outlined" className={classes.providerCard}>
<CardContent className={classes.providerCardContent}>
<ProviderInfo provider={selectedProvider} />
<Card variant="outlined" className={classes.makerCard}>
<CardContent className={classes.makerCardContent}>
<MakerInfo maker={selectedMaker} />
<IconButton onClick={handleSelectDialogOpen} size="small">
<ArrowForwardIosIcon />
</IconButton>

View file

@ -10,19 +10,19 @@ import {
import { Multiaddr } from "multiaddr";
import { ChangeEvent, useState } from "react";
type ProviderSubmitDialogProps = {
type MakerSubmitDialogProps = {
open: boolean;
onClose: () => void;
};
export default function ProviderSubmitDialog({
export default function MakerSubmitDialog({
open,
onClose,
}: ProviderSubmitDialogProps) {
}: MakerSubmitDialogProps) {
const [multiAddr, setMultiAddr] = useState("");
const [peerId, setPeerId] = useState("");
async function handleProviderSubmit() {
async function handleMakerSubmit() {
if (multiAddr && peerId) {
await fetch("https://api.unstoppableswap.net/api/submit-provider", {
method: "post",
@ -55,7 +55,7 @@ export default function ProviderSubmitDialog({
return "The multi address should not contain the peer id (/p2p/)";
}
if (multiAddress.protoNames().find((name) => name.includes("onion"))) {
return "It is currently not possible to add a provider that is only reachable via Tor";
return "It is currently not possible to add a maker that is only reachable via Tor";
}
return null;
} catch (e) {
@ -65,10 +65,10 @@ export default function ProviderSubmitDialog({
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Submit a provider to the public registry</DialogTitle>
<DialogTitle>Submit a maker to the public registry</DialogTitle>
<DialogContent dividers>
<DialogContentText>
If the provider is valid and reachable, it will be displayed to all
If the maker is valid and reachable, it will be displayed to all
other users to trade with.
</DialogContentText>
<TextField
@ -78,7 +78,7 @@ export default function ProviderSubmitDialog({
fullWidth
helperText={
getMultiAddressError() ||
"Tells the swap client where the provider can be reached"
"Tells the swap client where the maker can be reached"
}
value={multiAddr}
onChange={handleMultiAddrChange}
@ -89,7 +89,7 @@ export default function ProviderSubmitDialog({
margin="dense"
label="Peer ID"
fullWidth
helperText="Identifies the provider and allows for secure communication"
helperText="Identifies the maker and allows for secure communication"
value={peerId}
onChange={handlePeerIdChange}
placeholder="12D3KooWCdMKjesXMJz1SiZ7HgotrxuqhQJbP5sgBm2BwP1cqThi"
@ -99,7 +99,7 @@ export default function ProviderSubmitDialog({
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleProviderSubmit}
onClick={handleMakerSubmit}
disabled={!(multiAddr && peerId && !getMultiAddressError())}
color="primary"
>

View file

@ -1,105 +0,0 @@
import { Box, Chip, makeStyles, Tooltip, Typography } from "@material-ui/core";
import { VerifiedUser } from "@material-ui/icons";
import { ExtendedProviderStatus } from "models/apiModel";
import TruncatedText from "renderer/components/other/TruncatedText";
import {
MoneroBitcoinExchangeRate,
SatsAmount,
} from "renderer/components/other/Units";
import { satsToBtc, secondsToDays } from "utils/conversionUtils";
import { isProviderOutdated } from 'utils/multiAddrUtils';
import WarningIcon from '@material-ui/icons/Warning';
import { useAppSelector } from "store/hooks";
const useStyles = makeStyles((theme) => ({
content: {
flex: 1,
"& *": {
lineBreak: "anywhere",
},
},
chipsOuter: {
display: "flex",
marginTop: theme.spacing(1),
gap: theme.spacing(0.5),
flexWrap: "wrap",
},
}));
/**
* A chip that displays the markup of the provider's exchange rate compared to the market rate.
*/
function ProviderMarkupChip({ provider }: { provider: ExtendedProviderStatus }) {
const marketExchangeRate = useAppSelector(s => s.rates?.xmrBtcRate);
if (marketExchangeRate === null)
return null;
const providerExchangeRate = satsToBtc(provider.price);
/** The markup of the exchange rate compared to the market rate in percent */
const markup = (providerExchangeRate - marketExchangeRate) / marketExchangeRate * 100;
return (
<Tooltip title="The markup this provider charges compared to centralized markets. A lower markup means that you get more Monero for your Bitcoin.">
<Chip label={`Markup ${markup.toFixed(2)}%`} />
</Tooltip>
);
}
export default function ProviderInfo({
provider,
}: {
provider: ExtendedProviderStatus;
}) {
const classes = useStyles();
const isOutdated = isProviderOutdated(provider);
return (
<Box className={classes.content}>
<Typography color="textSecondary" gutterBottom>
Swap Provider
</Typography>
<Typography variant="h5" component="h2">
{provider.multiAddr}
</Typography>
<Typography color="textSecondary" gutterBottom>
<TruncatedText limit={16} truncateMiddle>{provider.peerId}</TruncatedText>
</Typography>
<Typography variant="caption">
Exchange rate:{" "}
<MoneroBitcoinExchangeRate rate={satsToBtc(provider.price)} />
<br />
Minimum swap amount: <SatsAmount amount={provider.minSwapAmount} />
<br />
Maximum swap amount: <SatsAmount amount={provider.maxSwapAmount} />
</Typography>
<Box className={classes.chipsOuter}>
{provider.testnet && <Chip label="Testnet" />}
{provider.uptime && (
<Tooltip title="A high uptime (>90%) indicates reliability. Providers with very low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label={`${Math.round(provider.uptime * 100)}% uptime`} />
</Tooltip>
)}
{provider.age ? (
<Chip
label={`Went online ${Math.round(secondsToDays(provider.age))} ${provider.age === 1 ? "day" : "days"
} ago`}
/>
) : (
<Chip label="Discovered via rendezvous point" />
)}
{provider.recommended === true && (
<Tooltip title="This provider has shown to be exceptionally reliable">
<Chip label="Recommended" icon={<VerifiedUser />} color="primary" />
</Tooltip>
)}
{isOutdated && (
<Tooltip title="This provider is running an older version of the software. Outdated providers may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label="Outdated" icon={<WarningIcon />} color="primary" />
</Tooltip>
)}
<ProviderMarkupChip provider={provider} />
</Box>
</Box>
);
}

View file

@ -37,7 +37,7 @@ export default function BitcoinLockTxInMempoolPage({
loading
additionalContent={
<>
Most swap providers require one confirmation before locking their
Most makers require one confirmation before locking their
Monero. After they lock their funds and the Monero transaction
receives one confirmation, the swap will proceed to the next step.
<br />

View file

@ -10,7 +10,7 @@ export default function SwapSetupInflightPage({
<CircularProgressWithSubtitle
description={
<>
Starting swap with provider to lock <SatsAmount amount={btc_lock_amount} />
Starting swap with maker to lock <SatsAmount amount={btc_lock_amount} />
</>
}
/>

View file

@ -37,13 +37,13 @@ export default function InitPage() {
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false);
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
const selectedMaker = useAppSelector(
(state) => state.makers.selectedMaker,
);
async function init() {
await buyXmr(
selectedProvider,
selectedMaker,
useExternalRefundAddress ? refundAddress : null,
redeemAddress,
);
@ -99,7 +99,7 @@ export default function InitPage() {
disabled={
(!refundAddressValid && useExternalRefundAddress) ||
!redeemAddressValid ||
!selectedProvider
!selectedMaker
}
variant="contained"
color="primary"

View file

@ -35,7 +35,7 @@ export default function TorInfoBox() {
internet. It is a free and open network that is operated by
volunteers. You can start and stop Tor by clicking the buttons
below. If Tor is running, all traffic will be routed through it and
the swap provider will not be able to see your IP address.
the maker will not be able to see your IP address.
</Typography>
<CliLogsBox label="Tor Daemon Logs" logs={torStdOut.split("\n")} />
</Box>

View file

@ -91,7 +91,7 @@ export default function HistoryRowExpanded({
</TableCell>
</TableRow>
<TableRow>
<TableCell>Provider Address</TableCell>
<TableCell>Maker Address</TableCell>
<TableCell>
<Box className={classes.outerAddressBox}>
{swap.seller.addresses.map((addr) => (

View file

@ -11,15 +11,15 @@ import InputAdornment from "@material-ui/core/InputAdornment";
import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
import SwapHorizIcon from "@material-ui/icons/SwapHoriz";
import { Alert } from "@material-ui/lab";
import { ExtendedProviderStatus } from "models/apiModel";
import { ExtendedMakerStatus } from "models/apiModel";
import { ChangeEvent, useEffect, useState } from "react";
import { useAppSelector } from "store/hooks";
import { satsToBtc } from "utils/conversionUtils";
import {
ListSellersDialogOpenButton,
ProviderSubmitDialogOpenButton,
} from "../../modal/provider/ProviderListDialog";
import ProviderSelect from "../../modal/provider/ProviderSelect";
MakerSubmitDialogOpenButton,
} from "../../modal/provider/MakerListDialog";
import MakerSelect from "../../modal/provider/MakerSelect";
import SwapDialog from "../../modal/swap/SwapDialog";
// After RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN failed reconnection attempts we can assume the public registry is down
@ -43,7 +43,7 @@ const useStyles = makeStyles((theme) => ({
headerText: {
padding: theme.spacing(1),
},
providerInfo: {
makerInfo: {
padding: theme.spacing(1),
},
swapIconOuter: {
@ -53,12 +53,12 @@ const useStyles = makeStyles((theme) => ({
swapIcon: {
marginRight: theme.spacing(1),
},
noProvidersAlertOuter: {
noMakersAlertOuter: {
display: "flex",
flexDirection: "column",
gap: theme.spacing(1),
},
noProvidersAlertButtonsOuter: {
noMakersAlertButtonsOuter: {
display: "flex",
gap: theme.spacing(1),
},
@ -76,17 +76,17 @@ function Title() {
);
}
function HasProviderSwapWidget({
selectedProvider,
function HasMakerSwapWidget({
selectedMaker,
}: {
selectedProvider: ExtendedProviderStatus;
selectedMaker: ExtendedMakerStatus;
}) {
const classes = useStyles();
const forceShowDialog = useAppSelector((state) => state.swap.state !== null);
const [showDialog, setShowDialog] = useState(false);
const [btcFieldValue, setBtcFieldValue] = useState<number | string>(
satsToBtc(selectedProvider.minSwapAmount),
satsToBtc(selectedMaker.minSwapAmount),
);
const [xmrFieldValue, setXmrFieldValue] = useState(1);
@ -100,7 +100,7 @@ function HasProviderSwapWidget({
setXmrFieldValue(0);
} else {
const convertedXmrAmount =
parsedBtcAmount / satsToBtc(selectedProvider.price);
parsedBtcAmount / satsToBtc(selectedMaker.price);
setXmrFieldValue(convertedXmrAmount);
}
}
@ -110,15 +110,15 @@ function HasProviderSwapWidget({
if (Number.isNaN(parsedBtcAmount)) {
return "This is not a valid number";
}
if (parsedBtcAmount < satsToBtc(selectedProvider.minSwapAmount)) {
if (parsedBtcAmount < satsToBtc(selectedMaker.minSwapAmount)) {
return `The minimum swap amount is ${satsToBtc(
selectedProvider.minSwapAmount,
)} BTC. Switch to a different provider if you want to swap less.`;
selectedMaker.minSwapAmount,
)} BTC. Switch to a different maker if you want to swap less.`;
}
if (parsedBtcAmount > satsToBtc(selectedProvider.maxSwapAmount)) {
if (parsedBtcAmount > satsToBtc(selectedMaker.maxSwapAmount)) {
return `The maximum swap amount is ${satsToBtc(
selectedProvider.maxSwapAmount,
)} BTC. Switch to a different provider if you want to swap more.`;
selectedMaker.maxSwapAmount,
)} BTC. Switch to a different maker if you want to swap more.`;
}
return null;
}
@ -127,7 +127,7 @@ function HasProviderSwapWidget({
setShowDialog(true);
}
useEffect(updateXmrValue, [btcFieldValue, selectedProvider]);
useEffect(updateXmrValue, [btcFieldValue, selectedMaker]);
return (
// 'elevation' prop can't be passed down (type def issue)
@ -160,7 +160,7 @@ function HasProviderSwapWidget({
endAdornment: <InputAdornment position="end">XMR</InputAdornment>,
}}
/>
<ProviderSelect />
<MakerSelect />
<Fab variant="extended" color="primary" onClick={handleGuideDialogOpen}>
<SwapHorizIcon className={classes.swapIcon} />
Swap
@ -173,22 +173,22 @@ function HasProviderSwapWidget({
);
}
function HasNoProvidersSwapWidget() {
function HasNoMakersSwapWidget() {
const forceShowDialog = useAppSelector((state) => state.swap.state !== null);
const isPublicRegistryDown = useAppSelector((state) =>
isRegistryDown(state.providers.registry.connectionFailsCount),
isRegistryDown(state.makers.registry.connectionFailsCount),
);
const classes = useStyles();
const alertBox = isPublicRegistryDown ? (
<Alert severity="info">
<Box className={classes.noProvidersAlertOuter}>
<Box className={classes.noMakersAlertOuter}>
<Typography>
Currently, the public registry of providers seems to be unreachable.
Currently, the public registry of makers seems to be unreachable.
Here&apos;s what you can do:
<ul>
<li>
Try discovering a provider by connecting to a rendezvous point
Try discovering a maker by connecting to a rendezvous point
</li>
<li>
Try again later when the public registry may be reachable again
@ -202,20 +202,20 @@ function HasNoProvidersSwapWidget() {
</Alert>
) : (
<Alert severity="info">
<Box className={classes.noProvidersAlertOuter}>
<Box className={classes.noMakersAlertOuter}>
<Typography>
Currently, there are no providers (trading partners) available in the
Currently, there are no makers (trading partners) available in the
official registry. Here&apos;s what you can do:
<ul>
<li>
Try discovering a provider by connecting to a rendezvous point
Try discovering a maker by connecting to a rendezvous point
</li>
<li>Add a new provider to the public registry</li>
<li>Try again later when more providers may be available</li>
<li>Add a new maker to the public registry</li>
<li>Try again later when more makers may be available</li>
</ul>
</Typography>
<Box>
<ProviderSubmitDialogOpenButton />
<MakerSubmitDialogOpenButton />
<ListSellersDialogOpenButton />
</Box>
</Box>
@ -225,12 +225,12 @@ function HasNoProvidersSwapWidget() {
return (
<Box>
{alertBox}
<SwapDialog open={forceShowDialog} onClose={() => {}} />
<SwapDialog open={forceShowDialog} onClose={() => { }} />
</Box>
);
}
function ProviderLoadingSwapWidget() {
function MakerLoadingSwapWidget() {
const classes = useStyles();
return (
@ -245,21 +245,21 @@ function ProviderLoadingSwapWidget() {
}
export default function SwapWidget() {
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
const selectedMaker = useAppSelector(
(state) => state.makers.selectedMaker,
);
// If we fail more than RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN reconnect attempts, we'll show the "no providers" widget. We can assume the public registry is down.
const providerLoading = useAppSelector(
// If we fail more than RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN reconnect attempts, we'll show the "no makers" widget. We can assume the public registry is down.
const makerLoading = useAppSelector(
(state) =>
state.providers.registry.providers === null &&
!isRegistryDown(state.providers.registry.connectionFailsCount),
state.makers.registry.makers === null &&
!isRegistryDown(state.makers.registry.connectionFailsCount),
);
if (providerLoading) {
return <ProviderLoadingSwapWidget />;
if (makerLoading) {
return <MakerLoadingSwapWidget />;
}
if (selectedProvider) {
return <HasProviderSwapWidget selectedProvider={selectedProvider} />;
if (selectedMaker) {
return <HasMakerSwapWidget selectedMaker={selectedMaker} />;
}
return <HasNoProvidersSwapWidget />;
return <HasNoMakersSwapWidget />;
}

View file

@ -40,7 +40,7 @@ import {
} from "store/features/rpcSlice";
import { swapProgressEventReceived } from "store/features/swapSlice";
import { store } from "./store/storeRenderer";
import { Provider } from "models/apiModel";
import { Maker } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel";
@ -48,7 +48,7 @@ import logger from "utils/logger";
import { getNetwork, getNetworkName, isTestnet } from "store/config";
import { Blockchain, Network } from "store/features/settingsSlice";
import { setStatus } from "store/features/nodesSlice";
import { discoveredProvidersByRendezvous } from "store/features/providersSlice";
import { discoveredMakersByRendezvous } from "store/features/makersSlice";
export const PRESET_RENDEZVOUS_POINTS = [
"/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE",
@ -57,7 +57,7 @@ export const PRESET_RENDEZVOUS_POINTS = [
export async function fetchSellersAtPresetRendezvousPoints() {
await Promise.all(PRESET_RENDEZVOUS_POINTS.map(async (rendezvousPoint) => {
const response = await listSellersAtRendezvousPoint(rendezvousPoint);
store.dispatch(discoveredProvidersByRendezvous(response.sellers));
store.dispatch(discoveredMakersByRendezvous(response.sellers));
logger.log(`Discovered ${response.sellers.length} sellers at rendezvous point ${rendezvousPoint} during startup fetch`);
}),
@ -118,7 +118,7 @@ export async function withdrawBtc(address: string): Promise<string> {
}
export async function buyXmr(
seller: Provider,
seller: Maker,
bitcoin_change_address: string | null,
monero_receive_address: string,
) {

View file

@ -1,14 +1,15 @@
import alertsSlice from "./features/alertsSlice";
import providersSlice from "./features/providersSlice";
import makersSlice from "./features/makersSlice";
import ratesSlice from "./features/ratesSlice";
import rpcSlice from "./features/rpcSlice";
import swapReducer from "./features/swapSlice";
import torSlice from "./features/torSlice";
import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice";
export const reducers = {
swap: swapReducer,
providers: providersSlice,
makers: makersSlice,
tor: torSlice,
rpc: rpcSlice,
alerts: alertsSlice,

View file

@ -1,4 +1,4 @@
import { ExtendedProviderStatus } from "models/apiModel";
import { ExtendedMakerStatus } from "models/apiModel";
import { splitPeerIdFromMultiAddress } from "utils/parseUtils";
import { getMatches } from '@tauri-apps/plugin-cli';
import { Network } from "./features/settingsSlice";
@ -19,14 +19,14 @@ export function isTestnet() {
export const isDevelopment = true;
export function getStubTestnetProvider(): ExtendedProviderStatus | null {
const stubProviderAddress = import.meta.env
export function getStubTestnetMaker(): ExtendedMakerStatus | null {
const stubMakerAddress = import.meta.env
.VITE_TESTNET_STUB_PROVIDER_ADDRESS;
if (stubProviderAddress != null) {
if (stubMakerAddress != null) {
try {
const [multiAddr, peerId] =
splitPeerIdFromMultiAddress(stubProviderAddress);
splitPeerIdFromMultiAddress(stubMakerAddress);
return {
multiAddr,

View file

@ -0,0 +1,127 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedMakerStatus, MakerStatus } from "models/apiModel";
import { Seller } from "models/tauriModel";
import { getStubTestnetMaker } from "store/config";
import { rendezvousSellerToMakerStatus } from "utils/conversionUtils";
import { isMakerOutdated } from "utils/multiAddrUtils";
import { sortMakerList } from "utils/sortUtils";
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,
};
function selectNewSelectedMaker(
slice: MakersSlice,
peerId?: string,
): MakerStatus {
const selectedPeerId = peerId || slice.selectedMaker?.peerId;
// Check if we still have a record of the currently selected provider
const currentMaker = slice.registry.makers?.find((prov) => prov.peerId === selectedPeerId) || slice.rendezvous.makers.find((prov) => prov.peerId === selectedPeerId);
// If the currently selected provider is not outdated, keep it
if (currentMaker != null && !isMakerOutdated(currentMaker)) {
return currentMaker;
}
// Otherwise we'd prefer to switch to a provider that has the newest version
const providers = sortMakerList([
...(slice.registry.makers ?? []),
...(slice.rendezvous.makers ?? []),
]);
return providers.at(0) || null;
}
export const makersSlice = createSlice({
name: "providers",
initialState,
reducers: {
discoveredMakersByRendezvous(slice, action: PayloadAction<Seller[]>) {
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);
}
});
// Sort the provider list and select a new provider if needed
slice.rendezvous.makers = sortMakerList(slice.rendezvous.makers);
slice.selectedMaker = selectNewSelectedMaker(slice);
},
setRegistryMakers(
slice,
action: PayloadAction<ExtendedMakerStatus[]>,
) {
if (stubTestnetMaker) {
action.payload.push(stubTestnetMaker);
}
// Sort the provider list and select a new provider if needed
slice.registry.makers = sortMakerList(action.payload);
slice.selectedMaker = selectNewSelectedMaker(slice);
},
registryConnectionFailed(slice) {
slice.registry.connectionFailsCount += 1;
},
setSelectedMaker(
slice,
action: PayloadAction<{
peerId: string;
}>,
) {
slice.selectedMaker = selectNewSelectedMaker(
slice,
action.payload.peerId,
);
},
},
});
export const {
discoveredMakersByRendezvous,
setRegistryMakers,
registryConnectionFailed,
setSelectedMaker,
} = makersSlice.actions;
export default makersSlice.reducer;

View file

@ -1,127 +0,0 @@
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 { isProviderOutdated } from "utils/multiAddrUtils";
import { sortProviderList } from "utils/sortUtils";
const stubTestnetProvider = getStubTestnetProvider();
export interface ProvidersSlice {
rendezvous: {
providers: (ExtendedProviderStatus | ProviderStatus)[];
};
registry: {
providers: ExtendedProviderStatus[] | null;
// This counts how many failed connections attempts we have counted since the last successful connection
connectionFailsCount: number;
};
selectedProvider: ExtendedProviderStatus | null;
}
const initialState: ProvidersSlice = {
rendezvous: {
providers: [],
},
registry: {
providers: stubTestnetProvider ? [stubTestnetProvider] : null,
connectionFailsCount: 0,
},
selectedProvider: null,
};
function selectNewSelectedProvider(
slice: ProvidersSlice,
peerId?: string,
): ProviderStatus {
const selectedPeerId = peerId || slice.selectedProvider?.peerId;
// Check if we still have a record of the currently selected provider
const currentProvider = slice.registry.providers?.find((prov) => prov.peerId === selectedPeerId) || slice.rendezvous.providers.find((prov) => prov.peerId === selectedPeerId);
// If the currently selected provider is not outdated, keep it
if (currentProvider != null && !isProviderOutdated(currentProvider)) {
return currentProvider;
}
// Otherwise we'd prefer to switch to a provider that has the newest version
const providers = sortProviderList([
...(slice.registry.providers ?? []),
...(slice.rendezvous.providers ?? []),
]);
return providers.at(0) || null;
}
export const providersSlice = createSlice({
name: "providers",
initialState,
reducers: {
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
const indexOfExistingProvider = slice.rendezvous.providers.findIndex(
(prov) =>
prov.peerId === discoveredProviderStatus.peerId &&
prov.multiAddr === discoveredProviderStatus.multiAddr,
);
// Avoid duplicate entries, replace them instead
if (indexOfExistingProvider !== -1) {
slice.rendezvous.providers[indexOfExistingProvider] =
discoveredProviderStatus;
} else {
slice.rendezvous.providers.push(discoveredProviderStatus);
}
});
// Sort the provider list and select a new provider if needed
slice.rendezvous.providers = sortProviderList(slice.rendezvous.providers);
slice.selectedProvider = selectNewSelectedProvider(slice);
},
setRegistryProviders(
slice,
action: PayloadAction<ExtendedProviderStatus[]>,
) {
if (stubTestnetProvider) {
action.payload.push(stubTestnetProvider);
}
// Sort the provider list and select a new provider if needed
slice.registry.providers = sortProviderList(action.payload);
slice.selectedProvider = selectNewSelectedProvider(slice);
},
registryConnectionFailed(slice) {
slice.registry.connectionFailsCount += 1;
},
setSelectedProvider(
slice,
action: PayloadAction<{
peerId: string;
}>,
) {
slice.selectedProvider = selectNewSelectedProvider(
slice,
action.payload.peerId,
);
},
},
});
export const {
discoveredProvidersByRendezvous,
setRegistryProviders,
registryConnectionFailed,
setSelectedProvider,
} = providersSlice.actions;
export default providersSlice.reducer;

View file

@ -1,5 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel";
import { ExtendedMakerStatus, MakerStatus } from "models/apiModel";
import {
TauriLogEvent,
GetSwapInfoResponse,
@ -16,7 +16,7 @@ import logger from "utils/logger";
interface State {
balance: number | null;
withdrawTxId: string | null;
rendezvous_discovered_sellers: (ExtendedProviderStatus | ProviderStatus)[];
rendezvous_discovered_sellers: (ExtendedMakerStatus | MakerStatus)[];
swapInfos: {
[swapId: string]: GetSwapInfoResponseExt;
};
@ -103,9 +103,9 @@ export const rpcSlice = createSlice({
rpcSetWithdrawTxId(slice, action: PayloadAction<string>) {
slice.state.withdrawTxId = action.payload;
},
rpcSetRendezvousDiscoveredProviders(
rpcSetRendezvousDiscoveredMakers(
slice,
action: PayloadAction<(ExtendedProviderStatus | ProviderStatus)[]>,
action: PayloadAction<(ExtendedMakerStatus | MakerStatus)[]>,
) {
slice.state.rendezvous_discovered_sellers = action.payload;
},
@ -146,7 +146,7 @@ export const {
rpcSetBalance,
rpcSetWithdrawTxId,
rpcResetWithdrawTxId,
rpcSetRendezvousDiscoveredProviders,
rpcSetRendezvousDiscoveredMakers,
rpcSetSwapInfo,
rpcSetMoneroRecoveryKeys,
rpcResetMoneroRecoveryKeys,

View file

@ -8,7 +8,7 @@ import { isCliLogRelatedToSwap } from "models/cliModel";
import { SettingsState } from "./features/settingsSlice";
import { NodesSlice } from "./features/nodesSlice";
import { RatesState } from "./features/ratesSlice";
import { sortProviderList } from "utils/sortUtils";
import { sortMakerList } from "utils/sortUtils";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
@ -70,13 +70,13 @@ export function useActiveSwapLogs() {
);
}
export function useAllProviders() {
export function useAllMakers() {
return useAppSelector((state) => {
const registryProviders = state.providers.registry.providers || [];
const listSellersProviders = state.providers.rendezvous.providers || [];
const all = [...registryProviders, ...listSellersProviders];
const registryMakers = state.makers.registry.makers || [];
const listSellersMakers = state.makers.rendezvous.makers || [];
const all = [...registryMakers, ...listSellersMakers];
return sortProviderList(all);
return sortMakerList(all);
});
}

View file

@ -1,4 +1,4 @@
import { ProviderStatus } from "models/apiModel";
import { MakerStatus } from "models/apiModel";
import { Seller } from "models/tauriModel";
import { isTestnet } from "store/config";
import { splitPeerIdFromMultiAddress } from "./parseUtils";
@ -45,12 +45,12 @@ export function secondsToDays(seconds: number): number {
return seconds / 86400;
}
// Convert the "Seller" object returned by the list sellers tauri endpoint to a "ProviderStatus" object
// 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 rendezvousSellerToProviderStatus(
export function rendezvousSellerToMakerStatus(
seller: Seller,
): ProviderStatus | null {
): MakerStatus | null {
if (seller.status.type === "Unreachable") {
return null;
}

View file

@ -1,23 +1,23 @@
import { ExtendedProviderStatus, Provider } from "models/apiModel";
import { ExtendedMakerStatus, Maker } from "models/apiModel";
import { Multiaddr } from "multiaddr";
import semver from "semver";
import { isTestnet } from "store/config";
const MIN_ASB_VERSION = "1.0.0-alpha.1"
export function providerToConcatenatedMultiAddr(provider: Provider) {
export function providerToConcatenatedMultiAddr(provider: Maker) {
return new Multiaddr(provider.multiAddr)
.encapsulate(`/p2p/${provider.peerId}`)
.toString();
}
export function isProviderOnCorrectNetwork(
provider: ExtendedProviderStatus,
export function isMakerOnCorrectNetwork(
provider: ExtendedMakerStatus,
): boolean {
return provider.testnet === isTestnet();
}
export function isProviderOutdated(provider: ExtendedProviderStatus): boolean {
export function isMakerOutdated(provider: ExtendedMakerStatus): boolean {
if (provider.version != null) {
if (semver.satisfies(provider.version, `>=${MIN_ASB_VERSION}`))
return false;

View file

@ -1,16 +1,16 @@
import { ExtendedProviderStatus } from "models/apiModel";
import { isProviderOnCorrectNetwork, isProviderOutdated } from "./multiAddrUtils";
import { ExtendedMakerStatus } from "models/apiModel";
import { isMakerOnCorrectNetwork, isMakerOutdated } from "./multiAddrUtils";
export function sortProviderList(list: ExtendedProviderStatus[]) {
export function sortMakerList(list: ExtendedMakerStatus[]) {
return list
// Filter out providers that are on the wrong network (testnet / mainnet)
.filter(isProviderOnCorrectNetwork)
// Filter out makers that are on the wrong network (testnet / mainnet)
.filter(isMakerOnCorrectNetwork)
.concat()
// Sort by criteria
.sort((firstEl, secondEl) => {
// If either provider is outdated, prioritize the one that isn't
if (isProviderOutdated(firstEl) && !isProviderOutdated(secondEl)) return 1;
if (!isProviderOutdated(firstEl) && isProviderOutdated(secondEl)) return -1;
if (isMakerOutdated(firstEl) && !isMakerOutdated(secondEl)) return 1;
if (!isMakerOutdated(firstEl) && isMakerOutdated(secondEl)) return -1;
// If neither of them have a relevancy score or they are the same, sort by price
if (firstEl.relevancy == secondEl.relevancy) {
@ -24,7 +24,7 @@ export function sortProviderList(list: ExtendedProviderStatus[]) {
// Otherwise, sort by relevancy score
return secondEl.relevancy - firstEl.relevancy;
})
// Remove duplicate providers
// Remove duplicate makers
.filter((provider, index, self) =>
index === self.findIndex((p) => p.peerId === provider.peerId)
)

View file

@ -1001,6 +1001,13 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.6.tgz#193ced6a40c8006cfc1ca3f4553444fb38f0e543"
integrity sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==
"@types/node@*":
version "22.9.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.3.tgz#08f3d64b3bc6d74b162d36f60213e8a6704ef2b4"
integrity sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==
dependencies:
undici-types "~6.19.8"
"@types/node@^20.14.10":
version "20.14.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.10.tgz#a1a218290f1b6428682e3af044785e5874db469a"
@ -1432,6 +1439,13 @@ caniuse-lite@^1.0.30001629:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz#32c467d4bf1f1a0faa63fc793c2ba81169e7652f"
integrity sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==
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.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.1.tgz#f035d9792a22b481ead1c65908d14bb62ec1c82c"
@ -2538,6 +2552,13 @@ iterator.prototype@^1.1.2:
reflect.getprototypeof "^1.0.4"
set-function-name "^2.0.1"
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"
joycon@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
@ -3750,6 +3771,11 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~6.19.8:
version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
update-browserslist-db@^1.0.16:
version "1.1.0"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e"