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/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/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index 0fa0c5587f..f3fcddf753 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -436,6 +436,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/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/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/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"} + 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/SwapStateStepper.tsx b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx index 263c347109..7b1f9c8c25 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx @@ -40,10 +40,16 @@ function getActiveStep(state: SwapState | null): PathStep | null { } const prevState = state.prev; - const isReleased = state.curr.type === "Released"; - - // If the swap is released we use the previous state to display the correct step - const latestState = isReleased ? prevState : state.curr; + // A Released event carrying `next_auto_resume_at_unix_ms` is just a retry + // signal, not a real release — keep the stepper rendering as if the swap + // were still mid-flight in its previous state. + const isReleased = + state.curr.type === "Released" && + state.curr.content.next_auto_resume_at_unix_ms == null; + + // If the swap event is Released (terminal or retry) we use the previous + // state to display the correct step. + const latestState = state.curr.type === "Released" ? prevState : state.curr; // If the swap is released but we do not have a previous state we fallback if (latestState === 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/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/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/navigation/NavigationHeader.tsx b/src-gui/src/renderer/components/navigation/NavigationHeader.tsx index d73837c173..5c366a6606 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, + useSwapPhaseSwapsCount, + 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,25 @@ function FeedbackIconWithBadge() { } function SwapIconWithBadge() { - const isSwapRunning = useIsSwapRunning(); + const swapPhaseSwapsCount = useSwapPhaseSwapsCount(); return ( - + ); } + +function OffersIconWithBadge() { + const hasOfferPhaseSwap = useHasOfferPhaseSwap(); + + return ( + + + + ); +} 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/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} + } onInvoke={resume} diff --git a/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx b/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx index c70fa6de4a..8e9859fdc6 100644 --- a/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx @@ -103,7 +103,7 @@ export default function WalletActionButtons({ onClick={() => 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 6650706e0e..f7651a38c0 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/CancelButton.tsx @@ -1,51 +1,70 @@ -import { Box, Button } from "@mui/material"; +import { Link } 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"; +import { useAppDispatch } from "store/hooks"; +import { swapProgressRemoved } from "store/features/swapSlice"; -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 dispatch = useAppDispatch(); - const hasFundsBeenLocked = haveFundsBeenLocked(swap.state?.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 onCancel() { - const swapId = await getCurrentSwapId(); + async function suspend() { + await suspendSwap(swapState.swapId); + } - if (swapId.swap_id !== null) { - if (hasFundsBeenLocked && isSwapRunning) { - setOpenSuspendAlert(true); - return; - } + async function onCancel() { + if (isReleased) { + // Swap is already done; "Close" just dismisses the final-state panel. + dispatch(swapProgressRemoved(swapState.swapId)); + return; + } - await suspendCurrentSwap(); + if (hasFundsBeenLocked) { + setOpenSuspendAlert(true); + return; } - dispatch(swapReset()); + await suspend(); } + const label = isReleased + ? "Close" + : hasFundsBeenLocked + ? "Suspend" + : "Cancel"; + return ( <> setOpenSuspendAlert(false)} + onSuspend={suspend} /> - - - + {label} + ); } 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 acaa43fc54..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) { @@ -58,7 +87,12 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) { break; case "SwapSetupInflight": if (state.curr.type === "SwapSetupInflight") { - return ; + return ( + + ); } break; case "RetrievingMoneroBlockheight": @@ -131,32 +165,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": @@ -170,34 +231,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; @@ -227,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 f166d22ded..4eed5d12d6 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/SwapWidget.tsx @@ -1,58 +1,274 @@ -import { Box, Button, Dialog, DialogActions, Paper } from "@mui/material"; -import { useState } from "react"; -import { useActiveSwapInfo, useAppSelector } from "store/hooks"; +import { + Box, + Button, + Dialog, + DialogActions, + Link, + Paper, + 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 { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { SwapState } from "models/storeModel"; +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 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"; +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 default function SwapWidget() { - const swapState = useAppSelector((state) => state.swap.state); - const swapInfo = useActiveSwapInfo(); - const [debug, setDebug] = useState(false); +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 + // 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(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), + ); + }); + + // 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 or resumable swap to show. + const showOfferPlaceholder = + mode === "offers" && combinedEntries.length === 0; return ( - {swapInfo != null && ( - - )} {import.meta.env.DEV && } - setDebug(false)} - > - - - - - - + {mode === "swaps" && combinedEntries.length === 0 ? ( + + ) : showOfferPlaceholder ? ( + + ) : ( + 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(); + + 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); + + return ( + + {swap != null && ( + <> + setDebug(false)} + > + + + + + + + )} + {swap != null && } + + {swap != null && } + {swap != null && } - + - {swapState !== null && ( - <> - - - - - - + {swap != null && } + {swap != null && ( + + + + + + + {swap.swapId} + + + + + + )} - + + + ); +} + +// 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.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/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 3705e4ee96..ff3fe214dd 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 (XmrRedeemed, BtcRefunded, BtcPunished, CooperativeRedeemRejected) 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..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()} )} - 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)} - - } + additionalContent={<>{formatConfirmations(btc_lock_confirmations)}} />
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/PreflightEncSig.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/PreflightEncSig.tsx index 727de0b074..6373b4249a 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/PreflightEncSig.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/PreflightEncSig.tsx @@ -2,6 +2,6 @@ import CircularProgressWithSubtitle from "../components/CircularProgressWithSubt export default function PreflightEncSigPage() { 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..6fd6f4c125 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -7,9 +7,12 @@ import { GetLogsResponse, GetSwapInfoResponse, MoneroRecoveryArgs, + ResumeAllSwapsArgs, + ResumeAllSwapsResponse, ResumeSwapArgs, ResumeSwapResponse, - SuspendCurrentSwapResponse, + SuspendSwapArgs, + SuspendSwapResponse, WithdrawBtcArgs, WithdrawBtcResponse, GetSwapInfoArgs, @@ -29,7 +32,6 @@ import { ResolveApprovalResponse, RedactArgs, RedactResponse, - GetCurrentSwapResponse, LabeledMoneroAddress, GetMoneroHistoryResponse, GetMoneroMainAddressResponse, @@ -386,12 +388,17 @@ export async function resumeSwap(swapId: string) { }); } -export async function suspendCurrentSwap() { - await invokeNoArgs("suspend_current_swap"); +export async function resumeAllSwaps(): Promise { + return await invoke( + "resume_all_swaps", + {}, + ); } -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..4af91867ad 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -1,9 +1,10 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { TauriSwapProgressEventWrapper } from "models/tauriModel"; +import { isOfferPhase } from "models/tauriModelExt"; import { SwapSlice } from "../../models/storeModel"; const initialState: SwapSlice = { - state: null, + swaps: {}, logs: [], // TODO: Remove this and replace logic entirely with Tauri events @@ -20,25 +21,48 @@ 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 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) + ) { + delete swap.swaps[action.payload.swap_id]; + return; + } + + 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; + // 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; } }, swapReset() { return initialState; }, + swapProgressRemoved(swap, action: PayloadAction) { + delete swap.swaps[action.payload]; + }, setMockOnlyDisableTauriCallsOnSwapProgress( swap, action: PayloadAction, @@ -51,6 +75,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..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, @@ -10,13 +11,13 @@ import { PendingLockBitcoinApprovalRequest, PendingSelectMakerApprovalRequest, isPendingSelectMakerApprovalEvent, - haveFundsBeenLocked, PendingSeedSelectionApprovalRequest, PendingSendMoneroApprovalRequest, isPendingSendMoneroApprovalEvent, PendingPasswordApprovalRequest, isPendingPasswordApprovalEvent, isContextFullyInitialized, + isOfferPhase, } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; @@ -71,51 +72,82 @@ 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) => - state.swap.state !== null && state.swap.state.curr.type !== "Released", + return useAppSelector((state) => + Object.values(state.swap.swaps).some(isSwapInFlight), ); } -/// 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, +/// Returns the number of swaps that are currently running +export function useRunningSwapsCount() { + return useAppSelector( + (state) => Object.values(state.swap.swaps).filter(isSwapInFlight).length, ); +} - // 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; - } +/// 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) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && isOfferPhase(phase); + }), + ); +} - return false; +/// 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) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && !isOfferPhase(phase); + }), + ); } -/// Returns true if we have a swap that is running -export function useIsSpecificSwapRunning(swapId: string | null) { +/// Returns the number of swaps that have progressed past the offer phase +export function useSwapPhaseSwapsCount() { return useAppSelector( (state) => - swapId != null && - state.swap.state !== null && - state.swap.state.swapId === swapId && - state.swap.state.curr.type !== "Released", + Object.values(state.swap.swaps).filter((swap) => { + if (!isSwapInFlight(swap)) return false; + const phase = effectivePhaseEvent(swap); + return phase != null && !isOfferPhase(phase); + }).length, ); } +/// Returns true if we have a swap that is running +export function useIsSpecificSwapRunning(swapId: string | null) { + return useAppSelector((state) => { + if (swapId == null) { + return false; + } + + const swap = state.swap.swaps[swapId]; + return swap != null && swap.curr.type !== "Released"; + }); +} + export function useIsContextAvailable() { return useAppSelector((state) => isContextFullyInitialized(state.rpc.status)); } @@ -130,17 +162,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(() => { @@ -183,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/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})`; +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e3f5c5b9cc..5d52bf0384 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,15 +9,15 @@ 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, + ResolveApprovalArgs, ResumeAllSwapsArgs, ResumeSwapArgs, SendMoneroArgs, + SetMoneroSubaddressLabelArgs, SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, + SuspendSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, }, @@ -46,16 +46,16 @@ macro_rules! generate_command_handlers { withdraw_btc, buy_xmr, resume_swap, + resume_all_swaps, 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, @@ -488,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); @@ -499,14 +500,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-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/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 6854ce6614..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,22 +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::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; @@ -74,141 +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(()) - } - } - - /// 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`) - /// - /// 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. - pub struct SwapLock { - current_swap: RwLock>, - suspension_trigger: Sender<()>, - } - - impl SwapLock { - pub fn new() -> Self { - let (suspension_trigger, _) = broadcast::channel(10); - SwapLock { - current_swap: RwLock::new(None), - suspension_trigger, - } - } - - 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 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"); - } - - tracing::debug!(swap_id = %swap_id, "Acquiring swap lock"); - *current_swap = Some(swap_id); - Ok(()) - } - - pub async fn get_current_swap_id(&self) -> Option { - *self.current_swap.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> { - 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() { - return Ok(()); - } - tokio::time::sleep(tokio::time::Duration::from_millis(INTERVAL)).await; - } - - bail!("Timed out waiting for swap 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"); - - 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"); - } - } - } - - impl Default for SwapLock { - fn default() -> Self { - Self::new() - } - } -} - -pub use swap_lock::{PendingTaskList, SwapLock}; - mod context { use super::*; use crate::cli::EventLoopHandle; @@ -236,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>>>, @@ -257,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)), @@ -354,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)), @@ -812,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 fca1bebd36..a79cc33cd4 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; @@ -100,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)] @@ -312,42 +333,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 +891,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_manager.suspend(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))] @@ -1070,166 +1070,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 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?; - - let select_offer_result = 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 { - 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> - }, - ) => { - Some(result?) - } - _ = 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."); - - if let Some(handle) = tauri_handle_for_suspension { - handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - } - - None - }, - }; - - 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 - 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?; - - // Add the seller's address to the swarm - event_loop_handle - .queue_peer_address(seller_peer_id, seller_multiaddr.clone()) - .await?; - - tauri_handle.emit_swap_progress_event( - swap_id, - TauriSwapProgressEvent::ReceivedQuote(quote.clone()), - ); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); - - context.tasks.clone().spawn(async move { - tokio::select! { - biased; - _ = context.swap_lock.listen_for_swap_force_suspension() => { - 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."); - - 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(), + // 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, - bitcoin_wallet.clone(), + 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, + seller_peer_id, + seller_multiaddr, + monero_receive_pool, + bitcoin_change_address, + tx_lock_amount, + tx_lock_fee, + }, + db, + bitcoin_wallet, 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 - .swap_lock - .release_swap_lock() - .await - .expect("Could not release swap lock"); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - Ok::<_, anyhow::Error>(()) - }.in_current_span()).await; + event_loop_handle, + tauri_handle, + ) + .await + } + }; + run_exclusive_initiation(&context.swap_manager, swap_id, body, tauri_handle).await?; Ok(()) } @@ -1244,84 +1173,51 @@ 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.acquire_swap_lock(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() => { - 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."); - - 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_swap_lock() - .await - .expect("Could not release swap lock"); - - tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); - - Ok::<(), anyhow::Error>(()) - } - .in_current_span(), - ).await; Ok(ResumeSwapResponse { result: "OK".to_string(), }) } +#[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, @@ -1330,26 +1226,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(); - context.swap_lock.acquire_swap_lock(swap_id).await?; - - 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); + 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))] @@ -1484,12 +1368,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/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 new file mode 100644 index 0000000000..139f5e9195 --- /dev/null +++ b/swap/src/cli/swap_manager.rs @@ -0,0 +1,825 @@ +//! 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, 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, 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 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. +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>, + /// `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 { + 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`. + /// + /// `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 + /// [`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?; + + 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(); + 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) + }) + } + }); + + 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 + } + + /// 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?; + + 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(); + 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) + }) + } + }); + + 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 + } + + /// 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 { + let crate::protocol::State::Bob(bob_state) = &state else { + continue; + }; + 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, + Arc::clone(&db), + Arc::clone(&bitcoin_wallet), + Arc::clone(&monero_wallet), + env_config, + event_loop_handle.clone(), + tauri_handle.clone(), + ) + .instrument(swap_span) + .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 + /// 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(SuspendReason::Terminate); + 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"), + } + } + + /// 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 + } + + 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 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, + bitcoin_wallet: Arc, + 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 { + next_auto_resume_at_unix_ms: None, + }, + ); + + 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_initial_swap: MakeInitialSwap, + rebuild_swap: RebuildSwap, + ) -> Result<()> { + // 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::<()>(); + + 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_initial_swap, + rebuild_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), + in_retry_backoff: false, + }, + ); + } + + 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 { + next_auto_resume_at_unix_ms: None, + }, + ); + 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 +/// (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`. 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, + tauri_handle: Option, + make_initial_swap: MakeInitialSwap, + mut rebuild_swap: RebuildSwap, +) { + 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(); + + let mut external_takeover = false; + let mut make_initial_swap = Some(make_initial_swap); + + 'retry: loop { + let outcome: Result = tokio::select! { + biased; + 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 = match make_initial_swap.take() { + Some(make_initial_swap) => make_initial_swap().await?, + None => rebuild_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); + 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(), + "Swap state machine failed: {:#}; retrying", + 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; + 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; + + // 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 +/// 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 394f5612f4..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, } } @@ -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_manager.is_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?; } } 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;