feat(gui): Display timelock status using a timeline (#153)

This commit is contained in:
binarybaron 2024-11-14 13:33:20 +01:00 committed by GitHub
parent 3e79bb3712
commit 4cf5cf719a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 499 additions and 343 deletions

View file

@ -18,7 +18,7 @@
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "^4.0.0-alpha.61",
"@reduxjs/toolkit": "^2.2.6",
"@reduxjs/toolkit": "^2.3.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-cli": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",

View file

@ -1,3 +1,4 @@
import { exhaustiveGuard } from "utils/typescriptUtils";
import {
ExpiredTimelocks,
GetSwapInfoResponse,
@ -30,6 +31,26 @@ export enum BobStateName {
SafelyAborted = "safely aborted",
}
export function bobStateNameToHumanReadable(stateName: BobStateName): string {
switch (stateName) {
case BobStateName.Started: return "Started";
case BobStateName.SwapSetupCompleted: return "Setup completed";
case BobStateName.BtcLocked: return "Bitcoin locked";
case BobStateName.XmrLockProofReceived: return "Monero locked";
case BobStateName.XmrLocked: return "Monero locked and fully confirmed";
case BobStateName.EncSigSent: return "Encrypted signature sent";
case BobStateName.BtcRedeemed: return "Bitcoin redeemed";
case BobStateName.CancelTimelockExpired: return "Cancel timelock expired";
case BobStateName.BtcCancelled: return "Bitcoin cancelled";
case BobStateName.BtcRefunded: return "Bitcoin refunded";
case BobStateName.XmrRedeemed: return "Monero redeemed";
case BobStateName.BtcPunished: return "Bitcoin punished";
case BobStateName.SafelyAborted: return "Safely aborted";
default:
return exhaustiveGuard(stateName);
}
}
// TODO: This is a temporary solution until we have a typeshare definition for BobStateName
export type GetSwapInfoResponseExt = GetSwapInfoResponse & {
state_name: BobStateName;
@ -39,6 +60,22 @@ export type TimelockNone = Extract<ExpiredTimelocks, { type: "None" }>;
export type TimelockCancel = Extract<ExpiredTimelocks, { type: "Cancel" }>;
export type TimelockPunish = Extract<ExpiredTimelocks, { type: "Punish" }>;
// This function returns the absolute block number of the timelock relative to the block the tx_lock was included in
export function getAbsoluteBlock(timelock: ExpiredTimelocks, cancelTimelock: number, punishTimelock: number): number {
if (timelock.type === "None") {
return cancelTimelock - timelock.content.blocks_left;
}
if (timelock.type === "Cancel") {
return cancelTimelock + punishTimelock - timelock.content.blocks_left;
}
if (timelock.type === "Punish") {
return cancelTimelock + punishTimelock;
}
// We match all cases
return exhaustiveGuard(timelock);
}
export type BobStateNameRunningSwap = Exclude<
BobStateName,
| BobStateName.Started
@ -50,7 +87,11 @@ export type BobStateNameRunningSwap = Exclude<
>;
export type GetSwapInfoResponseExtRunningSwap = GetSwapInfoResponseExt & {
stateName: BobStateNameRunningSwap;
state_name: BobStateNameRunningSwap;
};
export type GetSwapInfoResponseExtWithTimelock = GetSwapInfoResponseExt & {
timelock: ExpiredTimelocks;
};
export function isBobStateNameRunningSwap(
@ -157,3 +198,14 @@ export function isGetSwapInfoResponseRunningSwap(
): response is GetSwapInfoResponseExtRunningSwap {
return isBobStateNameRunningSwap(response.state_name);
}
/**
* Type guard for GetSwapInfoResponseExt to ensure timelock is not null
* @param response The swap info response to check
* @returns True if the timelock exists, false otherwise
*/
export function isGetSwapInfoResponseWithTimelock(
response: GetSwapInfoResponseExt
): response is GetSwapInfoResponseExtWithTimelock {
return response.timelock !== null;
}

View file

@ -5,31 +5,31 @@ import { SatsAmount } from "../other/Units";
import WalletRefreshButton from "../pages/wallet/WalletRefreshButton";
const useStyles = makeStyles((theme) => ({
outer: {
paddingBottom: theme.spacing(1),
},
outer: {
paddingBottom: theme.spacing(1),
},
}));
export default function RemainingFundsWillBeUsedAlert() {
const classes = useStyles();
const balance = useAppSelector((s) => s.rpc.state.balance);
const classes = useStyles();
const balance = useAppSelector((s) => s.rpc.state.balance);
if (balance == null || balance <= 0) {
return <></>;
}
if (balance == null || balance <= 0) {
return <></>;
}
return (
<Box className={classes.outer}>
<Alert
severity="warning"
action={<WalletRefreshButton />}
variant="filled"
>
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
instantaneously.
</Alert>
</Box>
);
return (
<Box className={classes.outer}>
<Alert
severity="warning"
action={<WalletRefreshButton />}
variant="filled"
>
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
instantaneously.
</Alert>
</Box>
);
}

View file

@ -1,100 +0,0 @@
import { makeStyles } from "@material-ui/core";
import { Alert, AlertTitle } from "@material-ui/lab";
import {
isSwapTimelockInfoCancelled,
isSwapTimelockInfoNone,
} from "models/rpcModel";
import { useActiveSwapInfo } from "store/hooks";
import HumanizedBitcoinBlockDuration from "../other/HumanizedBitcoinBlockDuration";
const useStyles = makeStyles((theme) => ({
outer: {
marginBottom: theme.spacing(1),
},
list: {
margin: theme.spacing(0.25),
},
}));
export default function SwapMightBeCancelledAlert({
bobBtcLockTxConfirmations,
}: {
bobBtcLockTxConfirmations: number;
}) {
// TODO: Reimplement this using Tauri
return <></>;
const classes = useStyles();
const swap = useActiveSwapInfo();
if (
bobBtcLockTxConfirmations < 5 ||
swap === null ||
swap.timelock === null
) {
return <></>;
}
const { timelock } = swap;
const punishTimelockOffset = swap.punish_timelock;
return (
<Alert severity="warning" className={classes.outer} variant="filled">
<AlertTitle>Be careful!</AlertTitle>
The swap provider has taken a long time to lock their Monero. This might
mean that:
<ul className={classes.list}>
<li>
There is a technical issue that prevents them from locking their funds
</li>
<li>They are a malicious actor (unlikely)</li>
</ul>
<br />
There is still hope for the swap to be successful but you have to be extra
careful. Regardless of why it has taken them so long, it is important that
you refund the swap within the required time period if the swap is not
completed. If you fail to to do so, you will be punished and lose your
money.
<ul className={classes.list}>
{isSwapTimelockInfoNone(timelock) && (
<>
<li>
<strong>
You will be able to refund in about{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.None.blocks_left}
/>
</strong>
</li>
<li>
<strong>
If you have not refunded or completed the swap in about{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.None.blocks_left + punishTimelockOffset}
/>
, you will lose your funds.
</strong>
</li>
</>
)}
{isSwapTimelockInfoCancelled(timelock) && (
<li>
<strong>
If you have not refunded or completed the swap in about{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.Cancel.blocks_left}
/>
, you will lose your funds.
</strong>
</li>
)}
<li>
As long as you see this screen, the swap will be refunded
automatically when the time comes. If this fails, you have to manually
refund by navigating to the History page.
</li>
</ul>
</Alert>
);
}

View file

@ -1,72 +1,79 @@
import { Box, makeStyles } from "@material-ui/core";
import { Alert, AlertTitle } from "@material-ui/lab/";
import { GetSwapInfoResponse } from "models/tauriModel";
import {
BobStateName,
GetSwapInfoResponseExt,
GetSwapInfoResponseExtRunningSwap,
isGetSwapInfoResponseRunningSwap,
isGetSwapInfoResponseWithTimelock,
TimelockCancel,
TimelockNone,
} from "models/tauriModelExt";
import { ReactNode } from "react";
import { exhaustiveGuard } from "utils/typescriptUtils";
import HumanizedBitcoinBlockDuration from "../other/HumanizedBitcoinBlockDuration";
import TruncatedText from "../other/TruncatedText";
import {
SwapCancelRefundButton,
SwapResumeButton,
} from "../pages/history/table/HistoryRowActions";
import { SwapMoneroRecoveryButton } from "../pages/history/table/SwapMoneroRecoveryButton";
import HumanizedBitcoinBlockDuration from "../../other/HumanizedBitcoinBlockDuration";
import TruncatedText from "../../other/TruncatedText";
import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton";
import { TimelockTimeline } from "./TimelockTimeline";
const useStyles = makeStyles({
const useStyles = makeStyles((theme) => ({
box: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
gap: theme.spacing(1),
},
list: {
padding: "0px",
margin: "0px",
"& li": {
marginBottom: theme.spacing(0.5),
"&:last-child": {
marginBottom: 0
}
},
},
});
alertMessage: {
flexGrow: 1,
},
}));
/**
* Component for displaying a list of messages.
* @param messages - Array of messages to display.
* @returns JSX.Element
*/
const MessageList = ({ messages }: { messages: ReactNode[] }) => {
function MessageList({ messages }: { messages: ReactNode[]; }) {
const classes = useStyles();
return (
<ul className={classes.list}>
{messages.map((msg, i) => (
{messages.filter(msg => msg != null).map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
);
};
}
/**
* Sub-component for displaying alerts when the swap is in a safe state.
* @param swap - The swap information.
* @returns JSX.Element
*/
const BitcoinRedeemedStateAlert = ({ swap }: { swap: GetSwapInfoResponse }) => {
function BitcoinRedeemedStateAlert({ swap }: { swap: GetSwapInfoResponseExt; }) {
const classes = useStyles();
return (
<Box className={classes.box}>
<MessageList
messages={[
"The Bitcoin has been redeemed by the other party",
"There is no risk of losing funds. You can take your time",
"The Monero will be automatically redeemed to the address you provided as soon as you resume the swap",
"If this step fails, you can manually redeem the funds",
]}
/>
"There is no risk of losing funds. Take as much time as you need",
"The Monero will automatically be redeemed to your provided address once you resume the swap",
"If this step fails, you can manually redeem your funds",
]} />
<SwapMoneroRecoveryButton swap={swap} size="small" variant="contained" />
</Box>
);
};
}
/**
* Sub-component for displaying alerts when the swap is in a state with no timelock info.
@ -74,30 +81,33 @@ const BitcoinRedeemedStateAlert = ({ swap }: { swap: GetSwapInfoResponse }) => {
* @param punishTimelockOffset - The punish timelock offset.
* @returns JSX.Element
*/
const BitcoinLockedNoTimelockExpiredStateAlert = ({
timelock,
punishTimelockOffset,
function BitcoinLockedNoTimelockExpiredStateAlert({
timelock, cancelTimelockOffset, punishTimelockOffset, isRunning,
}: {
timelock: TimelockNone;
cancelTimelockOffset: number;
punishTimelockOffset: number;
}) => (
<MessageList
messages={[
<>
Your Bitcoin is locked. If the swap is not completed in approximately{" "}
<HumanizedBitcoinBlockDuration blocks={timelock.content.blocks_left} />,
you need to refund
</>,
<>
You might lose your funds if you do not refund or complete the swap
within{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.content.blocks_left + punishTimelockOffset}
/>
</>,
]}
/>
);
isRunning: boolean;
}) {
return (
<MessageList
messages={[
isRunning ? "We are waiting for the other party to lock their Monero" : null,
<>
If the swap isn't completed in {" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.content.blocks_left}
displayBlocks={false}
/>, it needs to be refunded
</>,
"For that, you need to have the app open sometime within the refund period",
<>
After that, cooperation from the other party would be required to recover the funds
</>,
isRunning ? null : "Please resume the swap to continue"
]} />
);
}
/**
* Sub-component for displaying alerts when the swap timelock is expired
@ -106,46 +116,49 @@ const BitcoinLockedNoTimelockExpiredStateAlert = ({
* @param swap - The swap information.
* @returns JSX.Element
*/
const BitcoinPossiblyCancelledAlert = ({
swap,
timelock,
function BitcoinPossiblyCancelledAlert({
swap, timelock,
}: {
swap: GetSwapInfoResponseExt;
timelock: TimelockCancel;
}) => {
const classes = useStyles();
}) {
return (
<Box className={classes.box}>
<MessageList
messages={[
"The swap was cancelled because it did not complete in time",
"You must resume the swap immediately to refund your Bitcoin",
<>
You might lose your funds if you do not refund within{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.content.blocks_left}
/>
</>,
]}
/>
</Box>
<MessageList
messages={[
"The swap is being cancelled because it was not completed in time",
"To refund your Bitcoin, resume the swap",
<>
If we haven't refunded in{" "}
<HumanizedBitcoinBlockDuration
blocks={timelock.content.blocks_left} />
, cooperation from the other party will be required to recover the funds
</>
]} />
);
};
}
/**
* Sub-component for displaying alerts requiring immediate action.
* @returns JSX.Element
*/
const ImmediateActionAlert = () => (
<>Resume the swap immediately to avoid losing your funds</>
);
function PunishTimelockExpiredAlert() {
return (
<MessageList
messages={[
"We couldn't refund within the refund period",
"We might still be able to redeem the Monero. However, this will require cooperation from the other party",
"Resume the swap as soon as possible",
]} />
);
}
/**
* Main component for displaying the appropriate swap alert status text.
* @param swap - The swap information.
* @returns JSX.Element | null
*/
function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) {
export function StateAlert({ swap, isRunning }: { swap: GetSwapInfoResponseExtRunningSwap; isRunning: boolean; }) {
switch (swap.state_name) {
// This is the state where the swap is safe because the other party has redeemed the Bitcoin
// It cannot be punished anymore
@ -165,11 +178,12 @@ function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) {
case "None":
return (
<BitcoinLockedNoTimelockExpiredStateAlert
punishTimelockOffset={swap.punish_timelock}
timelock={swap.timelock}
cancelTimelockOffset={swap.cancel_timelock}
punishTimelockOffset={swap.punish_timelock}
isRunning={isRunning}
/>
);
case "Cancel":
return (
<BitcoinPossiblyCancelledAlert
@ -178,19 +192,17 @@ function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) {
/>
);
case "Punish":
return <ImmediateActionAlert />;
return <PunishTimelockExpiredAlert />;
default:
// We have covered all possible timelock states above
// If we reach this point, it means we have missed a case
exhaustiveGuard(swap.timelock);
}
}
return <ImmediateActionAlert />;
return <PunishTimelockExpiredAlert />;
default:
// TODO: fix the exhaustive guard
// return exhaustiveGuard(swap.state_name);
return <></>;
exhaustiveGuard(swap.state_name);
}
}
@ -201,27 +213,37 @@ function SwapAlertStatusText({ swap }: { swap: GetSwapInfoResponseExt }) {
*/
export default function SwapStatusAlert({
swap,
isRunning,
}: {
swap: GetSwapInfoResponseExt;
isRunning: boolean;
}): JSX.Element | null {
// If the swap is completed, there is no need to display the alert
// TODO: Here we should also check if the swap is in a state where any funds can be lost
// TODO: If the no Bitcoin have been locked yet, we can safely ignore the swap
const classes = useStyles();
// If the swap is completed, we do not need to display anything
if (!isGetSwapInfoResponseRunningSwap(swap)) {
return null;
}
// If we don't have a timelock for the swap, we cannot display the alert
if (!isGetSwapInfoResponseWithTimelock(swap)) {
return null;
}
return (
<Alert
key={swap.swap_id}
severity="warning"
action={<SwapResumeButton swap={swap}>Resume Swap</SwapResumeButton>}
variant="filled"
classes={{ message: classes.alertMessage }}
>
<AlertTitle>
Swap <TruncatedText>{swap.swap_id}</TruncatedText> is unfinished
{isRunning ? "Swap has been running for a while" : <>Swap <TruncatedText>{swap.swap_id}</TruncatedText> is not running</>}
</AlertTitle>
<SwapAlertStatusText swap={swap} />
<Box className={classes.box}>
<StateAlert swap={swap} isRunning={isRunning} />
<TimelockTimeline swap={swap} />
</Box>
</Alert>
);
}

View file

@ -0,0 +1,176 @@
import { useTheme, Tooltip, Typography, Box, LinearProgress, Paper } from "@material-ui/core";
import { ExpiredTimelocks } from "models/tauriModel";
import { GetSwapInfoResponseExt, getAbsoluteBlock } from "models/tauriModelExt";
import HumanizedBitcoinBlockDuration from "renderer/components/other/HumanizedBitcoinBlockDuration";
interface TimelineSegment {
title: string;
label: string;
bgcolor: string;
startBlock: number;
}
interface TimelineSegmentProps {
segment: TimelineSegment;
isActive: boolean;
absoluteBlock: number;
durationOfSegment: number | null;
totalBlocks: number;
}
function TimelineSegment({
segment,
isActive,
absoluteBlock,
durationOfSegment,
totalBlocks
}: TimelineSegmentProps) {
const theme = useTheme();
return (
<Tooltip title={<Typography variant="caption">{segment.title}</Typography>}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: segment.bgcolor,
width: `${durationOfSegment ? ((durationOfSegment / totalBlocks) * 85) : 15}%`,
position: 'relative',
}} style={{
opacity: isActive ? 1 : 0.3
}}>
{isActive && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${Math.max(5, ((absoluteBlock - segment.startBlock) / durationOfSegment) * 100)}%`,
zIndex: 1,
}}>
<LinearProgress
variant="indeterminate"
color="primary"
style={{
height: '100%',
backgroundColor: theme.palette.primary.dark,
opacity: 0.3,
}}
/>
</Box>
)}
<Typography variant="subtitle2" color="inherit" align="center" style={{ zIndex: 2 }}>
{segment.label}
</Typography>
{durationOfSegment && (
<Typography
variant="caption"
color="inherit"
align="center"
style={{
zIndex: 2,
opacity: 0.8
}}
>
{isActive && (
<>
<HumanizedBitcoinBlockDuration
blocks={durationOfSegment - (absoluteBlock - segment.startBlock)}
/>{" "}left
</>
)}
{!isActive && (
<HumanizedBitcoinBlockDuration
blocks={durationOfSegment}
/>
)}
</Typography>
)}
</Box>
</Tooltip>
);
}
export function TimelockTimeline({ swap }: {
// This forces the timelock to not be null
swap: GetSwapInfoResponseExt & { timelock: ExpiredTimelocks }
}) {
const theme = useTheme();
const timelineSegments: TimelineSegment[] = [
{
title: "Normally a swap is completed during this period",
label: "Normal",
bgcolor: theme.palette.success.main,
startBlock: 0,
},
{
title: "If the swap hasn't been completed before we reach this period, the Bitcoin needs to be refunded. For that, you need to have the app open sometime within the refund period",
label: "Refund",
bgcolor: theme.palette.warning.main,
startBlock: swap.cancel_timelock,
},
{
title: "If you didn't refund within the refund window, you will enter this period. At this point, the Bitcoin can no longer be refunded. It may still be possible to redeem the Monero with cooperation from the other party but this cannot be guaranteed.",
label: "Danger",
bgcolor: theme.palette.error.main,
startBlock: swap.cancel_timelock + swap.punish_timelock,
}
];
const totalBlocks = swap.cancel_timelock + swap.punish_timelock;
const absoluteBlock = getAbsoluteBlock(swap.timelock, swap.cancel_timelock, swap.punish_timelock);
// This calculates the duration of a segment
// by getting the the difference to the next segment
function durationOfSegment(index: number): number | null {
const nextSegment = timelineSegments[index + 1];
if (nextSegment == null) {
return null;
}
return nextSegment.startBlock - timelineSegments[index].startBlock;
}
// This function returns the index of the active segment based on the current block
// We iterate in reverse to find the first segment that has a start block less than the current block
function getActiveSegmentIndex() {
return Array.from(timelineSegments
.slice()
// We use .entries() to keep the indexes despite reversing
.entries())
.reverse()
.find(([_, segment]) => absoluteBlock >= segment.startBlock)?.[0] ?? 0;
}
return (
<Box sx={{
width: '100%',
minWidth: '100%',
flexGrow: 1
}}>
<Paper style={{
position: 'relative',
height: '5rem',
overflow: 'hidden',
}} elevation={3} variant="outlined">
<Box sx={{
position: 'relative',
height: '100%',
display: 'flex'
}}>
{timelineSegments.map((segment, index) => (
<TimelineSegment
key={index}
segment={segment}
isActive={getActiveSegmentIndex() === index}
absoluteBlock={absoluteBlock}
durationOfSegment={durationOfSegment(index)}
totalBlocks={totalBlocks}
/>
))}
</Box>
</Paper>
</Box>
);
}

View file

@ -1,6 +1,6 @@
import { Box, makeStyles } from "@material-ui/core";
import { useSwapInfosSortedByDate } from "store/hooks";
import SwapStatusAlert from "./SwapStatusAlert";
import SwapStatusAlert from "./SwapStatusAlert/SwapStatusAlert";
const useStyles = makeStyles((theme) => ({
outer: {
@ -21,7 +21,7 @@ export default function SwapTxLockAlertsBox() {
return (
<Box className={classes.outer}>
{swaps.map((swap) => (
<SwapStatusAlert key={swap.swap_id} swap={swap} />
<SwapStatusAlert key={swap.swap_id} swap={swap} isRunning={false} />
))}
</Box>
);

View file

@ -1,35 +1,51 @@
import { Box, DialogContentText } from "@material-ui/core";
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import SwapMightBeCancelledAlert from "../../../../alert/SwapMightBeCancelledAlert";
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox";
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
import { useActiveSwapInfo } from "store/hooks";
import { Box, DialogContentText } from "@material-ui/core";
// This is the number of blocks after which we consider the swap to be at risk of being unsuccessful
const BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD = 2;
export default function BitcoinLockTxInMempoolPage({
btc_lock_confirmations,
btc_lock_txid,
}: TauriSwapProgressEventContent<"BtcLockTxInMempool">) {
const swapInfo = useActiveSwapInfo();
return (
<Box>
<SwapMightBeCancelledAlert
bobBtcLockTxConfirmations={btc_lock_confirmations}
/>
<DialogContentText>
The Bitcoin lock transaction has been published. The swap will proceed
once the transaction is confirmed and the swap provider locks their
Monero.
</DialogContentText>
<BitcoinTransactionInfoBox
title="Bitcoin Lock Transaction"
txId={btc_lock_txid}
loading
additionalContent={
<>
Most swap providers require one confirmation before locking their
Monero
<br />
Confirmations: {btc_lock_confirmations}
</>
}
/>
{btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
<DialogContentText>
Your Bitcoin has been locked. {btc_lock_confirmations > 0 ?
"We are waiting for the other party to lock their Monero." :
"We are waiting for the blockchain to confirm the transaction. Once confirmed, the other party will lock their Monero."
}
</DialogContentText>
)}
<Box style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}>
{btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
<SwapStatusAlert swap={swapInfo} isRunning={true} />
)}
<BitcoinTransactionInfoBox
title="Bitcoin Lock Transaction"
txId={btc_lock_txid}
loading
additionalContent={
<>
Most swap providers 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 />
Confirmations: {btc_lock_confirmations}
</>
}
/>
</Box>
</Box>
);
}

View file

@ -8,12 +8,12 @@ import {
} from "@material-ui/core";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import { useState } from "react";
import RemainingFundsWillBeUsedAlert from "renderer/components/alert/RemainingFundsWillBeUsedAlert";
import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTextField";
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { buyXmr } from "renderer/rpc";
import { useAppSelector } from "store/hooks";
import RemainingFundsWillBeUsedAlert from "../../../../alert/RemainingFundsWillBeUsedAlert";
const useStyles = makeStyles((theme) => ({
initButton: {

View file

@ -4,14 +4,16 @@ const AVG_BLOCK_TIME_MS = 10 * 60 * 1000;
export default function HumanizedBitcoinBlockDuration({
blocks,
displayBlocks = true,
}: {
blocks: number;
displayBlocks?: boolean;
}) {
return (
<>
{`${humanizeDuration(blocks * AVG_BLOCK_TIME_MS, {
{`${humanizeDuration(blocks * AVG_BLOCK_TIME_MS, {
conjunction: " and ",
})} (${blocks} blocks)`}
})}${displayBlocks ? ` (${blocks} blocks)` : ""}`}
</>
);
}

View file

@ -347,6 +347,37 @@ function NodeTableModal({
)
}
// Create a circle SVG with a given color and radius
function Circle({ color, radius = 6 }: { color: string, radius?: number }) {
return <span>
<svg width={radius * 2} height={radius * 2} viewBox={`0 0 ${radius * 2} ${radius * 2}`}>
<circle cx={radius} cy={radius} r={radius} fill={color} />
</svg>
</span>
}
/**
* Displays a status indicator for a node
*/
function NodeStatus({ status }: { status: boolean | undefined }) {
const theme = useTheme();
switch (status) {
case true:
return <Tooltip title={"This node is available and responding to RPC requests"}>
<Circle color={theme.palette.success.dark} />
</Tooltip>;
case false:
return <Tooltip title={"This node is not available or not responding to RPC requests"}>
<Circle color={theme.palette.error.dark} />
</Tooltip>;
default:
return <Tooltip title={"The status of this node is currently unknown"}>
<HourglassEmpty />
</Tooltip>;
}
}
/**
* A table that displays the available nodes for a given network and blockchain.
* It allows you to add, remove, and move nodes up the list.
@ -368,31 +399,6 @@ function NodeTable({
const nodeStatuses = useNodes((s) => s.nodes);
const [newNode, setNewNode] = useState("");
const dispatch = useAppDispatch();
const theme = useTheme();
// Create a circle SVG with a given color and radius
const circle = (color: string, radius: number = 6) => <svg width={radius * 2} height={radius * 2} viewBox={`0 0 ${radius * 2} ${radius * 2}`}>
<circle cx={radius} cy={radius} r={radius} fill={color} />
</svg>;
// Show a green/red circle or a hourglass icon depending on the status of the node
const statusIcon = (node: string) => {
switch (nodeStatuses[blockchain][node]) {
case true:
return <Tooltip title={"This node is available and responding to RPC requests"}>
{circle(theme.palette.success.dark)}
</Tooltip>;
case false:
return <Tooltip title={"This node is not available or not responding to RPC requests"}>
{circle(theme.palette.error.dark)}
</Tooltip>;
default:
console.log(`Unknown status for node ${node}: ${nodeStatuses[node]}`);
return <Tooltip title={"The status of this node is currently unknown"}>
<HourglassEmpty />
</Tooltip>;
}
}
const onAddNewNode = () => {
dispatch(addNode({ network, type: blockchain, node: newNode }));
@ -438,7 +444,9 @@ function NodeTable({
<Typography variant="overline">{node}</Typography>
</TableCell>
{/* Node status icon */}
<TableCell align="center" children={statusIcon(node)} />
<TableCell align="center">
<NodeStatus status={nodeStatuses[blockchain][node]} />
</TableCell>
{/* Remove and move buttons */}
<TableCell>
<Box style={{ display: "flex" }}>

View file

@ -15,6 +15,7 @@ import TruncatedText from "renderer/components/other/TruncatedText";
import { PiconeroAmount, SatsAmount } from "../../../other/Units";
import HistoryRowActions from "./HistoryRowActions";
import HistoryRowExpanded from "./HistoryRowExpanded";
import { bobStateNameToHumanReadable, GetSwapInfoResponseExt } from "models/tauriModelExt";
const useStyles = makeStyles((theme) => ({
amountTransferContainer: {
@ -42,7 +43,7 @@ function AmountTransfer({
);
}
export default function HistoryRow(swap: GetSwapInfoResponse) {
export default function HistoryRow(swap: GetSwapInfoResponseExt) {
const [expanded, setExpanded] = useState(false);
return (
@ -62,7 +63,7 @@ export default function HistoryRow(swap: GetSwapInfoResponse) {
btcAmount={swap.btc_amount}
/>
</TableCell>
<TableCell>{swap.state_name.toString()}</TableCell>
<TableCell>{bobStateNameToHumanReadable(swap.state_name)}</TableCell>
<TableCell>
<HistoryRowActions {...swap} />
</TableCell>

View file

@ -27,6 +27,11 @@ const useStyles = makeStyles((theme) => ({
padding: theme.spacing(1),
gap: theme.spacing(1),
},
outerAddressBox: {
display: "flex",
flexDirection: "column",
gap: theme.spacing(1),
},
actionsOuter: {
display: "flex",
flexDirection: "row",
@ -88,7 +93,7 @@ export default function HistoryRowExpanded({
<TableRow>
<TableCell>Provider Address</TableCell>
<TableCell>
<Box>
<Box className={classes.outerAddressBox}>
{swap.seller.addresses.map((addr) => (
<ActionableMonospaceTextBox
key={addr}
@ -122,16 +127,6 @@ export default function HistoryRowExpanded({
variant="outlined"
size="small"
/>
{/*
// TOOD: reimplement these buttons using Tauri
<SwapCancelRefundButton swap={swap} variant="contained" size="small" />
<SwapMoneroRecoveryButton
swap={swap}
variant="contained"
size="small"
/>
*/}
</Box>
</Box>
);

View file

@ -44,8 +44,8 @@ import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel";
import logger from "utils/logger";
import { getNetwork, getNetworkName, isTestnet } from "store/config";
import { Blockchain, Network } from "store/features/settingsSlice";
import { resetStatuses, setPromise, setStatus, setStatuses } from "store/features/nodesSlice";
import { Blockchain } from "store/features/settingsSlice";
import { setStatus } from "store/features/nodesSlice";
export async function initEventListeners() {
// This operation is in-expensive
@ -164,14 +164,14 @@ export async function buyXmr(
"buy_xmr",
bitcoin_change_address == null
? {
seller: providerToConcatenatedMultiAddr(seller),
monero_receive_address,
}
seller: providerToConcatenatedMultiAddr(seller),
monero_receive_address,
}
: {
seller: providerToConcatenatedMultiAddr(seller),
monero_receive_address,
bitcoin_change_address,
},
seller: providerToConcatenatedMultiAddr(seller),
monero_receive_address,
bitcoin_change_address,
},
);
}
@ -220,11 +220,8 @@ export async function listSellersAtRendezvousPoint(
}
export async function initializeContext() {
console.log("Prepare: Initializing context with settings");
const network = getNetwork();
const settings = store.getState().settings;
let statuses = store.getState().nodes.nodes;
const testnet = isTestnet();
// Initialize Tauri settings with null values
const tauriSettings: TauriSettings = {
@ -232,29 +229,24 @@ export async function initializeContext() {
monero_node_url: null,
};
// Set the first available node, if set
if (Object.keys(statuses.bitcoin).length === 0) {
await updateAllNodeStatuses();
statuses = store.getState().nodes.nodes;
// If are missing any statuses, update them
if (Object.values(Blockchain).some(blockchain =>
Object.values(store.getState().nodes.nodes[blockchain]).length < store.getState().settings.nodes[network][blockchain].length
)) {
try {
console.log("Updating node statuses");
await updateAllNodeStatuses();
} catch (e) {
logger.error(e, "Failed to update node statuses");
}
}
const { bitcoin: bitcoinNodes, monero: moneroNodes } = store.getState().nodes.nodes;
let firstAvailableElectrumNode = settings.nodes[network][Blockchain.Bitcoin]
.find(node => statuses.bitcoin[node] === true);
const firstAvailableElectrumNode = Object.keys(bitcoinNodes).find(node => bitcoinNodes[node] === true);
const firstAvailableMoneroNode = Object.keys(moneroNodes).find(node => moneroNodes[node] === true);
if (firstAvailableElectrumNode !== undefined)
tauriSettings.electrum_rpc_url = firstAvailableElectrumNode;
else
logger.info("No custom Electrum node available, falling back to default.");
let firstAvailableMoneroNode = settings.nodes[network][Blockchain.Monero]
.find(node => statuses.monero[node] === true);
if (firstAvailableMoneroNode !== undefined)
tauriSettings.monero_node_url = firstAvailableMoneroNode;
else
logger.info("No custom Monero node available, falling back to default.");
const testnet = isTestnet();
tauriSettings.electrum_rpc_url = firstAvailableElectrumNode ?? null;
tauriSettings.monero_node_url = firstAvailableMoneroNode ?? null;
console.log("Initializing context with settings", tauriSettings);
@ -269,7 +261,7 @@ export async function getWalletDescriptor() {
}
export async function getMoneroNodeStatus(node: string): Promise<boolean> {
const response =await invoke<CheckMoneroNodeArgs, CheckMoneroNodeResponse>("check_monero_node", {
const response = await invoke<CheckMoneroNodeArgs, CheckMoneroNodeResponse>("check_monero_node", {
url: node,
network: getNetworkName(),
});
@ -289,33 +281,27 @@ export async function getNodeStatus(url: string, blockchain: Blockchain): Promis
switch (blockchain) {
case Blockchain.Monero: return await getMoneroNodeStatus(url);
case Blockchain.Bitcoin: return await getElectrumNodeStatus(url);
default: throw new Error(`Unknown blockchain: ${blockchain}`);
}
}
async function updateNodeStatus(node: string, blockchain: Blockchain) {
const status = await getNodeStatus(node, blockchain);
store.dispatch(setStatus({ node, status, blockchain }));
}
export async function updateAllNodeStatuses() {
const network = getNetwork();
const settings = store.getState().settings;
// We will update the statuses in batches
const newStatuses: Record<Blockchain, Record<string, boolean>> = {
[Blockchain.Bitcoin]: {},
[Blockchain.Monero]: {},
};
// For all nodes, check if they are available and store the new status (in parallel)
await Promise.all(
Object.values(Blockchain).flatMap(blockchain =>
settings.nodes[network][blockchain].map(async node => {
const status = await getNodeStatus(node, blockchain);
newStatuses[blockchain][node] = status;
})
settings.nodes[network][blockchain].map(node => updateNodeStatus(node, blockchain))
)
);
// When we are done, we update the statuses in the store
store.dispatch(setStatuses(newStatuses));
}
export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse> {
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
}

View file

@ -3,8 +3,8 @@ import { persistReducer, persistStore } from "redux-persist";
import sessionStorage from "redux-persist/lib/storage/session";
import { reducers } from "store/combinedReducer";
import { createMainListeners } from "store/middleware/storeListener";
import { LazyStore } from "@tauri-apps/plugin-store";
import { getNetworkName } from "store/config";
import { LazyStore } from "@tauri-apps/plugin-store";
// Goal: Maintain application state across page reloads while allowing a clean slate on application restart
// Settings are persisted across application restarts, while the rest of the state is cleared

View file

@ -47,7 +47,7 @@ export function getStubTestnetProvider(): ExtendedProviderStatus | null {
export function getNetworkName(): string {
if (isTestnet()) {
return "Testnet";
}else {
} else {
return "Mainnet";
}
}

View file

@ -6,21 +6,18 @@ export interface NodesSlice {
}
function initialState(): NodesSlice {
return {
nodes: {
[Blockchain.Bitcoin]: {},
[Blockchain.Monero]: {},
},
}
}
return {
nodes: {
[Blockchain.Bitcoin]: {},
[Blockchain.Monero]: {},
},
}
}
const nodesSlice = createSlice({
name: "nodes",
initialState: initialState(),
reducers: {
setStatuses(slice, action: PayloadAction<Record<Blockchain, Record<string, boolean>>>) {
slice.nodes = action.payload;
},
setStatus(slice, action: PayloadAction<{
node: string,
status: boolean,
@ -29,13 +26,13 @@ const nodesSlice = createSlice({
slice.nodes[action.payload.blockchain][action.payload.node] = action.payload.status;
},
resetStatuses(slice) {
slice.nodes = {
[Blockchain.Bitcoin]: {},
[Blockchain.Monero]: {},
}
slice.nodes = {
[Blockchain.Bitcoin]: {},
[Blockchain.Monero]: {},
}
},
},
});
export const { setStatus, setStatuses, resetStatuses } = nodesSlice.actions;
export const { setStatus, resetStatuses } = nodesSlice.actions;
export default nodesSlice.reducer;

View file

@ -148,4 +148,5 @@ export const {
setFetchFiatPrices,
setFiatCurrency,
} = alertsSlice.actions;
export default alertsSlice.reducer;

View file

@ -24,14 +24,12 @@ export const swapSlice = createSlice({
//
// Then we create a new swap state object that stores the current and previous events
if (swap.state === null || action.payload.swap_id !== swap.state.swapId) {
console.log("Creating new swap state object");
swap.state = {
curr: action.payload.event,
prev: null,
swapId: action.payload.swap_id,
};
} else {
console.log("Updating existing swap state object");
swap.state.prev = swap.state.curr;
swap.state.curr = action.payload.event;
}

View file

@ -542,10 +542,10 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@reduxjs/toolkit@^2.2.6":
version "2.2.6"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.6.tgz#4a8356dad9d0c1ab255607a555d492168e0e3bc1"
integrity sha512-kH0r495c5z1t0g796eDQAkYbEQ3a1OLYN9o8jQQVZyKyw367pfRGS+qZLkHYvFHiUUdafpoSlQ2QYObIApjPWA==
"@reduxjs/toolkit@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.3.0.tgz#d00134634d6c1678e8563ac50026e429e3b64420"
integrity sha512-WC7Yd6cNGfHx8zf+iu+Q1UPTfEcXhQ+ATi7CV1hlrSAaQBdlPzg7Ww/wJHNQem7qG9rxmWoFCDCPubSvFObGzA==
dependencies:
immer "^10.0.3"
redux "^5.0.1"

View file

@ -1327,7 +1327,8 @@ impl CheckMoneroNodeArgs {
static CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
// This function is called very frequently, so we set the timeout to be short
.timeout(Duration::from_secs(5))
.https_only(false)
.build()
.expect("reqwest client to work")

View file

@ -343,6 +343,7 @@ impl EventLoop {
if self.swarm.behaviour_mut().transfer_proof.send_response(response_channel, ()).is_err() {
tracing::warn!("Failed to send acknowledgment to Alice that we have received the transfer proof");
} else {
tracing::info!("Sent acknowledgment to Alice that we have received the transfer proof");
self.pending_transfer_proof = OptionFuture::from(None);
}
},