From dcbeb2614a63b9e818afcd99d17d46e2e08744fd Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Tue, 28 Apr 2026 11:46:33 +0200 Subject: [PATCH 1/5] feat(bob): concurrent swaps --- src-gui/src/models/storeModel.ts | 2 +- .../components/modal/SwapSuspendAlert.tsx | 5 +- .../components/modal/feedback/useFeedback.ts | 8 +- .../components/modal/swap/pages/DebugPage.tsx | 6 +- .../modal/swap/pages/MockSwapControls.tsx | 4 +- .../pages/history/table/HistoryRowActions.tsx | 41 +--- .../pages/swap/swap/CancelButton.tsx | 35 ++-- .../pages/swap/swap/SwapStatePage.tsx | 85 ++++++-- .../components/pages/swap/swap/SwapWidget.tsx | 153 +++++++++----- .../swap/done/BitcoinPartialRefundPage.tsx | 78 +++++-- .../swap/swap/done/BitcoinRefundedPage.tsx | 24 ++- .../swap/swap/exited/ProcessExitedPage.tsx | 6 +- .../EncryptedSignatureSentPage.tsx | 2 - .../in_progress/SwapSetupInflightPage.tsx | 22 +- src-gui/src/renderer/rpc.ts | 14 +- src-gui/src/store/features/swapSlice.ts | 21 +- src-gui/src/store/hooks.ts | 61 ++---- src-tauri/src/commands.rs | 11 +- swap/src/cli/api.rs | 190 ++++++++++++----- swap/src/cli/api/request.rs | 198 +++++++++++------- swap/src/cli/watcher.rs | 16 +- 21 files changed, 579 insertions(+), 403 deletions(-) diff --git a/src-gui/src/models/storeModel.ts b/src-gui/src/models/storeModel.ts index 9065265e8d..b048415d0e 100644 --- a/src-gui/src/models/storeModel.ts +++ b/src-gui/src/models/storeModel.ts @@ -8,7 +8,7 @@ export type SwapState = { }; export interface SwapSlice { - state: SwapState | null; + swaps: Record; logs: CliLog[]; spawnType: SwapSpawnType | null; /** DEV ONLY: When true, prevents Tauri calls in the swap progress listener */ diff --git a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx index df274aa6cb..10789a9e77 100644 --- a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx +++ b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx @@ -12,17 +12,18 @@ import { Typography, } from "@mui/material"; import CircleIcon from "@mui/icons-material/Circle"; -import { suspendCurrentSwap } from "renderer/rpc"; import PromiseInvokeButton from "../PromiseInvokeButton"; type SwapCancelAlertProps = { open: boolean; onClose: () => void; + onSuspend: () => Promise; }; export default function SwapSuspendAlert({ open, onClose, + onSuspend, }: SwapCancelAlertProps) { return ( @@ -71,7 +72,7 @@ export default function SwapSuspendAlert({ Suspend diff --git a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts index 56fec91e0c..0845a0a891 100644 --- a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts +++ b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { store } from "renderer/store/storeRenderer"; -import { useActiveSwapInfo } from "store/hooks"; import { logsToRawString } from "utils/parseUtils"; import { getLogsOfSwap, redactLogs } from "renderer/rpc"; import { parseCliLogString } from "models/cliModel"; @@ -40,13 +39,10 @@ const initialLogsState: FeedbackLogsState = { }; export function useFeedback() { - const currentSwapId = useActiveSwapInfo(); const { enqueueSnackbar } = useSnackbar(); - const [inputState, setInputState] = useState({ - ...initialInputState, - selectedSwap: currentSwapId?.swap_id || null, - }); + const [inputState, setInputState] = + useState(initialInputState); const [logsState, setLogsState] = useState(initialLogsState); const [error, setError] = useState(null); diff --git a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx index 54d7dec36e..4da53b1828 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx @@ -1,9 +1,9 @@ import { Box, DialogContentText } from "@mui/material"; -import { useActiveSwapLogs } from "store/hooks"; +import { useSwapLogs } from "store/hooks"; import CliLogsBox from "../../../other/RenderedCliLog"; -export default function DebugPage() { - const logs = useActiveSwapLogs(); +export default function DebugPage({ swapId }: { swapId: string }) { + const logs = useSwapLogs(swapId); return ( diff --git a/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx b/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx index 80846c0c5a..d3e5c6b2b2 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/MockSwapControls.tsx @@ -27,7 +27,7 @@ import { } from "store/features/rpcSlice"; import { swapProgressEventReceived, - swapReset, + swapProgressRemoved, setMockOnlyDisableTauriCallsOnSwapProgress, } from "store/features/swapSlice"; @@ -72,7 +72,7 @@ export default function MockSwapControls() { setScenario(null); setIndex(0); dispatch(setMockOnlyDisableTauriCallsOnSwapProgress(false)); - dispatch(swapReset()); + dispatch(swapProgressRemoved(MOCK_SWAP_ID)); // Clean up mock alerts (mark as SafelyAborted so SwapStatusAlert hides them) for (const info of getMockAlertCleanupData()) dispatch(rpcSetSwapInfo(info)); diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx index f14b2a596c..3fadcc88f6 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRowActions.tsx @@ -1,23 +1,13 @@ import { Tooltip } from "@mui/material"; import { ButtonProps } from "@mui/material/Button"; -import { green, red } from "@mui/material/colors"; +import { green } from "@mui/material/colors"; import DoneIcon from "@mui/icons-material/Done"; -import ErrorIcon from "@mui/icons-material/Error"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import { GetSwapInfoResponse } from "models/tauriModel"; -import { - BobStateName, - GetSwapInfoResponseExt, - isBobStateNamePossiblyCancellableSwap, - isBobStateNamePossiblyRefundableSwap, -} from "models/tauriModelExt"; +import { BobStateName } from "models/tauriModelExt"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { resumeSwap, suspendCurrentSwap } from "renderer/rpc"; -import { - useIsSpecificSwapRunning, - useIsSwapRunning, - useIsSwapRunningAndHasFundsLocked, -} from "store/hooks"; +import { resumeSwap } from "renderer/rpc"; +import { useIsSpecificSwapRunning } from "store/hooks"; import { useNavigate } from "react-router-dom"; export function SwapResumeButton({ @@ -30,20 +20,7 @@ export function SwapResumeButton({ // We cannot resume at all if the swap of this button is already running const isAlreadyRunning = useIsSpecificSwapRunning(swap.swap_id); - // If another swap is running, we can resume but only if no funds have been locked - // for that swap. If funds have been locked, we cannot resume. If no funds have been locked, - // we suspend the other swap and resume this one. - const isAnotherSwapRunningAndHasFundsLocked = - useIsSwapRunningAndHasFundsLocked() && !isAlreadyRunning; - async function resume() { - // We always suspend the current swap first - // If that swap has any funds locked, the button will be disabled - // and this function will not be called - // If no swap is running, this is a no-op - await suspendCurrentSwap(); - - // Now resume this swap await resumeSwap(swap.swap_id); // Navigate to the swap page @@ -52,19 +29,13 @@ export function SwapResumeButton({ const tooltipTitle = isAlreadyRunning ? "This swap is already running" - : isAnotherSwapRunningAndHasFundsLocked - ? "Another swap is running. Suspend it first before resuming this one" - : undefined; + : undefined; return ( } onInvoke={resume} diff --git a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx index 6650706e0e..20b243197a 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -1,32 +1,26 @@ import { Box, Button } from "@mui/material"; +import { SwapState } from "models/storeModel"; import { haveFundsBeenLocked } from "models/tauriModelExt"; -import { getCurrentSwapId, suspendCurrentSwap } from "renderer/rpc"; -import { swapReset } from "store/features/swapSlice"; -import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks"; +import { suspendSwap } from "renderer/rpc"; import { useState } from "react"; import SwapSuspendAlert from "renderer/components/modal/SwapSuspendAlert"; -export default function CancelButton() { - const dispatch = useAppDispatch(); - const swap = useAppSelector((state) => state.swap); - const isSwapRunning = useIsSwapRunning(); +export default function CancelButton({ swapState }: { swapState: SwapState }) { const [openSuspendAlert, setOpenSuspendAlert] = useState(false); - const hasFundsBeenLocked = haveFundsBeenLocked(swap.state?.curr); + const hasFundsBeenLocked = haveFundsBeenLocked(swapState.curr); - async function onCancel() { - const swapId = await getCurrentSwapId(); - - if (swapId.swap_id !== null) { - if (hasFundsBeenLocked && isSwapRunning) { - setOpenSuspendAlert(true); - return; - } + async function suspend() { + await suspendSwap(swapState.swapId); + } - await suspendCurrentSwap(); + async function onCancel() { + if (hasFundsBeenLocked) { + setOpenSuspendAlert(true); + return; } - dispatch(swapReset()); + await suspend(); } return ( @@ -34,14 +28,15 @@ export default function CancelButton() { setOpenSuspendAlert(false)} + onSuspend={suspend} /> diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx index 13462edc50..65de4b5c48 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx @@ -56,7 +56,12 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { break; case "SwapSetupInflight": if (state.curr.type === "SwapSetupInflight") { - return ; + return ( + + ); } break; case "RetrievingMoneroBlockheight": @@ -122,32 +127,59 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { //// 8 different types of Bitcoin refund states we can be in case "BtcRefundPublished": // tx_refund has been published but has not been confirmed yet if (state.curr.type === "BtcRefundPublished") { - return ; + return ( + + ); } break; case "BtcEarlyRefundPublished": // tx_early_refund has been published but has not been confirmed yet if (state.curr.type === "BtcEarlyRefundPublished") { - return ; + return ( + + ); } break; case "BtcRefunded": // tx_refund has been confirmed if (state.curr.type === "BtcRefunded") { - return ; + return ( + + ); } break; case "BtcEarlyRefunded": // tx_early_refund has been confirmed if (state.curr.type === "BtcEarlyRefunded") { - return ; + return ( + + ); } break; case "BtcPartialRefundPublished": if (state.curr.type === "BtcPartialRefundPublished") { - return ; + return ( + + ); } break; case "BtcPartiallyRefunded": if (state.curr.type === "BtcPartiallyRefunded") { - return ; + return ( + + ); } break; case "WaitingForEarnestDepositTimelockExpiration": @@ -161,34 +193,61 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { break; case "BtcAmnestyPublished": if (state.curr.type === "BtcAmnestyPublished") { - return ; + return ( + + ); } break; case "BtcAmnestyReceived": if (state.curr.type === "BtcAmnestyReceived") { - return ; + return ( + + ); } break; //// 4 different types of withhold / mercy states case "BtcWithholdPublished": if (state.curr.type === "BtcWithholdPublished") { - return ; + return ( + + ); } break; case "BtcWithheld": if (state.curr.type === "BtcWithheld") { - return ; + return ( + + ); } break; case "BtcMercyPublished": if (state.curr.type === "BtcMercyPublished") { - return ; + return ( + + ); } break; case "BtcMercyConfirmed": if (state.curr.type === "BtcMercyConfirmed") { - return ; + return ( + + ); } break; diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index f166d22ded..7258bddf30 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -1,84 +1,133 @@ -import { Box, Button, Dialog, DialogActions, Paper } from "@mui/material"; +import { + Box, + Button, + Dialog, + DialogActions, + Paper, + Tooltip, + Typography, +} from "@mui/material"; import { useState } from "react"; -import { useActiveSwapInfo, useAppSelector } from "store/hooks"; +import { SwapState } from "models/storeModel"; +import { useAppSelector } from "store/hooks"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; import CancelButton from "./CancelButton"; import SwapStateStepper from "renderer/components/modal/swap/SwapStateStepper"; -import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert"; import DebugPageSwitchBadge from "renderer/components/modal/swap/pages/DebugPageSwitchBadge"; import DebugPage from "renderer/components/modal/swap/pages/DebugPage"; import MockSwapControls from "renderer/components/modal/swap/pages/MockSwapControls"; export default function SwapWidget() { - const swapState = useAppSelector((state) => state.swap.state); - const swapInfo = useActiveSwapInfo(); - const [debug, setDebug] = useState(false); + const runningSwaps = useAppSelector((state) => + Object.values(state.swap.swaps).filter( + (swap) => swap.curr.type !== "Released", + ), + ); + const visibleSwaps = runningSwaps.length > 0 ? runningSwaps : [null]; return ( - {swapInfo != null && ( - - )} {import.meta.env.DEV && } - setDebug(false)} - > - - - - - - + {visibleSwaps.map((swap, index) => ( + + ))} + + + ); +} + +function SwapStatePanel({ + swap, + index, +}: { + swap: SwapState | null; + index: number; +}) { + const [debug, setDebug] = useState(false); + + return ( + + {swap != null && ( + <> + + Swap {index + 1} + + + {swap.swapId} + + + + setDebug(false)} + > + + + + + + + )} + + + + {swap != null && } + {swap != null && ( - + + - {swapState !== null && ( - <> - - - - - - - )} - - + )} + ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPartialRefundPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPartialRefundPage.tsx index 7d6b4fea31..973109f568 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPartialRefundPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinPartialRefundPage.tsx @@ -21,7 +21,7 @@ import { Typography, } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import { useActiveSwapInfo, useAppSelector } from "store/hooks"; +import { useAppSelector, useSwapInfo } from "store/hooks"; import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; import DiscordIcon from "renderer/components/icons/DiscordIcon"; @@ -30,12 +30,16 @@ import { Book } from "@mui/icons-material"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; export function BitcoinPartialRefundPublished({ + swapId, btc_partial_refund_txid, btc_lock_amount, btc_amnesty_amount, -}: TauriSwapProgressEventContent<"BtcPartialRefundPublished">) { +}: { + swapId: string; +} & TauriSwapProgressEventContent<"BtcPartialRefundPublished">) { return ( ) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcPartiallyRefunded">) { return ( ) { - return ; +}: { swapId: string } & TauriSwapProgressEventContent<"BtcAmnestyPublished">) { + return ( + + ); } export function BitcoinAmnestyReceived({ + swapId, btc_amnesty_txid, -}: TauriSwapProgressEventContent<"BtcAmnestyReceived">) { - return ; +}: { swapId: string } & TauriSwapProgressEventContent<"BtcAmnestyReceived">) { + return ( + + ); } function AmnestyPage({ + swapId, txid, confirmed, }: { + swapId: string; txid: string; confirmed: boolean; }) { - const swap = useActiveSwapInfo(); + const swap = useSwapInfo(swapId); const mainMessage = confirmed ? "All your Bitcoin have been refunded. The swap is complete." @@ -202,12 +218,14 @@ function AmnestyPage({ // If we're in this state, it means the maker actively published TxWithhold to revoke it. export function BitcoinWithholdPublished({ + swapId, btc_withhold_txid, btc_lock_amount, btc_amnesty_amount, -}: TauriSwapProgressEventContent<"BtcWithholdPublished">) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcWithholdPublished">) { return ( ) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcWithheld">) { return ( s.swap._mockOnlyDisableTauriCallsOnSwapProgress, ); - const swapId = + const displaySwapId = swapInfo?.swap_id ?? - (isMock ? "a1b2c3d4-e5f6-7890-abcd-ef1234567890" : null); + (isMock ? "a1b2c3d4-e5f6-7890-abcd-ef1234567890" : swapId); const peerId = swapInfo?.seller.peer_id ?? (isMock ? "12D3KooWF1rGmFnqJhNrHhEMPVbMM3eRnuf3XPG3JcvedAMdSHkj" : null); @@ -335,13 +357,13 @@ function WithholdPage({ !confirmed ? "Waiting for transaction to be confirmed..." : null } /> - {(swapId != null || peerId != null) && ( + {(displaySwapId != null || peerId != null) && ( - {swapId != null && ( + {displaySwapId != null && ( )} {peerId != null && ( @@ -360,19 +382,29 @@ function WithholdPage({ // Mercy pages - The maker granted mercy after the user appealed export function BitcoinMercyPublished({ + swapId, btc_mercy_txid, -}: TauriSwapProgressEventContent<"BtcMercyPublished">) { - return ; +}: { swapId: string } & TauriSwapProgressEventContent<"BtcMercyPublished">) { + return ; } export function BitcoinMercyConfirmed({ + swapId, btc_mercy_txid, -}: TauriSwapProgressEventContent<"BtcMercyConfirmed">) { - return ; +}: { swapId: string } & TauriSwapProgressEventContent<"BtcMercyConfirmed">) { + return ; } -function MercyPage({ txid, confirmed }: { txid: string; confirmed: boolean }) { - const swap = useActiveSwapInfo(); +function MercyPage({ + swapId, + txid, + confirmed, +}: { + swapId: string; + txid: string; + confirmed: boolean; +}) { + const swap = useSwapInfo(swapId); const mainMessage = confirmed ? "The market maker has release the earnest deposit they withheld. The refund is complete." diff --git a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx index 866a6fcc41..599a578c93 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/done/BitcoinRefundedPage.tsx @@ -1,14 +1,16 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import { useActiveSwapInfo } from "store/hooks"; +import { useSwapInfo } from "store/hooks"; import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; export function BitcoinRefundPublishedPage({ + swapId, btc_refund_txid, -}: TauriSwapProgressEventContent<"BtcRefundPublished">) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcRefundPublished">) { return ( @@ -16,10 +18,14 @@ export function BitcoinRefundPublishedPage({ } export function BitcoinEarlyRefundPublishedPage({ + swapId, btc_early_refund_txid, -}: TauriSwapProgressEventContent<"BtcEarlyRefundPublished">) { +}: { + swapId: string; +} & TauriSwapProgressEventContent<"BtcEarlyRefundPublished">) { return ( @@ -27,10 +33,12 @@ export function BitcoinEarlyRefundPublishedPage({ } export function BitcoinRefundedPage({ + swapId, btc_refund_txid, -}: TauriSwapProgressEventContent<"BtcRefunded">) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcRefunded">) { return ( @@ -38,10 +46,12 @@ export function BitcoinRefundedPage({ } export function BitcoinEarlyRefundedPage({ + swapId, btc_early_refund_txid, -}: TauriSwapProgressEventContent<"BtcEarlyRefunded">) { +}: { swapId: string } & TauriSwapProgressEventContent<"BtcEarlyRefunded">) { return ( @@ -49,13 +59,15 @@ export function BitcoinEarlyRefundedPage({ } function MultiBitcoinRefundedPage({ + swapId, btc_refund_txid, btc_refund_finalized, }: { + swapId: string; btc_refund_txid: string; btc_refund_finalized: boolean; }) { - const swap = useActiveSwapInfo(); + const swap = useSwapInfo(swapId); const additionalContent = swap ? ( <> {!btc_refund_finalized && diff --git a/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx index acee82ea82..e0dd03893f 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/exited/ProcessExitedPage.tsx @@ -1,7 +1,7 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEvent } from "models/tauriModel"; import CliLogsBox from "renderer/components/other/RenderedCliLog"; -import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks"; +import { useSwapInfo, useSwapLogs } from "store/hooks"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; export default function ProcessExitedPage({ @@ -11,8 +11,8 @@ export default function ProcessExitedPage({ prevState: TauriSwapProgressEvent | null; swapId: string; }) { - const swap = useActiveSwapInfo(); - const logs = useActiveSwapLogs(); + const swap = useSwapInfo(swapId); + const logs = useSwapLogs(swapId); // If we have a previous state, we can show the user the last state of the swap // We only show the last state if its a final state (XmrRedeemInMempool, BtcRefunded, BtcPunished, CooperativeRedeemRejected) diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx index 7b1d81fcb1..5ab4d60dbf 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/EncryptedSignatureSentPage.tsx @@ -1,6 +1,4 @@ import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; -import { useActiveSwapInfo, useSwapInfosSortedByDate } from "store/hooks"; -import { Box } from "@mui/material"; export default function EncryptedSignatureSentPage() { return ( diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx index 3e86ea30de..501b8daed9 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/SwapSetupInflightPage.tsx @@ -6,11 +6,7 @@ import { } from "models/tauriModelExt"; import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units"; import { Box, Typography, Paper, Divider, Theme, Link } from "@mui/material"; -import { - useActiveSwapId, - usePendingLockBitcoinApproval, - useAppSelector, -} from "store/hooks"; +import { usePendingLockBitcoinApproval, useAppSelector } from "store/hooks"; import { getMarkup, satsToBtc, piconerosToXmr } from "utils/conversionUtils"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle"; @@ -18,21 +14,19 @@ import CheckIcon from "@mui/icons-material/Check"; import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt"; import TruncatedText from "renderer/components/other/TruncatedText"; -/// A hook that returns the LockBitcoin confirmation request for the active swap -/// Returns null if no confirmation request is found -function useActiveLockBitcoinApprovalRequest(): PendingLockBitcoinApprovalRequest | null { +function useLockBitcoinApprovalRequest( + swapId: string, +): PendingLockBitcoinApprovalRequest | null { const approvals = usePendingLockBitcoinApproval(); - const activeSwapId = useActiveSwapId(); - return ( - approvals?.find((r) => r.request.content.swap_id === activeSwapId) || null - ); + return approvals?.find((r) => r.request.content.swap_id === swapId) || null; } export default function SwapSetupInflightPage({ + swapId, btc_lock_amount, -}: TauriSwapProgressEventContent<"SwapSetupInflight">) { - const request = useActiveLockBitcoinApprovalRequest(); +}: { swapId: string } & TauriSwapProgressEventContent<"SwapSetupInflight">) { + const request = useLockBitcoinApprovalRequest(swapId); // Get market rate for markup calculation (must be called unconditionally) const xmrBtcRate = useAppSelector((state) => state.rates.xmrBtcRate); diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index ec819a4c60..26ee3e9290 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -9,7 +9,8 @@ import { MoneroRecoveryArgs, ResumeSwapArgs, ResumeSwapResponse, - SuspendCurrentSwapResponse, + SuspendSwapArgs, + SuspendSwapResponse, WithdrawBtcArgs, WithdrawBtcResponse, GetSwapInfoArgs, @@ -29,7 +30,6 @@ import { ResolveApprovalResponse, RedactArgs, RedactResponse, - GetCurrentSwapResponse, LabeledMoneroAddress, GetMoneroHistoryResponse, GetMoneroMainAddressResponse, @@ -386,12 +386,10 @@ export async function resumeSwap(swapId: string) { }); } -export async function suspendCurrentSwap() { - await invokeNoArgs("suspend_current_swap"); -} - -export async function getCurrentSwapId() { - return await invokeNoArgs("get_current_swap"); +export async function suspendSwap(swapId: string) { + await invoke("suspend_swap", { + swap_id: swapId, + }); } export async function getMoneroRecoveryKeys( diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index 6bf863b53b..a607d967e3 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -3,7 +3,7 @@ import { TauriSwapProgressEventWrapper } from "models/tauriModel"; import { SwapSlice } from "../../models/storeModel"; const initialState: SwapSlice = { - state: null, + swaps: {}, logs: [], // TODO: Remove this and replace logic entirely with Tauri events @@ -20,25 +20,25 @@ export const swapSlice = createSlice({ swap, action: PayloadAction, ) { - // If either: - // 1. No swap is currently running - // 2. The swap ID of the event does not match the current swap ID - // - // 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) { - swap.state = { + const existingSwap = swap.swaps[action.payload.swap_id]; + + if (existingSwap == null) { + swap.swaps[action.payload.swap_id] = { curr: action.payload.event, prev: null, swapId: action.payload.swap_id, }; } else { - swap.state.prev = swap.state.curr; - swap.state.curr = action.payload.event; + existingSwap.prev = existingSwap.curr; + existingSwap.curr = action.payload.event; } }, swapReset() { return initialState; }, + swapProgressRemoved(swap, action: PayloadAction) { + delete swap.swaps[action.payload]; + }, setMockOnlyDisableTauriCallsOnSwapProgress( swap, action: PayloadAction, @@ -51,6 +51,7 @@ export const swapSlice = createSlice({ export const { swapReset, swapProgressEventReceived, + swapProgressRemoved, setMockOnlyDisableTauriCallsOnSwapProgress, } = swapSlice.actions; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 910bad3a2d..92c4fa2241 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -10,7 +10,6 @@ import { PendingLockBitcoinApprovalRequest, PendingSelectMakerApprovalRequest, isPendingSelectMakerApprovalEvent, - haveFundsBeenLocked, PendingSeedSelectionApprovalRequest, PendingSendMoneroApprovalRequest, isPendingSendMoneroApprovalEvent, @@ -73,47 +72,23 @@ export function useResumeableSwapsCountExcludingPunished() { /// Returns true if we have any swap that is running export function useIsSwapRunning() { - return useAppSelector( - (state) => - state.swap.state !== null && state.swap.state.curr.type !== "Released", - ); -} - -/// Returns true if we have a swap that is running and -/// that swap has any funds locked -export function useIsSwapRunningAndHasFundsLocked() { - const swapInfo = useActiveSwapInfo(); - const swapTauriState = useAppSelector( - (state) => state.swap.state?.curr ?? null, + return useAppSelector((state) => + Object.values(state.swap.swaps).some( + (swap) => swap.curr.type !== "Released", + ), ); - - // If the swap is in the Released state, we return false - if (swapTauriState?.type === "Released") { - return false; - } - - // If the tauri state tells us that funds have been locked, we return true - if (haveFundsBeenLocked(swapTauriState)) { - return true; - } - - // If we have a database entry (swapInfo) for this swap, we return true - if (swapInfo != null) { - return true; - } - - return false; } /// Returns true if we have a swap that is running export function useIsSpecificSwapRunning(swapId: string | null) { - return useAppSelector( - (state) => - swapId != null && - state.swap.state !== null && - state.swap.state.swapId === swapId && - state.swap.state.curr.type !== "Released", - ); + return useAppSelector((state) => { + if (swapId == null) { + return false; + } + + const swap = state.swap.swaps[swapId]; + return swap != null && swap.curr.type !== "Released"; + }); } export function useIsContextAvailable() { @@ -130,17 +105,7 @@ export function useSwapInfo( ); } -export function useActiveSwapId(): string | null { - return useAppSelector((s) => s.swap.state?.swapId ?? null); -} - -export function useActiveSwapInfo(): GetSwapInfoResponseExt | null { - const swapId = useActiveSwapId(); - return useSwapInfo(swapId); -} - -export function useActiveSwapLogs() { - const swapId = useActiveSwapId(); +export function useSwapLogs(swapId: string | null) { const logs = useAppSelector((s) => s.logs.state.logs); return useMemo(() => { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e3f5c5b9cc..207bcec2cd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,15 +9,14 @@ use swap::cli::{ CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, CreateMoneroSubaddressArgs, DeleteAllLogsArgs, DfxAuthenticateResponse, ExportBitcoinWalletArgs, - GetBitcoinAddressArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, + GetBitcoinAddressArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSubaddressesArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, RedactArgs, RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, - SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, - WithdrawBtcArgs, + SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, }, @@ -49,13 +48,12 @@ macro_rules! generate_command_handlers { get_history, monero_recovery, get_logs, - suspend_current_swap, + suspend_swap, cancel_and_refund, initialize_context, check_monero_node, check_electrum_node, get_wallet_descriptor, - get_current_swap, get_data_dir, resolve_approval_request, redact, @@ -499,14 +497,13 @@ tauri_command!(change_monero_node, ChangeMoneroNodeArgs); // These commands require no arguments tauri_command!(get_bitcoin_address, GetBitcoinAddressArgs, no_args); tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); -tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); +tauri_command!(suspend_swap, SuspendSwapArgs); tauri_command!(get_swap_info, GetSwapInfoArgs); tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args); tauri_command!(get_swap_timelock, GetSwapTimelockArgs); tauri_command!(get_history, GetHistoryArgs, no_args); tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args); tauri_command!(get_monero_history, GetMoneroHistoryArgs, no_args); -tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args); tauri_command!(set_monero_restore_height, SetRestoreHeightArgs); tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args); tauri_command!(set_monero_wallet_password, SetMoneroWalletPasswordArgs); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 6854ce6614..d0f23e5e41 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -14,6 +14,7 @@ use anyhow::{Context as AnyContext, Error, Result, bail}; use arti_client::TorClient; use futures::future::try_join_all; use libp2p::{Multiaddr, PeerId}; +use std::collections::HashMap; use std::fmt; use std::future::Future; use std::path::{Path, PathBuf}; @@ -106,96 +107,169 @@ mod swap_lock { } } - /// The `SwapLock` manages the state of the current swap, ensuring that only one swap can be active at a time. - /// It includes: - /// - A lock for the current swap (`current_swap`) - /// - A broadcast channel for suspension signals (`suspension_trigger`) + /// Coordinates swap initiation and running swap tasks. /// - /// The `SwapLock` provides methods to acquire and release the swap lock, and to listen for suspension signals. - /// This ensures that swap operations do not overlap and can be safely suspended if needed. + /// Swap initiation is globally exclusive because `determine_btc_to_swap` + /// reasons about wallet-wide spendable balance. Once a Bob state machine is + /// spawned, running swap suspension is tracked per swap id. pub struct SwapLock { - current_swap: RwLock>, - suspension_trigger: Sender<()>, + current_initiation: RwLock>, + initiation_suspension_trigger: Sender<()>, + running_swaps: RwLock>>, } impl SwapLock { pub fn new() -> Self { - let (suspension_trigger, _) = broadcast::channel(10); + let (initiation_suspension_trigger, _) = broadcast::channel(10); SwapLock { - current_swap: RwLock::new(None), - suspension_trigger, + current_initiation: RwLock::new(None), + initiation_suspension_trigger, + running_swaps: RwLock::new(HashMap::new()), } } - pub async fn listen_for_swap_force_suspension(&self) -> Result<(), Error> { - let mut listener = self.suspension_trigger.subscribe(); - let event = listener.recv().await; - match event { - Ok(_) => Ok(()), - Err(e) => { - tracing::error!("Error receiving swap suspension signal: {}", e); - bail!(e) - } + pub async fn listen_for_initiation_force_suspension(&self) -> Result<(), Error> { + receive_suspension(self.initiation_suspension_trigger.subscribe()).await + } + + pub async fn listen_for_swap_force_suspension(&self, swap_id: Uuid) -> Result<(), Error> { + let listener = { + let running_swaps = self.running_swaps.read().await; + let trigger = running_swaps.get(&swap_id).context("Swap is not running")?; + + trigger.subscribe() + }; + + receive_suspension(listener).await + } + + pub async fn acquire_initiation_lock(&self, swap_id: Uuid) -> Result<(), Error> { + let mut current_initiation = self.current_initiation.write().await; + if current_initiation.is_some() { + bail!("There already exists an active swap initiation"); } + + tracing::debug!(swap_id = %swap_id, "Acquiring swap initiation lock"); + *current_initiation = Some(swap_id); + Ok(()) } - pub async fn acquire_swap_lock(&self, swap_id: Uuid) -> Result<(), Error> { - let mut current_swap = self.current_swap.write().await; - if current_swap.is_some() { - bail!("There already exists an active swap lock"); + pub async fn release_initiation_lock(&self, swap_id: Uuid) -> Result<(), Error> { + let mut current_initiation = self.current_initiation.write().await; + let Some(current_swap_id) = *current_initiation else { + bail!("There is no current swap initiation lock to release"); + }; + + if current_swap_id != swap_id { + bail!( + "Cannot release swap initiation lock for {swap_id}; current initiation is {current_swap_id}" + ); } - tracing::debug!(swap_id = %swap_id, "Acquiring swap lock"); - *current_swap = Some(swap_id); + tracing::debug!(swap_id = %swap_id, "Releasing swap initiation lock"); + *current_initiation = None; Ok(()) } - pub async fn get_current_swap_id(&self) -> Option { - *self.current_swap.read().await + pub async fn get_current_initiation_swap_id(&self) -> Option { + *self.current_initiation.read().await } - /// Sends a signal to suspend all ongoing swap processes. - /// - /// This function performs the following steps: - /// 1. Triggers the suspension by sending a unit `()` signal to all listeners via `self.suspension_trigger`. - /// 2. Polls the `current_swap` state every 50 milliseconds to check if it has been set to `None`, indicating that the swap processes have been suspended and the lock released. - /// 3. If the lock is not released within 10 seconds, the function returns an error. - /// - /// If we send a suspend signal while no swap is in progress, the function will not fail, but will return immediately. - /// - /// # Returns - /// - `Ok(())` if the swap lock is successfully released. - /// - `Err(Error)` if the function times out waiting for the swap lock to be released. - /// - /// # Notes - /// The 50ms polling interval is considered negligible overhead compared to the typical time required to suspend ongoing swap processes. - pub async fn send_suspend_signal(&self) -> Result<(), Error> { + pub async fn register_running_swap(&self, swap_id: Uuid) -> Result<(), Error> { + let mut running_swaps = self.running_swaps.write().await; + if running_swaps.contains_key(&swap_id) { + bail!("Swap {swap_id} is already running"); + } + + let (suspension_trigger, _) = broadcast::channel(10); + running_swaps.insert(swap_id, suspension_trigger); + tracing::debug!(swap_id = %swap_id, "Registered running swap"); + Ok(()) + } + + pub async fn release_running_swap(&self, swap_id: Uuid) -> Result<(), Error> { + let mut running_swaps = self.running_swaps.write().await; + if running_swaps.remove(&swap_id).is_none() { + bail!("Swap {swap_id} is not running"); + } + + tracing::debug!(swap_id = %swap_id, "Released running swap"); + Ok(()) + } + + pub async fn get_running_swap_ids(&self) -> Vec { + self.running_swaps.read().await.keys().copied().collect() + } + + pub async fn is_swap_running(&self, swap_id: Uuid) -> bool { + self.running_swaps.read().await.contains_key(&swap_id) + } + + pub async fn send_initiation_suspend_signal(&self) -> Result<(), Error> { + let swap_id = self.get_current_initiation_swap_id().await; + + let Some(swap_id) = swap_id else { + return Ok(()); + }; + + let _ = self.initiation_suspension_trigger.send(())?; + self.wait_until_not_initiating(swap_id).await + } + + pub async fn send_swap_suspend_signal(&self, swap_id: Uuid) -> Result<(), Error> { + if self.get_current_initiation_swap_id().await == Some(swap_id) { + return self.send_initiation_suspend_signal().await; + } + + let trigger = { + let running_swaps = self.running_swaps.read().await; + let Some(trigger) = running_swaps.get(&swap_id) else { + return Ok(()); + }; + + trigger.clone() + }; + + let _ = trigger.send(())?; + self.wait_until_not_running(swap_id).await + } + + async fn wait_until_not_initiating(&self, swap_id: Uuid) -> Result<(), Error> { const TIMEOUT: u64 = 10_000; const INTERVAL: u64 = 50; - let _ = self.suspension_trigger.send(())?; - for _ in 0..(TIMEOUT / INTERVAL) { - if self.get_current_swap_id().await.is_none() { + if self.get_current_initiation_swap_id().await != Some(swap_id) { return Ok(()); } tokio::time::sleep(tokio::time::Duration::from_millis(INTERVAL)).await; } - bail!("Timed out waiting for swap lock to be released"); + bail!("Timed out waiting for swap initiation lock to be released"); } - pub async fn release_swap_lock(&self) -> Result { - let mut current_swap = self.current_swap.write().await; - if let Some(swap_id) = current_swap.as_ref() { - tracing::debug!(swap_id = %swap_id, "Releasing swap lock"); + async fn wait_until_not_running(&self, swap_id: Uuid) -> Result<(), Error> { + const TIMEOUT: u64 = 10_000; + const INTERVAL: u64 = 50; - let prev_swap_id = *swap_id; - *current_swap = None; - drop(current_swap); - Ok(prev_swap_id) - } else { - bail!("There is no current swap lock to release"); + for _ in 0..(TIMEOUT / INTERVAL) { + if !self.is_swap_running(swap_id).await { + return Ok(()); + } + tokio::time::sleep(tokio::time::Duration::from_millis(INTERVAL)).await; + } + + bail!("Timed out waiting for running swap to be released"); + } + } + + async fn receive_suspension(mut listener: broadcast::Receiver<()>) -> Result<(), Error> { + let event = listener.recv().await; + match event { + Ok(_) => Ok(()), + Err(e) => { + tracing::error!("Error receiving swap suspension signal: {}", e); + bail!(e) } } } diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index bec2f057c4..04bf94e1d0 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -312,42 +312,25 @@ pub struct AliceAddress { pub addresses: Vec, } -// Suspend current swap -#[derive(Debug, Deserialize)] -pub struct SuspendCurrentSwapArgs; - #[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct SuspendCurrentSwapResponse { - // If no swap was running, we still return Ok(...) but this is set to None - #[typeshare(serialized_as = "Option")] - pub swap_id: Option, -} - -impl Request for SuspendCurrentSwapArgs { - type Response = SuspendCurrentSwapResponse; - - async fn request(self, ctx: Arc) -> Result { - suspend_current_swap(ctx).await - } +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SuspendSwapArgs { + #[typeshare(serialized_as = "string")] + pub swap_id: Uuid, } -#[typeshare] -#[derive(Debug, Serialize, Deserialize)] -pub struct GetCurrentSwapArgs; - #[typeshare] #[derive(Serialize, Deserialize, Debug)] -pub struct GetCurrentSwapResponse { - #[typeshare(serialized_as = "Option")] - pub swap_id: Option, +pub struct SuspendSwapResponse { + #[typeshare(serialized_as = "string")] + pub swap_id: Uuid, } -impl Request for GetCurrentSwapArgs { - type Response = GetCurrentSwapResponse; +impl Request for SuspendSwapArgs { + type Response = SuspendSwapResponse; async fn request(self, ctx: Arc) -> Result { - get_current_swap(ctx).await + suspend_swap(self, ctx).await } } @@ -887,20 +870,16 @@ impl Request for SendMoneroArgs { } } -#[tracing::instrument(fields(method = "suspend_current_swap"), skip(context))] -pub async fn suspend_current_swap(context: Arc) -> Result { - let swap_id = context.swap_lock.get_current_swap_id().await; +#[tracing::instrument(fields(method = "suspend_swap"), skip(context))] +pub async fn suspend_swap( + args: SuspendSwapArgs, + context: Arc, +) -> Result { + let SuspendSwapArgs { swap_id } = args; - if let Some(id_value) = swap_id { - context.swap_lock.send_suspend_signal().await?; + context.swap_lock.send_swap_suspend_signal(swap_id).await?; - Ok(SuspendCurrentSwapResponse { - swap_id: Some(id_value), - }) - } else { - // If no swap was running, we still return Ok(...) with None - Ok(SuspendCurrentSwapResponse { swap_id: None }) - } + Ok(SuspendSwapResponse { swap_id }) } #[tracing::instrument(fields(method = "get_swap_infos_all"), skip(context))] @@ -1094,11 +1073,19 @@ pub async fn buy_xmr( let tauri_handle_for_selection = tauri_handle.clone(); let tauri_handle_for_suspension = tauri_handle.clone(); - // Acquire the lock before the user has selected a maker and we already have funds in the wallet - // because we need to be able to cancel the determine_btc_to_swap(..) - context.swap_lock.acquire_swap_lock(swap_id).await?; + // Acquire the initiation lock before the user has selected a maker and we already have funds + // in the wallet because `determine_btc_to_swap` reasons about wallet-wide spendable funds. + context.swap_lock.acquire_initiation_lock(swap_id).await?; - let select_offer_result = tokio::select! { + let select_offer_result: Result< + Option<( + Multiaddr, + PeerId, + BidQuote, + bitcoin::Amount, + bitcoin::Amount, + )>, + > = tokio::select! { result = determine_btc_to_swap( quotes_rx, bitcoin_wallet.new_address(), @@ -1138,19 +1125,32 @@ pub async fn buy_xmr( }) as Box> + Send> }, ) => { - Some(result?) + result.map(Some) } - _ = context.swap_lock.listen_for_swap_force_suspension() => { - context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); + _ = context.swap_lock.listen_for_initiation_force_suspension() => { + context.swap_lock.release_initiation_lock(swap_id).await.expect("Shutdown signal received but failed to release swap initiation lock."); if let Some(handle) = tauri_handle_for_suspension { handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); } - None + Ok(None) }, }; + let select_offer_result = match select_offer_result { + Ok(result) => result, + Err(error) => { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed offer selection"); + + return Err(error); + } + }; + let Some((seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee)) = select_offer_result else { @@ -1158,18 +1158,55 @@ pub async fn buy_xmr( }; // Insert the peer_id into the database - db.insert_peer_id(swap_id, seller_peer_id).await?; + if let Err(error) = db.insert_peer_id(swap_id, seller_peer_id).await { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed setup"); - db.insert_address(seller_peer_id, seller_multiaddr.clone()) - .await?; + return Err(error); + } - db.insert_monero_address_pool(swap_id, monero_receive_pool.clone()) - .await?; + if let Err(error) = db + .insert_address(seller_peer_id, seller_multiaddr.clone()) + .await + { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed setup"); + + return Err(error); + } + + if let Err(error) = db + .insert_monero_address_pool(swap_id, monero_receive_pool.clone()) + .await + { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed setup"); + + return Err(error); + } // Add the seller's address to the swarm - event_loop_handle + if let Err(error) = event_loop_handle .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) - .await?; + .await + { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed setup"); + + return Err(error); + } tauri_handle.emit_swap_progress_event( swap_id, @@ -1178,12 +1215,23 @@ pub async fn buy_xmr( tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); + if let Err(error) = context.swap_lock.register_running_swap(swap_id).await { + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock after failed setup"); + + return Err(error); + } + + let context_for_task = context.clone(); context.tasks.clone().spawn(async move { tokio::select! { biased; - _ = context.swap_lock.listen_for_swap_force_suspension() => { + _ = context_for_task.swap_lock.listen_for_swap_force_suspension(swap_id) => { tracing::debug!("Shutdown signal received, exiting"); - context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); + context_for_task.swap_lock.release_running_swap(swap_id).await.expect("Shutdown signal received but failed to release running swap."); tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); @@ -1219,17 +1267,23 @@ pub async fn buy_xmr( }; tracing::debug!(%swap_id, "Swap completed"); - context + context_for_task .swap_lock - .release_swap_lock() + .release_running_swap(swap_id) .await - .expect("Could not release swap lock"); + .expect("Could not release running swap"); tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); Ok::<_, anyhow::Error>(()) }.in_current_span()).await; + context + .swap_lock + .release_initiation_lock(swap_id) + .await + .expect("Could not release swap initiation lock"); + Ok(()) } @@ -1275,7 +1329,7 @@ pub async fn resume_swap( .await? .with_event_emitter(tauri_handle.clone()); - context.swap_lock.acquire_swap_lock(swap_id).await?; + context.swap_lock.register_running_swap(swap_id).await?; tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); @@ -1283,9 +1337,9 @@ pub async fn resume_swap( async move { tokio::select! { biased; - _ = context.swap_lock.listen_for_swap_force_suspension() => { + _ = context.swap_lock.listen_for_swap_force_suspension(swap_id) => { tracing::debug!("Shutdown signal received, exiting"); - context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); + context.swap_lock.release_running_swap(swap_id).await.expect("Shutdown signal received but failed to release running swap."); tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); @@ -1306,9 +1360,9 @@ pub async fn resume_swap( } context .swap_lock - .release_swap_lock() + .release_running_swap(swap_id) .await - .expect("Could not release swap lock"); + .expect("Could not release running swap"); tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); @@ -1331,16 +1385,12 @@ pub async fn cancel_and_refund( let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let db = context.try_get_db().await?; - context.swap_lock.acquire_swap_lock(swap_id).await?; + if context.swap_lock.is_swap_running(swap_id).await { + bail!("Cannot cancel and refund swap {swap_id} because it is currently running"); + } let state = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await; - context - .swap_lock - .release_swap_lock() - .await - .expect("Could not release swap lock"); - context .tauri_handle .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); @@ -1481,12 +1531,6 @@ pub async fn monero_recovery( } } -#[tracing::instrument(fields(method = "get_current_swap"), skip(context))] -pub async fn get_current_swap(context: Arc) -> Result { - let swap_id = context.swap_lock.get_current_swap_id().await; - Ok(GetCurrentSwapResponse { swap_id }) -} - // TODO: Let this take a refresh interval as an argument pub async fn refresh_wallet_task( max_giveable_fn: FMG, diff --git a/swap/src/cli/watcher.rs b/swap/src/cli/watcher.rs index 394f5612f4..2a9e0225ce 100644 --- a/swap/src/cli/watcher.rs +++ b/swap/src/cli/watcher.rs @@ -121,16 +121,9 @@ impl Watcher { continue; } - // If the swap is already running, we can skip the refund - // The refund will be handled by the state machine - if let Some(current_swap_id) = self.swap_lock.get_current_swap_id().await { - if current_swap_id == swap_id { - continue; - } - } - - if let Err(e) = self.swap_lock.acquire_swap_lock(swap_id).await { - tracing::error!(%e, %swap_id, "Watcher failed to refund a swap in the background because another swap is already running"); + // If the swap is already running, we can skip the refund. + // The refund will be handled by that swap's state machine. + if self.swap_lock.is_swap_running(swap_id).await { continue; } @@ -152,9 +145,6 @@ impl Watcher { } background_process_handle.finish(); - - // We have to release the swap lock when we are done - self.swap_lock.release_swap_lock().await?; } } From 12f6c59844a54679325e58221194d7dc61e64314 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 5 May 2026 14:28:38 +0200 Subject: [PATCH 2/5] feat(swap): extract SwapManager with retry-with-backoff --- swap/src/bin/swap.rs | 2 +- swap/src/cli.rs | 2 + swap/src/cli/api.rs | 230 +------------- swap/src/cli/api/request.rs | 408 ++++++------------------- swap/src/cli/swap_manager.rs | 572 +++++++++++++++++++++++++++++++++++ swap/src/cli/watcher.rs | 10 +- 6 files changed, 687 insertions(+), 537 deletions(-) create mode 100644 swap/src/cli/swap_manager.rs diff --git a/swap/src/bin/swap.rs b/swap/src/bin/swap.rs index 3367edb108..e334b1ca1a 100644 --- a/swap/src/bin/swap.rs +++ b/swap/src/bin/swap.rs @@ -23,7 +23,7 @@ pub async fn main() -> Result<()> { match parse_args_and_apply_defaults(env::args_os()).await? { ParseResult::Success(context) => { - context.tasks.wait_for_tasks().await?; + context.swap_manager.wait_for_tasks().await?; } ParseResult::PrintAndExitZero { message } => { println!("{}", message); diff --git a/swap/src/cli.rs b/swap/src/cli.rs index 49fea258fb..fbacb668f0 100644 --- a/swap/src/cli.rs +++ b/swap/src/cli.rs @@ -1,6 +1,7 @@ pub mod api; pub mod cancel_and_refund; pub mod command; +pub mod swap_manager; pub mod transport; pub mod watcher; @@ -12,3 +13,4 @@ pub use behaviour::{Behaviour, OutEvent}; pub use cancel_and_refund::{cancel, cancel_and_refund, refund}; pub use event_loop::{EventLoop, EventLoopHandle, SwapEventLoopHandle}; pub use list_sellers::QuoteWithAddress; +pub use swap_manager::{StartSwapInputs, SwapManager, run_exclusive_initiation}; diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index d0f23e5e41..ca672c49d2 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -3,6 +3,7 @@ pub mod tauri_bindings; use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice}; use crate::cli::command::{Bitcoin, Monero}; +use crate::cli::swap_manager::SwapManager; use crate::common::tor::{bootstrap_tor_client, create_tor_client}; use crate::common::tracing_util::Format; use crate::database::{AccessMode, open_db}; @@ -10,23 +11,19 @@ use crate::network::rendezvous::XmrBtcNamespace; use crate::protocol::Database; use crate::seed::Seed; use crate::{common, monero}; -use anyhow::{Context as AnyContext, Error, Result, bail}; +use anyhow::{Context as AnyContext, Error, Result}; use arti_client::TorClient; -use futures::future::try_join_all; use libp2p::{Multiaddr, PeerId}; -use std::collections::HashMap; use std::fmt; -use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::{Arc, Once}; use swap_env::env::{Config as EnvConfig, GetConfig, Mainnet, Testnet}; use swap_fs::system_data_dir; use tauri_bindings::{MoneroNodeConfig, TauriBackgroundProgress, TauriEmitter, TauriHandle}; -use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, broadcast::Sender}; +use tokio::sync::RwLock; use tokio::task::JoinHandle; use tokio_util::task::AbortOnDropHandle; use tor_rtcompat::tokio::TokioRustlsRuntime; -use uuid::Uuid; use super::watcher::Watcher; @@ -75,214 +72,6 @@ mod config { pub use config::Config; -mod swap_lock { - use super::*; - - #[derive(Default)] - pub struct PendingTaskList(TokioMutex>>); - - impl PendingTaskList { - pub async fn spawn(&self, future: F) - where - F: Future + Send + 'static, - T: Send + 'static, - { - let handle = tokio::spawn(async move { - let _ = future.await; - }); - - self.0.lock().await.push(handle); - } - - pub async fn wait_for_tasks(&self) -> Result<()> { - let tasks = { - // Scope for the lock, to avoid holding it for the entire duration of the async block - let mut guard = self.0.lock().await; - guard.drain(..).collect::>() - }; - - try_join_all(tasks).await?; - - Ok(()) - } - } - - /// Coordinates swap initiation and running swap tasks. - /// - /// Swap initiation is globally exclusive because `determine_btc_to_swap` - /// reasons about wallet-wide spendable balance. Once a Bob state machine is - /// spawned, running swap suspension is tracked per swap id. - pub struct SwapLock { - current_initiation: RwLock>, - initiation_suspension_trigger: Sender<()>, - running_swaps: RwLock>>, - } - - impl SwapLock { - pub fn new() -> Self { - let (initiation_suspension_trigger, _) = broadcast::channel(10); - SwapLock { - current_initiation: RwLock::new(None), - initiation_suspension_trigger, - running_swaps: RwLock::new(HashMap::new()), - } - } - - pub async fn listen_for_initiation_force_suspension(&self) -> Result<(), Error> { - receive_suspension(self.initiation_suspension_trigger.subscribe()).await - } - - pub async fn listen_for_swap_force_suspension(&self, swap_id: Uuid) -> Result<(), Error> { - let listener = { - let running_swaps = self.running_swaps.read().await; - let trigger = running_swaps.get(&swap_id).context("Swap is not running")?; - - trigger.subscribe() - }; - - receive_suspension(listener).await - } - - pub async fn acquire_initiation_lock(&self, swap_id: Uuid) -> Result<(), Error> { - let mut current_initiation = self.current_initiation.write().await; - if current_initiation.is_some() { - bail!("There already exists an active swap initiation"); - } - - tracing::debug!(swap_id = %swap_id, "Acquiring swap initiation lock"); - *current_initiation = Some(swap_id); - Ok(()) - } - - pub async fn release_initiation_lock(&self, swap_id: Uuid) -> Result<(), Error> { - let mut current_initiation = self.current_initiation.write().await; - let Some(current_swap_id) = *current_initiation else { - bail!("There is no current swap initiation lock to release"); - }; - - if current_swap_id != swap_id { - bail!( - "Cannot release swap initiation lock for {swap_id}; current initiation is {current_swap_id}" - ); - } - - tracing::debug!(swap_id = %swap_id, "Releasing swap initiation lock"); - *current_initiation = None; - Ok(()) - } - - pub async fn get_current_initiation_swap_id(&self) -> Option { - *self.current_initiation.read().await - } - - pub async fn register_running_swap(&self, swap_id: Uuid) -> Result<(), Error> { - let mut running_swaps = self.running_swaps.write().await; - if running_swaps.contains_key(&swap_id) { - bail!("Swap {swap_id} is already running"); - } - - let (suspension_trigger, _) = broadcast::channel(10); - running_swaps.insert(swap_id, suspension_trigger); - tracing::debug!(swap_id = %swap_id, "Registered running swap"); - Ok(()) - } - - pub async fn release_running_swap(&self, swap_id: Uuid) -> Result<(), Error> { - let mut running_swaps = self.running_swaps.write().await; - if running_swaps.remove(&swap_id).is_none() { - bail!("Swap {swap_id} is not running"); - } - - tracing::debug!(swap_id = %swap_id, "Released running swap"); - Ok(()) - } - - pub async fn get_running_swap_ids(&self) -> Vec { - self.running_swaps.read().await.keys().copied().collect() - } - - pub async fn is_swap_running(&self, swap_id: Uuid) -> bool { - self.running_swaps.read().await.contains_key(&swap_id) - } - - pub async fn send_initiation_suspend_signal(&self) -> Result<(), Error> { - let swap_id = self.get_current_initiation_swap_id().await; - - let Some(swap_id) = swap_id else { - return Ok(()); - }; - - let _ = self.initiation_suspension_trigger.send(())?; - self.wait_until_not_initiating(swap_id).await - } - - pub async fn send_swap_suspend_signal(&self, swap_id: Uuid) -> Result<(), Error> { - if self.get_current_initiation_swap_id().await == Some(swap_id) { - return self.send_initiation_suspend_signal().await; - } - - let trigger = { - let running_swaps = self.running_swaps.read().await; - let Some(trigger) = running_swaps.get(&swap_id) else { - return Ok(()); - }; - - trigger.clone() - }; - - let _ = trigger.send(())?; - self.wait_until_not_running(swap_id).await - } - - async fn wait_until_not_initiating(&self, swap_id: Uuid) -> Result<(), Error> { - const TIMEOUT: u64 = 10_000; - const INTERVAL: u64 = 50; - - for _ in 0..(TIMEOUT / INTERVAL) { - if self.get_current_initiation_swap_id().await != Some(swap_id) { - return Ok(()); - } - tokio::time::sleep(tokio::time::Duration::from_millis(INTERVAL)).await; - } - - bail!("Timed out waiting for swap initiation lock to be released"); - } - - async fn wait_until_not_running(&self, swap_id: Uuid) -> Result<(), Error> { - const TIMEOUT: u64 = 10_000; - const INTERVAL: u64 = 50; - - for _ in 0..(TIMEOUT / INTERVAL) { - if !self.is_swap_running(swap_id).await { - return Ok(()); - } - tokio::time::sleep(tokio::time::Duration::from_millis(INTERVAL)).await; - } - - bail!("Timed out waiting for running swap to be released"); - } - } - - async fn receive_suspension(mut listener: broadcast::Receiver<()>) -> Result<(), Error> { - let event = listener.recv().await; - match event { - Ok(_) => Ok(()), - Err(e) => { - tracing::error!("Error receiving swap suspension signal: {}", e); - bail!(e) - } - } - } - - impl Default for SwapLock { - fn default() -> Self { - Self::new() - } - } -} - -pub use swap_lock::{PendingTaskList, SwapLock}; - mod context { use super::*; use crate::cli::EventLoopHandle; @@ -310,9 +99,8 @@ mod context { #[derive(Clone)] pub struct Context { pub db: Arc>>>, - pub swap_lock: Arc, + pub swap_manager: Arc, pub config: Arc>>, - pub tasks: Arc, pub tauri_handle: Option, pub(super) bitcoin_wallet: Arc>>>, pub monero_manager: Arc>>>, @@ -331,13 +119,12 @@ mod context { Self::new(None) } - /// Creates an empty Context with only the swap_lock and tasks initialized + /// Creates an empty Context with only the swap_manager initialized fn new(tauri_handle: Option) -> Self { Self { db: Arc::new(RwLock::new(None)), - swap_lock: Arc::new(SwapLock::new()), + swap_manager: Arc::new(SwapManager::new()), config: Arc::new(RwLock::new(None)), - tasks: Arc::new(PendingTaskList::default()), tauri_handle, bitcoin_wallet: Arc::new(RwLock::new(None)), monero_manager: Arc::new(RwLock::new(None)), @@ -428,8 +215,7 @@ mod context { monero_manager: Arc::new(RwLock::new(Some(bob_monero_wallet))), config: Arc::new(RwLock::new(Some(config))), db: Arc::new(RwLock::new(Some(db))), - swap_lock: SwapLock::new().into(), - tasks: PendingTaskList::default().into(), + swap_manager: Arc::new(SwapManager::new()), tauri_handle: None, tor_client: Arc::new(RwLock::new(None)), monero_rpc_pool_handle: Arc::new(RwLock::new(None)), @@ -886,7 +672,7 @@ mod builder { wallet, db.clone(), self.tauri_handle.clone(), - context.swap_lock.clone(), + context.swap_manager.clone(), ); tokio::spawn(watcher.run()); } diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 04bf94e1d0..2bfcb1dd84 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -5,13 +5,14 @@ use crate::cli::api::tauri_bindings::{ TauriSwapProgressEvent, }; use crate::cli::list_sellers::QuoteWithAddress; +use crate::cli::swap_manager::{StartSwapInputs, run_exclusive_initiation}; use crate::common::{get_logs, redact}; +use crate::monero; use crate::monero::MoneroAddressPool; use crate::monero::wallet_rpc::MoneroDaemon; use crate::network::quote::BidQuote; use crate::protocol::State; -use crate::protocol::bob::{self, BobState, Swap}; -use crate::{cli, monero}; +use crate::protocol::bob::BobState; use ::bitcoin::Txid; use ::bitcoin::address::NetworkUnchecked; use ::monero_address::Network; @@ -877,7 +878,7 @@ pub async fn suspend_swap( ) -> Result { let SuspendSwapArgs { swap_id } = args; - context.swap_lock.send_swap_suspend_signal(swap_id).await?; + context.swap_manager.suspend(swap_id).await?; Ok(SuspendSwapResponse { swap_id }) } @@ -1049,241 +1050,95 @@ pub async fn buy_xmr( }; let monero_wallet = context.try_get_monero_manager().await?; - let env_config = config.env_config; - - // Prepare variables for the quote fetching process let tauri_handle = context.tauri_handle.clone(); - // Get the existing event loop handle from context - let mut event_loop_handle = context.try_get_event_loop_handle().await?; + let event_loop_handle = context.try_get_event_loop_handle().await?; let quotes_rx = event_loop_handle.cached_quotes(); - // Wait for the user to approve a seller and to deposit coins - // Calling determine_btc_to_swap let address_len = bitcoin_wallet.new_address().await?.script_pubkey().len(); - let bitcoin_wallet_for_closures = Arc::clone(&bitcoin_wallet); - - // Clone variables before moving them into closures - let bitcoin_change_address_for_spawn = bitcoin_change_address.clone(); - - // Clone tauri_handle for different closures - let tauri_handle_for_determine = tauri_handle.clone(); - let tauri_handle_for_selection = tauri_handle.clone(); - let tauri_handle_for_suspension = tauri_handle.clone(); - - // Acquire the initiation lock before the user has selected a maker and we already have funds - // in the wallet because `determine_btc_to_swap` reasons about wallet-wide spendable funds. - context.swap_lock.acquire_initiation_lock(swap_id).await?; - - let select_offer_result: Result< - Option<( - Multiaddr, - PeerId, - BidQuote, - bitcoin::Amount, - bitcoin::Amount, - )>, - > = tokio::select! { - result = determine_btc_to_swap( - quotes_rx, - bitcoin_wallet.new_address(), - { - let wallet = Arc::clone(&bitcoin_wallet_for_closures); - move || { - let w = wallet.clone(); - async move { w.balance().await } - } - }, - { - let wallet = Arc::clone(&bitcoin_wallet_for_closures); - move || { - let w = wallet.clone(); - async move { w.max_giveable(address_len).await } - } - }, - { - let wallet = Arc::clone(&bitcoin_wallet_for_closures); - move || { - let w = wallet.clone(); - async move { w.sync().await } - } - }, - tauri_handle_for_determine, - swap_id, - |quote_with_address| { - let tauri_handle_clone = tauri_handle_for_selection.clone(); - Box::new(async move { - let details = SelectMakerDetails { + // The whole "select a maker + start the swap" sequence runs under the + // globally exclusive initiation lock so that no other initiation can + // observe partially-consumed wallet balance between selection and the + // bitcoin lock transaction. This matches the original `buy_xmr`. + let manager = Arc::clone(&context.swap_manager); + let body = { + let bitcoin_wallet = Arc::clone(&bitcoin_wallet); + let tauri_handle = tauri_handle.clone(); + async move { + let wallet_for_closures = Arc::clone(&bitcoin_wallet); + let tauri_for_determine = tauri_handle.clone(); + let tauri_for_selection = tauri_handle.clone(); + + let (seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee) = + determine_btc_to_swap( + quotes_rx, + bitcoin_wallet.new_address(), + { + let w = Arc::clone(&wallet_for_closures); + move || { + let w = w.clone(); + async move { w.balance().await } + } + }, + { + let w = Arc::clone(&wallet_for_closures); + move || { + let w = w.clone(); + async move { w.max_giveable(address_len).await } + } + }, + { + let w = Arc::clone(&wallet_for_closures); + move || { + let w = w.clone(); + async move { w.sync().await } + } + }, + tauri_for_determine, + swap_id, + move |quote_with_address| { + let tauri = tauri_for_selection.clone(); + Box::new(async move { + let details = SelectMakerDetails { + swap_id, + btc_amount_to_swap: quote_with_address.quote.max_quantity, + maker: quote_with_address, + }; + + tauri.request_maker_selection(details, 300).await + }) as Box> + Send> + }, + ) + .await?; + + tauri_handle + .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); + + manager + .start( + StartSwapInputs { swap_id, - btc_amount_to_swap: quote_with_address.quote.max_quantity, - maker: quote_with_address, - }; - - tauri_handle_clone.request_maker_selection(details, 300).await - }) as Box> + Send> - }, - ) => { - result.map(Some) - } - _ = context.swap_lock.listen_for_initiation_force_suspension() => { - context.swap_lock.release_initiation_lock(swap_id).await.expect("Shutdown signal received but failed to release swap initiation lock."); - - if let Some(handle) = tauri_handle_for_suspension { - handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - } - - Ok(None) - }, - }; - - let select_offer_result = match select_offer_result { - Ok(result) => result, - Err(error) => { - context - .swap_lock - .release_initiation_lock(swap_id) + seller_peer_id, + seller_multiaddr, + monero_receive_pool, + bitcoin_change_address, + tx_lock_amount, + tx_lock_fee, + }, + db, + bitcoin_wallet, + monero_wallet, + env_config, + event_loop_handle, + tauri_handle, + ) .await - .expect("Could not release swap initiation lock after failed offer selection"); - - return Err(error); } }; - let Some((seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee)) = - select_offer_result - else { - return Ok(()); - }; - - // Insert the peer_id into the database - if let Err(error) = db.insert_peer_id(swap_id, seller_peer_id).await { - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock after failed setup"); - - return Err(error); - } - - if let Err(error) = db - .insert_address(seller_peer_id, seller_multiaddr.clone()) - .await - { - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock after failed setup"); - - return Err(error); - } - - if let Err(error) = db - .insert_monero_address_pool(swap_id, monero_receive_pool.clone()) - .await - { - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock after failed setup"); - - return Err(error); - } - - // Add the seller's address to the swarm - if let Err(error) = event_loop_handle - .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) - .await - { - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock after failed setup"); - - return Err(error); - } - - tauri_handle.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::ReceivedQuote(quote.clone()), - ); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); - - if let Err(error) = context.swap_lock.register_running_swap(swap_id).await { - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock after failed setup"); - - return Err(error); - } - - let context_for_task = context.clone(); - context.tasks.clone().spawn(async move { - tokio::select! { - biased; - _ = context_for_task.swap_lock.listen_for_swap_force_suspension(swap_id) => { - tracing::debug!("Shutdown signal received, exiting"); - context_for_task.swap_lock.release_running_swap(swap_id).await.expect("Shutdown signal received but failed to release running swap."); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - bail!("Shutdown signal received"); - }, - - swap_result = async { - let swap_event_loop_handle = event_loop_handle.swap_handle(seller_peer_id, swap_id).await?; - let swap = Swap::new( - db.clone(), - swap_id, - bitcoin_wallet.clone(), - monero_wallet, - env_config, - swap_event_loop_handle, - monero_receive_pool.clone(), - bitcoin_change_address_for_spawn, - tx_lock_amount, - tx_lock_fee - ).with_event_emitter(tauri_handle.clone()); - - bob::run(swap).await - } => { - match swap_result { - Ok(state) => { - tracing::debug!(%swap_id, state=%state, "Swap completed") - } - Err(error) => { - tracing::error!(%swap_id, "Failed to complete swap: {:#}", error) - } - } - }, - }; - tracing::debug!(%swap_id, "Swap completed"); - - context_for_task - .swap_lock - .release_running_swap(swap_id) - .await - .expect("Could not release running swap"); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - Ok::<_, anyhow::Error>(()) - }.in_current_span()).await; - - context - .swap_lock - .release_initiation_lock(swap_id) - .await - .expect("Could not release swap initiation lock"); - + run_exclusive_initiation(&context.swap_manager, swap_id, body, tauri_handle).await?; Ok(()) } @@ -1298,78 +1153,21 @@ pub async fn resume_swap( let config = context.try_get_config().await?; let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let monero_manager = context.try_get_monero_manager().await?; - - let seller_peer_id = db.get_peer_id(swap_id).await?; - let seller_addresses = db.get_addresses(seller_peer_id).await?; - - let mut event_loop_handle = context.try_get_event_loop_handle().await?; - - for seller_address in seller_addresses { - event_loop_handle - .queue_peer_address(seller_peer_id, seller_address) - .await?; - } - - let monero_receive_pool = db.get_monero_address_pool(swap_id).await?; - + let event_loop_handle = context.try_get_event_loop_handle().await?; let tauri_handle = context.tauri_handle.clone(); - let swap_event_loop_handle = event_loop_handle - .swap_handle(seller_peer_id, swap_id) + context + .swap_manager + .resume( + swap_id, + db, + bitcoin_wallet, + monero_manager, + config.env_config, + event_loop_handle, + tauri_handle, + ) .await?; - let swap = Swap::from_db( - db.clone(), - swap_id, - bitcoin_wallet, - monero_manager, - config.env_config, - swap_event_loop_handle, - monero_receive_pool, - ) - .await? - .with_event_emitter(tauri_handle.clone()); - - context.swap_lock.register_running_swap(swap_id).await?; - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); - - context.tasks.clone().spawn( - async move { - tokio::select! { - biased; - _ = context.swap_lock.listen_for_swap_force_suspension(swap_id) => { - tracing::debug!("Shutdown signal received, exiting"); - context.swap_lock.release_running_swap(swap_id).await.expect("Shutdown signal received but failed to release running swap."); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - bail!("Shutdown signal received"); - }, - - swap_result = bob::run(swap) => { - match swap_result { - Ok(state) => { - tracing::debug!(%swap_id, state=%state, "Swap completed after resuming") - } - Err(error) => { - tracing::error!(%swap_id, "Failed to resume swap: {:#}", error) - } - } - - } - } - context - .swap_lock - .release_running_swap(swap_id) - .await - .expect("Could not release running swap"); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - Ok::<(), anyhow::Error>(()) - } - .in_current_span(), - ).await; Ok(ResumeSwapResponse { result: "OK".to_string(), @@ -1384,22 +1182,14 @@ pub async fn cancel_and_refund( let CancelAndRefundArgs { swap_id } = cancel_and_refund; let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let db = context.try_get_db().await?; + let tauri_handle = context.tauri_handle.clone(); - if context.swap_lock.is_swap_running(swap_id).await { - bail!("Cannot cancel and refund swap {swap_id} because it is currently running"); - } - - let state = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await; - - context - .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + let state = context + .swap_manager + .cancel_and_refund(swap_id, bitcoin_wallet, db, tauri_handle) + .await?; - state.map(|state| { - json!({ - "result": state, - }) - }) + Ok(json!({ "result": state })) } #[tracing::instrument(fields(method = "get_history"), skip(context))] diff --git a/swap/src/cli/swap_manager.rs b/swap/src/cli/swap_manager.rs new file mode 100644 index 0000000000..006859adae --- /dev/null +++ b/swap/src/cli/swap_manager.rs @@ -0,0 +1,572 @@ +//! Owns the lifecycle of Bob state machines. +//! +//! [`SwapManager`] is the single entry point for starting, resuming, suspending +//! and refunding swaps. It internally tracks per-swap [`JoinHandle`]s and +//! force-suspension senders, and coordinates the globally exclusive +//! "initiation" phase (the pre-swap maker selection / deposit waiting) via +//! [`run_exclusive_initiation`]. +//! +//! Read-only swap inspection (history, swap info, timelock checks, monero +//! recovery) intentionally stays in `cli::api::request` — this manager is +//! about state-machine lifecycle, not generic database access. + +use crate::cli; +use crate::cli::EventLoopHandle; +use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent}; +use crate::monero; +use crate::monero::MoneroAddressPool; +use crate::protocol::Database; +use crate::protocol::bob::{self, BobState, Swap}; +use anyhow::{Context as AnyContext, Error, Result, bail}; +use backoff::backoff::Backoff; +use futures::future::{BoxFuture, try_join_all}; +use libp2p::{Multiaddr, PeerId}; +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use swap_core::bitcoin; +use swap_env::env::Config as EnvConfig; +use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, oneshot}; +use tokio::task::JoinHandle; +use tracing::Instrument; +use uuid::Uuid; + +const RETRY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); +const RETRY_MAX_INTERVAL: Duration = Duration::from_secs(60); + +/// Closure that produces a fresh [`bob::Swap`] for each attempt of a retry +/// loop. The closure is invoked once per retry; it is responsible for +/// reloading state from the DB on subsequent invocations and for registering +/// a fresh swap-handle with the event loop. +type MakeSwap = Box BoxFuture<'static, Result> + Send + 'static>; + +/// Inputs needed to start a fresh swap, after the user has selected a maker +/// and the wallet has enough deposited bitcoin to cover the lock amount + fee. +pub struct StartSwapInputs { + pub swap_id: Uuid, + pub seller_peer_id: PeerId, + pub seller_multiaddr: Multiaddr, + pub monero_receive_pool: MoneroAddressPool, + pub bitcoin_change_address: bitcoin::Address, + pub tx_lock_amount: bitcoin::Amount, + pub tx_lock_fee: bitcoin::Amount, +} + +/// Owns the lifecycle of Bob state machines. +pub struct SwapManager { + /// Per-swap force-suspension senders + JoinHandles. + running: TokioMutex>, + /// Tracks the currently-running initiation phase, if any. + current_initiation: RwLock>, + /// Trigger to force-suspend the currently-running initiation. + initiation_suspend: broadcast::Sender<()>, +} + +struct RunningSwap { + /// Force-suspension trigger for this swap's state machine task. + suspend: broadcast::Sender<()>, + /// JoinHandle for the spawned state-machine task. `None` once + /// [`SwapManager::suspend`] has taken it. Removal of the entry itself is + /// always done by [`SwapManager::release_running`] on the task's exit + /// path, so that [`is_running`](Self::is_running) stays true until the + /// state machine has actually finished cleaning up. + handle: Option>, +} + +impl SwapManager { + pub fn new() -> Self { + let (initiation_suspend, _) = broadcast::channel(10); + Self { + running: TokioMutex::new(HashMap::new()), + current_initiation: RwLock::new(None), + initiation_suspend, + } + } + + /// Whether a swap state machine is currently running. + pub async fn is_running(&self, swap_id: Uuid) -> bool { + self.running.lock().await.contains_key(&swap_id) + } + + /// Returns the swap-ids of all currently running swaps. + pub async fn running_swap_ids(&self) -> Vec { + self.running.lock().await.keys().copied().collect() + } + + /// Returns the swap-id of the swap currently in its initiation phase, if any. + pub async fn current_initiation_swap_id(&self) -> Option { + *self.current_initiation.read().await + } + + /// Acquire the globally exclusive initiation lock for `swap_id`. Most + /// callers should use [`run_exclusive_initiation`] instead, which pairs + /// this with the suspension `select!` and an unconditional release. + pub async fn acquire_initiation_lock(&self, swap_id: Uuid) -> Result<()> { + let mut current = self.current_initiation.write().await; + if current.is_some() { + bail!("There already exists an active swap initiation"); + } + tracing::debug!(%swap_id, "Acquiring swap initiation lock"); + *current = Some(swap_id); + Ok(()) + } + + /// Release the initiation lock for `swap_id`. + pub async fn release_initiation_lock(&self, swap_id: Uuid) -> Result<()> { + let mut current = self.current_initiation.write().await; + let Some(current_swap_id) = *current else { + bail!("There is no current swap initiation lock to release"); + }; + if current_swap_id != swap_id { + bail!( + "Cannot release swap initiation lock for {swap_id}; current initiation is {current_swap_id}" + ); + } + tracing::debug!(%swap_id, "Releasing swap initiation lock"); + *current = None; + Ok(()) + } + + /// Start a fresh swap state machine. + /// + /// Persists peer/address/monero-pool to the DB, registers the swap as + /// running, and spawns the [`bob::run`] task. The task retries the state + /// machine with exponential backoff on error and exits when either: + /// - `bob::run` returns `Ok` (the swap reached a terminal state), or + /// - [`suspend`](Self::suspend) is called for `swap_id`. + /// + /// The first attempt uses [`Swap::new`] with the inputs supplied here; + /// subsequent retries reload state from the DB via [`Swap::from_db`] + /// (which sees whatever progress `bob::run` persisted on the previous + /// attempt). + /// + /// The pre-swap maker selection (currently `determine_btc_to_swap`) must + /// run before calling this and produce the [`StartSwapInputs`]. Use + /// [`run_exclusive_initiation`] to guard that pre-swap phase. + #[allow(clippy::too_many_arguments)] + pub async fn start( + self: &Arc, + inputs: StartSwapInputs, + db: Arc, + bitcoin_wallet: Arc, + monero_wallet: Arc, + env_config: EnvConfig, + mut event_loop_handle: EventLoopHandle, + tauri_handle: Option, + ) -> Result<()> { + let StartSwapInputs { + swap_id, + seller_peer_id, + seller_multiaddr, + monero_receive_pool, + bitcoin_change_address, + tx_lock_amount, + tx_lock_fee, + } = inputs; + + db.insert_peer_id(swap_id, seller_peer_id).await?; + db.insert_address(seller_peer_id, seller_multiaddr.clone()) + .await?; + db.insert_monero_address_pool(swap_id, monero_receive_pool.clone()) + .await?; + + event_loop_handle + .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) + .await?; + + // Persist the initial `Started` state so every retry — including the + // very first one if its prior attempt failed before any transition — + // can uniformly reload via `Swap::from_db`. + let initial_state = BobState::Started { + btc_amount: tx_lock_amount, + tx_lock_fee, + change_address: bitcoin_change_address, + }; + db.insert_latest_state(swap_id, initial_state.into()) + .await + .context("Failed to persist initial swap state")?; + + let tauri_handle_for_release = tauri_handle.clone(); + + let make_swap: MakeSwap = Box::new(move || { + let mut event_loop_handle = event_loop_handle.clone(); + let db = Arc::clone(&db); + let bitcoin_wallet = Arc::clone(&bitcoin_wallet); + let monero_wallet = Arc::clone(&monero_wallet); + let monero_receive_pool = monero_receive_pool.clone(); + let tauri_handle = tauri_handle.clone(); + Box::pin(async move { + let swap_event_loop_handle = event_loop_handle + .swap_handle(seller_peer_id, swap_id) + .await?; + let swap = Swap::from_db( + db, + swap_id, + bitcoin_wallet, + monero_wallet, + env_config, + swap_event_loop_handle, + monero_receive_pool, + ) + .await? + .with_event_emitter(tauri_handle); + Ok(swap) + }) + }); + + self.spawn_swap_task(swap_id, tauri_handle_for_release, make_swap) + .await + } + + /// Resume a swap state machine from its persisted state. + /// Retries with exponential backoff until completion or suspension. + pub async fn resume( + self: &Arc, + swap_id: Uuid, + db: Arc, + bitcoin_wallet: Arc, + monero_wallet: Arc, + env_config: EnvConfig, + mut event_loop_handle: EventLoopHandle, + tauri_handle: Option, + ) -> Result<()> { + let seller_peer_id = db.get_peer_id(swap_id).await?; + let seller_addresses = db.get_addresses(seller_peer_id).await?; + for addr in seller_addresses { + event_loop_handle + .queue_peer_address(seller_peer_id, addr) + .await?; + } + + let monero_receive_pool = db.get_monero_address_pool(swap_id).await?; + + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); + + let tauri_handle_for_release = tauri_handle.clone(); + + let make_swap: MakeSwap = Box::new(move || { + let mut event_loop_handle = event_loop_handle.clone(); + let db = Arc::clone(&db); + let bitcoin_wallet = Arc::clone(&bitcoin_wallet); + let monero_wallet = Arc::clone(&monero_wallet); + let monero_receive_pool = monero_receive_pool.clone(); + let tauri_handle = tauri_handle.clone(); + Box::pin(async move { + let swap_event_loop_handle = event_loop_handle + .swap_handle(seller_peer_id, swap_id) + .await?; + let swap = Swap::from_db( + db, + swap_id, + bitcoin_wallet, + monero_wallet, + env_config, + swap_event_loop_handle, + monero_receive_pool, + ) + .await? + .with_event_emitter(tauri_handle); + Ok(swap) + }) + }); + + self.spawn_swap_task(swap_id, tauri_handle_for_release, make_swap) + .await + } + + /// Suspend a swap. + /// + /// If `swap_id` is currently in the initiation phase, sends an initiation + /// suspend signal and waits for the lock to be released. Otherwise sends a + /// per-swap suspend signal and awaits the spawned task's completion. The + /// running-map entry is left in place; the task's own exit path + /// ([`release_running`](Self::release_running)) is what removes it, so + /// [`is_running`](Self::is_running) stays true until the state machine has + /// finished cleaning up. + pub async fn suspend(&self, swap_id: Uuid) -> Result<()> { + if self.current_initiation_swap_id().await == Some(swap_id) { + return self.suspend_initiation(swap_id).await; + } + + let handle = { + let mut running = self.running.lock().await; + let Some(entry) = running.get_mut(&swap_id) else { + return Ok(()); + }; + // Best-effort: a task with no live subscriber means it already + // raced past the select! and we'll just await it below. + let _ = entry.suspend.send(()); + entry.handle.take() + }; + + let Some(handle) = handle else { + // Another suspend has already taken the handle. Fall back to + // polling so this call still upholds the "returns only after the + // swap is no longer running" contract. + return self.wait_until_not_running(swap_id).await; + }; + + tracing::debug!(%swap_id, "Awaiting state machine task completion after suspend"); + match tokio::time::timeout(Duration::from_secs(10), handle).await { + Ok(Ok(())) => Ok(()), + Ok(Err(join_err)) => { + Err(Error::from(join_err) + .context("State machine task panicked while shutting down")) + } + Err(_) => bail!("Timed out waiting for swap state machine task to exit"), + } + } + + async fn suspend_initiation(&self, swap_id: Uuid) -> Result<()> { + let _ = self.initiation_suspend.send(()); + self.wait_until_not_initiating(swap_id).await + } + + async fn wait_until_not_initiating(&self, swap_id: Uuid) -> Result<()> { + wait_with_timeout(|| async { self.current_initiation_swap_id().await != Some(swap_id) }) + .await + .map_err(|_| { + anyhow::anyhow!("Timed out waiting for swap initiation lock to be released") + }) + } + + async fn wait_until_not_running(&self, swap_id: Uuid) -> Result<()> { + wait_with_timeout(|| async { !self.is_running(swap_id).await }) + .await + .map_err(|_| anyhow::anyhow!("Timed out waiting for swap to exit")) + } + + /// Cancel and refund a swap. Bails if the swap is currently running, since + /// the running state machine is responsible for its own refunds. + pub async fn cancel_and_refund( + &self, + swap_id: Uuid, + bitcoin_wallet: Arc, + db: Arc, + tauri_handle: Option, + ) -> Result { + if self.is_running(swap_id).await { + bail!("Cannot cancel and refund swap {swap_id} because it is currently running"); + } + + let result = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await; + + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + + result + } + + /// Wait for all currently-running swap tasks to complete. + /// Used during graceful shutdown. + pub async fn wait_for_tasks(&self) -> Result<()> { + let handles: Vec> = { + let mut running = self.running.lock().await; + running + .values_mut() + .filter_map(|entry| entry.handle.take()) + .collect() + }; + + try_join_all(handles) + .await + .context("Failed to await running swap tasks")?; + Ok(()) + } + + /// Subscribe to the initiation force-suspension signal. Used internally + /// by [`run_exclusive_initiation`]. + async fn await_initiation_force_suspension(&self) -> Result<()> { + let mut listener = self.initiation_suspend.subscribe(); + listener + .recv() + .await + .context("initiation suspend channel closed")?; + Ok(()) + } + + /// Spawn `make_swap` as a tracked, retrying state-machine task under + /// `swap_id`. See [`run_swap_task`] for the retry semantics. + /// + /// The spawn / register sequence is gated on a oneshot so that the + /// running map entry is guaranteed to exist (with the real + /// [`JoinHandle`]) before any code in `make_swap` executes — this rules + /// out a race in which `release_running` is called by the task before + /// the entry is inserted, or `suspend` finds an entry whose handle is a + /// placeholder. + async fn spawn_swap_task( + self: &Arc, + swap_id: Uuid, + tauri_handle: Option, + make_swap: MakeSwap, + ) -> Result<()> { + let suspend_tx = broadcast::channel::<()>(10).0; + let suspend_rx = suspend_tx.subscribe(); + let (gate_tx, gate_rx) = oneshot::channel::<()>(); + + let manager = Arc::clone(self); + let span = tracing::Span::current(); + let handle = tokio::spawn( + async move { + if gate_rx.await.is_err() { + return; + } + run_swap_task(manager, swap_id, suspend_rx, tauri_handle, make_swap).await; + } + .instrument(span), + ); + + { + let mut running = self.running.lock().await; + if running.contains_key(&swap_id) { + handle.abort(); + bail!("Swap {swap_id} is already running"); + } + running.insert( + swap_id, + RunningSwap { + suspend: suspend_tx, + handle: Some(handle), + }, + ); + } + + let _ = gate_tx.send(()); + tracing::debug!(%swap_id, "Registered running swap"); + Ok(()) + } + + async fn release_running(&self, swap_id: Uuid) { + let mut running = self.running.lock().await; + if running.remove(&swap_id).is_some() { + tracing::debug!(%swap_id, "Released running swap"); + } + } +} + +impl Default for SwapManager { + fn default() -> Self { + Self::new() + } +} + +/// Acquire the initiation lock for `swap_id`, run `body` while listening for +/// force-suspension, and release the lock on every exit path. The lock is +/// held across the *entire* `body`, so callers can perform DB writes and +/// spawn the state-machine task without a gap between selection and +/// registration. +/// +/// Returns `Ok(None)` if the initiation was force-suspended, otherwise +/// `Ok(Some(value))` where `value` is whatever `body` produced. +pub async fn run_exclusive_initiation( + manager: &SwapManager, + swap_id: Uuid, + body: F, + tauri_handle: Option, +) -> Result> +where + F: Future>, +{ + manager.acquire_initiation_lock(swap_id).await?; + + let result = tokio::select! { + result = body => result.map(Some), + _ = manager.await_initiation_force_suspension() => { + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + Ok(None) + } + }; + + manager + .release_initiation_lock(swap_id) + .await + .context("Failed to release initiation lock")?; + result +} + +/// Drive a single swap task. Retries the state machine with exponential +/// backoff on `Err`, exits on `Ok` (terminal state reached) or on receipt of +/// a force-suspension signal. Always releases the running-map entry and +/// emits `Released` on exit. +/// +/// The retry behaviour is intentional: individual states inside `bob::run` +/// already retry their own operations, but `bob::run` itself can still +/// return `Err`. +async fn run_swap_task( + manager: Arc, + swap_id: Uuid, + mut suspend_rx: broadcast::Receiver<()>, + tauri_handle: Option, + mut make_swap: MakeSwap, +) { + let mut backoff = backoff::ExponentialBackoffBuilder::new() + .with_initial_interval(RETRY_INITIAL_INTERVAL) + .with_max_interval(RETRY_MAX_INTERVAL) + // Retry indefinitely; the only stop conditions are Ok or suspend. + .with_max_elapsed_time(None) + .build(); + + 'retry: loop { + let outcome: Result = tokio::select! { + biased; + _ = suspend_rx.recv() => { + tracing::debug!(%swap_id, "Suspend signal received, exiting state machine"); + break 'retry; + } + result = async { + let swap = make_swap().await?; + bob::run(swap).await + } => result, + }; + + match outcome { + Ok(state) => { + tracing::debug!(%swap_id, %state, "Swap completed"); + break 'retry; + } + Err(error) => { + let next = backoff.next_backoff().unwrap_or(RETRY_MAX_INTERVAL); + tracing::error!( + %swap_id, + retry_in_secs = next.as_secs(), + "Swap state machine failed: {:#}; retrying", + error, + ); + + tokio::select! { + biased; + _ = suspend_rx.recv() => { + tracing::debug!( + %swap_id, + "Suspend signal received during retry backoff, exiting state machine", + ); + break 'retry; + } + _ = tokio::time::sleep(next) => {} + } + } + } + } + + manager.release_running(swap_id).await; + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); +} + +/// Poll `predicate` every 50ms for up to 10s, returning `Ok(())` when it +/// returns true and `Err(())` on timeout. Used as a fallback for the rare +/// suspend-after-suspend case where we no longer own a JoinHandle. +async fn wait_with_timeout(mut predicate: F) -> Result<(), ()> +where + F: FnMut() -> Fut, + Fut: Future, +{ + const TIMEOUT_MS: u64 = 10_000; + const INTERVAL_MS: u64 = 50; + for _ in 0..(TIMEOUT_MS / INTERVAL_MS) { + if predicate().await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(INTERVAL_MS)).await; + } + Err(()) +} diff --git a/swap/src/cli/watcher.rs b/swap/src/cli/watcher.rs index 2a9e0225ce..408aaa6afe 100644 --- a/swap/src/cli/watcher.rs +++ b/swap/src/cli/watcher.rs @@ -1,7 +1,7 @@ -use super::api::SwapLock; use super::api::tauri_bindings::{BackgroundRefundProgress, TauriBackgroundProgress, TauriEmitter}; use super::cancel_and_refund; use crate::cli::api::tauri_bindings::TauriHandle; +use crate::cli::swap_manager::SwapManager; use crate::protocol::bob::BobState; use crate::protocol::{Database, State}; use anyhow::{Context, Result}; @@ -18,7 +18,7 @@ pub struct Watcher { wallet: Arc, database: Arc, tauri: Option, - swap_lock: Arc, + swap_manager: Arc, /// This saves for every running swap the last known timelock status cached_timelocks: HashMap>, } @@ -32,14 +32,14 @@ impl Watcher { wallet: Arc, database: Arc, tauri: Option, - swap_lock: Arc, + swap_manager: Arc, ) -> Self { Self { wallet, database, cached_timelocks: HashMap::new(), tauri, - swap_lock, + swap_manager, } } @@ -123,7 +123,7 @@ impl Watcher { // If the swap is already running, we can skip the refund. // The refund will be handled by that swap's state machine. - if self.swap_lock.is_swap_running(swap_id).await { + if self.swap_manager.is_running(swap_id).await { continue; } From 2e5aa696ea959dcbb323b0b39752c19784ac0d88 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 5 May 2026 14:28:44 +0200 Subject: [PATCH 3/5] feat(gui): split swap page into Offers and Swaps tabs --- src-gui/src/models/tauriModelExt.ts | 16 ++++ src-gui/src/renderer/components/App.tsx | 10 ++- .../modal/swap/pages/DebugPageSwitchBadge.tsx | 27 +++--- .../navigation/NavigationHeader.tsx | 26 +++++- .../pages/history/table/HistoryRow.tsx | 12 ++- .../monero/components/WalletActionButtons.tsx | 2 +- .../components/pages/swap/SwapPage.tsx | 6 +- .../pages/swap/swap/CancelButton.tsx | 28 ++++--- .../components/pages/swap/swap/SwapWidget.tsx | 82 +++++++++++-------- .../BitcoinLockTxInMempoolPage.tsx | 6 +- src-gui/src/store/hooks.ts | 19 +++++ src-gui/src/utils/swapColor.ts | 11 +++ 12 files changed, 167 insertions(+), 78 deletions(-) create mode 100644 src-gui/src/utils/swapColor.ts diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index 4c54ec219d..ba4873351a 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -430,6 +430,22 @@ export function haveFundsBeenLocked( return true; } +/** + * A swap is in the "offer phase" while the user is still picking/accepting an + * offer (no funds committed yet). Once setup completes and we move to locking + * Bitcoin, the swap belongs to the "swaps" tab instead. + */ +export function isOfferPhase(event: TauriSwapProgressEvent): boolean { + switch (event.type) { + case "ReceivedQuote": + case "WaitingForBtcDeposit": + case "SwapSetupInflight": + return true; + default: + return false; + } +} + export function isContextFullyInitialized( status: ResultContextStatus | null, ): boolean { diff --git a/src-gui/src/renderer/components/App.tsx b/src-gui/src/renderer/components/App.tsx index ad59a961c3..9c003d6174 100644 --- a/src-gui/src/renderer/components/App.tsx +++ b/src-gui/src/renderer/components/App.tsx @@ -99,11 +99,19 @@ function InnerContent() { } /> + + + + } + /> - + } /> diff --git a/src-gui/src/renderer/components/modal/swap/pages/DebugPageSwitchBadge.tsx b/src-gui/src/renderer/components/modal/swap/pages/DebugPageSwitchBadge.tsx index f9c6698f13..2bd31ff769 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/DebugPageSwitchBadge.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/DebugPageSwitchBadge.tsx @@ -1,6 +1,4 @@ -import { Tooltip } from "@mui/material"; -import IconButton from "@mui/material/IconButton"; -import DeveloperBoardIcon from "@mui/icons-material/DeveloperBoard"; +import { Link } from "@mui/material"; export default function DebugPageSwitchBadge({ enabled, @@ -9,19 +7,16 @@ export default function DebugPageSwitchBadge({ enabled: boolean; setEnabled: (enabled: boolean) => void; }) { - const handleToggle = () => { - setEnabled(!enabled); - }; - return ( - - - - - + setEnabled(!enabled)} + variant="caption" + color={enabled ? "primary" : "text.secondary"} + underline="hover" + > + Debug + ); } diff --git a/src-gui/src/renderer/components/navigation/NavigationHeader.tsx b/src-gui/src/renderer/components/navigation/NavigationHeader.tsx index d73837c173..f18e7cdfc7 100644 --- a/src-gui/src/renderer/components/navigation/NavigationHeader.tsx +++ b/src-gui/src/renderer/components/navigation/NavigationHeader.tsx @@ -1,10 +1,15 @@ import { Box, List, Badge } from "@mui/material"; import HistoryOutlinedIcon from "@mui/icons-material/HistoryOutlined"; import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined"; +import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined"; import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined"; import RouteListItemIconButton from "./RouteListItemIconButton"; import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge"; -import { useIsSwapRunning, useTotalUnreadMessagesCount } from "store/hooks"; +import { + useHasOfferPhaseSwap, + useHasSwapPhaseSwap, + useTotalUnreadMessagesCount, +} from "store/hooks"; import SettingsIcon from "@mui/icons-material/Settings"; import BitcoinIcon from "../icons/BitcoinIcon"; import MoneroIcon from "../icons/MoneroIcon"; @@ -19,7 +24,10 @@ export default function NavigationHeader() { - + + + + @@ -54,11 +62,21 @@ function FeedbackIconWithBadge() { } function SwapIconWithBadge() { - const isSwapRunning = useIsSwapRunning(); + const hasSwapPhaseSwap = useHasSwapPhaseSwap(); return ( - + ); } + +function OffersIconWithBadge() { + const hasOfferPhaseSwap = useHasOfferPhaseSwap(); + + return ( + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx b/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx index 59645187c6..cca558701e 100644 --- a/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx +++ b/src-gui/src/renderer/components/pages/history/table/HistoryRow.tsx @@ -12,6 +12,7 @@ import { bobStateNameToHumanReadable, GetSwapInfoResponseExt, } from "models/tauriModelExt"; +import { swapIdColor } from "utils/swapColor"; function AmountTransfer({ btcAmount, @@ -47,7 +48,16 @@ export default function HistoryRow(swap: GetSwapInfoResponseExt) { - {swap.swap_id} + + {swap.swap_id} + setSendDialogOpen(true)} /> navigate("/swap")} + onClick={() => navigate("/offers")} icon={} label="Swap" variant="button" diff --git a/src-gui/src/renderer/components/pages/swap/SwapPage.tsx b/src-gui/src/renderer/components/pages/swap/SwapPage.tsx index 3685a1cb0c..2ece7933f6 100644 --- a/src-gui/src/renderer/components/pages/swap/SwapPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/SwapPage.tsx @@ -1,9 +1,9 @@ import { Box } from "@mui/material"; import ApiAlertsBox from "./ApiAlertsBox"; -import SwapWidget from "./swap/SwapWidget"; +import SwapWidget, { SwapWidgetMode } from "./swap/SwapWidget"; import AntiSpamInfoModal from "../../modal/anti-spam-info/AntiSpamInfoModal"; -export default function SwapPage() { +export default function SwapPage({ mode }: { mode: SwapWidgetMode }) { return ( - + ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx index 20b243197a..b558d862cc 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -1,4 +1,4 @@ -import { Box, Button } from "@mui/material"; +import { Link } from "@mui/material"; import { SwapState } from "models/storeModel"; import { haveFundsBeenLocked } from "models/tauriModelExt"; import { suspendSwap } from "renderer/rpc"; @@ -23,6 +23,13 @@ export default function CancelButton({ swapState }: { swapState: SwapState }) { await suspend(); } + const label = + hasFundsBeenLocked && swapState.curr.type !== "Released" + ? "Suspend" + : swapState.curr.type === "Released" + ? "Close" + : "Cancel"; + return ( <> setOpenSuspendAlert(false)} onSuspend={suspend} /> - - - + {label} + ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index 7258bddf30..283b20bd66 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -3,12 +3,13 @@ import { Button, Dialog, DialogActions, + Link, Paper, Tooltip, - Typography, } from "@mui/material"; import { useState } from "react"; import { SwapState } from "models/storeModel"; +import { isOfferPhase } from "models/tauriModelExt"; import { useAppSelector } from "store/hooks"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; import CancelButton from "./CancelButton"; @@ -16,14 +17,26 @@ import SwapStateStepper from "renderer/components/modal/swap/SwapStateStepper"; import DebugPageSwitchBadge from "renderer/components/modal/swap/pages/DebugPageSwitchBadge"; import DebugPage from "renderer/components/modal/swap/pages/DebugPage"; import MockSwapControls from "renderer/components/modal/swap/pages/MockSwapControls"; +import ClickToCopy from "renderer/components/other/ClickToCopy"; +import TruncatedText from "renderer/components/other/TruncatedText"; +import { swapIdColor } from "utils/swapColor"; -export default function SwapWidget() { - const runningSwaps = useAppSelector((state) => - Object.values(state.swap.swaps).filter( - (swap) => swap.curr.type !== "Released", - ), +export type SwapWidgetMode = "offers" | "swaps"; + +export default function SwapWidget({ mode }: { mode: SwapWidgetMode }) { + const matchingSwaps = useAppSelector((state) => + Object.values(state.swap.swaps).filter((swap) => { + if (swap.curr.type === "Released") return false; + return mode === "offers" + ? isOfferPhase(swap.curr) + : !isOfferPhase(swap.curr); + }), ); - const visibleSwaps = runningSwaps.length > 0 ? runningSwaps : [null]; + // The offers tab shows the InitPage placeholder when no offer is in flight, + // so the user can start a new swap. The swaps tab simply renders nothing + // when there is no in-progress swap to show. + const visibleSwaps: (SwapState | null)[] = + mode === "offers" && matchingSwaps.length === 0 ? [null] : matchingSwaps; return ( - {visibleSwaps.map((swap, index) => ( - + {visibleSwaps.map((swap) => ( + ))} ); } -function SwapStatePanel({ - swap, - index, -}: { - swap: SwapState | null; - index: number; -}) { +function SwapStatePanel({ swap }: { swap: SwapState | null }) { const [debug, setDebug] = useState(false); return ( @@ -70,26 +73,13 @@ function SwapStatePanel({ gap: 2, borderRadius: 2, padding: 2, + ...(swap != null && { + borderTop: `2px solid ${swapIdColor(swap.swapId, 0.85)}`, + }), }} > {swap != null && ( <> - - Swap {index + 1} - - - {swap.swapId} - - - + + + + + {swap.swapId} + + + + )} diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx index 1d484db1be..51aa5c7013 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx @@ -37,11 +37,7 @@ export default function BitcoinLockTxInMempoolPage({ loading additionalContent={ <> - 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. -
- Confirmations: {formatConfirmations(btc_lock_confirmations)} + {formatConfirmations(btc_lock_confirmations)} } /> diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 92c4fa2241..f089fa874c 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -16,6 +16,7 @@ import { PendingPasswordApprovalRequest, isPendingPasswordApprovalEvent, isContextFullyInitialized, + isOfferPhase, } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; @@ -79,6 +80,24 @@ export function useIsSwapRunning() { ); } +/// Returns true if we have a swap that is still in the offer/setup phase +export function useHasOfferPhaseSwap() { + return useAppSelector((state) => + Object.values(state.swap.swaps).some( + (swap) => swap.curr.type !== "Released" && isOfferPhase(swap.curr), + ), + ); +} + +/// Returns true if we have a swap that has progressed past the offer phase +export function useHasSwapPhaseSwap() { + return useAppSelector((state) => + Object.values(state.swap.swaps).some( + (swap) => swap.curr.type !== "Released" && !isOfferPhase(swap.curr), + ), + ); +} + /// Returns true if we have a swap that is running export function useIsSpecificSwapRunning(swapId: string | null) { return useAppSelector((state) => { diff --git a/src-gui/src/utils/swapColor.ts b/src-gui/src/utils/swapColor.ts new file mode 100644 index 0000000000..5cf99904d4 --- /dev/null +++ b/src-gui/src/utils/swapColor.ts @@ -0,0 +1,11 @@ +import { fnv1a } from "./hash"; + +/** + * Derives a stable, visually distinct color for a given swap id. Used to + * give each swap a subtle visual identity (underline + top border) so users + * can recognize the same swap across different views. + */ +export function swapIdColor(swapId: string, alpha = 1): string { + const hue = parseInt(fnv1a(swapId), 16) % 360; + return `hsla(${hue}, 45%, 68%, ${alpha})`; +} From 9deb326b97359f290b72d10494f5c9a826573f31 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Tue, 5 May 2026 19:11:40 +0200 Subject: [PATCH 4/5] feat: resume all swaps on start up --- src-gui/src/renderer/background.ts | 26 +++++ .../pages/swap/swap/CancelButton.tsx | 19 +++- .../components/pages/swap/swap/SwapWidget.tsx | 96 +++++++++++++++++-- .../BitcoinLockTxInMempoolPage.tsx | 6 +- src-gui/src/renderer/rpc.ts | 9 ++ src-gui/src/store/features/swapSlice.ts | 14 +++ src-tauri/src/commands.rs | 7 +- swap/src/cli/api/request.rs | 44 +++++++++ swap/src/cli/swap_manager.rs | 51 ++++++++++ 9 files changed, 250 insertions(+), 22 deletions(-) diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 75baf34cc8..27f1614f52 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -24,6 +24,7 @@ import { getSwapTimelock, initializeContext, refreshApprovals, + resumeAllSwaps, updateAllNodeStatuses, } from "./rpc"; import { store } from "./store/storeRenderer"; @@ -62,6 +63,28 @@ const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000; // Check context status every 2 seconds const CHECK_CONTEXT_STATUS_INTERVAL = 2 * 1_000; +// Retry resume_all_swaps every 3 seconds until it succeeds once +const RESUME_ALL_SWAPS_RETRY_INTERVAL = 3 * 1_000; + +async function resumeAllSwapsUntilSuccess(): Promise { + while (true) { + try { + const response = await resumeAllSwaps(); + logger.info( + `Resumed ${response.resumed_swap_ids.length} swap(s) on startup`, + ); + return; + } catch (error) { + logger.debug( + `resume_all_swaps not ready yet, retrying in ${RESUME_ALL_SWAPS_RETRY_INTERVAL}ms: ${error}`, + ); + await new Promise((resolve) => + setTimeout(resolve, RESUME_ALL_SWAPS_RETRY_INTERVAL), + ); + } + } +} + function setIntervalImmediate(callback: () => void, interval: number): void { callback(); setInterval(callback, interval); @@ -89,6 +112,9 @@ export async function setupBackgroundTasks(): Promise { // Fetch all alerts updateAlerts(); + // Kick off resume-all-swaps; it will retry itself until the context is ready + resumeAllSwapsUntilSuccess(); + // Setup Tauri event listeners // Check if the context is already available. This is to prevent unnecessary re-initialization setIntervalImmediate(async () => { diff --git a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx index b558d862cc..430833cc69 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -4,10 +4,14 @@ import { haveFundsBeenLocked } from "models/tauriModelExt"; import { suspendSwap } from "renderer/rpc"; import { useState } from "react"; import SwapSuspendAlert from "renderer/components/modal/SwapSuspendAlert"; +import { useAppDispatch } from "store/hooks"; +import { swapProgressRemoved } from "store/features/swapSlice"; export default function CancelButton({ swapState }: { swapState: SwapState }) { const [openSuspendAlert, setOpenSuspendAlert] = useState(false); + const dispatch = useAppDispatch(); + const isReleased = swapState.curr.type === "Released"; const hasFundsBeenLocked = haveFundsBeenLocked(swapState.curr); async function suspend() { @@ -15,6 +19,12 @@ export default function CancelButton({ swapState }: { swapState: SwapState }) { } async function onCancel() { + if (isReleased) { + // Swap is already done; "Close" just dismisses the final-state panel. + dispatch(swapProgressRemoved(swapState.swapId)); + return; + } + if (hasFundsBeenLocked) { setOpenSuspendAlert(true); return; @@ -23,12 +33,11 @@ export default function CancelButton({ swapState }: { swapState: SwapState }) { await suspend(); } - const label = - hasFundsBeenLocked && swapState.curr.type !== "Released" + const label = isReleased + ? "Close" + : hasFundsBeenLocked ? "Suspend" - : swapState.curr.type === "Released" - ? "Close" - : "Cancel"; + : "Cancel"; return ( <> diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index 283b20bd66..6944d63eac 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -6,8 +6,12 @@ import { Link, Paper, Tooltip, + Typography, } from "@mui/material"; +import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined"; +import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined"; import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { SwapState } from "models/storeModel"; import { isOfferPhase } from "models/tauriModelExt"; import { useAppSelector } from "store/hooks"; @@ -20,18 +24,35 @@ import MockSwapControls from "renderer/components/modal/swap/pages/MockSwapContr import ClickToCopy from "renderer/components/other/ClickToCopy"; import TruncatedText from "renderer/components/other/TruncatedText"; import { swapIdColor } from "utils/swapColor"; +import { parseDateString } from "utils/parseUtils"; +import { sortBy } from "lodash"; export type SwapWidgetMode = "offers" | "swaps"; export default function SwapWidget({ mode }: { mode: SwapWidgetMode }) { - const matchingSwaps = useAppSelector((state) => - Object.values(state.swap.swaps).filter((swap) => { - if (swap.curr.type === "Released") return false; + const matchingSwaps = useAppSelector((state) => { + const filtered = Object.values(state.swap.swaps).filter((swap) => { + // For released swaps the meaningful state is `prev` (curr is just the + // generic "Released" marker). We keep them visible until acknowledged. + const phaseEvent = swap.curr.type === "Released" ? swap.prev : swap.curr; + if (phaseEvent == null) return false; return mode === "offers" - ? isOfferPhase(swap.curr) - : !isOfferPhase(swap.curr); - }), - ); + ? isOfferPhase(phaseEvent) + : !isOfferPhase(phaseEvent); + }); + // Newest first. A swap may exist in the redux swap slice before its + // SwapInfo row has been fetched - if any swap is missing info, leave the + // list in its current order; the sort kicks in once everything is loaded. + const swapInfos = state.rpc.state.swapInfos; + if (swapInfos == null) return filtered; + if (filtered.some((swap) => swapInfos[swap.swapId] == null)) { + return filtered; + } + return sortBy( + filtered, + (swap) => -parseDateString(swapInfos[swap.swapId].start_date), + ); + }); // The offers tab shows the InitPage placeholder when no offer is in flight, // so the user can start a new swap. The swaps tab simply renders nothing // when there is no in-progress swap to show. @@ -53,14 +74,69 @@ export default function SwapWidget({ mode }: { mode: SwapWidgetMode }) { gap: 2, }} > - {visibleSwaps.map((swap) => ( - - ))} + {mode === "swaps" && matchingSwaps.length === 0 ? ( + + ) : ( + visibleSwaps.map((swap) => ( + + )) + )} ); } +function NoSwapsPlaceholder() { + const navigate = useNavigate(); + + return ( + + + + + + No swaps in progress + + Browse live offers from makers to start your next swap. + + + + + ); +} + function SwapStatePanel({ swap }: { swap: SwapState | null }) { const [debug, setDebug] = useState(false); diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx index 51aa5c7013..42448f3753 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx @@ -35,11 +35,7 @@ export default function BitcoinLockTxInMempoolPage({ title="Bitcoin Lock Transaction" txId={btc_lock_txid} loading - additionalContent={ - <> - {formatConfirmations(btc_lock_confirmations)} - - } + additionalContent={<>{formatConfirmations(btc_lock_confirmations)}} /> diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 26ee3e9290..6fd6f4c125 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -7,6 +7,8 @@ import { GetLogsResponse, GetSwapInfoResponse, MoneroRecoveryArgs, + ResumeAllSwapsArgs, + ResumeAllSwapsResponse, ResumeSwapArgs, ResumeSwapResponse, SuspendSwapArgs, @@ -386,6 +388,13 @@ export async function resumeSwap(swapId: string) { }); } +export async function resumeAllSwaps(): Promise { + return await invoke( + "resume_all_swaps", + {}, + ); +} + export async function suspendSwap(swapId: string) { await invoke("suspend_swap", { swap_id: swapId, diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index a607d967e3..f0546ebd24 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { TauriSwapProgressEventWrapper } from "models/tauriModel"; +import { isOfferPhase } from "models/tauriModelExt"; import { SwapSlice } from "../../models/storeModel"; const initialState: SwapSlice = { @@ -22,6 +23,19 @@ export const swapSlice = createSlice({ ) { const existingSwap = swap.swaps[action.payload.swap_id]; + // If a swap is released while still in the offer phase (e.g. the user + // cancelled before any funds were committed) there is no meaningful + // final state worth keeping around — drop it instead of leaving a + // panel for the user to acknowledge. + if ( + action.payload.event.type === "Released" && + existingSwap != null && + isOfferPhase(existingSwap.curr) + ) { + delete swap.swaps[action.payload.swap_id]; + return; + } + if (existingSwap == null) { swap.swaps[action.payload.swap_id] = { curr: action.payload.event, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 207bcec2cd..5d52bf0384 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -15,8 +15,9 @@ use swap::cli::{ GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, RedactArgs, RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, - ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, - SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendSwapArgs, WithdrawBtcArgs, + ResolveApprovalArgs, ResumeAllSwapsArgs, ResumeSwapArgs, SendMoneroArgs, + SetMoneroSubaddressLabelArgs, SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, + SuspendSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, }, @@ -45,6 +46,7 @@ macro_rules! generate_command_handlers { withdraw_btc, buy_xmr, resume_swap, + resume_all_swaps, get_history, monero_recovery, get_logs, @@ -486,6 +488,7 @@ pub async fn dfx_authenticate( tauri_command!(get_balance, BalanceArgs); tauri_command!(buy_xmr, BuyXmrArgs); tauri_command!(resume_swap, ResumeSwapArgs); +tauri_command!(resume_all_swaps, ResumeAllSwapsArgs, no_args); tauri_command!(withdraw_btc, WithdrawBtcArgs); tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 2bfcb1dd84..55accec7b1 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -101,6 +101,26 @@ impl Request for ResumeSwapArgs { } } +// ResumeAllSwaps +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ResumeAllSwapsArgs; + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct ResumeAllSwapsResponse { + #[typeshare(serialized_as = "Vec")] + pub resumed_swap_ids: Vec, +} + +impl Request for ResumeAllSwapsArgs { + type Response = ResumeAllSwapsResponse; + + async fn request(self, ctx: Arc) -> Result { + resume_all_swaps(ctx).await + } +} + // CancelAndRefund #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -1174,6 +1194,30 @@ pub async fn resume_swap( }) } +#[tracing::instrument(fields(method = "resume_all_swaps"), skip(context))] +pub async fn resume_all_swaps(context: Arc) -> Result { + let db = context.try_get_db().await?; + let config = context.try_get_config().await?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; + let monero_manager = context.try_get_monero_manager().await?; + let event_loop_handle = context.try_get_event_loop_handle().await?; + let tauri_handle = context.tauri_handle.clone(); + + let resumed_swap_ids = context + .swap_manager + .resume_all( + db, + bitcoin_wallet, + monero_manager, + config.env_config, + event_loop_handle, + tauri_handle, + ) + .await?; + + Ok(ResumeAllSwapsResponse { resumed_swap_ids }) +} + #[tracing::instrument(fields(method = "cancel_and_refund"), skip(context))] pub async fn cancel_and_refund( cancel_and_refund: CancelAndRefundArgs, diff --git a/swap/src/cli/swap_manager.rs b/swap/src/cli/swap_manager.rs index 006859adae..e0b188d0a0 100644 --- a/swap/src/cli/swap_manager.rs +++ b/swap/src/cli/swap_manager.rs @@ -275,6 +275,57 @@ impl SwapManager { .await } + /// Resume every Bob swap that is in a resumable state. + /// + /// A swap is considered resumable when it has not reached a terminal + /// state and is not already running. Each resumable swap is started via + /// [`resume`](Self::resume); failures for individual swaps are logged + /// and skipped, so one bad swap does not prevent the rest from resuming. + pub async fn resume_all( + self: &Arc, + db: Arc, + bitcoin_wallet: Arc, + monero_wallet: Arc, + env_config: EnvConfig, + event_loop_handle: EventLoopHandle, + tauri_handle: Option, + ) -> Result> { + let swaps = db.all().await.context("Failed to load swaps from db")?; + + let mut resumed = Vec::new(); + for (_, swap_id, state) in swaps { + if !matches!(state, crate::protocol::State::Bob(_)) { + continue; + } + if state.swap_finished() { + continue; + } + if self.is_running(swap_id).await { + continue; + } + + match self + .resume( + swap_id, + Arc::clone(&db), + Arc::clone(&bitcoin_wallet), + Arc::clone(&monero_wallet), + env_config, + event_loop_handle.clone(), + tauri_handle.clone(), + ) + .await + { + Ok(()) => resumed.push(swap_id), + Err(error) => { + tracing::error!(%swap_id, "Failed to resume swap: {:#}", error); + } + } + } + + Ok(resumed) + } + /// Suspend a swap. /// /// If `swap_id` is currently in the initiation phase, sends an initiation From 21dfabe837e19dd1610f3bb21753c8b868f42076 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Wed, 6 May 2026 18:21:29 +0200 Subject: [PATCH 5/5] bunch of fixes --- ...d23c693e941ddbe1d6fe0c50a47e120ac2332.json | 8 +- src-gui/src/dev/mockSwapEvents.ts | 14 +- .../alert/SwapStatusAlert/SwapStatusAlert.tsx | 60 ++- .../components/alert/SwapTxLockAlertsBox.tsx | 5 +- .../modal/swap/SwapStateStepper.tsx | 14 +- .../navigation/NavigationHeader.tsx | 10 +- .../navigation/UnfinishedSwapsCountBadge.tsx | 13 +- .../components/pages/history/HistoryPage.tsx | 1 - .../pages/swap/swap/CancelButton.tsx | 13 +- .../pages/swap/swap/RetryBackoffAlert.tsx | 59 +++ .../pages/swap/swap/SwapStatePage.tsx | 47 +++ .../components/pages/swap/swap/SwapWidget.tsx | 274 +++++++++++-- .../pages/swap/swap/TimelockButton.tsx | 79 ++++ .../BitcoinLockTxInMempoolPage.tsx | 13 +- .../swap/swap/in_progress/PreflightEncSig.tsx | 2 +- src-gui/src/store/features/swapSlice.ts | 20 +- src-gui/src/store/hooks.ts | 71 +++- swap-machine/src/bob/mod.rs | 19 + swap/src/cli/api/tauri_bindings.rs | 12 +- swap/src/cli/swap_manager.rs | 376 ++++++++++++++---- swap/src/protocol/bob/swap.rs | 2 + ...ob_refunds_when_xmr_amount_is_not_exact.rs | 4 +- 22 files changed, 930 insertions(+), 186 deletions(-) create mode 100644 src-gui/src/renderer/components/pages/swap/swap/RetryBackoffAlert.tsx create mode 100644 src-gui/src/renderer/components/pages/swap/swap/TimelockButton.tsx diff --git a/.sqlx/query-8502f53826cfae8b9dbe494191ad23c693e941ddbe1d6fe0c50a47e120ac2332.json b/.sqlx/query-8502f53826cfae8b9dbe494191ad23c693e941ddbe1d6fe0c50a47e120ac2332.json index 8daede91dd..fcce880b96 100644 --- a/.sqlx/query-8502f53826cfae8b9dbe494191ad23c693e941ddbe1d6fe0c50a47e120ac2332.json +++ b/.sqlx/query-8502f53826cfae8b9dbe494191ad23c693e941ddbe1d6fe0c50a47e120ac2332.json @@ -32,13 +32,7 @@ "parameters": { "Right": 2 }, - "nullable": [ - false, - false, - false, - true, - false - ] + "nullable": [false, false, false, true, false] }, "hash": "8502f53826cfae8b9dbe494191ad23c693e941ddbe1d6fe0c50a47e120ac2332" } diff --git a/src-gui/src/dev/mockSwapEvents.ts b/src-gui/src/dev/mockSwapEvents.ts index 14995a30b7..ba46415d01 100644 --- a/src-gui/src/dev/mockSwapEvents.ts +++ b/src-gui/src/dev/mockSwapEvents.ts @@ -215,7 +215,7 @@ const happyPath: TauriSwapProgressEvent[] = [ xmr_receive_pool: MOCK_RECEIVE_POOL, }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const cooperativeRedeem: TauriSwapProgressEvent[] = [ @@ -238,7 +238,7 @@ const cooperativeRedeem: TauriSwapProgressEvent[] = [ xmr_receive_pool: MOCK_RECEIVE_POOL, }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const cooperativeRedeemRejected: TauriSwapProgressEvent[] = [ @@ -261,7 +261,7 @@ const cooperativeRedeemRejected: TauriSwapProgressEvent[] = [ content: { btc_refund_txid: MOCK_BTC_REFUND_TXID }, }, { type: "BtcRefunded", content: { btc_refund_txid: MOCK_BTC_REFUND_TXID } }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const earlyRefund: TauriSwapProgressEvent[] = [ @@ -274,7 +274,7 @@ const earlyRefund: TauriSwapProgressEvent[] = [ type: "BtcEarlyRefunded", content: { btc_early_refund_txid: MOCK_BTC_EARLY_REFUND_TXID }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const partialRefundWithAmnesty: TauriSwapProgressEvent[] = [ @@ -333,7 +333,7 @@ const partialRefundWithAmnesty: TauriSwapProgressEvent[] = [ btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const partialRefundWithBurn: TauriSwapProgressEvent[] = [ @@ -392,7 +392,7 @@ const partialRefundWithBurn: TauriSwapProgressEvent[] = [ btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; const partialRefundWithWithholdAndMercy: TauriSwapProgressEvent[] = [ @@ -467,7 +467,7 @@ const partialRefundWithWithholdAndMercy: TauriSwapProgressEvent[] = [ btc_amnesty_amount: MOCK_BTC_AMNESTY_AMOUNT, }, }, - { type: "Released" }, + { type: "Released", content: {} }, ]; export const scenarios: Record = { diff --git a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx index e0b134730f..3e20ed7f3c 100644 --- a/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/SwapStatusAlert/SwapStatusAlert.tsx @@ -17,6 +17,7 @@ import { TimelockTimeline } from "./TimelockTimeline"; import { useIsSpecificSwapRunning, useAppSelector } from "store/hooks"; import { selectSwapTimelock } from "store/selectors"; import { ExpiredTimelocks } from "models/tauriModel"; +import { swapIdColor } from "utils/swapColor"; /** * Component for displaying a list of messages. @@ -123,15 +124,19 @@ function BitcoinLockedNoTimelockExpiredStateAlert({ function BitcoinPossiblyCancelledAlert({ swap, timelock, + isRunning, }: { swap: GetSwapInfoResponseExt; timelock: TimelockCancel; + isRunning: boolean; }) { return ( If we haven't refunded in{" "} ); @@ -167,8 +172,10 @@ function PunishTimelockExpiredAlert() { */ function WaitingForRemainingRefundTimelockAlert({ blocksLeft, + isRunning, }: { blocksLeft: number; + isRunning: boolean; }) { return ( , "The maker can withhold the Bitcoin anti-spam deposit before the timelock expires", "We will refund the Bitcoin anti-spam deposit once the timelock expires", - "Keep the app running or resume the swap once the timelock expires", + isRunning + ? null + : "Keep the app running or resume the swap once the timelock expires", ]} /> ); @@ -290,10 +299,14 @@ export function StateAlert({ ); case "Cancel": return ( - + ); case "Punish": - return ; + return ; // These two timelock types only exist once the partial refund tx has been confirmed // They shouldn't occur for these states, so return null case "WaitingForRemainingRefund": @@ -303,7 +316,7 @@ export function StateAlert({ exhaustiveGuard(timelock); } } - return ; + return ; case BobStateName.BtcPartiallyRefunded: // Reuse existing timelock alerts for the amnesty waiting period @@ -313,6 +326,7 @@ export function StateAlert({ return ( ); case "RemainingRefund": @@ -403,18 +417,26 @@ export default function SwapStatusAlert({ }, }} > - - {isRunning ? ( - hasUnusualAmountOfTimePassed ? ( - "Swap has been running for a while" - ) : ( - "Swap is running" - ) - ) : ( - <> - Swap {swap.swap_id} is not running - - )} + + + {swap.swap_id} + + + {isRunning + ? hasUnusualAmountOfTimePassed + ? "has been running for a while" + : "is running" + : "is not running"} + + ); diff --git a/src-gui/src/renderer/components/navigation/UnfinishedSwapsCountBadge.tsx b/src-gui/src/renderer/components/navigation/UnfinishedSwapsCountBadge.tsx index 4fd559c3e4..06b2bc6e2e 100644 --- a/src-gui/src/renderer/components/navigation/UnfinishedSwapsCountBadge.tsx +++ b/src-gui/src/renderer/components/navigation/UnfinishedSwapsCountBadge.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Badge } from "@mui/material"; import { - useIsSwapRunning, + useRunningSwapsCount, useResumeableSwapsCountExcludingPunished, } from "store/hooks"; @@ -10,16 +10,17 @@ export default function UnfinishedSwapsBadge({ }: { children: React.ReactNode; }) { - const isSwapRunning = useIsSwapRunning(); + const runningSwapsCount = useRunningSwapsCount(); const resumableSwapsCount = useResumeableSwapsCountExcludingPunished(); - const displayedResumableSwapsCount = isSwapRunning - ? resumableSwapsCount - 1 - : resumableSwapsCount; + const displayedResumableSwapsCount = Math.max( + 0, + resumableSwapsCount - runningSwapsCount, + ); if (displayedResumableSwapsCount > 0) { return ( - + {children} ); diff --git a/src-gui/src/renderer/components/pages/history/HistoryPage.tsx b/src-gui/src/renderer/components/pages/history/HistoryPage.tsx index c195d0d63c..b872d58230 100644 --- a/src-gui/src/renderer/components/pages/history/HistoryPage.tsx +++ b/src-gui/src/renderer/components/pages/history/HistoryPage.tsx @@ -1,4 +1,3 @@ -import { Typography } from "@mui/material"; import SwapTxLockAlertsBox from "../../alert/SwapTxLockAlertsBox"; import HistoryTable from "./table/HistoryTable"; diff --git a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx index 430833cc69..f7651a38c0 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -11,8 +11,17 @@ export default function CancelButton({ swapState }: { swapState: SwapState }) { const [openSuspendAlert, setOpenSuspendAlert] = useState(false); const dispatch = useAppDispatch(); - const isReleased = swapState.curr.type === "Released"; - const hasFundsBeenLocked = haveFundsBeenLocked(swapState.curr); + // A Released event with `next_auto_resume_at_unix_ms` is just a retry + // signal — the swap is still in flight, so keep the cancel/suspend + // behavior driven by the previous state. + const isReleased = + swapState.curr.type === "Released" && + swapState.curr.content.next_auto_resume_at_unix_ms == null; + const effectiveCurr = + swapState.curr.type === "Released" && swapState.prev != null + ? swapState.prev + : swapState.curr; + const hasFundsBeenLocked = haveFundsBeenLocked(effectiveCurr); async function suspend() { await suspendSwap(swapState.swapId); diff --git a/src-gui/src/renderer/components/pages/swap/swap/RetryBackoffAlert.tsx b/src-gui/src/renderer/components/pages/swap/swap/RetryBackoffAlert.tsx new file mode 100644 index 0000000000..9fadd4f8d8 --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/RetryBackoffAlert.tsx @@ -0,0 +1,59 @@ +import { Alert } from "@mui/material"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import { useEffect, useState } from "react"; +import { resumeSwap } from "renderer/rpc"; +import { useAppSelector } from "store/hooks"; + +// Renders only when the swap is currently waiting in the auto-retry backoff +// (`curr.type === "Released"` with `next_auto_resume_at_unix_ms` set). Shows +// the remaining time until the manager will auto-retry; clicking the alert +// pre-empts the wait and resumes immediately. +export default function RetryBackoffAlert({ swapId }: { swapId: string }) { + const nextRetryAtMs = useAppSelector((state) => { + const s = state.swap.swaps[swapId]; + if (s == null || s.curr.type !== "Released") return null; + return s.curr.content.next_auto_resume_at_unix_ms ?? null; + }); + + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (nextRetryAtMs == null) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [nextRetryAtMs]); + + if (nextRetryAtMs == null) return null; + + const secondsLeft = Math.max(0, Math.ceil((nextRetryAtMs - now) / 1000)); + + return ( + { + void resumeSwap(swapId); + }} + action={} + sx={{ + cursor: "pointer", + py: 0.5, + px: 2, + alignItems: "center", + userSelect: "none", + transition: + "transform 80ms ease-out, filter 120ms ease-out, box-shadow 120ms ease-out", + "&:hover": { filter: "brightness(1.05)" }, + "&:active": { + transform: "scale(0.985)", + filter: "brightness(0.92)", + boxShadow: "inset 0 2px 4px rgba(0,0,0,0.25)", + }, + "& .MuiAlert-message": { py: 0 }, + "& .MuiAlert-action": { py: 0, mr: 0 }, + }} + > + Swap encountered an error. Retrying in {secondsLeft}s. Click to resume + now. + + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx index 491c605f2c..d62aa451ec 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapStatePage.tsx @@ -1,5 +1,8 @@ +import { Box, DialogContentText } from "@mui/material"; import { SwapState } from "models/storeModel"; import { TauriSwapProgressEventType } from "models/tauriModelExt"; +import CliLogsBox from "renderer/components/other/RenderedCliLog"; +import { useSwapLogs } from "store/hooks"; import CircularProgressWithSubtitle from "./components/CircularProgressWithSubtitle"; import BitcoinPunishedPage from "./done/BitcoinPunishedPage"; import { @@ -43,6 +46,32 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { return ; } + // A Released event with `next_auto_resume_at_unix_ms` is not terminal — the + // swap manager is just waiting to auto-retry. The retry banner mounted + // above this page already shows the countdown, so the body of the page + // should reflect what state the swap is actually in. + // + // - If `prev` carries a meaningful in-flight state, render that page. + // - If `prev` is just `Resuming` (a transitional event emitted before + // `bob::run` ever produced anything), showing the "Resuming swap..." + // spinner would be misleading — fall through to a neutral placeholder. + // - The `prev.type !== "Released"` guard prevents infinite recursion in + // the (slice-prevented but cheap to defend) case where `prev` is also + // Released. + if ( + state.curr.type === "Released" && + state.curr.content.next_auto_resume_at_unix_ms != null + ) { + if ( + state.prev != null && + state.prev.type !== "Released" && + state.prev.type !== "Resuming" + ) { + return ; + } + return ; + } + const type: TauriSwapProgressEventType = state.curr.type; switch (type) { @@ -286,3 +315,21 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { return exhaustiveGuard(type); } } + +// Shown when the swap is in retry-backoff and there's no meaningful previous +// state to render (the state machine errored before producing any progress). +// The retry banner above this page already shows the error and countdown; +// we surface the swap-specific logs here so the user can inspect what +// actually went wrong. +function RetryBackoffLogsPage({ swapId }: { swapId: string }) { + const logs = useSwapLogs(swapId); + return ( + + + The swap hit an error before it could make any progress. We'll retry + automatically. See the logs below for details. + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx index 6944d63eac..4eed5d12d6 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -8,28 +8,44 @@ import { Tooltip, Typography, } from "@mui/material"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined"; import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { SwapState } from "models/storeModel"; -import { isOfferPhase } from "models/tauriModelExt"; -import { useAppSelector } from "store/hooks"; +import { GetSwapInfoResponseExt, isOfferPhase } from "models/tauriModelExt"; +import { + useAppSelector, + useIdleResumableSwapInfos, + useSwapInfo, +} from "store/hooks"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; import CancelButton from "./CancelButton"; +import TimelockButton from "./TimelockButton"; +import RetryBackoffAlert from "./RetryBackoffAlert"; import SwapStateStepper from "renderer/components/modal/swap/SwapStateStepper"; import DebugPageSwitchBadge from "renderer/components/modal/swap/pages/DebugPageSwitchBadge"; import DebugPage from "renderer/components/modal/swap/pages/DebugPage"; import MockSwapControls from "renderer/components/modal/swap/pages/MockSwapControls"; import ClickToCopy from "renderer/components/other/ClickToCopy"; +import BitcoinIcon from "renderer/components/icons/BitcoinIcon"; +import MoneroIcon from "renderer/components/icons/MoneroIcon"; +import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units"; import TruncatedText from "renderer/components/other/TruncatedText"; +import { SwapResumeButton } from "renderer/components/pages/history/table/HistoryRowActions"; import { swapIdColor } from "utils/swapColor"; import { parseDateString } from "utils/parseUtils"; import { sortBy } from "lodash"; export type SwapWidgetMode = "offers" | "swaps"; +type SwapsListEntry = + | { kind: "active"; state: SwapState; swapId: string } + | { kind: "idle"; info: GetSwapInfoResponseExt; swapId: string }; + export default function SwapWidget({ mode }: { mode: SwapWidgetMode }) { + useRedirectOnOfferAccepted(mode); const matchingSwaps = useAppSelector((state) => { const filtered = Object.values(state.swap.swaps).filter((swap) => { // For released swaps the meaningful state is `prev` (curr is just the @@ -53,11 +69,50 @@ export default function SwapWidget({ mode }: { mode: SwapWidgetMode }) { (swap) => -parseDateString(swapInfos[swap.swapId].start_date), ); }); + + // Idle resumable swaps belong only on the "swaps" tab — they are by + // definition past the offer phase (funds locked). + const idleResumableSwaps = useIdleResumableSwapInfos(); + const swapInfos = useAppSelector((state) => state.rpc.state.swapInfos); + + const combinedEntries: SwapsListEntry[] = (() => { + if (mode !== "swaps") { + return matchingSwaps.map((s) => ({ + kind: "active" as const, + state: s, + swapId: s.swapId, + })); + } + const entries: SwapsListEntry[] = matchingSwaps.map((s) => ({ + kind: "active" as const, + state: s, + swapId: s.swapId, + })); + // Dedupe defensively: a swap that has any entry in the redux swap slice + // (running, retry-backoff, or terminally Released) is already covered by + // an active panel, so we must not also surface an "idle resumable" panel + // for it. The hook itself already filters these out, but the active list + // also includes redux entries that *don't* round-trip through the hook + // filter (e.g. swaps still being driven by an in-flight retry banner), + // so we re-check here. + const activeIds = new Set(entries.map((e) => e.swapId)); + for (const info of idleResumableSwaps) { + if (activeIds.has(info.swap_id)) continue; + entries.push({ kind: "idle", info, swapId: info.swap_id }); + } + if (swapInfos == null) return entries; + if (entries.some((e) => swapInfos[e.swapId] == null)) return entries; + return sortBy( + entries, + (e) => -parseDateString(swapInfos[e.swapId].start_date), + ); + })(); + // The offers tab shows the InitPage placeholder when no offer is in flight, // so the user can start a new swap. The swaps tab simply renders nothing - // when there is no in-progress swap to show. - const visibleSwaps: (SwapState | null)[] = - mode === "offers" && matchingSwaps.length === 0 ? [null] : matchingSwaps; + // when there is no in-progress or resumable swap to show. + const showOfferPlaceholder = + mode === "offers" && combinedEntries.length === 0; return ( - {mode === "swaps" && matchingSwaps.length === 0 ? ( + {mode === "swaps" && combinedEntries.length === 0 ? ( + ) : showOfferPlaceholder ? ( + ) : ( - visibleSwaps.map((swap) => ( - - )) + combinedEntries.map((entry) => + entry.kind === "active" ? ( + + ) : ( + + ), + ) )} ); } +// When a swap on the offers tab transitions out of the offer phase (i.e. the +// user accepted an offer and we're now locking funds), pull them over to +// /swap so they don't have to navigate manually. We track which swap ids +// we've already redirected for so a re-render can't bounce them back. +function useRedirectOnOfferAccepted(mode: SwapWidgetMode) { + const navigate = useNavigate(); + const swaps = useAppSelector((state) => state.swap.swaps); + const redirected = useRef>(new Set()); + + useEffect(() => { + if (mode !== "offers") return; + for (const swap of Object.values(swaps)) { + if (redirected.current.has(swap.swapId)) continue; + if (swap.prev == null) continue; + if (!isOfferPhase(swap.prev)) continue; + if (isOfferPhase(swap.curr)) continue; + if (swap.curr.type === "Released") continue; + redirected.current.add(swap.swapId); + navigate("/swap"); + return; + } + }, [swaps, mode, navigate]); +} + function NoSwapsPlaceholder() { const navigate = useNavigate(); @@ -146,9 +231,11 @@ function SwapStatePanel({ swap }: { swap: SwapState | null }) { sx={{ display: "flex", flexDirection: "column", - gap: 2, borderRadius: 2, - padding: 2, + // Clip TimelockButton's alert to the paper's rounded corners so it + // visually attaches to the top of the box rather than floating + // inside it. + overflow: "hidden", ...(swap != null && { borderTop: `2px solid ${swapIdColor(swap.swapId, 0.85)}`, }), @@ -171,28 +258,128 @@ function SwapStatePanel({ swap }: { swap: SwapState | null }) {
)} + {swap != null && } - + {swap != null && } + {swap != null && } + + + + {swap != null && } + {swap != null && ( + + + + + + + {swap.swapId} + + + + + + + )} - {swap != null && } - {swap != null && ( + + ); +} + +// A resumable swap that is not currently being driven by a state machine — +// shown alongside active swaps on the Swaps page so the user can resume it +// without navigating to the History view. Once the user clicks Resume the +// state machine starts emitting progress events and the swap migrates to a +// regular `SwapStatePanel`. +function IdleResumableSwapPanel({ swap }: { swap: GetSwapInfoResponseExt }) { + return ( + + + + - - - + Swap is suspended + Resume + + + + - {swap.swapId} + {swap.swap_id} - - )} + ); } + +// Header showing the swap's BTC -> XMR amounts. Reads from the swapInfo +// (which is fetched lazily after the swap_id appears), so we render nothing +// until the amounts are known to avoid a flash of "undefined -> undefined". +function SwapAmountHeader({ swapId }: { swapId: string }) { + const swapInfo = useSwapInfo(swapId); + if (swapInfo == null) return null; + + return ( + + + + + + + + + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/TimelockButton.tsx b/src-gui/src/renderer/components/pages/swap/swap/TimelockButton.tsx new file mode 100644 index 0000000000..695de172cd --- /dev/null +++ b/src-gui/src/renderer/components/pages/swap/swap/TimelockButton.tsx @@ -0,0 +1,79 @@ +import { Alert, Button, Dialog, DialogActions } from "@mui/material"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import { useState } from "react"; +import { isGetSwapInfoResponseRunningSwap } from "models/tauriModelExt"; +import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert"; +import { selectSwapTimelock } from "store/selectors"; +import { useAppSelector, useSwapInfo } from "store/hooks"; + +export default function TimelockButton({ swapId }: { swapId: string }) { + const [open, setOpen] = useState(false); + const swap = useSwapInfo(swapId); + const timelock = useAppSelector(selectSwapTimelock(swapId)); + + if (swap == null) return null; + if (!isGetSwapInfoResponseRunningSwap(swap)) return null; + if (timelock == null) return null; + + // Only show once the Bitcoin lock has more than three confirmations — + // before that the swap is still in its normal early phase and the + // "running for a while" hint is premature. We derive the confirmation + // count from the timelock state: in `None`, `blocks_left` counts down + // from `swap.cancel_timelock`, so confirmations = `cancel_timelock - + // blocks_left`. Any other state means the cancel timelock has expired, + // so we're well past 3 confirmations and should always show. + const btcLockConfirmations = + timelock.type === "None" + ? swap.cancel_timelock - timelock.content.blocks_left + : swap.cancel_timelock; + if (btcLockConfirmations <= 3) return null; + + return ( + <> + setOpen(true)} + icon={false} + action={} + sx={{ + cursor: "pointer", + // Sit flush against the parent Paper's rounded top edge: the + // parent clips us so we don't need our own border-radius. + borderRadius: 0, + py: 0.5, + px: 2, + alignItems: "center", + "& .MuiAlert-message": { py: 0 }, + "& .MuiAlert-action": { py: 0, mr: 0 }, + }} + > + Swap has been running for a while... + + setOpen(false)} + fullWidth + maxWidth="sm" + PaperProps={{ + sx: { + overflow: "hidden", + bgcolor: "warning.main", + color: "warning.contrastText", + }, + }} + > + + + + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx index 42448f3753..44f3f053b4 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/BitcoinLockTxInMempoolPage.tsx @@ -3,8 +3,10 @@ import { formatConfirmations } from "utils/formatUtils"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; import { Box, DialogContentText } from "@mui/material"; -// 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; +// Once the lock has this many confirmations the swap is essentially safe +// from being orphaned, so we suppress the descriptive paragraph above the +// transaction box to keep the page compact. +const BITCOIN_CONFIRMATIONS_HIDE_DESCRIPTION_THRESHOLD = 3; export default function BitcoinLockTxInMempoolPage({ btc_lock_confirmations, @@ -18,10 +20,13 @@ export default function BitcoinLockTxInMempoolPage({ return "We have locked our Bitcoin. We are waiting for the transaction to be confirmed."; } + const showDescription = + btc_lock_confirmations == null || + btc_lock_confirmations < BITCOIN_CONFIRMATIONS_HIDE_DESCRIPTION_THRESHOLD; + return ( <> - {(btc_lock_confirmations === undefined || - btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && ( + {showDescription && ( {description()} )} + ); } diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index f0546ebd24..4af91867ad 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -23,12 +23,15 @@ export const swapSlice = createSlice({ ) { const existingSwap = swap.swaps[action.payload.swap_id]; - // If a swap is released while still in the offer phase (e.g. the user - // cancelled before any funds were committed) there is no meaningful - // final state worth keeping around — drop it instead of leaving a - // panel for the user to acknowledge. + // If a swap is *terminally* released while still in the offer phase + // (e.g. the user cancelled before any funds were committed) there is no + // meaningful final state worth keeping around — drop it. A Released + // event carrying `next_auto_resume_at_unix_ms` is just a retry signal, + // not a terminal release, so we keep the entry around so the UI can + // show the retry banner. if ( action.payload.event.type === "Released" && + action.payload.event.content.next_auto_resume_at_unix_ms == null && existingSwap != null && isOfferPhase(existingSwap.curr) ) { @@ -43,7 +46,14 @@ export const swapSlice = createSlice({ swapId: action.payload.swap_id, }; } else { - existingSwap.prev = existingSwap.curr; + // Preserve `prev` as the last *non-Released* event. Two consecutive + // Released events (e.g. `make_swap` fails before `bob::run` emits + // any progress, so we go straight from one retry-Released to the + // next) would otherwise squash the meaningful prior state and force + // every consumer to walk further back themselves. + if (existingSwap.curr.type !== "Released") { + existingSwap.prev = existingSwap.curr; + } existingSwap.curr = action.payload.event; } }, diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index f089fa874c..673f9ea4a3 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -3,6 +3,7 @@ import { BobStateName, GetSwapInfoResponseExt, isBitcoinSyncProgress, + isBobStateNameRunningSwap, isPendingBackgroundProcess, isPendingLockBitcoinApprovalEvent, isPendingSeedSelectionApprovalEvent, @@ -71,30 +72,67 @@ export function useResumeableSwapsCountExcludingPunished() { ); } +// A swap entry counts as "still in flight" while its current event is anything +// other than a *terminal* Released. A Released event carrying +// `next_auto_resume_at_unix_ms` is a retry signal — the swap manager will +// auto-resume — so the GUI should keep treating those swaps as active. +function isSwapInFlight(swap: import("models/storeModel").SwapState) { + if (swap.curr.type !== "Released") return true; + return swap.curr.content.next_auto_resume_at_unix_ms != null; +} + +// For "in flight, past the offer phase" we look at the previous event when the +// current is Released — `prev` carries the actual swap-machine state. +function effectivePhaseEvent(swap: import("models/storeModel").SwapState) { + if (swap.curr.type !== "Released") return swap.curr; + return swap.prev; +} + /// Returns true if we have any swap that is running export function useIsSwapRunning() { return useAppSelector((state) => - Object.values(state.swap.swaps).some( - (swap) => swap.curr.type !== "Released", - ), + Object.values(state.swap.swaps).some(isSwapInFlight), + ); +} + +/// Returns the number of swaps that are currently running +export function useRunningSwapsCount() { + return useAppSelector( + (state) => Object.values(state.swap.swaps).filter(isSwapInFlight).length, ); } /// Returns true if we have a swap that is still in the offer/setup phase export function useHasOfferPhaseSwap() { return useAppSelector((state) => - Object.values(state.swap.swaps).some( - (swap) => swap.curr.type !== "Released" && isOfferPhase(swap.curr), - ), + Object.values(state.swap.swaps).some((swap) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && isOfferPhase(phase); + }), ); } /// Returns true if we have a swap that has progressed past the offer phase export function useHasSwapPhaseSwap() { return useAppSelector((state) => - Object.values(state.swap.swaps).some( - (swap) => swap.curr.type !== "Released" && !isOfferPhase(swap.curr), - ), + Object.values(state.swap.swaps).some((swap) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && !isOfferPhase(phase); + }), + ); +} + +/// Returns the number of swaps that have progressed past the offer phase +export function useSwapPhaseSwapsCount() { + return useAppSelector( + (state) => + Object.values(state.swap.swaps).filter((swap) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && !isOfferPhase(phase); + }).length, ); } @@ -167,6 +205,21 @@ export function useSwapInfosSortedByDate() { return sortBy(swapInfos, (swap) => -parseDateString(swap.start_date)); } +/// Swaps that are resumable per the on-disk state (`isBobStateNameRunningSwap`) +/// but have no entry in the redux swap slice — i.e. no state-machine task in +/// this session has touched them. The Swaps page surfaces these so the user +/// can resume them without leaving the page. Swaps that *do* have a redux +/// entry (running, retry-backoff, or terminally released) are left to their +/// existing in-flight panel. +export function useIdleResumableSwapInfos(): GetSwapInfoResponseExt[] { + const saneSwapInfos = useSaneSwapInfos(); + const swaps = useAppSelector((state) => state.swap.swaps); + return saneSwapInfos.filter( + (info) => + isBobStateNameRunningSwap(info.state_name) && swaps[info.swap_id] == null, + ); +} + /// Returns true if swapInfos has been loaded /// False means means we haven't fetched the swap infos yet export function useAreSwapInfosLoaded(): boolean { diff --git a/swap-machine/src/bob/mod.rs b/swap-machine/src/bob/mod.rs index 53ebf1fec7..f3b006d72f 100644 --- a/swap-machine/src/bob/mod.rs +++ b/swap-machine/src/bob/mod.rs @@ -296,6 +296,25 @@ pub fn is_complete(state: &BobState) -> bool { ) } +/// States the swap is in *before* any Bitcoin has been locked. These are +/// reachable when the user terminated the setup flow prior to approving the +/// offer, so there is nothing on-chain to recover and the swap should not +/// be auto-resumed by `resume_all`. Treated by the GUI as un-listable for +/// the same reason. +pub fn is_pre_lock_setup(state: &BobState) -> bool { + matches!( + state, + BobState::Started { .. } | BobState::SwapSetupCompleted(..) + ) +} + +/// Whether the swap is in a state that `resume_all` should pick up on +/// startup: not yet terminal, and past the pre-lock setup phase where no +/// funds have been committed. +pub fn is_resumable(state: &BobState) -> bool { + !is_complete(state) && !is_pre_lock_setup(state) +} + #[allow(non_snake_case)] #[derive(Clone, Debug, PartialEq)] pub struct State0 { diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 3679fcd364..76bc648f26 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1215,7 +1215,17 @@ pub enum TauriSwapProgressEvent { CooperativeRedeemRejected { reason: String, }, - Released, + /// The swap manager has dropped its handle on this swap. If the swap exited + /// because of a transient error and the manager is going to auto-retry the + /// resume, `next_auto_resume_at_unix_ms` carries the wall-clock time at + /// which the next retry will fire — the GUI can use this to show a + /// countdown and the user can still trigger a manual resume in the + /// meantime. `None` means the swap has actually finished (terminal state, + /// suspended by the user, etc.). + Released { + #[typeshare(serialized_as = "Option")] + next_auto_resume_at_unix_ms: Option, + }, } /// This event is emitted whenever there is a log message issued in the CLI. diff --git a/swap/src/cli/swap_manager.rs b/swap/src/cli/swap_manager.rs index e0b188d0a0..139f5e9195 100644 --- a/swap/src/cli/swap_manager.rs +++ b/swap/src/cli/swap_manager.rs @@ -24,22 +24,37 @@ use libp2p::{Multiaddr, PeerId}; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use swap_core::bitcoin; use swap_env::env::Config as EnvConfig; use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, oneshot}; use tokio::task::JoinHandle; -use tracing::Instrument; +use tracing::{Instrument, debug_span}; use uuid::Uuid; const RETRY_INITIAL_INTERVAL: Duration = Duration::from_secs(1); const RETRY_MAX_INTERVAL: Duration = Duration::from_secs(60); -/// Closure that produces a fresh [`bob::Swap`] for each attempt of a retry -/// loop. The closure is invoked once per retry; it is responsible for -/// reloading state from the DB on subsequent invocations and for registering -/// a fresh swap-handle with the event loop. -type MakeSwap = Box BoxFuture<'static, Result> + Send + 'static>; +/// Closure that rebuilds a [`bob::Swap`] for a retry attempt by reloading +/// state from the DB and registering a fresh swap-handle with the event +/// loop. Only invoked when the previous attempt errored — `bob::run` +/// persists state transitions itself, so retries simply pick up whatever +/// was last persisted. +type RebuildSwap = Box BoxFuture<'static, Result> + Send + 'static>; +type MakeInitialSwap = Box BoxFuture<'static, Result> + Send + 'static>; + +/// Why a swap-task was asked to suspend. Lets the task decide whether to +/// emit a final `Released` event on the way out: a regular `Terminate` +/// (user-initiated suspend, shutdown, etc.) does emit, but an +/// `ExternalTakeover` (another `start`/`resume`/`cancel_and_refund` is about +/// to take over the swap) suppresses it so the frontend doesn't see a +/// spurious "released" flicker before the new owner emits its own progress +/// event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SuspendReason { + Terminate, + ExternalTakeover, +} /// Inputs needed to start a fresh swap, after the user has selected a maker /// and the wallet has enough deposited bitcoin to cover the lock amount + fee. @@ -65,13 +80,18 @@ pub struct SwapManager { struct RunningSwap { /// Force-suspension trigger for this swap's state machine task. - suspend: broadcast::Sender<()>, + suspend: broadcast::Sender, /// JoinHandle for the spawned state-machine task. `None` once /// [`SwapManager::suspend`] has taken it. Removal of the entry itself is /// always done by [`SwapManager::release_running`] on the task's exit /// path, so that [`is_running`](Self::is_running) stays true until the /// state machine has actually finished cleaning up. handle: Option>, + /// `true` while the task is sleeping in retry backoff after an error. + /// In that state the state machine is idle, so `start`/`resume`/ + /// `cancel_and_refund` can pre-empt the pending retry by signalling + /// `ExternalTakeover` on `suspend` rather than bailing. + in_retry_backoff: bool, } impl SwapManager { @@ -136,10 +156,8 @@ impl SwapManager { /// - `bob::run` returns `Ok` (the swap reached a terminal state), or /// - [`suspend`](Self::suspend) is called for `swap_id`. /// - /// The first attempt uses [`Swap::new`] with the inputs supplied here; - /// subsequent retries reload state from the DB via [`Swap::from_db`] - /// (which sees whatever progress `bob::run` persisted on the previous - /// attempt). + /// `bob::run` persists state transitions as they happen, so retries + /// simply reload whatever was last persisted via [`Swap::from_db`]. /// /// The pre-swap maker selection (currently `determine_btc_to_swap`) must /// run before calling this and produce the [`StartSwapInputs`]. Use @@ -175,47 +193,48 @@ impl SwapManager { .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) .await?; - // Persist the initial `Started` state so every retry — including the - // very first one if its prior attempt failed before any transition — - // can uniformly reload via `Swap::from_db`. - let initial_state = BobState::Started { - btc_amount: tx_lock_amount, - tx_lock_fee, - change_address: bitcoin_change_address, - }; - db.insert_latest_state(swap_id, initial_state.into()) - .await - .context("Failed to persist initial swap state")?; - - let tauri_handle_for_release = tauri_handle.clone(); - - let make_swap: MakeSwap = Box::new(move || { + let make_initial_swap: MakeInitialSwap = Box::new({ let mut event_loop_handle = event_loop_handle.clone(); let db = Arc::clone(&db); let bitcoin_wallet = Arc::clone(&bitcoin_wallet); let monero_wallet = Arc::clone(&monero_wallet); let monero_receive_pool = monero_receive_pool.clone(); let tauri_handle = tauri_handle.clone(); - Box::pin(async move { - let swap_event_loop_handle = event_loop_handle - .swap_handle(seller_peer_id, swap_id) - .await?; - let swap = Swap::from_db( - db, - swap_id, - bitcoin_wallet, - monero_wallet, - env_config, - swap_event_loop_handle, - monero_receive_pool, - ) - .await? - .with_event_emitter(tauri_handle); - Ok(swap) - }) + move || { + Box::pin(async move { + let swap = Swap::new( + db, + swap_id, + bitcoin_wallet, + monero_wallet, + env_config, + event_loop_handle + .swap_handle(seller_peer_id, swap_id) + .await?, + monero_receive_pool, + bitcoin_change_address, + tx_lock_amount, + tx_lock_fee, + ) + .with_event_emitter(tauri_handle); + Ok(swap) + }) + } }); - self.spawn_swap_task(swap_id, tauri_handle_for_release, make_swap) + let rebuild_swap = build_rebuild_swap( + seller_peer_id, + swap_id, + db, + bitcoin_wallet, + monero_wallet, + env_config, + event_loop_handle, + monero_receive_pool, + tauri_handle.clone(), + ); + + self.spawn_swap_task(swap_id, tauri_handle, make_initial_swap, rebuild_swap) .await } @@ -241,37 +260,48 @@ impl SwapManager { let monero_receive_pool = db.get_monero_address_pool(swap_id).await?; - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); - - let tauri_handle_for_release = tauri_handle.clone(); - - let make_swap: MakeSwap = Box::new(move || { + let make_initial_swap: MakeInitialSwap = Box::new({ let mut event_loop_handle = event_loop_handle.clone(); let db = Arc::clone(&db); let bitcoin_wallet = Arc::clone(&bitcoin_wallet); let monero_wallet = Arc::clone(&monero_wallet); let monero_receive_pool = monero_receive_pool.clone(); let tauri_handle = tauri_handle.clone(); - Box::pin(async move { - let swap_event_loop_handle = event_loop_handle - .swap_handle(seller_peer_id, swap_id) - .await?; - let swap = Swap::from_db( - db, - swap_id, - bitcoin_wallet, - monero_wallet, - env_config, - swap_event_loop_handle, - monero_receive_pool, - ) - .await? - .with_event_emitter(tauri_handle); - Ok(swap) - }) + move || { + Box::pin(async move { + tauri_handle + .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); + let swap = Swap::from_db( + db, + swap_id, + bitcoin_wallet, + monero_wallet, + env_config, + event_loop_handle + .swap_handle(seller_peer_id, swap_id) + .await?, + monero_receive_pool, + ) + .await? + .with_event_emitter(tauri_handle); + Ok(swap) + }) + } }); - self.spawn_swap_task(swap_id, tauri_handle_for_release, make_swap) + let rebuild_swap = build_rebuild_swap( + seller_peer_id, + swap_id, + db, + bitcoin_wallet, + monero_wallet, + env_config, + event_loop_handle, + monero_receive_pool, + tauri_handle.clone(), + ); + + self.spawn_swap_task(swap_id, tauri_handle, make_initial_swap, rebuild_swap) .await } @@ -294,16 +324,21 @@ impl SwapManager { let mut resumed = Vec::new(); for (_, swap_id, state) in swaps { - if !matches!(state, crate::protocol::State::Bob(_)) { + let crate::protocol::State::Bob(bob_state) = &state else { continue; - } - if state.swap_finished() { + }; + if !bob::is_resumable(bob_state) { continue; } if self.is_running(swap_id).await { continue; } + // Match the per-swap span that `request()` attaches for a + // single `resume_swap` call so the spawned state-machine task + // is tagged with `swap{swap_id=…}` and log lines stay + // filterable by swap. + let swap_span = debug_span!("swap", %swap_id); match self .resume( swap_id, @@ -314,6 +349,7 @@ impl SwapManager { event_loop_handle.clone(), tauri_handle.clone(), ) + .instrument(swap_span) .await { Ok(()) => resumed.push(swap_id), @@ -347,7 +383,7 @@ impl SwapManager { }; // Best-effort: a task with no live subscriber means it already // raced past the select! and we'll just await it below. - let _ = entry.suspend.send(()); + let _ = entry.suspend.send(SuspendReason::Terminate); entry.handle.take() }; @@ -369,6 +405,49 @@ impl SwapManager { } } + /// If a swap-task is currently sleeping in retry backoff, signal it to + /// exit silently and await its completion. No-op if the swap is not + /// running, or is running but not in backoff. + /// + /// Used by `start`, `resume`, and `cancel_and_refund` to take over a swap + /// whose state machine is idle between retries. + async fn cancel_pending_retry_if_any(&self, swap_id: Uuid) -> Result<()> { + let handle = { + let mut running = self.running.lock().await; + let Some(entry) = running.get_mut(&swap_id) else { + return Ok(()); + }; + if !entry.in_retry_backoff { + return Ok(()); + } + let _ = entry.suspend.send(SuspendReason::ExternalTakeover); + entry.handle.take() + }; + + let Some(handle) = handle else { + return self.wait_until_not_running(swap_id).await; + }; + + tracing::debug!(%swap_id, "Awaiting pending-retry task exit before takeover"); + match tokio::time::timeout(Duration::from_secs(10), handle).await { + Ok(Ok(())) => Ok(()), + Ok(Err(join_err)) => { + Err(Error::from(join_err) + .context("Pending-retry task panicked while being cancelled")) + } + Err(_) => bail!("Timed out waiting for pending-retry task to exit"), + } + } + + /// Set the `in_retry_backoff` flag on the running entry. Called by the + /// task when it enters / exits the inter-retry sleep. + async fn set_in_retry_backoff(&self, swap_id: Uuid, value: bool) { + let mut running = self.running.lock().await; + if let Some(entry) = running.get_mut(&swap_id) { + entry.in_retry_backoff = value; + } + } + async fn suspend_initiation(&self, swap_id: Uuid) -> Result<()> { let _ = self.initiation_suspend.send(()); self.wait_until_not_initiating(swap_id).await @@ -388,8 +467,11 @@ impl SwapManager { .map_err(|_| anyhow::anyhow!("Timed out waiting for swap to exit")) } - /// Cancel and refund a swap. Bails if the swap is currently running, since - /// the running state machine is responsible for its own refunds. + /// Cancel and refund a swap. Bails if the swap is actively running (its + /// state machine is in flight), since the running state machine is + /// responsible for its own refunds. A swap that is sleeping in retry + /// backoff is pre-empted: we cancel the pending retry and then run the + /// refund ourselves. pub async fn cancel_and_refund( &self, swap_id: Uuid, @@ -397,13 +479,20 @@ impl SwapManager { db: Arc, tauri_handle: Option, ) -> Result { + self.cancel_pending_retry_if_any(swap_id).await?; + if self.is_running(swap_id).await { bail!("Cannot cancel and refund swap {swap_id} because it is currently running"); } let result = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await; - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::Released { + next_auto_resume_at_unix_ms: None, + }, + ); result } @@ -449,9 +538,16 @@ impl SwapManager { self: &Arc, swap_id: Uuid, tauri_handle: Option, - make_swap: MakeSwap, + make_initial_swap: MakeInitialSwap, + rebuild_swap: RebuildSwap, ) -> Result<()> { - let suspend_tx = broadcast::channel::<()>(10).0; + // If this swap is currently asleep between retries, pre-empt it: the + // existing task will exit silently and free the slot. An actively- + // running task is left alone (the slot-conflict check below will + // surface a clear error to the caller). + self.cancel_pending_retry_if_any(swap_id).await?; + + let suspend_tx = broadcast::channel::(10).0; let suspend_rx = suspend_tx.subscribe(); let (gate_tx, gate_rx) = oneshot::channel::<()>(); @@ -462,7 +558,15 @@ impl SwapManager { if gate_rx.await.is_err() { return; } - run_swap_task(manager, swap_id, suspend_rx, tauri_handle, make_swap).await; + run_swap_task( + manager, + swap_id, + suspend_rx, + tauri_handle, + make_initial_swap, + rebuild_swap, + ) + .await; } .instrument(span), ); @@ -478,6 +582,7 @@ impl SwapManager { RunningSwap { suspend: suspend_tx, handle: Some(handle), + in_retry_backoff: false, }, ); } @@ -523,7 +628,12 @@ where let result = tokio::select! { result = body => result.map(Some), _ = manager.await_initiation_force_suspension() => { - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::Released { + next_auto_resume_at_unix_ms: None, + }, + ); Ok(None) } }; @@ -538,17 +648,21 @@ where /// Drive a single swap task. Retries the state machine with exponential /// backoff on `Err`, exits on `Ok` (terminal state reached) or on receipt of /// a force-suspension signal. Always releases the running-map entry and -/// emits `Released` on exit. +/// (unless pre-empted by an external takeover) emits a final `Released` on +/// exit. /// /// The retry behaviour is intentional: individual states inside `bob::run` /// already retry their own operations, but `bob::run` itself can still -/// return `Err`. +/// return `Err`. While we're sleeping between retries the `in_retry_backoff` +/// flag is set on our running entry so `start`/`resume`/`cancel_and_refund` +/// can pre-empt us instead of bailing with "already running". async fn run_swap_task( manager: Arc, swap_id: Uuid, - mut suspend_rx: broadcast::Receiver<()>, + mut suspend_rx: broadcast::Receiver, tauri_handle: Option, - mut make_swap: MakeSwap, + make_initial_swap: MakeInitialSwap, + mut rebuild_swap: RebuildSwap, ) { let mut backoff = backoff::ExponentialBackoffBuilder::new() .with_initial_interval(RETRY_INITIAL_INTERVAL) @@ -557,15 +671,22 @@ async fn run_swap_task( .with_max_elapsed_time(None) .build(); + let mut external_takeover = false; + let mut make_initial_swap = Some(make_initial_swap); + 'retry: loop { let outcome: Result = tokio::select! { biased; - _ = suspend_rx.recv() => { + reason = suspend_rx.recv() => { tracing::debug!(%swap_id, "Suspend signal received, exiting state machine"); + external_takeover = matches!(reason, Ok(SuspendReason::ExternalTakeover)); break 'retry; } result = async { - let swap = make_swap().await?; + let swap = match make_initial_swap.take() { + Some(make_initial_swap) => make_initial_swap().await?, + None => rebuild_swap().await?, + }; bob::run(swap).await } => result, }; @@ -577,6 +698,8 @@ async fn run_swap_task( } Err(error) => { let next = backoff.next_backoff().unwrap_or(RETRY_MAX_INTERVAL); + let next_at_unix_ms = unix_now_ms().saturating_add(next.as_millis() as u64); + tracing::error!( %swap_id, retry_in_secs = next.as_secs(), @@ -584,23 +707,102 @@ async fn run_swap_task( error, ); + // Mark the slot as idle and tell the frontend we've released + // the swap *with* a hint about when we'll auto-resume — the + // user can manually resume / cancel during this window and + // pre-empt us. + manager.set_in_retry_backoff(swap_id, true).await; + tauri_handle.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::Released { + next_auto_resume_at_unix_ms: Some(next_at_unix_ms), + }, + ); + tokio::select! { biased; - _ = suspend_rx.recv() => { + reason = suspend_rx.recv() => { tracing::debug!( %swap_id, "Suspend signal received during retry backoff, exiting state machine", ); + external_takeover = matches!(reason, Ok(SuspendReason::ExternalTakeover)); break 'retry; } _ = tokio::time::sleep(next) => {} } + + // Sleep finished naturally — clear the flag so the next + // iteration's `make_swap` runs under "actively running" + // semantics again. + manager.set_in_retry_backoff(swap_id, false).await; } } } manager.release_running(swap_id).await; - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + + // Suppress the final Released only when another caller is about to take + // over the swap and will emit its own progress event. This avoids a + // brief "released" flash in the frontend between takeovers. + if !external_takeover { + tauri_handle.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::Released { + next_auto_resume_at_unix_ms: None, + }, + ); + } +} + +fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Build the closure that the retry loop calls to reconstruct a [`Swap`] +/// from whatever state `bob::run` last persisted. Identical for `start` and +/// `resume`, since after the first attempt the source-of-truth is always +/// the DB. +#[allow(clippy::too_many_arguments)] +fn build_rebuild_swap( + seller_peer_id: PeerId, + swap_id: Uuid, + db: Arc, + bitcoin_wallet: Arc, + monero_wallet: Arc, + env_config: EnvConfig, + event_loop_handle: EventLoopHandle, + monero_receive_pool: MoneroAddressPool, + tauri_handle: Option, +) -> RebuildSwap { + Box::new(move || { + let mut event_loop_handle = event_loop_handle.clone(); + let db = Arc::clone(&db); + let bitcoin_wallet = Arc::clone(&bitcoin_wallet); + let monero_wallet = Arc::clone(&monero_wallet); + let monero_receive_pool = monero_receive_pool.clone(); + let tauri_handle = tauri_handle.clone(); + Box::pin(async move { + let swap_event_loop_handle = event_loop_handle + .swap_handle(seller_peer_id, swap_id) + .await?; + let swap = Swap::from_db( + db, + swap_id, + bitcoin_wallet, + monero_wallet, + env_config, + swap_event_loop_handle, + monero_receive_pool, + ) + .await? + .with_event_emitter(tauri_handle); + Ok(swap) + }) + }) } /// Poll `predicate` every 50ms for up to 10s, returning `Ok(())` when it diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index e5010d9226..9f8cb9bf95 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -116,6 +116,8 @@ async fn next_state( ) -> Result { tracing::debug!(%state, "Advancing state"); + // anyhow::bail!("test"); + Ok(match state { BobState::Started { btc_amount, diff --git a/swap/tests/bob_refunds_when_xmr_amount_is_not_exact.rs b/swap/tests/bob_refunds_when_xmr_amount_is_not_exact.rs index 5b673b600b..53ce7719a4 100644 --- a/swap/tests/bob_refunds_when_xmr_amount_is_not_exact.rs +++ b/swap/tests/bob_refunds_when_xmr_amount_is_not_exact.rs @@ -61,8 +61,8 @@ async fn bob_refunds_when_xmr_amount_is_not_exact() { .stop_and_resume_bob_from_db(bob_join_handle, bob_swap_id) .await; - let bob_state = bob::run_until(bob_swap, |s| matches!(s, BobState::BtcRefunded(..))) - .await?; + let bob_state = + bob::run_until(bob_swap, |s| matches!(s, BobState::BtcRefunded(..))).await?; ctx.assert_bob_refunded(bob_state).await; ctx.assert_alice_refunded(alice_swap.await??).await;