diff --git a/src-gui/src/models/cliModel.ts b/src-gui/src/models/cliModel.ts index d25cba14..619f77b4 100644 --- a/src-gui/src/models/cliModel.ts +++ b/src-gui/src/models/cliModel.ts @@ -31,9 +31,13 @@ function isCliLog(log: unknown): log is CliLog { } export function isCliLogRelatedToSwap( - log: CliLog | string, + log: CliLog | string | null | undefined, swapId: string, ): boolean { + if (log === null || log === undefined) { + return false; + } + // If we only have a string, simply check if the string contains the swap id // This provides reasonable backwards compatability if (typeof log === "string") { @@ -44,7 +48,7 @@ export function isCliLogRelatedToSwap( // - the log has the swap id as an attribute // - there exists a span which has the swap id as an attribute return ( - log.fields["swap_id"] === swapId || + ("fields" in log && log.fields["swap_id"] === swapId) || (log.spans?.some((span) => span["swap_id"] === swapId) ?? false) ); } diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index f2a5f7d3..f2a2ae18 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -3,13 +3,17 @@ import { ApprovalRequest, ExpiredTimelocks, GetSwapInfoResponse, - PendingCompleted, - QuoteWithAddress, SelectMakerDetails, TauriBackgroundProgress, TauriSwapProgressEvent, SendMoneroDetails, + ContextStatus, } from "./tauriModel"; +import { + ContextStatusType, + ResultContextStatus, + RPCSlice, +} from "store/features/rpcSlice"; export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"]; @@ -382,3 +386,30 @@ export function haveFundsBeenLocked( return true; } + +export function isContextFullyInitialized( + status: ResultContextStatus, +): boolean { + if (status == null || status.type === ContextStatusType.Error) { + return false; + } + + return ( + status.status.bitcoin_wallet_available && + status.status.monero_wallet_available && + status.status.database_available && + status.status.tor_available + ); +} + +export function isContextWithBitcoinWallet( + status: ContextStatus | null, +): boolean { + return status?.bitcoin_wallet_available ?? false; +} + +export function isContextWithMoneroWallet( + status: ContextStatus | null, +): boolean { + return status?.monero_wallet_available ?? false; +} diff --git a/src-gui/src/renderer/api.ts b/src-gui/src/renderer/api.ts index c50cb487..ca60c6ab 100644 --- a/src-gui/src/renderer/api.ts +++ b/src-gui/src/renderer/api.ts @@ -227,9 +227,8 @@ export async function fetchAllConversations(): Promise { store.dispatch(setConversation({ feedbackId, messages })); } catch (error) { logger.error( - error, - "Error fetching messages for feedback id", - feedbackId, + { error, feedbackId }, + "Error fetching messages for feedback", ); } } diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index ecbdc658..ecde6939 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -1,7 +1,8 @@ import { listen } from "@tauri-apps/api/event"; -import { TauriContextStatusEvent, TauriEvent } from "models/tauriModel"; +import { TauriEvent } from "models/tauriModel"; import { contextStatusEventReceived, + contextInitializationFailed, rpcSetBalance, timelockChangeEventReceived, approvalEventReceived, @@ -18,7 +19,7 @@ import { updateRates, } from "./api"; import { - checkContextAvailability, + checkContextStatus, getSwapInfo, initializeContext, listSellersAtRendezvousPoint, @@ -53,6 +54,9 @@ const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000; // Fetch pending approvals every 2 seconds const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000; +// Check context status every 2 seconds +const CHECK_CONTEXT_STATUS_INTERVAL = 2 * 1_000; + function setIntervalImmediate(callback: () => void, interval: number): void { callback(); setInterval(callback, interval); @@ -76,87 +80,86 @@ export async function setupBackgroundTasks(): Promise { // Setup Tauri event listeners // Check if the context is already available. This is to prevent unnecessary re-initialization - if (await checkContextAvailability()) { - store.dispatch( - contextStatusEventReceived(TauriContextStatusEvent.Available), - ); - } else { + setIntervalImmediate(async () => { + const contextStatus = await checkContextStatus(); + store.dispatch(contextStatusEventReceived(contextStatus)); + }, CHECK_CONTEXT_STATUS_INTERVAL); + + const contextStatus = await checkContextStatus(); + + // If all components are unavailable, we need to initialize the context + if ( + !contextStatus.bitcoin_wallet_available && + !contextStatus.monero_wallet_available && + !contextStatus.database_available && + !contextStatus.tor_available + ) // Warning: If we reload the page while the Context is being initialized, this function will throw an error initializeContext().catch((e) => { logger.error( e, "Failed to initialize context on page load. This might be because we reloaded the page while the context was being initialized", ); - // Wait a short time before retrying - setTimeout(() => { - initializeContext().catch((e) => { - logger.error(e, "Failed to initialize context even after retry"); - }); - }, 2000); + store.dispatch(contextInitializationFailed(String(e))); }); - } - - // Listen for the unified event - listen(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => { - const { channelName, event: eventData } = event.payload; - - switch (channelName) { - case "SwapProgress": - store.dispatch(swapProgressEventReceived(eventData)); - break; - - case "ContextInitProgress": - store.dispatch(contextStatusEventReceived(eventData)); - break; - - case "CliLog": - store.dispatch(receivedCliLog(eventData)); - break; - - case "BalanceChange": - store.dispatch(rpcSetBalance(eventData.balance)); - break; - - case "SwapDatabaseStateUpdate": - getSwapInfo(eventData.swap_id); - - // This is ugly but it's the best we can do for now - // Sometimes we are too quick to fetch the swap info and the new state is not yet reflected - // in the database. So we wait a bit before fetching the new state - setTimeout(() => getSwapInfo(eventData.swap_id), 3000); - break; - - case "TimelockChange": - store.dispatch(timelockChangeEventReceived(eventData)); - break; - - case "Approval": - store.dispatch(approvalEventReceived(eventData)); - break; - - case "BackgroundProgress": - store.dispatch(backgroundProgressEventReceived(eventData)); - break; - - case "PoolStatusUpdate": - store.dispatch(poolStatusReceived(eventData)); - break; - - case "MoneroWalletUpdate": - console.log("MoneroWalletUpdate", eventData); - if (eventData.type === "BalanceChange") { - store.dispatch(setBalance(eventData.content)); - } - if (eventData.type === "HistoryUpdate") { - store.dispatch(setHistory(eventData.content)); - } - if (eventData.type === "SyncProgress") { - store.dispatch(setSyncProgress(eventData.content)); - } - break; - - default: - exhaustiveGuard(channelName); - } - }); } + +// Listen for the unified event +listen(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => { + const { channelName, event: eventData } = event.payload; + + switch (channelName) { + case "SwapProgress": + store.dispatch(swapProgressEventReceived(eventData)); + break; + + case "CliLog": + store.dispatch(receivedCliLog(eventData)); + break; + + case "BalanceChange": + store.dispatch(rpcSetBalance(eventData.balance)); + break; + + case "SwapDatabaseStateUpdate": + getSwapInfo(eventData.swap_id); + + // This is ugly but it's the best we can do for now + // Sometimes we are too quick to fetch the swap info and the new state is not yet reflected + // in the database. So we wait a bit before fetching the new state + setTimeout(() => getSwapInfo(eventData.swap_id), 3000); + break; + + case "TimelockChange": + store.dispatch(timelockChangeEventReceived(eventData)); + break; + + case "Approval": + store.dispatch(approvalEventReceived(eventData)); + break; + + case "BackgroundProgress": + store.dispatch(backgroundProgressEventReceived(eventData)); + break; + + case "PoolStatusUpdate": + store.dispatch(poolStatusReceived(eventData)); + break; + + case "MoneroWalletUpdate": + console.log("MoneroWalletUpdate", eventData); + if (eventData.type === "BalanceChange") { + store.dispatch(setBalance(eventData.content)); + } + if (eventData.type === "HistoryUpdate") { + store.dispatch(setHistory(eventData.content)); + } + if (eventData.type === "SyncProgress") { + store.dispatch(setSyncProgress(eventData.content)); + } + break; + + default: + exhaustiveGuard(channelName); + } +}); diff --git a/src-gui/src/renderer/components/App.tsx b/src-gui/src/renderer/components/App.tsx index dcf62cf3..9f928344 100644 --- a/src-gui/src/renderer/components/App.tsx +++ b/src-gui/src/renderer/components/App.tsx @@ -25,6 +25,7 @@ import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog"; +import ContextErrorDialog from "./modal/context-error/ContextErrorDialog"; declare module "@mui/material/styles" { interface Theme { @@ -54,6 +55,7 @@ export default function App() { + diff --git a/src-gui/src/renderer/components/PromiseInvokeButton.tsx b/src-gui/src/renderer/components/PromiseInvokeButton.tsx index e5934543..90d573f9 100644 --- a/src-gui/src/renderer/components/PromiseInvokeButton.tsx +++ b/src-gui/src/renderer/components/PromiseInvokeButton.tsx @@ -8,9 +8,12 @@ import { Tooltip, } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; +import { ContextStatus } from "models/tauriModel"; +import { isContextFullyInitialized } from "models/tauriModelExt"; import { useSnackbar } from "notistack"; import { ReactNode, useState } from "react"; -import { useIsContextAvailable } from "store/hooks"; +import { ContextStatusType } from "store/features/rpcSlice"; +import { useAppSelector, useIsContextAvailable } from "store/hooks"; interface PromiseInvokeButtonProps { onSuccess?: (data: T) => void | null; @@ -23,7 +26,10 @@ interface PromiseInvokeButtonProps { disabled?: boolean; displayErrorSnackbar?: boolean; tooltipTitle?: string | null; - requiresContext?: boolean; + // true means that the entire context must be available + // false means that the context doesn't have to be available at all + // a custom function means that the context must satisfy the function + contextRequirement?: ((status: ContextStatus) => boolean) | false | true; } export default function PromiseInvokeButton({ @@ -39,13 +45,11 @@ export default function PromiseInvokeButton({ isChipButton = false, displayErrorSnackbar = false, onPendingChange = null, - requiresContext = true, + contextRequirement = true, tooltipTitle = null, ...rest }: PromiseInvokeButtonProps & ButtonProps) { const { enqueueSnackbar } = useSnackbar(); - const isContextAvailable = useIsContextAvailable(); - const [isPending, setIsPending] = useState(false); const isLoading = isPending || isLoadingOverride; @@ -73,7 +77,23 @@ export default function PromiseInvokeButton({ } } - const requiresContextButNotAvailable = requiresContext && !isContextAvailable; + const requiresContextButNotAvailable = useAppSelector((state) => { + const status = state.rpc.status; + + if (contextRequirement === false) { + return false; + } + + if (contextRequirement === true || contextRequirement == null) { + return !isContextFullyInitialized(status); + } + + if (status == null || status.type === ContextStatusType.Error) { + return true; + } + + return !contextRequirement(status.status); + }); const isDisabled = disabled || isLoading || requiresContextButNotAvailable; const actualTooltipTitle = diff --git a/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx b/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx index e561a5b2..14c3ec57 100644 --- a/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx +++ b/src-gui/src/renderer/components/alert/DaemonStatusAlert.tsx @@ -5,10 +5,7 @@ import { useAppSelector, usePendingBackgroundProcesses } from "store/hooks"; import { exhaustiveGuard } from "utils/typescriptUtils"; import { LoadingSpinnerAlert } from "./LoadingSpinnerAlert"; import { bytesToMb } from "utils/conversionUtils"; -import { - TauriBackgroundProgress, - TauriContextStatusEvent, -} from "models/tauriModel"; +import { TauriBackgroundProgress } from "models/tauriModel"; import { useEffect, useState } from "react"; import TruncatedText from "../other/TruncatedText"; import BitcoinIcon from "../icons/BitcoinIcon"; @@ -182,41 +179,6 @@ function PartialInitStatus({ } } -export default function DaemonStatusAlert() { - const contextStatus = useAppSelector((s) => s.rpc.status); - const navigate = useNavigate(); - - switch (contextStatus) { - case null: - return null; - case TauriContextStatusEvent.NotInitialized: - return null; - case TauriContextStatusEvent.Initializing: - return null; - case TauriContextStatusEvent.Available: - return The daemon is running; - case TauriContextStatusEvent.Failed: - return ( - navigate("/settings#daemon-control-box")} - > - View Logs - - } - > - The daemon has stopped unexpectedly - - ); - default: - return exhaustiveGuard(contextStatus); - } -} - export function BackgroundProgressAlerts() { const backgroundProgress = usePendingBackgroundProcesses(); diff --git a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx index 51b37179..df274aa6 100644 --- a/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx +++ b/src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx @@ -72,6 +72,7 @@ export default function SwapSuspendAlert({ color="primary" onSuccess={onClose} onInvoke={suspendCurrentSwap} + contextRequirement={false} > Suspend diff --git a/src-gui/src/renderer/components/modal/context-error/ContextErrorDialog.tsx b/src-gui/src/renderer/components/modal/context-error/ContextErrorDialog.tsx new file mode 100644 index 00000000..93699f4b --- /dev/null +++ b/src-gui/src/renderer/components/modal/context-error/ContextErrorDialog.tsx @@ -0,0 +1,63 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from "@mui/material"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { useAppSelector } from "store/hooks"; +import CliLogsBox from "renderer/components/other/RenderedCliLog"; +import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import ContactInfoBox from "renderer/components/other/ContactInfoBox"; +import { ContextStatusType } from "store/features/rpcSlice"; + +export default function ContextErrorDialog() { + const logs = useAppSelector((state) => state.logs.state.logs); + const errorMessage = useAppSelector((state) => + state.rpc.status?.type === ContextStatusType.Error + ? state.rpc.status.error + : null, + ); + + if (errorMessage === null) { + return null; + } + + return ( + + Failed to start + + + Check the logs below for details. Try restarting the GUI. Reach out to + the developers and the community if this continues. + + + + + + + + + + + + + + ); +} diff --git a/src-gui/src/renderer/components/modal/feedback/FeedbackDialog.tsx b/src-gui/src/renderer/components/modal/feedback/FeedbackDialog.tsx index 9d7ec8eb..e70099a0 100644 --- a/src-gui/src/renderer/components/modal/feedback/FeedbackDialog.tsx +++ b/src-gui/src/renderer/components/modal/feedback/FeedbackDialog.tsx @@ -227,7 +227,7 @@ export default function FeedbackDialog({ void; - logs: (string | CliLog)[] | null; + logs: HashedLog[]; setIsRedacted: (_: boolean) => void; isRedacted: boolean; } diff --git a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts index e68287b8..deec3b98 100644 --- a/src-gui/src/renderer/components/modal/feedback/useFeedback.ts +++ b/src-gui/src/renderer/components/modal/feedback/useFeedback.ts @@ -3,12 +3,13 @@ import { store } from "renderer/store/storeRenderer"; import { useActiveSwapInfo } from "store/hooks"; import { logsToRawString } from "utils/parseUtils"; import { getLogsOfSwap, redactLogs } from "renderer/rpc"; -import { CliLog, parseCliLogString } from "models/cliModel"; +import { parseCliLogString } from "models/cliModel"; import logger from "utils/logger"; import { submitFeedbackViaHttp } from "renderer/api"; import { addFeedbackId } from "store/features/conversationsSlice"; import { AttachmentInput } from "models/apiModel"; import { useSnackbar } from "notistack"; +import { HashedLog, hashLogs } from "store/features/logsSlice"; export const MAX_FEEDBACK_LENGTH = 4000; @@ -21,8 +22,8 @@ interface FeedbackInputState { } interface FeedbackLogsState { - swapLogs: (string | CliLog)[] | null; - daemonLogs: (string | CliLog)[] | null; + swapLogs: HashedLog[]; + daemonLogs: HashedLog[]; } const initialInputState: FeedbackInputState = { @@ -34,8 +35,8 @@ const initialInputState: FeedbackInputState = { }; const initialLogsState: FeedbackLogsState = { - swapLogs: null, - daemonLogs: null, + swapLogs: [], + daemonLogs: [], }; export function useFeedback() { @@ -55,56 +56,60 @@ export function useFeedback() { useEffect(() => { if (inputState.selectedSwap === null) { - setLogsState((prev) => ({ ...prev, swapLogs: null })); + setLogsState((prev) => ({ ...prev, swapLogs: [] })); return; } getLogsOfSwap(inputState.selectedSwap, inputState.isSwapLogsRedacted) .then((response) => { + const parsedLogs = response.logs.map(parseCliLogString); setLogsState((prev) => ({ ...prev, - swapLogs: response.logs.map(parseCliLogString), + swapLogs: hashLogs(parsedLogs), })); setError(null); }) .catch((e) => { logger.error(`Failed to fetch swap logs: ${e}`); - setLogsState((prev) => ({ ...prev, swapLogs: null })); + setLogsState((prev) => ({ ...prev, swapLogs: [] })); setError(`Failed to fetch swap logs: ${e}`); }); }, [inputState.selectedSwap, inputState.isSwapLogsRedacted]); useEffect(() => { if (!inputState.attachDaemonLogs) { - setLogsState((prev) => ({ ...prev, daemonLogs: null })); + setLogsState((prev) => ({ ...prev, daemonLogs: [] })); return; } try { + const hashedLogs = store.getState().logs?.state.logs ?? []; + if (inputState.isDaemonLogsRedacted) { - redactLogs(store.getState().logs?.state.logs) + const logs = hashedLogs.map((h) => h.log); + redactLogs(logs) .then((redactedLogs) => { setLogsState((prev) => ({ ...prev, - daemonLogs: redactedLogs, + daemonLogs: hashLogs(redactedLogs), })); setError(null); }) .catch((e) => { logger.error(`Failed to redact daemon logs: ${e}`); - setLogsState((prev) => ({ ...prev, daemonLogs: null })); + setLogsState((prev) => ({ ...prev, daemonLogs: [] })); setError(`Failed to redact daemon logs: ${e}`); }); } else { setLogsState((prev) => ({ ...prev, - daemonLogs: store.getState().logs?.state.logs, + daemonLogs: hashedLogs, })); setError(null); } } catch (e) { logger.error(`Failed to fetch daemon logs: ${e}`); - setLogsState((prev) => ({ ...prev, daemonLogs: null })); + setLogsState((prev) => ({ ...prev, daemonLogs: [] })); setError(`Failed to fetch daemon logs: ${e}`); } }, [inputState.attachDaemonLogs, inputState.isDaemonLogsRedacted]); @@ -123,18 +128,18 @@ export function useFeedback() { const attachments: AttachmentInput[] = []; // Add swap logs as an attachment - if (logsState.swapLogs) { + if (logsState.swapLogs.length > 0) { attachments.push({ key: `swap_logs_${inputState.selectedSwap}.txt`, - content: logsToRawString(logsState.swapLogs), + content: logsToRawString(logsState.swapLogs.map((h) => h.log)), }); } // Handle daemon logs - if (logsState.daemonLogs) { + if (logsState.daemonLogs.length > 0) { attachments.push({ key: "daemon_logs.txt", - content: logsToRawString(logsState.daemonLogs), + content: logsToRawString(logsState.daemonLogs.map((h) => h.log)), }); } diff --git a/src-gui/src/renderer/components/modal/password-entry/PasswordEntryDialog.tsx b/src-gui/src/renderer/components/modal/password-entry/PasswordEntryDialog.tsx index 840cdabc..f5a50426 100644 --- a/src-gui/src/renderer/components/modal/password-entry/PasswordEntryDialog.tsx +++ b/src-gui/src/renderer/components/modal/password-entry/PasswordEntryDialog.tsx @@ -116,7 +116,7 @@ export default function PasswordEntryDialog() { Unlock diff --git a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx index 2f7287a7..9016c214 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -364,7 +364,7 @@ export default function SeedSelectionDialog() { No wallet (Legacy) @@ -373,7 +373,7 @@ export default function SeedSelectionDialog() { onInvoke={accept} variant="contained" disabled={isDisabled} - requiresContext={false} + contextRequirement={false} > Continue 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 e2f96b72..54d7dec3 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx @@ -1,15 +1,9 @@ import { Box, DialogContentText } from "@mui/material"; -import { - useActiveSwapInfo, - useActiveSwapLogs, - useAppSelector, -} from "store/hooks"; -import JsonTreeView from "../../../other/JSONViewTree"; +import { useActiveSwapLogs } from "store/hooks"; import CliLogsBox from "../../../other/RenderedCliLog"; export default function DebugPage() { const logs = useActiveSwapLogs(); - const cliState = useActiveSwapInfo(); return ( diff --git a/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx b/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx index 01a963de..14a19abe 100644 --- a/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx +++ b/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx @@ -6,6 +6,7 @@ import DialogHeader from "../DialogHeader"; import AddressInputPage from "./pages/AddressInputPage"; import BtcTxInMempoolPageContent from "./pages/BitcoinWithdrawTxInMempoolPage"; import WithdrawDialogContent from "./WithdrawDialogContent"; +import { isContextWithBitcoinWallet } from "models/tauriModelExt"; export default function WithdrawDialog({ open, @@ -61,6 +62,7 @@ export default function WithdrawDialog({ onInvoke={() => withdrawBtc(withdrawAddress)} onPendingChange={setPending} onSuccess={setWithdrawTxId} + contextRequirement={isContextWithBitcoinWallet} > Withdraw diff --git a/src-gui/src/renderer/components/navigation/NavigationFooter.tsx b/src-gui/src/renderer/components/navigation/NavigationFooter.tsx index 0a31ccd3..2c31925a 100644 --- a/src-gui/src/renderer/components/navigation/NavigationFooter.tsx +++ b/src-gui/src/renderer/components/navigation/NavigationFooter.tsx @@ -1,14 +1,9 @@ import { Box, Tooltip } from "@mui/material"; -import GitHubIcon from "@mui/icons-material/GitHub"; -import DaemonStatusAlert, { - BackgroundProgressAlerts, -} from "../alert/DaemonStatusAlert"; +import { BackgroundProgressAlerts } from "../alert/DaemonStatusAlert"; import FundsLeftInWalletAlert from "../alert/FundsLeftInWalletAlert"; import UnfinishedSwapsAlert from "../alert/UnfinishedSwapsAlert"; -import LinkIconButton from "../icons/LinkIconButton"; import BackgroundRefundAlert from "../alert/BackgroundRefundAlert"; -import MatrixIcon from "../icons/MatrixIcon"; -import { MenuBook } from "@mui/icons-material"; +import ContactInfoBox from "../other/ContactInfoBox"; export default function NavigationFooter() { return ( @@ -23,36 +18,8 @@ export default function NavigationFooter() { - - - - - - - - - - - - - - - - - - - - - - - - + ); } diff --git a/src-gui/src/renderer/components/other/ContactInfoBox.tsx b/src-gui/src/renderer/components/other/ContactInfoBox.tsx new file mode 100644 index 00000000..0b56589a --- /dev/null +++ b/src-gui/src/renderer/components/other/ContactInfoBox.tsx @@ -0,0 +1,46 @@ +import MatrixIcon from "../icons/MatrixIcon"; +import { MenuBook } from "@mui/icons-material"; +import DiscordIcon from "../icons/DiscordIcon"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import LinkIconButton from "../icons/LinkIconButton"; +import { Box, Tooltip } from "@mui/material"; + +export default function ContactInfoBox() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src-gui/src/renderer/components/other/RenderedCliLog.tsx b/src-gui/src/renderer/components/other/RenderedCliLog.tsx index 0db9aab1..a3a3eabf 100644 --- a/src-gui/src/renderer/components/other/RenderedCliLog.tsx +++ b/src-gui/src/renderer/components/other/RenderedCliLog.tsx @@ -1,5 +1,6 @@ import { Box, Chip, Typography } from "@mui/material"; import { CliLog } from "models/cliModel"; +import { HashedLog } from "store/features/logsSlice"; import { ReactNode, useMemo, useState } from "react"; import { logsToRawString } from "utils/parseUtils"; import ScrollablePaperTextBox from "./ScrollablePaperTextBox"; @@ -62,44 +63,54 @@ export default function CliLogsBox({ label, logs, topRightButton = null, - autoScroll = false, + autoScroll = true, minHeight, }: { label: string; - logs: (CliLog | string)[]; + logs: HashedLog[]; topRightButton?: ReactNode; autoScroll?: boolean; minHeight?: string; }) { const [searchQuery, setSearchQuery] = useState(""); - const memoizedLogs = useMemo(() => { + const filteredLogs = useMemo(() => { if (searchQuery.length === 0) { return logs; } - return logs.filter((log) => + + return logs.filter(({ log }) => JSON.stringify(log).toLowerCase().includes(searchQuery.toLowerCase()), ); }, [logs, searchQuery]); + const rows = useMemo(() => { + return filteredLogs.map(({ log, hash }) => + typeof log === "string" ? ( + + {log} + + ) : ( + + ), + ); + }, [filteredLogs]); + + const rawStrings = useMemo( + () => filteredLogs.map(({ log }) => log), + [filteredLogs], + ); + return ( - typeof log === "string" ? ( - - {log} - - ) : ( - - ), - )} + rows={rows} /> ); } diff --git a/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx b/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx index c4f4803e..eb8d02ad 100644 --- a/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx +++ b/src-gui/src/renderer/components/pages/help/ConversationsBox.tsx @@ -293,7 +293,7 @@ function ConversationModal({ // Fetch updated conversations fetchAllConversations(); } catch (error) { - logger.error("Error sending message:", error); + logger.error(`Error sending message: ${error}`); enqueueSnackbar("Failed to send message. Please try again.", { variant: "error", }); diff --git a/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx b/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx index 654fcc57..84c5f9d1 100644 --- a/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx +++ b/src-gui/src/renderer/components/pages/help/DaemonControlBox.tsx @@ -1,31 +1,27 @@ import { Box } from "@mui/material"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; -import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { useAppSelector } from "store/hooks"; import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox"; import CliLogsBox from "renderer/components/other/RenderedCliLog"; -import { getDataDir, initializeContext } from "renderer/rpc"; +import { getDataDir } from "renderer/rpc"; import { relaunch } from "@tauri-apps/plugin-process"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { TauriContextStatusEvent } from "models/tauriModel"; +import { ContextStatusType } from "store/features/rpcSlice"; export default function DaemonControlBox() { const logs = useAppSelector((s) => s.logs.state.logs); - // The daemon can be manually started if it has failed or if it has not been started yet - const canContextBeManuallyStarted = useAppSelector( - (s) => - s.rpc.status === TauriContextStatusEvent.Failed || s.rpc.status === null, - ); - const isContextInitializing = useAppSelector( - (s) => s.rpc.status === TauriContextStatusEvent.Initializing, - ); - - const stringifiedDaemonStatus = useAppSelector( - (s) => s.rpc.status ?? "not started", - ); + const stringifiedDaemonStatus = useAppSelector((s) => { + if (s.rpc.status === null) { + return "not started"; + } + if (s.rpc.status.type === ContextStatusType.Error) { + return "failed"; + } + return "running"; + }); return ( - } - onInvoke={initializeContext} - requiresContext={false} - disabled={!canContextBeManuallyStarted} - isLoadingOverride={isContextInitializing} - displayErrorSnackbar - > - Start Daemon - } onInvoke={relaunch} - requiresContext={false} + contextRequirement={false} displayErrorSnackbar > Restart GUI @@ -59,7 +44,7 @@ export default function DaemonControlBox() { } isIconButton - requiresContext={false} + contextRequirement={false} size="small" tooltipTitle="Open the data directory in your file explorer" onInvoke={async () => { diff --git a/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx b/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx index 4b0b19b5..23eb2326 100644 --- a/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx +++ b/src-gui/src/renderer/components/pages/help/ExportDataBox.tsx @@ -15,6 +15,7 @@ import { getWalletDescriptor } from "renderer/rpc"; import { ExportBitcoinWalletResponse } from "models/tauriModel"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import { isContextWithBitcoinWallet } from "models/tauriModelExt"; export default function ExportDataBox() { const [walletDescriptor, setWalletDescriptor] = @@ -52,6 +53,7 @@ export default function ExportDataBox() { onInvoke={getWalletDescriptor} onSuccess={setWalletDescriptor} displayErrorSnackbar={true} + contextRequirement={isContextWithBitcoinWallet} > Reveal Bitcoin Wallet Private Key diff --git a/src-gui/src/renderer/components/pages/history/table/ExportLogsButton.tsx b/src-gui/src/renderer/components/pages/history/table/ExportLogsButton.tsx index 5a37f30b..059bc2ae 100644 --- a/src-gui/src/renderer/components/pages/history/table/ExportLogsButton.tsx +++ b/src-gui/src/renderer/components/pages/history/table/ExportLogsButton.tsx @@ -14,7 +14,8 @@ export default function ExportLogsButton({ }: ExportLogsButtonProps) { async function handleExportLogs() { const swapLogs = await getLogsOfSwap(swap_id, false); - const daemonLogs = store.getState().logs?.state.logs; + const hashedDaemonLogs = store.getState().logs?.state.logs ?? []; + const daemonLogs = hashedDaemonLogs.map((h) => h.log); const logContent = { swap_logs: logsToRawString(swapLogs.logs), diff --git a/src-gui/src/renderer/components/pages/history/table/SwapLogFileOpenButton.tsx b/src-gui/src/renderer/components/pages/history/table/SwapLogFileOpenButton.tsx index ab827017..619b7d0b 100644 --- a/src-gui/src/renderer/components/pages/history/table/SwapLogFileOpenButton.tsx +++ b/src-gui/src/renderer/components/pages/history/table/SwapLogFileOpenButton.tsx @@ -6,21 +6,23 @@ import { DialogTitle, } from "@mui/material"; import { ButtonProps } from "@mui/material/Button"; -import { CliLog, parseCliLogString } from "models/cliModel"; +import { parseCliLogString } from "models/cliModel"; import { GetLogsResponse } from "models/tauriModel"; import { useState } from "react"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { getLogsOfSwap } from "renderer/rpc"; import CliLogsBox from "../../../other/RenderedCliLog"; +import { HashedLog, hashLogs } from "store/features/logsSlice"; export default function SwapLogFileOpenButton({ swapId, ...props }: { swapId: string } & ButtonProps) { - const [logs, setLogs] = useState<(CliLog | string)[] | null>(null); + const [logs, setLogs] = useState(null); function onLogsReceived(response: GetLogsResponse) { - setLogs(response.logs.map(parseCliLogString)); + const parsedLogs = response.logs.map(parseCliLogString); + setLogs(hashLogs(parsedLogs)); } return ( diff --git a/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx b/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx index c5f76dc2..775ceab1 100644 --- a/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx +++ b/src-gui/src/renderer/components/pages/monero/SeedPhraseButton.tsx @@ -6,6 +6,7 @@ import { GetMoneroSeedResponse, GetRestoreHeightResponse, } from "models/tauriModel"; +import { isContextWithMoneroWallet } from "models/tauriModelExt"; interface SeedPhraseButtonProps { onMenuClose: () => void; @@ -32,6 +33,7 @@ export default function SeedPhraseButton({ onSuccess={handleSeedPhraseSuccess} displayErrorSnackbar={true} variant="text" + contextRequirement={isContextWithMoneroWallet} sx={{ justifyContent: "flex-start", textTransform: "none", diff --git a/src-gui/src/renderer/components/pages/monero/SetRestoreHeightModal.tsx b/src-gui/src/renderer/components/pages/monero/SetRestoreHeightModal.tsx index 2d93e254..7ebc3a27 100644 --- a/src-gui/src/renderer/components/pages/monero/SetRestoreHeightModal.tsx +++ b/src-gui/src/renderer/components/pages/monero/SetRestoreHeightModal.tsx @@ -17,6 +17,7 @@ import { getRestoreHeight, setMoneroRestoreHeight } from "renderer/rpc"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { Dayjs } from "dayjs"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; +import { isContextWithMoneroWallet } from "models/tauriModelExt"; enum RestoreOption { BlockHeight = "blockHeight", @@ -133,6 +134,7 @@ export default function SetRestoreHeightModal({ onSuccess={onClose} displayErrorSnackbar={true} onPendingChange={setIsPending} + contextRequirement={isContextWithMoneroWallet} > Confirm diff --git a/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx b/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx index d23b24d4..a0178c49 100644 --- a/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/DFXWidget.tsx @@ -13,6 +13,7 @@ import DFXSwissLogo from "assets/dfx-logo.svg"; import { useState } from "react"; import { dfxAuthenticate } from "renderer/rpc"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; +import { isContextWithMoneroWallet } from "models/tauriModelExt"; function DFXLogo({ height = 24 }: { height?: number }) { return ( @@ -53,6 +54,7 @@ export default function DfxButton() { tooltipTitle="Buy Monero with fiat using DFX" displayErrorSnackbar isChipButton + contextRequirement={isContextWithMoneroWallet} > Buy Monero diff --git a/src-gui/src/renderer/components/pages/monero/components/SendApprovalContent.tsx b/src-gui/src/renderer/components/pages/monero/components/SendApprovalContent.tsx index 407ee5fa..929f4818 100644 --- a/src-gui/src/renderer/components/pages/monero/components/SendApprovalContent.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/SendApprovalContent.tsx @@ -130,7 +130,7 @@ export default function SendApprovalContent({ color="error" startIcon={} displayErrorSnackbar={true} - requiresContext={false} + contextRequirement={false} > Reject @@ -141,7 +141,7 @@ export default function SendApprovalContent({ color="primary" startIcon={} displayErrorSnackbar={true} - requiresContext={false} + contextRequirement={false} > Send diff --git a/src-gui/src/renderer/components/pages/monero/components/SendTransactionContent.tsx b/src-gui/src/renderer/components/pages/monero/components/SendTransactionContent.tsx index 0f65b780..e69827cb 100644 --- a/src-gui/src/renderer/components/pages/monero/components/SendTransactionContent.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/SendTransactionContent.tsx @@ -14,6 +14,7 @@ import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { sendMoneroTransaction } from "renderer/rpc"; import { useAppSelector } from "store/hooks"; import { SendMoneroResponse } from "models/tauriModel"; +import { isContextWithMoneroWallet } from "models/tauriModelExt"; interface SendTransactionContentProps { balance: { @@ -168,6 +169,7 @@ export default function SendTransactionContent({ disabled={isSendDisabled} onSuccess={handleSendSuccess} onPendingChange={setIsSending} + contextRequirement={isContextWithMoneroWallet} > Send 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 9c7184be..01ee48ae 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 @@ -145,7 +145,6 @@ export default function SwapSetupInflightPage({ resolveApproval(request.request_id, false as unknown as object) } displayErrorSnackbar - requiresContext > Deny @@ -158,7 +157,6 @@ export default function SwapSetupInflightPage({ resolveApproval(request.request_id, true as unknown as object) } displayErrorSnackbar - requiresContext endIcon={} > {`Confirm`} diff --git a/src-gui/src/renderer/components/pages/wallet/WalletRefreshButton.tsx b/src-gui/src/renderer/components/pages/wallet/WalletRefreshButton.tsx index a2348736..c839947a 100644 --- a/src-gui/src/renderer/components/pages/wallet/WalletRefreshButton.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WalletRefreshButton.tsx @@ -2,6 +2,7 @@ import RefreshIcon from "@mui/icons-material/Refresh"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import { checkBitcoinBalance } from "renderer/rpc"; import { isSyncingBitcoin } from "store/hooks"; +import { isContextWithBitcoinWallet } from "models/tauriModelExt"; export default function WalletRefreshButton() { const isSyncing = isSyncingBitcoin(); @@ -14,6 +15,7 @@ export default function WalletRefreshButton() { onInvoke={() => checkBitcoinBalance()} displayErrorSnackbar size="small" + contextRequirement={isContextWithBitcoinWallet} /> ); } diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 4dfc43ef..6f86ba69 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -46,11 +46,13 @@ import { GetRestoreHeightResponse, MoneroNodeConfig, GetMoneroSeedResponse, + ContextStatus, } from "models/tauriModel"; import { rpcSetBalance, rpcSetSwapInfo, approvalRequestsReplaced, + contextInitializationFailed, } from "store/features/rpcSlice"; import { setMainAddress, @@ -282,9 +284,8 @@ export async function getMoneroRecoveryKeys( ); } -export async function checkContextAvailability(): Promise { - const available = await invokeNoArgs("is_context_available"); - return available; +export async function checkContextStatus(): Promise { + return await invokeNoArgs("get_context_status"); } export async function getLogsOfSwap( @@ -335,7 +336,6 @@ export async function initializeContext() { store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null; // Check the state of the Monero node - const moneroNodeConfig = useMoneroRpcPool || moneroNodeUrl == null || @@ -356,18 +356,17 @@ export async function initializeContext() { enable_monero_tor: useMoneroTor, }; - logger.info("Initializing context with settings", tauriSettings); + logger.info({ tauriSettings }, "Initializing context with settings"); try { await invokeUnsafe("initialize_context", { settings: tauriSettings, testnet, }); + logger.info("Initialized context"); } catch (error) { - throw new Error("Couldn't initialize context: " + error); + throw new Error(error); } - - logger.info("Initialized context"); } export async function getWalletDescriptor() { diff --git a/src-gui/src/store/features/logsSlice.ts b/src-gui/src/store/features/logsSlice.ts index 16575bd8..7916c681 100644 --- a/src-gui/src/store/features/logsSlice.ts +++ b/src-gui/src/store/features/logsSlice.ts @@ -2,9 +2,13 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { TauriLogEvent } from "models/tauriModel"; import { parseLogsFromString } from "utils/parseUtils"; import { CliLog } from "models/cliModel"; +import { fnv1a } from "utils/hash"; + +/// We only keep the last 5000 logs in the store +const MAX_LOG_ENTRIES = 5000; interface LogsState { - logs: (CliLog | string)[]; + logs: HashedLog[]; } export interface LogsSlice { @@ -17,21 +21,67 @@ const initialState: LogsSlice = { }, }; +export type HashedLog = { + log: CliLog | string; + hash: string; +}; + export const logsSlice = createSlice({ name: "logs", initialState, reducers: { receivedCliLog(slice, action: PayloadAction) { - const buffer = action.payload.buffer; - const logs = parseLogsFromString(buffer); - const logsWithoutExisting = logs.filter( - (log) => !slice.state.logs.includes(log), - ); - slice.state.logs = slice.state.logs.concat(logsWithoutExisting); + const parsedLogs = parseLogsFromString(action.payload.buffer); + const hashedLogs = parsedLogs.map(createHashedLog); + for (const entry of hashedLogs) { + slice.state.logs.push(entry); + } + + // If we have too many logs, discard 1/10 of them (oldest logs) + // We explictly discard more than we need to, such that we don't have to + // do this too often + if (slice.state.logs.length > MAX_LOG_ENTRIES) { + const removeCount = Math.floor(slice.state.logs.length / 10); + slice.state.logs = slice.state.logs.slice(removeCount); + } + }, + clearLogs(slice) { + slice.state.logs = []; }, }, }); -export const { receivedCliLog } = logsSlice.actions; +function serializeLog(log: CliLog | string): string { + if (typeof log === "string") { + return `str:${log}`; + } + + const parts = [ + "obj", + log.timestamp, + log.level, + log.target ?? "", + JSON.stringify(log.fields), + ]; + + if (log.spans != null && log.spans.length > 0) { + parts.push(JSON.stringify(log.spans)); + } + + return parts.join("|"); +} + +function createHashedLog(log: CliLog | string): HashedLog { + return { + log, + hash: fnv1a(serializeLog(log)), + }; +} + +export function hashLogs(logs: (CliLog | string)[]): HashedLog[] { + return logs.map(createHashedLog); +} + +export const { receivedCliLog, clearLogs } = logsSlice.actions; export default logsSlice.reducer; diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index 0ae618ee..74a30e61 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { ExtendedMakerStatus, MakerStatus } from "models/apiModel"; import { GetSwapInfoResponse, - TauriContextStatusEvent, + ContextStatus, TauriTimelockChangeEvent, BackgroundRefundState, ApprovalRequest, @@ -37,8 +37,17 @@ interface State { }; } +export enum ContextStatusType { + Status = "status", + Error = "error", +} + +export type ResultContextStatus = + | { type: ContextStatusType.Status; status: ContextStatus } + | { type: ContextStatusType.Error; error: string }; + export interface RPCSlice { - status: TauriContextStatusEvent | null; + status: ResultContextStatus | null; state: State; } @@ -60,11 +69,18 @@ export const rpcSlice = createSlice({ name: "rpc", initialState, reducers: { - contextStatusEventReceived( - slice, - action: PayloadAction, - ) { - slice.status = action.payload; + contextStatusEventReceived(slice, action: PayloadAction) { + // Don't overwrite error state + // + // Once we're in an error state, stay there + if (slice.status?.type === ContextStatusType.Error) { + return; + } + + slice.status = { type: ContextStatusType.Status, status: action.payload }; + }, + contextInitializationFailed(slice, action: PayloadAction) { + slice.status = { type: ContextStatusType.Error, error: action.payload }; }, timelockChangeEventReceived( slice: RPCSlice, @@ -160,6 +176,7 @@ export const rpcSlice = createSlice({ export const { contextStatusEventReceived, + contextInitializationFailed, rpcSetBalance, rpcSetWithdrawTxId, rpcResetWithdrawTxId, diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 7edcf6db..18c02424 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -16,6 +16,7 @@ import { isPendingSendMoneroApprovalEvent, PendingPasswordApprovalRequest, isPendingPasswordApprovalEvent, + isContextFullyInitialized, } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; @@ -28,7 +29,6 @@ import { RatesState } from "./features/ratesSlice"; import { TauriBackgroundProgress, TauriBitcoinSyncProgress, - TauriContextStatusEvent, } from "models/tauriModel"; export const useAppDispatch = () => useDispatch(); @@ -111,9 +111,7 @@ export function useIsSpecificSwapRunning(swapId: string | null) { } export function useIsContextAvailable() { - return useAppSelector( - (state) => state.rpc.status === TauriContextStatusEvent.Available, - ); + return useAppSelector((state) => isContextFullyInitialized(state.rpc.status)); } /// We do not use a sanity check here, as opposed to the other useSwapInfo hooks, @@ -139,10 +137,13 @@ export function useActiveSwapLogs() { const swapId = useActiveSwapId(); const logs = useAppSelector((s) => s.logs.state.logs); - return useMemo( - () => logs.filter((log) => isCliLogRelatedToSwap(log, swapId)), - [logs, swapId], - ); + return useMemo(() => { + if (swapId == null) { + return []; + } + + return logs.filter((log) => isCliLogRelatedToSwap(log.log, swapId)); + }, [logs, swapId]); } export function useAllMakers() { diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index 12a6c72f..5a961a24 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -11,7 +11,10 @@ import { getCurrentMoneroNodeConfig, } from "renderer/rpc"; import logger from "utils/logger"; -import { contextStatusEventReceived } from "store/features/rpcSlice"; +import { + contextStatusEventReceived, + ContextStatusType, +} from "store/features/rpcSlice"; import { addNode, setFetchFiatPrices, @@ -21,14 +24,12 @@ import { Network, } from "store/features/settingsSlice"; import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api"; -import { store } from "renderer/store/storeRenderer"; +import { RootState, store } from "renderer/store/storeRenderer"; import { swapProgressEventReceived } from "store/features/swapSlice"; import { addFeedbackId, setConversation, } from "store/features/conversationsSlice"; -import { TauriContextStatusEvent, MoneroNodeConfig } from "models/tauriModel"; -import { getNetwork } from "store/config"; // Create a Map to store throttled functions per swap_id const throttledGetSwapInfoFunctions = new Map< @@ -60,30 +61,80 @@ const getThrottledSwapInfoUpdater = (swapId: string) => { export function createMainListeners() { const listener = createListenerMiddleware(); - // Listener for when the Context becomes available + // Listener for when the Context status state changes // When the context becomes available, we check the bitcoin balance, fetch all swap infos and connect to the rendezvous point listener.startListening({ - actionCreator: contextStatusEventReceived, - effect: async (action) => { - const status = action.payload; + predicate: (action, currentState, previousState) => { + const currentStatus = (currentState as RootState).rpc.status; + const previousStatus = (previousState as RootState).rpc.status; - // If the context is available, check the Bitcoin balance and fetch all swap infos - if (status === TauriContextStatusEvent.Available) { - logger.debug( - "Context is available, checking Bitcoin balance and history", + // Only trigger if the status actually changed + return currentStatus !== previousStatus; + }, + effect: async (action, api) => { + const currentStatus = (api.getState() as RootState).rpc.status; + const previousStatus = (api.getOriginalState() as RootState).rpc.status; + + const status = + currentStatus?.type === ContextStatusType.Status + ? currentStatus.status + : null; + const previousContextStatus = + previousStatus?.type === ContextStatusType.Status + ? previousStatus.status + : null; + + if (!status) return; + + // If the Bitcoin wallet just came available, check the Bitcoin balance + if ( + status.bitcoin_wallet_available && + !previousContextStatus?.bitcoin_wallet_available + ) { + logger.info( + "Bitcoin wallet just became available, checking balance...", ); - await Promise.allSettled([ - checkBitcoinBalance(), - getAllSwapInfos(), - fetchSellersAtPresetRendezvousPoints(), - initializeMoneroWallet(), - ]); + await checkBitcoinBalance(); + } + + // If the Monero wallet just came available, initialize the Monero wallet + if ( + status.monero_wallet_available && + !previousContextStatus?.monero_wallet_available + ) { + logger.info("Monero wallet just became available, initializing..."); + await initializeMoneroWallet(); // Also set the Monero node to the current one - // In case the user changed this WHILE the context was unavailable const nodeConfig = await getCurrentMoneroNodeConfig(); await changeMoneroNode(nodeConfig); } + + // If the database and Bitcoin wallet just came available, fetch all swap infos + if ( + status.database_available && + status.bitcoin_wallet_available && + !( + previousContextStatus?.database_available && + previousContextStatus?.bitcoin_wallet_available + ) + ) { + logger.info( + "Database & Bitcoin wallet just became available, fetching swap infos...", + ); + await getAllSwapInfos(); + } + + // If the database just became availiable, fetch sellers at preset rendezvous points + if ( + status.database_available && + !previousContextStatus?.database_available + ) { + logger.info( + "Database just became available, fetching sellers at preset rendezvous points...", + ); + await fetchSellersAtPresetRendezvousPoints(); + } }, }); @@ -151,9 +202,9 @@ export function createMainListeners() { try { const nodeConfig = await getCurrentMoneroNodeConfig(); await changeMoneroNode(nodeConfig); - logger.info("Changed Monero node configuration to: ", nodeConfig); + logger.info({ nodeConfig }, "Changed Monero node configuration to: "); } catch (error) { - logger.error("Failed to change Monero node configuration:", error); + logger.error({ error }, "Failed to change Monero node configuration:"); } }, }); diff --git a/src-gui/src/utils/hash.ts b/src-gui/src/utils/hash.ts new file mode 100644 index 00000000..34567663 --- /dev/null +++ b/src-gui/src/utils/hash.ts @@ -0,0 +1,13 @@ +const FNV_OFFSET_BASIS = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +export function fnv1a(value: string): string { + let hash = FNV_OFFSET_BASIS; + + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = (hash * FNV_PRIME) >>> 0; + } + + return hash.toString(16).padStart(8, "0"); +} diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 462fc145..efa64efc 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -2,16 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "desktop-capability", "description": "Capabilities for desktop windows", - "platforms": [ - "macOS", - "windows", - "linux" - ], - "windows": [ - "main" - ], - "permissions": [ - "cli:default", - "cli:allow-cli-matches" - ] -} \ No newline at end of file + "platforms": ["macOS", "windows", "linux"], + "windows": ["main"], + "permissions": ["cli:default", "cli:allow-cli-matches"] +} diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json index 90eea7ec..dd3b8bcc 100644 --- a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,116 +1,116 @@ { - "images" : [ + "images": [ { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "AppIcon-20x20@2x.png", - "scale" : "2x" + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@2x.png", + "scale": "2x" }, { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "AppIcon-20x20@3x.png", - "scale" : "3x" + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@3x.png", + "scale": "3x" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "AppIcon-29x29@2x-1.png", - "scale" : "2x" + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@2x-1.png", + "scale": "2x" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "AppIcon-29x29@3x.png", - "scale" : "3x" + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@3x.png", + "scale": "3x" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "AppIcon-40x40@2x.png", - "scale" : "2x" + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@2x.png", + "scale": "2x" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "AppIcon-40x40@3x.png", - "scale" : "3x" + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@3x.png", + "scale": "3x" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "AppIcon-60x60@2x.png", - "scale" : "2x" + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@2x.png", + "scale": "2x" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "AppIcon-60x60@3x.png", - "scale" : "3x" + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@3x.png", + "scale": "3x" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "AppIcon-20x20@1x.png", - "scale" : "1x" + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@1x.png", + "scale": "1x" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "AppIcon-20x20@2x-1.png", - "scale" : "2x" + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@2x-1.png", + "scale": "2x" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "AppIcon-29x29@1x.png", - "scale" : "1x" + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@1x.png", + "scale": "1x" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "AppIcon-29x29@2x.png", - "scale" : "2x" + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@2x.png", + "scale": "2x" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "AppIcon-40x40@1x.png", - "scale" : "1x" + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@1x.png", + "scale": "1x" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "AppIcon-40x40@2x-1.png", - "scale" : "2x" + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@2x-1.png", + "scale": "2x" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "AppIcon-76x76@1x.png", - "scale" : "1x" + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@1x.png", + "scale": "1x" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "AppIcon-76x76@2x.png", - "scale" : "2x" + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@2x.png", + "scale": "2x" }, { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "AppIcon-83.5x83.5@2x.png", - "scale" : "2x" + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "AppIcon-83.5x83.5@2x.png", + "scale": "2x" }, { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "AppIcon-512@2x.png", - "scale" : "1x" + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "AppIcon-512@2x.png", + "scale": "1x" } ], - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "version": 1, + "author": "xcode" } -} \ No newline at end of file +} diff --git a/src-tauri/gen/apple/Assets.xcassets/Contents.json b/src-tauri/gen/apple/Assets.xcassets/Contents.json index da4a164c..97a8662e 100644 --- a/src-tauri/gen/apple/Assets.xcassets/Contents.json +++ b/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "version": 1, + "author": "xcode" } -} \ No newline at end of file +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 00000000..4f8d1100 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,452 @@ +use std::collections::HashMap; +use std::io::Write; +use std::result::Result; +use swap::cli::{ + api::{ + data, + request::{ + BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, + CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, + CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse, + ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, + GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, + GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs, + GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, + GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, + RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, + SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, + }, + tauri_bindings::{ContextStatus, TauriSettings}, + ContextBuilder, + }, + command::Bitcoin, +}; +use tauri_plugin_dialog::DialogExt; +use zip::{write::SimpleFileOptions, ZipWriter}; + +use crate::{commands::util::ToStringResult, State}; + +/// This macro returns the list of all command handlers +/// You can call this and insert the output into [`tauri::app::Builder::invoke_handler`] +/// +/// Note: When you add a new command, add it here. +#[macro_export] +macro_rules! generate_command_handlers { + () => { + tauri::generate_handler![ + get_balance, + get_monero_addresses, + get_swap_info, + get_swap_infos_all, + withdraw_btc, + buy_xmr, + resume_swap, + get_history, + monero_recovery, + get_logs, + list_sellers, + suspend_current_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, + save_txt_files, + get_monero_history, + get_monero_main_address, + get_monero_balance, + send_monero, + get_monero_sync_progress, + get_monero_seed, + check_seed, + get_pending_approvals, + set_monero_restore_height, + reject_approval_request, + get_restore_height, + dfx_authenticate, + change_monero_node, + get_context_status + ] + }; +} + +#[macro_use] +mod util { + use std::result::Result; + + /// Trait to convert Result to Result + /// Tauri commands require the error type to be a string + pub(crate) trait ToStringResult { + fn to_string_result(self) -> Result; + } + + impl ToStringResult for Result { + fn to_string_result(self) -> Result { + self.map_err(|e| e.to_string()) + } + } + + /// This macro is used to create boilerplate functions as tauri commands + /// that simply delegate handling to the respective request type. + /// + /// # Example + /// ```ignored + /// tauri_command!(get_balance, BalanceArgs); + /// ``` + /// will resolve to + /// ```ignored + /// #[tauri::command] + /// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result { + /// args.handle(context.inner().clone()).await.to_string_result() + /// } + /// ``` + /// # Example 2 + /// ```ignored + /// tauri_command!(get_balance, BalanceArgs, no_args); + /// ``` + /// will resolve to + /// ```ignored + /// #[tauri::command] + /// async fn get_balance(context: tauri::State<'...>) -> Result { + /// BalanceArgs {}.handle(context.inner().clone()).await.to_string_result() + /// } + /// ``` + macro_rules! tauri_command { + ($fn_name:ident, $request_name:ident) => { + #[tauri::command] + pub async fn $fn_name( + state: tauri::State<'_, State>, + args: $request_name, + ) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> { + <$request_name as swap::cli::api::request::Request>::request(args, state.context()) + .await + .to_string_result() + } + }; + ($fn_name:ident, $request_name:ident, no_args) => { + #[tauri::command] + pub async fn $fn_name( + state: tauri::State<'_, State>, + ) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> { + <$request_name as swap::cli::api::request::Request>::request( + $request_name {}, + state.context(), + ) + .await + .to_string_result() + } + }; + } +} + +/// Tauri command to initialize the Context +#[tauri::command] +pub async fn initialize_context( + settings: TauriSettings, + testnet: bool, + state: tauri::State<'_, State>, +) -> Result<(), String> { + // We want to prevent multiple initalizations at the same time + let _context_lock = state + .context_lock + .try_lock() + .map_err(|_| "Context is already being initialized".to_string())?; + + // Fail if the context is already initialized + // TODO: Maybe skip the stuff below if one of the context fields is already initialized? + // if context_lock.is_some() { + // return Err("Context is already initialized".to_string()); + // } + + // Get tauri handle from the state + let tauri_handle = state.handle.clone(); + + // Now populate the context in the background + let context_result = ContextBuilder::new(testnet) + .with_bitcoin(Bitcoin { + bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(), + bitcoin_target_block: None, + }) + .with_monero(settings.monero_node_config) + .with_json(false) + .with_debug(true) + .with_tor(settings.use_tor) + .with_enable_monero_tor(settings.enable_monero_tor) + .with_tauri(tauri_handle.clone()) + .build(state.context()) + .await; + + match context_result { + Ok(()) => { + tracing::info!("Context initialized"); + Ok(()) + } + Err(e) => { + tracing::error!(error = ?e, "Failed to initialize context"); + Err(e.to_string()) + } + } +} + +#[tauri::command] +pub async fn get_context_status(state: tauri::State<'_, State>) -> Result { + Ok(state.context().status().await) +} + +#[tauri::command] +pub async fn resolve_approval_request( + args: ResolveApprovalArgs, + state: tauri::State<'_, State>, +) -> Result<(), String> { + let request_id = args + .request_id + .parse() + .map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?; + + state + .handle + .resolve_approval(request_id, args.accept) + .await + .to_string_result()?; + + Ok(()) +} + +#[tauri::command] +pub async fn reject_approval_request( + args: RejectApprovalArgs, + state: tauri::State<'_, State>, +) -> Result { + let request_id = args + .request_id + .parse() + .map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?; + + state + .handle + .reject_approval(request_id) + .await + .to_string_result()?; + + Ok(RejectApprovalResponse { success: true }) +} + +#[tauri::command] +pub async fn get_pending_approvals( + state: tauri::State<'_, State>, +) -> Result { + let approvals = state + .handle + .get_pending_approvals() + .await + .to_string_result()?; + + Ok(GetPendingApprovalsResponse { approvals }) +} + +#[tauri::command] +pub async fn check_monero_node( + args: CheckMoneroNodeArgs, + _: tauri::State<'_, State>, +) -> Result { + args.request().await.to_string_result() +} + +#[tauri::command] +pub async fn check_electrum_node( + args: CheckElectrumNodeArgs, + _: tauri::State<'_, State>, +) -> Result { + args.request().await.to_string_result() +} + +#[tauri::command] +pub async fn check_seed( + args: CheckSeedArgs, + _: tauri::State<'_, State>, +) -> Result { + args.request().await.to_string_result() +} + +// Returns the data directory +// This is independent of the context to ensure the user can open the directory even if the context cannot +// be initialized (for troubleshooting purposes) +#[tauri::command] +pub async fn get_data_dir( + args: GetDataDirArgs, + _: tauri::State<'_, State>, +) -> Result { + Ok(data::data_dir_from(None, args.is_testnet) + .to_string_result()? + .to_string_lossy() + .to_string()) +} + +#[tauri::command] +pub async fn save_txt_files( + app: tauri::AppHandle, + zip_file_name: String, + content: HashMap, +) -> Result<(), String> { + // Step 1: Get the owned PathBuf from the dialog + let path_buf_from_dialog: tauri_plugin_dialog::FilePath = app + .dialog() + .file() + .set_file_name(format!("{}.zip", &zip_file_name).as_str()) + .add_filter(&zip_file_name, &["zip"]) + .blocking_save_file() // This returns Option + .ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result and unwraps to PathBuf + + // Step 2: Now get a &Path reference from the owned PathBuf. + // The user's code structure implied an .as_path().ok_or_else(...) chain which was incorrect for &Path. + // We'll directly use the PathBuf, or if &Path is strictly needed: + let selected_file_path: &std::path::Path = path_buf_from_dialog + .as_path() + .ok_or_else(|| "Could not convert file path".to_string())?; + + let zip_file = std::fs::File::create(selected_file_path) + .map_err(|e| format!("Failed to create file: {}", e))?; + + let mut zip = ZipWriter::new(zip_file); + + for (filename, file_content_str) in content.iter() { + zip.start_file( + format!("{}.txt", filename).as_str(), + SimpleFileOptions::default(), + ) // Pass &str to start_file + .map_err(|e| format!("Failed to start file {}: {}", &filename, e))?; // Use &filename + + zip.write_all(file_content_str.as_bytes()) + .map_err(|e| format!("Failed to write to file {}: {}", &filename, e))?; + // Use &filename + } + + zip.finish() + .map_err(|e| format!("Failed to finish zip: {}", e))?; + + Ok(()) +} + +#[tauri::command] +pub async fn dfx_authenticate( + state: tauri::State<'_, State>, +) -> Result { + use dfx_swiss_sdk::{DfxClient, SignRequest}; + use tokio::sync::{mpsc, oneshot}; + use tokio_util::task::AbortOnDropHandle; + + let context = state.context(); + + // Get the monero wallet manager + let monero_manager = context + .try_get_monero_manager() + .await + .map_err(|_| "Monero wallet manager not available for DFX authentication".to_string())?; + + let wallet = monero_manager.main_wallet().await; + let address = wallet.main_address().await.to_string(); + + // Create channel for authentication + let (auth_tx, mut auth_rx) = mpsc::channel::<(SignRequest, oneshot::Sender)>(10); + + // Create DFX client + let mut client = DfxClient::new(address, Some("https://api.dfx.swiss".to_string()), auth_tx); + + // Start signing task with AbortOnDropHandle + let signing_task = tokio::spawn(async move { + tracing::info!("DFX signing service started and listening for requests"); + + while let Some((sign_request, response_tx)) = auth_rx.recv().await { + tracing::debug!( + message = %sign_request.message, + blockchains = ?sign_request.blockchains, + "Received DFX signing request" + ); + + // Sign the message using the main Monero wallet + let signature = match wallet + .sign_message(&sign_request.message, None, false) + .await + { + Ok(sig) => { + tracing::debug!( + signature_preview = %&sig[..std::cmp::min(50, sig.len())], + "Message signed successfully for DFX" + ); + sig + } + Err(e) => { + tracing::error!(error = ?e, "Failed to sign message for DFX"); + continue; + } + }; + + // Send signature back to DFX client + if let Err(_) = response_tx.send(signature) { + tracing::warn!("Failed to send signature response through channel to DFX client"); + } + } + + tracing::info!("DFX signing service stopped"); + }); + + // Create AbortOnDropHandle so the task gets cleaned up + let _abort_handle = AbortOnDropHandle::new(signing_task); + + // Authenticate with DFX + tracing::info!("Starting DFX authentication..."); + client + .authenticate() + .await + .map_err(|e| format!("Failed to authenticate with DFX: {}", e))?; + + let access_token = client + .access_token + .as_ref() + .ok_or("No access token available after authentication")? + .clone(); + + let kyc_url = format!("https://app.dfx.swiss/buy?session={}", access_token); + + tracing::info!("DFX authentication completed successfully"); + + Ok(DfxAuthenticateResponse { + access_token, + kyc_url, + }) +} + +// Here we define the Tauri commands that will be available to the frontend +// The commands are defined using the `tauri_command!` macro. +// Implementations are handled by the Request trait +tauri_command!(get_balance, BalanceArgs); +tauri_command!(buy_xmr, BuyXmrArgs); +tauri_command!(resume_swap, ResumeSwapArgs); +tauri_command!(withdraw_btc, WithdrawBtcArgs); +tauri_command!(monero_recovery, MoneroRecoveryArgs); +tauri_command!(get_logs, GetLogsArgs); +tauri_command!(list_sellers, ListSellersArgs); +tauri_command!(cancel_and_refund, CancelAndRefundArgs); +tauri_command!(redact, RedactArgs); +tauri_command!(send_monero, SendMoneroArgs); +tauri_command!(change_monero_node, ChangeMoneroNodeArgs); + +// These commands require no arguments +tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); +tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); +tauri_command!(get_swap_info, GetSwapInfoArgs); +tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args); +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!(get_monero_main_address, GetMoneroMainAddressArgs, no_args); +tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args); +tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args); +tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d3e857a..cf9cce4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,121 +1,43 @@ -use std::collections::HashMap; -use std::io::Write; use std::result::Result; use std::sync::Arc; -use swap::cli::{ - api::{ - data, - request::{ - BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, - CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, - CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse, - ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, - GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, - GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs, - GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, - GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, - RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, - SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, - }, - tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, - Context, ContextBuilder, - }, - command::Bitcoin, -}; -use tauri::{async_runtime::RwLock, Manager, RunEvent}; -use tauri_plugin_dialog::DialogExt; -use zip::{write::SimpleFileOptions, ZipWriter}; +use swap::cli::api::{tauri_bindings::TauriHandle, Context}; +use tauri::{Manager, RunEvent}; +use tokio::sync::Mutex; -/// Trait to convert Result to Result -/// Tauri commands require the error type to be a string -trait ToStringResult { - fn to_string_result(self) -> Result; -} +mod commands; -impl ToStringResult for Result { - fn to_string_result(self) -> Result { - self.map_err(|e| e.to_string()) - } -} - -/// This macro is used to create boilerplate functions as tauri commands -/// that simply delegate handling to the respective request type. -/// -/// # Example -/// ```ignored -/// tauri_command!(get_balance, BalanceArgs); -/// ``` -/// will resolve to -/// ```ignored -/// #[tauri::command] -/// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result { -/// args.handle(context.inner().clone()).await.to_string_result() -/// } -/// ``` -/// # Example 2 -/// ```ignored -/// tauri_command!(get_balance, BalanceArgs, no_args); -/// ``` -/// will resolve to -/// ```ignored -/// #[tauri::command] -/// async fn get_balance(context: tauri::State<'...>) -> Result { -/// BalanceArgs {}.handle(context.inner().clone()).await.to_string_result() -/// } -/// ``` -macro_rules! tauri_command { - ($fn_name:ident, $request_name:ident) => { - #[tauri::command] - async fn $fn_name( - state: tauri::State<'_, State>, - args: $request_name, - ) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> { - // Throw error if context is not available - let context = state.try_get_context()?; - - <$request_name as swap::cli::api::request::Request>::request(args, context) - .await - .to_string_result() - } - }; - ($fn_name:ident, $request_name:ident, no_args) => { - #[tauri::command] - async fn $fn_name( - state: tauri::State<'_, State>, - ) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> { - // Throw error if context is not available - let context = state.try_get_context()?; - - <$request_name as swap::cli::api::request::Request>::request($request_name {}, context) - .await - .to_string_result() - } - }; -} +use commands::*; /// Represents the shared Tauri state. It is accessed by Tauri commands struct State { - pub context: RwLock>>, + pub context: Arc, + /// Whenever someone wants to modify the context, they should acquire this lock + /// + /// [`Context`] uses RwLock internally which means we do not need write access to the context + /// to modify its internal state. + /// + /// However, we want to avoid multiple processes intializing the context at the same time. + pub context_lock: Mutex<()>, pub handle: TauriHandle, } impl State { /// Creates a new State instance with no Context fn new(handle: TauriHandle) -> Self { + let context = Arc::new(Context::new_with_tauri_handle(handle.clone())); + let context_lock = Mutex::new(()); + Self { - context: RwLock::new(None), + context, + context_lock, handle, } } /// Attempts to retrieve the context /// Returns an error if the context is not available - fn try_get_context(&self) -> Result, String> { - self.context - .try_read() - .map_err(|_| "Context is being modified".to_string())? - .clone() - .ok_or("Context not available".to_string()) + fn context(&self) -> Arc { + self.context.clone() } } @@ -177,44 +99,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .invoke_handler(tauri::generate_handler![ - get_balance, - get_monero_addresses, - get_swap_info, - get_swap_infos_all, - withdraw_btc, - buy_xmr, - resume_swap, - get_history, - monero_recovery, - get_logs, - list_sellers, - suspend_current_swap, - cancel_and_refund, - is_context_available, - initialize_context, - check_monero_node, - check_electrum_node, - get_wallet_descriptor, - get_current_swap, - get_data_dir, - resolve_approval_request, - redact, - save_txt_files, - get_monero_history, - get_monero_main_address, - get_monero_balance, - send_monero, - get_monero_sync_progress, - get_monero_seed, - check_seed, - get_pending_approvals, - set_monero_restore_height, - reject_approval_request, - get_restore_height, - dfx_authenticate, - change_monero_node, - ]) + .invoke_handler(generate_command_handlers!()) .setup(setup) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -225,335 +110,13 @@ pub fn run() { // If the application is forcibly closed, this may not be called. // TODO: fix that let state = app.state::(); - let context_to_cleanup = if let Ok(context_lock) = state.context.try_read() { - context_lock.clone() - } else { - println!("Failed to acquire lock on context"); - None - }; - - if let Some(context) = context_to_cleanup { - if let Err(err) = context.cleanup() { - println!("Cleanup failed {}", err); + let lock = state.context_lock.try_lock(); + if let Ok(_) = lock { + if let Err(e) = state.context().cleanup() { + println!("Failed to cleanup context: {}", e); } } } _ => {} }) } - -// Here we define the Tauri commands that will be available to the frontend -// The commands are defined using the `tauri_command!` macro. -// Implementations are handled by the Request trait -tauri_command!(get_balance, BalanceArgs); -tauri_command!(buy_xmr, BuyXmrArgs); -tauri_command!(resume_swap, ResumeSwapArgs); -tauri_command!(withdraw_btc, WithdrawBtcArgs); -tauri_command!(monero_recovery, MoneroRecoveryArgs); -tauri_command!(get_logs, GetLogsArgs); -tauri_command!(list_sellers, ListSellersArgs); -tauri_command!(cancel_and_refund, CancelAndRefundArgs); -tauri_command!(redact, RedactArgs); -tauri_command!(send_monero, SendMoneroArgs); -tauri_command!(change_monero_node, ChangeMoneroNodeArgs); - -// These commands require no arguments -tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); -tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); -tauri_command!(get_swap_info, GetSwapInfoArgs); -tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args); -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!(get_monero_main_address, GetMoneroMainAddressArgs, no_args); -tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args); -tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args); -tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); - -/// Here we define Tauri commands whose implementation is not delegated to the Request trait -#[tauri::command] -async fn is_context_available(state: tauri::State<'_, State>) -> Result { - // TODO: Here we should return more information about status of the context (e.g. initializing, failed) - Ok(state.try_get_context().is_ok()) -} - -#[tauri::command] -async fn check_monero_node( - args: CheckMoneroNodeArgs, - _: tauri::State<'_, State>, -) -> Result { - args.request().await.to_string_result() -} - -#[tauri::command] -async fn check_electrum_node( - args: CheckElectrumNodeArgs, - _: tauri::State<'_, State>, -) -> Result { - args.request().await.to_string_result() -} - -#[tauri::command] -async fn check_seed( - args: CheckSeedArgs, - _: tauri::State<'_, State>, -) -> Result { - args.request().await.to_string_result() -} - -// Returns the data directory -// This is independent of the context to ensure the user can open the directory even if the context cannot -// be initialized (for troubleshooting purposes) -#[tauri::command] -async fn get_data_dir(args: GetDataDirArgs, _: tauri::State<'_, State>) -> Result { - Ok(data::data_dir_from(None, args.is_testnet) - .to_string_result()? - .to_string_lossy() - .to_string()) -} - -#[tauri::command] -async fn save_txt_files( - app: tauri::AppHandle, - zip_file_name: String, - content: HashMap, -) -> Result<(), String> { - // Step 1: Get the owned PathBuf from the dialog - let path_buf_from_dialog: tauri_plugin_dialog::FilePath = app - .dialog() - .file() - .set_file_name(format!("{}.zip", &zip_file_name).as_str()) - .add_filter(&zip_file_name, &["zip"]) - .blocking_save_file() // This returns Option - .ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result and unwraps to PathBuf - - // Step 2: Now get a &Path reference from the owned PathBuf. - // The user's code structure implied an .as_path().ok_or_else(...) chain which was incorrect for &Path. - // We'll directly use the PathBuf, or if &Path is strictly needed: - let selected_file_path: &std::path::Path = path_buf_from_dialog - .as_path() - .ok_or_else(|| "Could not convert file path".to_string())?; - - let zip_file = std::fs::File::create(selected_file_path) - .map_err(|e| format!("Failed to create file: {}", e))?; - - let mut zip = ZipWriter::new(zip_file); - - for (filename, file_content_str) in content.iter() { - zip.start_file( - format!("{}.txt", filename).as_str(), - SimpleFileOptions::default(), - ) // Pass &str to start_file - .map_err(|e| format!("Failed to start file {}: {}", &filename, e))?; // Use &filename - - zip.write_all(file_content_str.as_bytes()) - .map_err(|e| format!("Failed to write to file {}: {}", &filename, e))?; - // Use &filename - } - - zip.finish() - .map_err(|e| format!("Failed to finish zip: {}", e))?; - - Ok(()) -} - -#[tauri::command] -async fn resolve_approval_request( - args: ResolveApprovalArgs, - state: tauri::State<'_, State>, -) -> Result<(), String> { - let request_id = args - .request_id - .parse() - .map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?; - - state - .handle - .resolve_approval(request_id, args.accept) - .await - .to_string_result()?; - - Ok(()) -} - -#[tauri::command] -async fn reject_approval_request( - args: RejectApprovalArgs, - state: tauri::State<'_, State>, -) -> Result { - let request_id = args - .request_id - .parse() - .map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?; - - state - .handle - .reject_approval(request_id) - .await - .to_string_result()?; - - Ok(RejectApprovalResponse { success: true }) -} - -#[tauri::command] -async fn get_pending_approvals( - state: tauri::State<'_, State>, -) -> Result { - let approvals = state - .handle - .get_pending_approvals() - .await - .to_string_result()?; - - Ok(GetPendingApprovalsResponse { approvals }) -} - -/// Tauri command to initialize the Context -#[tauri::command] -async fn initialize_context( - settings: TauriSettings, - testnet: bool, - state: tauri::State<'_, State>, -) -> Result<(), String> { - // Lock at the beginning - fail immediately if already locked - let mut context_lock = state - .context - .try_write() - .map_err(|_| "Context is already being initialized".to_string())?; - - // Fail if the context is already initialized - if context_lock.is_some() { - return Err("Context is already initialized".to_string()); - } - - // Get tauri handle from the state - let tauri_handle = state.handle.clone(); - - // Notify frontend that the context is being initialized - tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing); - - let context_result = ContextBuilder::new(testnet) - .with_bitcoin(Bitcoin { - bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(), - bitcoin_target_block: None, - }) - .with_monero(settings.monero_node_config) - .with_json(false) - .with_debug(true) - .with_tor(settings.use_tor) - .with_enable_monero_tor(settings.enable_monero_tor) - .with_tauri(tauri_handle.clone()) - .build() - .await; - - match context_result { - Ok(context_instance) => { - *context_lock = Some(Arc::new(context_instance)); - - tracing::info!("Context initialized"); - - // Emit event to frontend - tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available); - Ok(()) - } - Err(e) => { - tracing::error!(error = ?e, "Failed to initialize context"); - - // Emit event to frontend - tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Failed); - Err(e.to_string()) - } - } -} - -#[tauri::command] -async fn dfx_authenticate( - state: tauri::State<'_, State>, -) -> Result { - use dfx_swiss_sdk::{DfxClient, SignRequest}; - use tokio::sync::{mpsc, oneshot}; - use tokio_util::task::AbortOnDropHandle; - - let context = state.try_get_context()?; - - // Get the monero wallet manager - let monero_manager = context - .monero_manager - .as_ref() - .ok_or("Monero wallet manager not available for DFX authentication")?; - - let wallet = monero_manager.main_wallet().await; - let address = wallet.main_address().await.to_string(); - - // Create channel for authentication - let (auth_tx, mut auth_rx) = mpsc::channel::<(SignRequest, oneshot::Sender)>(10); - - // Create DFX client - let mut client = DfxClient::new(address, Some("https://api.dfx.swiss".to_string()), auth_tx); - - // Start signing task with AbortOnDropHandle - let signing_task = tokio::spawn(async move { - tracing::info!("DFX signing service started and listening for requests"); - - while let Some((sign_request, response_tx)) = auth_rx.recv().await { - tracing::debug!( - message = %sign_request.message, - blockchains = ?sign_request.blockchains, - "Received DFX signing request" - ); - - // Sign the message using the main Monero wallet - let signature = match wallet - .sign_message(&sign_request.message, None, false) - .await - { - Ok(sig) => { - tracing::debug!( - signature_preview = %&sig[..std::cmp::min(50, sig.len())], - "Message signed successfully for DFX" - ); - sig - } - Err(e) => { - tracing::error!(error = ?e, "Failed to sign message for DFX"); - continue; - } - }; - - // Send signature back to DFX client - if let Err(_) = response_tx.send(signature) { - tracing::warn!("Failed to send signature response through channel to DFX client"); - } - } - - tracing::info!("DFX signing service stopped"); - }); - - // Create AbortOnDropHandle so the task gets cleaned up - let _abort_handle = AbortOnDropHandle::new(signing_task); - - // Authenticate with DFX - tracing::info!("Starting DFX authentication..."); - client - .authenticate() - .await - .map_err(|e| format!("Failed to authenticate with DFX: {}", e))?; - - let access_token = client - .access_token - .as_ref() - .ok_or("No access token available after authentication")? - .clone(); - - let kyc_url = format!("https://app.dfx.swiss/buy?session={}", access_token); - - tracing::info!("DFX authentication completed successfully"); - - Ok(DfxAuthenticateResponse { - access_token, - kyc_url, - }) -} diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index f94fbbb8..5d34f53c 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -1,7 +1,7 @@ pub mod request; pub mod tauri_bindings; -use crate::cli::api::tauri_bindings::SeedChoice; +use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice}; use crate::cli::command::{Bitcoin, Monero}; use crate::common::tor::{bootstrap_tor_client, create_tor_client}; use crate::common::tracing_util::Format; @@ -19,9 +19,7 @@ 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, TauriContextStatusEvent, TauriEmitter, TauriHandle, -}; +use tauri_bindings::{MoneroNodeConfig, TauriBackgroundProgress, TauriEmitter, TauriHandle}; use tokio::sync::{broadcast, broadcast::Sender, Mutex as TokioMutex, RwLock}; use tokio::task::JoinHandle; use tokio_util::task::AbortOnDropHandle; @@ -34,537 +32,687 @@ use super::watcher::Watcher; static START: Once = Once::new(); -#[derive(Clone, PartialEq, Debug)] -pub struct Config { - namespace: XmrBtcNamespace, - pub env_config: EnvConfig, - seed: Option, - debug: bool, - json: bool, - log_dir: PathBuf, - data_dir: PathBuf, - is_testnet: bool, -} +mod config { + 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); + #[derive(Clone, PartialEq, Debug)] + pub struct Config { + pub(super) namespace: XmrBtcNamespace, + pub env_config: EnvConfig, + pub(super) seed: Option, + pub(super) debug: bool, + pub(super) json: bool, + pub(super) log_dir: PathBuf, + pub(super) data_dir: PathBuf, + pub(super) is_testnet: bool, } - 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::>() - }; + impl Config { + pub fn for_harness(seed: Seed, env_config: EnvConfig) -> Self { + let data_dir = + super::data::data_dir_from(None, false).expect("Could not find data directory"); + let log_dir = data_dir.join("logs"); // not used in production - 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) + Self { + namespace: XmrBtcNamespace::from_is_testnet(false), + env_config, + seed: seed.into(), + debug: false, + json: false, + is_testnet: false, + data_dir, + log_dir, } } } - pub async fn acquire_swap_lock(&self, swap_id: Uuid) -> Result<(), Error> { - let mut current_swap = self.current_swap.write().await; - if current_swap.is_some() { - bail!("There already exists an active swap lock"); + pub(super) fn env_config_from(testnet: bool) -> EnvConfig { + if testnet { + Testnet::get_config() + } else { + Mainnet::get_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); } - tracing::debug!(swap_id = %swap_id, "Acquiring swap lock"); - *current_swap = Some(swap_id); - Ok(()) + 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(()) + } } - pub async fn get_current_swap_id(&self) -> Option { - *self.current_swap.read().await + /// 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<()>, } - /// 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(()); + impl SwapLock { + pub fn new() -> Self { + let (suspension_trigger, _) = broadcast::channel(10); + SwapLock { + current_swap: RwLock::new(None), + suspension_trigger, } - 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() - } -} - -/// Holds shared data for different parts of the CLI. -/// -/// Some components are optional, allowing initialization of only necessary parts. -/// For example, the `history` command doesn't require wallet initialization. -/// -/// Many fields are wrapped in `Arc` for thread-safe sharing. -#[derive(Clone)] -pub struct Context { - pub db: Arc, - pub swap_lock: Arc, - pub config: Config, - pub tasks: Arc, - tauri_handle: Option, - bitcoin_wallet: Option>, - pub monero_manager: Option>, - tor_client: Option>>, - #[allow(dead_code)] - monero_rpc_pool_handle: Option>, -} - -/// A conveniant builder struct for [`Context`]. -#[must_use = "ContextBuilder must be built to be useful"] -pub struct ContextBuilder { - monero_config: Option, - bitcoin: Option, - data: Option, - is_testnet: bool, - debug: bool, - json: bool, - tor: bool, - enable_monero_tor: bool, - tauri_handle: Option, -} - -impl ContextBuilder { - /// Start building a context - pub fn new(is_testnet: bool) -> Self { - if is_testnet { - Self::testnet() - } else { - Self::mainnet() - } - } - - /// Basic builder with default options for mainnet - pub fn mainnet() -> Self { - ContextBuilder { - monero_config: None, - bitcoin: None, - data: None, - is_testnet: false, - debug: false, - json: false, - tor: false, - enable_monero_tor: false, - tauri_handle: None, - } - } - - /// Basic builder with default options for testnet - pub fn testnet() -> Self { - let mut builder = Self::mainnet(); - builder.is_testnet = true; - builder - } - - /// Configures the Context to initialize a Monero wallet with the given configuration. - pub fn with_monero(mut self, monero_config: impl Into>) -> Self { - self.monero_config = monero_config.into(); - self - } - - /// Configures the Context to initialize a Bitcoin wallet with the given configuration. - pub fn with_bitcoin(mut self, bitcoin: impl Into>) -> Self { - self.bitcoin = bitcoin.into(); - self - } - - /// Attach a handle to Tauri to the Context for emitting events etc. - pub fn with_tauri(mut self, tauri_handle: impl Into>) -> Self { - self.tauri_handle = tauri_handle.into(); - self - } - - /// Configures where the data and logs are saved in the filesystem - pub fn with_data_dir(mut self, data: impl Into>) -> Self { - self.data = data.into(); - self - } - - /// Whether to include debug level logging messages (default false) - pub fn with_debug(mut self, debug: bool) -> Self { - self.debug = debug; - self - } - - /// Set logging format to json (default false) - pub fn with_json(mut self, json: bool) -> Self { - self.json = json; - self - } - - /// Whether to initialize a Tor client (default false) - pub fn with_tor(mut self, tor: bool) -> Self { - self.tor = tor; - self - } - - /// Whether to route Monero wallet traffic through Tor (default false) - pub fn with_enable_monero_tor(mut self, enable_monero_tor: bool) -> Self { - self.enable_monero_tor = enable_monero_tor; - self - } - - /// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context. - pub async fn build(self) -> Result { - // This is the data directory for the eigenwallet (wallet files) - let eigenwallet_data_dir = &eigenwallet_data::new(self.is_testnet)?; - - let base_data_dir = &data::data_dir_from(self.data, self.is_testnet)?; - let log_dir = base_data_dir.join("logs"); - let env_config = env_config_from(self.is_testnet); - - // Initialize logging - let format = if self.json { Format::Json } else { Format::Raw }; - let level_filter = if self.debug { - LevelFilter::from_level(Level::DEBUG) - } else { - LevelFilter::from_level(Level::INFO) - }; - - START.call_once(|| { - let _ = common::tracing_util::init( - level_filter, - format, - log_dir.clone(), - self.tauri_handle.clone(), - false, - ); - tracing::info!( - binary = "cli", - version = env!("VERGEN_GIT_DESCRIBE"), - os = std::env::consts::OS, - arch = std::env::consts::ARCH, - "Setting up context" - ); - }); - - let future_seed_choice_and_database = { - let tauri_handle = self.tauri_handle.clone(); - - async move { - // Initialize wallet database for tracking recent wallets - let wallet_database = monero_sys::Database::new(eigenwallet_data_dir.clone()) - .await - .context("Failed to initialize wallet database")?; - - // Request the user to select a wallet to use - let seed_choice = match tauri_handle { - Some(tauri_handle) => { - Some(request_seed_choice(tauri_handle, &wallet_database).await?) - } - None => None, - }; - - anyhow::Result::<_>::Ok((wallet_database, seed_choice)) - } - }; - - // Create unbootstrapped Tor client early if enabled - let future_unbootstrapped_tor_client_rpc_pool = { - let tauri_handle = self.tauri_handle.clone(); - async move { - let unbootstrapped_tor_client = if self.tor { - match create_tor_client(&base_data_dir).await.inspect_err(|err| { - tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor"); - }) { - Ok(client) => Some(client), - Err(_) => None, + 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) } - } else { - tracing::warn!("Internal Tor client not enabled, skipping initialization"); - None - }; + } + } - // Start the rpc pool for the monero wallet with optional Tor client based on enable_monero_tor setting - let (server_info, status_receiver, pool_handle) = - monero_rpc_pool::start_server_with_random_port( - monero_rpc_pool::config::Config::new_random_port_with_tor_client( - base_data_dir.join("monero-rpc-pool"), - if self.enable_monero_tor { - unbootstrapped_tor_client.clone() - } else { - None - }, - match self.is_testnet { - true => monero::Network::Stagenet, - false => monero::Network::Mainnet, - }, - ), - ) - .await?; + 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"); + } - let bootstrap_tor_client_task = AbortOnDropHandle::new(tokio::spawn({ - let unbootstrapped_tor_client = unbootstrapped_tor_client.clone(); - let tauri_handle = tauri_handle.clone(); + tracing::debug!(swap_id = %swap_id, "Acquiring swap lock"); + *current_swap = Some(swap_id); + Ok(()) + } - async move { - if let Some(tor_client) = unbootstrapped_tor_client { - bootstrap_tor_client(tor_client.clone(), tauri_handle.clone()) + 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::*; + + /// Holds shared data for different parts of the CLI. + /// + /// Some components are optional, allowing initialization of only necessary parts. + /// For example, the `history` command doesn't require wallet initialization. + /// + /// Components are wrapped in Arc to allow independent initialization and cloning. + #[derive(Clone)] + pub struct Context { + pub db: Arc>>>, + pub swap_lock: Arc, + pub config: Arc>>, + pub tasks: Arc, + pub tauri_handle: Option, + pub(super) bitcoin_wallet: Arc>>>, + pub monero_manager: Arc>>>, + pub(super) tor_client: Arc>>>>, + #[allow(dead_code)] + pub(super) monero_rpc_pool_handle: Arc>>>, + } + + impl Context { + pub fn new_with_tauri_handle(tauri_handle: TauriHandle) -> Self { + Self::new(Some(tauri_handle)) + } + + pub fn new_without_tauri_handle() -> Self { + Self::new(None) + } + + /// Creates an empty Context with only the swap_lock and tasks initialized + fn new(tauri_handle: Option) -> Self { + Self { + db: Arc::new(RwLock::new(None)), + swap_lock: Arc::new(SwapLock::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)), + tor_client: Arc::new(RwLock::new(None)), + monero_rpc_pool_handle: Arc::new(RwLock::new(None)), + } + } + + pub async fn status(&self) -> ContextStatus { + ContextStatus { + bitcoin_wallet_available: self.try_get_bitcoin_wallet().await.is_ok(), + monero_wallet_available: self.try_get_monero_manager().await.is_ok(), + database_available: self.try_get_db().await.is_ok(), + tor_available: self.try_get_tor_client().await.is_ok(), + } + } + + /// Get the Bitcoin wallet, returning an error if not initialized + pub async fn try_get_bitcoin_wallet(&self) -> Result> { + self.bitcoin_wallet + .read() + .await + .clone() + .context("Bitcoin wallet not initialized") + } + + /// Get the Monero manager, returning an error if not initialized + pub async fn try_get_monero_manager(&self) -> Result> { + self.monero_manager + .read() + .await + .clone() + .context("Monero wallet manager not initialized") + } + + /// Get the database, returning an error if not initialized + pub async fn try_get_db(&self) -> Result> { + self.db + .read() + .await + .clone() + .context("Database not initialized") + } + + /// Get the config, returning an error if not initialized + pub async fn try_get_config(&self) -> Result { + self.config + .read() + .await + .clone() + .context("Config not initialized") + } + + /// Get the Tor client, returning an error if not initialized + pub async fn try_get_tor_client(&self) -> Result>> { + self.tor_client + .read() + .await + .clone() + .context("Tor client not initialized") + } + + pub async fn for_harness( + seed: Seed, + env_config: EnvConfig, + db_path: PathBuf, + bob_bitcoin_wallet: Arc, + bob_monero_wallet: Arc, + ) -> Self { + let config = Config::for_harness(seed, env_config); + let db = open_db(db_path, AccessMode::ReadWrite, None) + .await + .expect("Could not open sqlite database"); + + Self { + bitcoin_wallet: Arc::new(RwLock::new(Some(bob_bitcoin_wallet))), + 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(), + tauri_handle: None, + tor_client: Arc::new(RwLock::new(None)), + monero_rpc_pool_handle: Arc::new(RwLock::new(None)), + } + } + + pub fn cleanup(&self) -> Result<()> { + // TODO: close all monero wallets + // call store(..) on all wallets + + // TODO: This doesn't work because "there is no reactor running, must be called from the context of a Tokio 1.x runtime" + // let monero_manager = self.monero_manager.clone(); + // tokio::spawn(async move { + // if let Some(monero_manager) = monero_manager { + // let wallet = monero_manager.main_wallet().await; + // wallet.store(None).await; + // } + // }); + + Ok(()) + } + + pub async fn bitcoin_wallet(&self) -> Option> { + self.bitcoin_wallet.read().await.clone() + } + + /// Change the Monero node configuration for all wallets + pub async fn change_monero_node(&self, node_config: MoneroNodeConfig) -> Result<()> { + let monero_manager = self.try_get_monero_manager().await?; + + // Determine the daemon configuration based on the node config + let daemon = match node_config { + MoneroNodeConfig::Pool => { + // Use the pool handle to get server info + let pool_handle = self + .monero_rpc_pool_handle + .read() .await - .inspect_err(|err| { - tracing::warn!(%err, "Failed to bootstrap Tor client. It will remain unbootstrapped"); - }) - .ok(); - } - } - })); + .clone() + .context("Pool handle not available")?; - anyhow::Result::<_>::Ok(( + let server_info = pool_handle.server_info(); + let pool_url: String = server_info.clone().into(); + tracing::info!("Switching to Monero RPC pool: {}", pool_url); + + monero_sys::Daemon::try_from(pool_url)? + } + MoneroNodeConfig::SingleNode { url } => { + tracing::info!("Switching to single Monero node: {}", url); + + monero_sys::Daemon::try_from(url.clone())? + } + }; + + // Update the wallet manager's daemon configuration + monero_manager + .change_monero_node(daemon.clone()) + .await + .context("Failed to change Monero node in wallet manager")?; + + tracing::info!(?daemon, "Switched Monero node"); + + Ok(()) + } + } + + impl fmt::Debug for Context { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "") + } + } +} + +pub use context::Context; + +mod builder { + use super::*; + + /// A conveniant builder struct for [`Context`]. + #[must_use = "ContextBuilder must be built to be useful"] + pub struct ContextBuilder { + monero_config: Option, + bitcoin: Option, + data: Option, + is_testnet: bool, + debug: bool, + json: bool, + tor: bool, + enable_monero_tor: bool, + tauri_handle: Option, + } + + impl ContextBuilder { + /// Start building a context + pub fn new(is_testnet: bool) -> Self { + if is_testnet { + Self::testnet() + } else { + Self::mainnet() + } + } + + /// Basic builder with default options for mainnet + pub fn mainnet() -> Self { + ContextBuilder { + monero_config: None, + bitcoin: None, + data: None, + is_testnet: false, + debug: false, + json: false, + tor: false, + enable_monero_tor: false, + tauri_handle: None, + } + } + + /// Basic builder with default options for testnet + pub fn testnet() -> Self { + let mut builder = Self::mainnet(); + builder.is_testnet = true; + builder + } + + /// Configures the Context to initialize a Monero wallet with the given configuration. + pub fn with_monero(mut self, monero_config: impl Into>) -> Self { + self.monero_config = monero_config.into(); + self + } + + /// Configures the Context to initialize a Bitcoin wallet with the given configuration. + pub fn with_bitcoin(mut self, bitcoin: impl Into>) -> Self { + self.bitcoin = bitcoin.into(); + self + } + + /// Attach a handle to Tauri to the Context for emitting events etc. + pub fn with_tauri(mut self, tauri_handle: impl Into>) -> Self { + self.tauri_handle = tauri_handle.into(); + self + } + + /// Configures where the data and logs are saved in the filesystem + pub fn with_data_dir(mut self, data: impl Into>) -> Self { + self.data = data.into(); + self + } + + /// Whether to include debug level logging messages (default false) + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + /// Set logging format to json (default false) + pub fn with_json(mut self, json: bool) -> Self { + self.json = json; + self + } + + /// Whether to initialize a Tor client (default false) + pub fn with_tor(mut self, tor: bool) -> Self { + self.tor = tor; + self + } + + /// Whether to route Monero wallet traffic through Tor (default false) + pub fn with_enable_monero_tor(mut self, enable_monero_tor: bool) -> Self { + self.enable_monero_tor = enable_monero_tor; + self + } + + /// Initializes the context by populating it with all configured components. + /// + /// Context fields are set as early as possible for availability to other parts of the system. + pub async fn build(self, context: Arc) -> Result<()> { + let eigenwallet_data_dir = &eigenwallet_data::new(self.is_testnet)?; + let base_data_dir = &data::data_dir_from(self.data, self.is_testnet)?; + let log_dir = base_data_dir.join("logs"); + let env_config = config::env_config_from(self.is_testnet); + + // Initialize logging + let format = if self.json { Format::Json } else { Format::Raw }; + let level_filter = if self.debug { + LevelFilter::from_level(Level::DEBUG) + } else { + LevelFilter::from_level(Level::INFO) + }; + + START.call_once(|| { + let _ = common::tracing_util::init( + level_filter, + format, + log_dir.clone(), + self.tauri_handle.clone(), + false, + ); + tracing::info!( + binary = "cli", + version = env!("VERGEN_GIT_DESCRIBE"), + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + "Setting up context" + ); + }); + + // Prepare parallel initialization tasks + let future_seed_choice_and_database = { + let tauri_handle = self.tauri_handle.clone(); + + async move { + let wallet_database = monero_sys::Database::new(eigenwallet_data_dir.clone()) + .await + .context("Failed to initialize wallet database")?; + + let seed_choice = match tauri_handle { + Some(tauri_handle) => { + Some(wallet::request_seed_choice(tauri_handle, &wallet_database).await?) + } + None => None, + }; + + anyhow::Result::<_>::Ok((wallet_database, seed_choice)) + } + }; + + let future_unbootstrapped_tor_client_rpc_pool = { + let tauri_handle = self.tauri_handle.clone(); + async move { + let unbootstrapped_tor_client = if self.tor { + match create_tor_client(&base_data_dir).await.inspect_err(|err| { + tracing::warn!(%err, "Failed to create Tor client. We will continue without Tor"); + }) { + Ok(client) => Some(client), + Err(_) => None, + } + } else { + tracing::warn!("Internal Tor client not enabled, skipping initialization"); + None + }; + + // Start Monero RPC pool server + let (server_info, status_receiver, pool_handle) = + monero_rpc_pool::start_server_with_random_port( + monero_rpc_pool::config::Config::new_random_port_with_tor_client( + base_data_dir.join("monero-rpc-pool"), + if self.enable_monero_tor { + unbootstrapped_tor_client.clone() + } else { + None + }, + match self.is_testnet { + true => monero::Network::Stagenet, + false => monero::Network::Mainnet, + }, + ), + ) + .await?; + + // Bootstrap Tor client in background + let bootstrap_tor_client_task = AbortOnDropHandle::new(tokio::spawn({ + let unbootstrapped_tor_client = unbootstrapped_tor_client.clone(); + let tauri_handle = tauri_handle.clone(); + + async move { + if let Some(tor_client) = unbootstrapped_tor_client { + bootstrap_tor_client(tor_client.clone(), tauri_handle.clone()) + .await + .inspect_err(|err| { + tracing::warn!(%err, "Failed to bootstrap Tor client. It will remain unbootstrapped"); + }) + .ok(); + } + } + })); + + anyhow::Result::<_>::Ok(( + unbootstrapped_tor_client, + bootstrap_tor_client_task, + server_info, + status_receiver, + pool_handle, + )) + } + }; + + let ( + (wallet_database, seed_choice), + ( unbootstrapped_tor_client, bootstrap_tor_client_task, server_info, - status_receiver, + mut status_receiver, pool_handle, - )) - } - }; + ), + ) = tokio::try_join!( + future_seed_choice_and_database, + future_unbootstrapped_tor_client_rpc_pool + )?; - let ( - (wallet_database, seed_choice), - ( - unbootstrapped_tor_client, - bootstrap_tor_client_task, - server_info, - mut status_receiver, - pool_handle, - ), - ) = tokio::try_join!( - future_seed_choice_and_database, - future_unbootstrapped_tor_client_rpc_pool - )?; + *context.tor_client.write().await = unbootstrapped_tor_client.clone(); - // Listen for pool status updates and forward them to frontend - let pool_tauri_handle = self.tauri_handle.clone(); - tokio::spawn(async move { - while let Ok(status) = status_receiver.recv().await { - pool_tauri_handle.emit_pool_status_update(status); - } - }); + // Forward pool status updates to frontend + let pool_tauri_handle = self.tauri_handle.clone(); + tokio::spawn(async move { + while let Ok(status) = status_receiver.recv().await { + pool_tauri_handle.emit_pool_status_update(status); + } + }); - // Determine the monero node address to use - let (monero_node_address, monero_rpc_pool_handle) = match &self.monero_config { - Some(MoneroNodeConfig::Pool) => { - let rpc_url = server_info.into(); - (rpc_url, Some(Arc::new(pool_handle))) - } - Some(MoneroNodeConfig::SingleNode { url }) => (url.clone(), None), - None => { - // Default to pool if no monero config is provided - let rpc_url = server_info.into(); - (rpc_url, Some(Arc::new(pool_handle))) - } - }; + // Determine Monero daemon to use + let (monero_node_address, monero_rpc_pool_handle) = match &self.monero_config { + Some(MoneroNodeConfig::Pool) => { + let rpc_url = server_info.into(); + (rpc_url, Some(Arc::new(pool_handle))) + } + Some(MoneroNodeConfig::SingleNode { url }) => (url.clone(), None), + None => { + let rpc_url = server_info.into(); + (rpc_url, Some(Arc::new(pool_handle))) + } + }; - // Create a daemon struct for the monero wallet based on the node address - let daemon = monero_sys::Daemon::try_from(monero_node_address)?; + *context.monero_rpc_pool_handle.write().await = monero_rpc_pool_handle.clone(); - // Prompt the user to open/create a Monero wallet - let (wallet, seed) = open_monero_wallet( - self.tauri_handle.clone(), - eigenwallet_data_dir, - base_data_dir, - env_config, - &daemon, - seed_choice, - &wallet_database, - ) - .await?; + let daemon = monero_sys::Daemon::try_from(monero_node_address)?; - let primary_address = wallet.main_address().await; - - // Derive data directory from primary address - let data_dir = base_data_dir - .join("identities") - .join(primary_address.to_string()); - - // Ensure the identity directory exists - swap_fs::ensure_directory_exists(&data_dir) - .context("Failed to create identity directory")?; - - tracing::info!( - primary_address = %primary_address, - data_dir = %data_dir.display(), - "Using wallet-specific data directory" - ); - - let wallet_database = Some(Arc::new(wallet_database)); - - // Create the monero wallet manager - let monero_manager = Some(Arc::new( - monero::Wallets::new_with_existing_wallet( - eigenwallet_data_dir.to_path_buf(), - daemon.clone(), - env_config.monero_network, - false, + // Open or create Monero wallet + let (wallet, seed) = wallet::open_monero_wallet( self.tauri_handle.clone(), - wallet, - wallet_database, + eigenwallet_data_dir, + base_data_dir, + env_config, + &daemon, + seed_choice, + &wallet_database, ) - .await - .context("Failed to initialize Monero wallets with existing wallet")?, - )); + .await?; - // Create the data structure we use to manage the swap lock - let swap_lock = Arc::new(SwapLock::new()); - let tasks = PendingTaskList::default().into(); + let primary_address = wallet.main_address().await; - // Initialize the database - let database_progress_handle = self - .tauri_handle - .new_background_process_with_initial_progress( - TauriBackgroundProgress::OpeningDatabase, - (), + // Derive wallet-specific data directory + let data_dir = base_data_dir + .join("identities") + .join(primary_address.to_string()); + + swap_fs::ensure_directory_exists(&data_dir) + .context("Failed to create identity directory")?; + + tracing::info!( + primary_address = %primary_address, + data_dir = %data_dir.display(), + "Using wallet-specific data directory" ); - let db = open_db( - data_dir.join("sqlite"), - AccessMode::ReadWrite, - self.tauri_handle.clone(), - ) - .await?; + let wallet_database = Some(Arc::new(wallet_database)); - database_progress_handle.finish(); - - let tauri_handle = &self.tauri_handle.clone(); - - let initialize_bitcoin_wallet = async { - match self.bitcoin { - Some(bitcoin) => { - let (urls, target_block) = bitcoin.apply_defaults(self.is_testnet)?; - - let bitcoin_progress_handle = tauri_handle - .new_background_process_with_initial_progress( - TauriBackgroundProgress::OpeningBitcoinWallet, - (), - ); - - let wallet = init_bitcoin_wallet( - urls, - &seed, - &data_dir, - env_config, - target_block, + // Initialize Monero wallet manager + async { + let manager = Arc::new( + monero::Wallets::new_with_existing_wallet( + eigenwallet_data_dir.to_path_buf(), + daemon.clone(), + env_config.monero_network, + false, self.tauri_handle.clone(), - ) - .await?; - - bitcoin_progress_handle.finish(); - - Ok::>, Error>(Some(Arc::new( wallet, - ))) - } - None => Ok(None), - } - }; - - let bitcoin_wallet = initialize_bitcoin_wallet.await?; - - // If we have a bitcoin wallet and a tauri handle, we start a background task - if let Some(wallet) = bitcoin_wallet.clone() { - if self.tauri_handle.is_some() { - let watcher = Watcher::new( - wallet, - db.clone(), - self.tauri_handle.clone(), - swap_lock.clone(), + wallet_database, + ) + .await + .context("Failed to initialize Monero wallets with existing wallet")?, ); - tokio::spawn(watcher.run()); + + *context.monero_manager.write().await = Some(manager); + + Ok::<_, Error>(()) } - } + .await?; - bootstrap_tor_client_task.await?; - - let context = Context { - db, - bitcoin_wallet, - monero_manager, - config: Config { + // Initialize config + *context.config.write().await = Some(Config { namespace: XmrBtcNamespace::from_is_testnet(self.is_testnet), env_config, seed: seed.clone().into(), @@ -573,391 +721,372 @@ impl ContextBuilder { is_testnet: self.is_testnet, data_dir: data_dir.clone(), log_dir: log_dir.clone(), - }, - swap_lock, - tasks, - tauri_handle: self.tauri_handle, - tor_client: unbootstrapped_tor_client, - monero_rpc_pool_handle, - }; + }); - tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available); - - Ok(context) - } -} - -impl Context { - pub fn with_tauri_handle(mut self, tauri_handle: impl Into>) -> Self { - self.tauri_handle = tauri_handle.into(); - - self - } - - pub async fn for_harness( - seed: Seed, - env_config: EnvConfig, - db_path: PathBuf, - bob_bitcoin_wallet: Arc, - bob_monero_wallet: Arc, - ) -> Self { - let config = Config::for_harness(seed, env_config); - - Self { - bitcoin_wallet: Some(bob_bitcoin_wallet), - monero_manager: Some(bob_monero_wallet), - config, - db: open_db(db_path, AccessMode::ReadWrite, None) - .await - .expect("Could not open sqlite database"), - swap_lock: SwapLock::new().into(), - tasks: PendingTaskList::default().into(), - tauri_handle: None, - tor_client: None, - monero_rpc_pool_handle: None, - } - } - - pub fn cleanup(&self) -> Result<()> { - // TODO: close all monero wallets - // call store(..) on all wallets - - // TODO: This doesn't work because "there is no reactor running, must be called from the context of a Tokio 1.x runtime" - // let monero_manager = self.monero_manager.clone(); - // tokio::spawn(async move { - // if let Some(monero_manager) = monero_manager { - // let wallet = monero_manager.main_wallet().await; - // wallet.store(None).await; - // } - // }); - - Ok(()) - } - - pub fn bitcoin_wallet(&self) -> Option> { - self.bitcoin_wallet.clone() - } - - pub fn tauri_handle(&self) -> Option { - self.tauri_handle.clone() - } - - /// Change the Monero node configuration for all wallets - pub async fn change_monero_node(&self, node_config: MoneroNodeConfig) -> Result<()> { - let monero_manager = self - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; - - // Determine the daemon configuration based on the node config - let daemon = match node_config { - MoneroNodeConfig::Pool => { - // Use the pool handle to get server info - let pool_handle = self - .monero_rpc_pool_handle - .as_ref() - .context("Pool handle not available")?; - - let server_info = pool_handle.server_info(); - let pool_url: String = server_info.clone().into(); - tracing::info!("Switching to Monero RPC pool: {}", pool_url); - - monero_sys::Daemon::try_from(pool_url)? - } - MoneroNodeConfig::SingleNode { url } => { - tracing::info!("Switching to single Monero node: {}", url); - - monero_sys::Daemon::try_from(url.clone())? - } - }; - - // Update the wallet manager's daemon configuration - monero_manager - .change_monero_node(daemon.clone()) - .await - .context("Failed to change Monero node in wallet manager")?; - - tracing::info!(?daemon, "Switched Monero node"); - - Ok(()) - } -} - -impl fmt::Debug for Context { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "") - } -} - -async fn init_bitcoin_wallet( - electrum_rpc_urls: Vec, - seed: &Seed, - data_dir: &Path, - env_config: EnvConfig, - bitcoin_target_block: u16, - tauri_handle_option: Option, -) -> Result> { - let mut builder = bitcoin::wallet::WalletBuilder::default() - .seed(seed.clone()) - .network(env_config.bitcoin_network) - .electrum_rpc_urls(electrum_rpc_urls) - .persister(bitcoin::wallet::PersisterConfig::SqliteFile { - data_dir: data_dir.to_path_buf(), - }) - .finality_confirmations(env_config.bitcoin_finality_confirmations) - .target_block(bitcoin_target_block) - .sync_interval(env_config.bitcoin_sync_interval()); - - if let Some(handle) = tauri_handle_option { - builder = builder.tauri_handle(handle.clone()); - } - - let wallet = builder - .build() - .await - .context("Failed to initialize Bitcoin wallet")?; - - Ok(wallet) -} - -async fn request_and_open_monero_wallet_legacy( - data_dir: &PathBuf, - env_config: EnvConfig, - daemon: &monero_sys::Daemon, -) -> Result { - let wallet_path = data_dir.join("swap-tool-blockchain-monitoring-wallet"); - - let wallet = monero::Wallet::open_or_create( - wallet_path.display().to_string(), - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to create wallet")?; - - Ok(wallet) -} - -/// Requests the user to select a seed choice from a list of recent wallets -async fn request_seed_choice( - tauri_handle: TauriHandle, - database: &monero_sys::Database, -) -> Result { - let recent_wallets = database.get_recent_wallets(5).await?; - let recent_wallets: Vec = recent_wallets.into_iter().map(|w| w.wallet_path).collect(); - - let seed_choice = tauri_handle - .request_seed_selection_with_recent_wallets(recent_wallets) - .await?; - - Ok(seed_choice) -} - -/// Opens or creates a Monero wallet after asking the user via the Tauri UI. -/// -/// The user can: -/// - Create a new wallet with a random seed. -/// - Recover a wallet from a given seed phrase. -/// - Open an existing wallet file (with password verification). -/// -/// Errors if the user aborts, provides an incorrect password, or the wallet -/// fails to open/create. -async fn open_monero_wallet( - tauri_handle: Option, - eigenwallet_data_dir: &PathBuf, - legacy_data_dir: &PathBuf, - env_config: EnvConfig, - daemon: &monero_sys::Daemon, - seed_choice: Option, - database: &monero_sys::Database, -) -> Result<(monero_sys::WalletHandle, Seed), Error> { - let eigenwallet_wallets_dir = eigenwallet_data_dir.join("wallets"); - - let wallet = match seed_choice { - Some(mut seed_choice) => { - // This loop continually requests the user to select a wallet file - // It then requests the user to provide a password. - // It repeats until the user provides a valid password or rejects the password request - // When the user rejects the password request, we prompt him to select a wallet again - loop { - let _monero_progress_handle = tauri_handle + // Initialize swap database + let db = async { + let database_progress_handle = self + .tauri_handle .new_background_process_with_initial_progress( - TauriBackgroundProgress::OpeningMoneroWallet, + TauriBackgroundProgress::OpeningDatabase, (), ); - fn new_wallet_path(eigenwallet_wallets_dir: &PathBuf) -> Result { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); + let db = open_db( + data_dir.join("sqlite"), + AccessMode::ReadWrite, + self.tauri_handle.clone(), + ) + .await?; - let wallet_path = eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp)); + database_progress_handle.finish(); - if let Some(parent) = wallet_path.parent() { - swap_fs::ensure_directory_exists(parent) - .context("Failed to create wallet directory")?; + *context.db.write().await = Some(db.clone()); + + Ok::<_, Error>(db) + } + .await?; + + let tauri_handle = &self.tauri_handle.clone(); + + // Initialize Bitcoin wallet + let bitcoin_wallet = async { + let wallet = match self.bitcoin { + Some(bitcoin) => { + let (urls, target_block) = bitcoin.apply_defaults(self.is_testnet)?; + + let bitcoin_progress_handle = tauri_handle + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningBitcoinWallet, + (), + ); + + let wallet = wallet::init_bitcoin_wallet( + urls, + &seed, + &data_dir, + env_config, + target_block, + self.tauri_handle.clone(), + ) + .await?; + + bitcoin_progress_handle.finish(); + + Some(Arc::new(wallet)) } + None => None, + }; - Ok(wallet_path) + *context.bitcoin_wallet.write().await = wallet.clone(); + + Ok::<_, Error>(wallet) + } + .await?; + + // If we have a bitcoin wallet and a tauri handle, we start a background task + if let Some(wallet) = bitcoin_wallet.clone() { + if self.tauri_handle.is_some() { + let watcher = Watcher::new( + wallet, + db.clone(), + self.tauri_handle.clone(), + context.swap_lock.clone(), + ); + tokio::spawn(watcher.run()); } + } - let wallet = match seed_choice { - SeedChoice::RandomSeed => { - // Create wallet with Unix timestamp as name - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) - .context("Failed to determine path for new wallet")?; + // Wait for Tor client to fully bootstrap + bootstrap_tor_client_task.await?; - monero::Wallet::open_or_create( - wallet_path.display().to_string(), - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to create wallet from random seed")? + Ok(()) + } + } +} + +pub use builder::ContextBuilder; + +mod wallet { + use super::*; + + pub(super) async fn init_bitcoin_wallet( + electrum_rpc_urls: Vec, + seed: &Seed, + data_dir: &Path, + env_config: EnvConfig, + bitcoin_target_block: u16, + tauri_handle_option: Option, + ) -> Result> { + let mut builder = bitcoin::wallet::WalletBuilder::default() + .seed(seed.clone()) + .network(env_config.bitcoin_network) + .electrum_rpc_urls(electrum_rpc_urls) + .persister(bitcoin::wallet::PersisterConfig::SqliteFile { + data_dir: data_dir.to_path_buf(), + }) + .finality_confirmations(env_config.bitcoin_finality_confirmations) + .target_block(bitcoin_target_block) + .sync_interval(env_config.bitcoin_sync_interval()); + + if let Some(handle) = tauri_handle_option { + builder = builder.tauri_handle(handle.clone()); + } + + let wallet = builder + .build() + .await + .context("Failed to initialize Bitcoin wallet")?; + + Ok(wallet) + } + + pub(super) async fn request_and_open_monero_wallet_legacy( + data_dir: &PathBuf, + env_config: EnvConfig, + daemon: &monero_sys::Daemon, + ) -> Result { + let wallet_path = data_dir.join("swap-tool-blockchain-monitoring-wallet"); + + let wallet = monero::Wallet::open_or_create( + wallet_path.display().to_string(), + daemon.clone(), + env_config.monero_network, + true, + ) + .await + .context("Failed to create wallet")?; + + Ok(wallet) + } + + /// Requests the user to select a seed choice from a list of recent wallets + pub(super) async fn request_seed_choice( + tauri_handle: TauriHandle, + database: &monero_sys::Database, + ) -> Result { + let recent_wallets = database.get_recent_wallets(5).await?; + let recent_wallets: Vec = + recent_wallets.into_iter().map(|w| w.wallet_path).collect(); + + let seed_choice = tauri_handle + .request_seed_selection_with_recent_wallets(recent_wallets) + .await?; + + Ok(seed_choice) + } + + /// Opens or creates a Monero wallet after asking the user via the Tauri UI. + /// + /// The user can: + /// - Create a new wallet with a random seed. + /// - Recover a wallet from a given seed phrase. + /// - Open an existing wallet file (with password verification). + /// + /// Errors if the user aborts, provides an incorrect password, or the wallet + /// fails to open/create. + pub(super) async fn open_monero_wallet( + tauri_handle: Option, + eigenwallet_data_dir: &PathBuf, + legacy_data_dir: &PathBuf, + env_config: EnvConfig, + daemon: &monero_sys::Daemon, + seed_choice: Option, + database: &monero_sys::Database, + ) -> Result<(monero_sys::WalletHandle, Seed), Error> { + let eigenwallet_wallets_dir = eigenwallet_data_dir.join("wallets"); + + let wallet = match seed_choice { + Some(mut seed_choice) => { + // This loop continually requests the user to select a wallet file + // It then requests the user to provide a password. + // It repeats until the user provides a valid password or rejects the password request + // When the user rejects the password request, we prompt him to select a wallet again + loop { + let _monero_progress_handle = tauri_handle + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningMoneroWallet, + (), + ); + + fn new_wallet_path(eigenwallet_wallets_dir: &PathBuf) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let wallet_path = + eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp)); + + if let Some(parent) = wallet_path.parent() { + swap_fs::ensure_directory_exists(parent) + .context("Failed to create wallet directory")?; + } + + Ok(wallet_path) } - SeedChoice::FromSeed { seed: mnemonic } => { - // Create wallet from provided seed - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) - .context("Failed to determine path for new wallet")?; - monero::Wallet::open_or_create_from_seed( - wallet_path.display().to_string(), - mnemonic, - env_config.monero_network, - 0, - true, - daemon.clone(), - ) - .await - .context("Failed to create wallet from provided seed")? - } - SeedChoice::FromWalletPath { ref wallet_path } => { - let wallet_path = wallet_path.clone(); + let wallet = match seed_choice { + SeedChoice::RandomSeed => { + // Create wallet with Unix timestamp as name + let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) + .context("Failed to determine path for new wallet")?; - // Helper function to verify password - let verify_password = |password: String| -> Result { - monero_sys::WalletHandle::verify_wallet_password( - wallet_path.clone(), - password, + monero::Wallet::open_or_create( + wallet_path.display().to_string(), + daemon.clone(), + env_config.monero_network, + true, ) - .map_err(|e| anyhow::anyhow!("Failed to verify wallet password: {}", e)) - }; + .await + .context("Failed to create wallet from random seed")? + } + SeedChoice::FromSeed { seed: mnemonic } => { + // Create wallet from provided seed + let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) + .context("Failed to determine path for new wallet")?; - // Request and verify password before opening wallet - let wallet_password: Option = { - const WALLET_EMPTY_PASSWORD: &str = ""; + monero::Wallet::open_or_create_from_seed( + wallet_path.display().to_string(), + mnemonic, + env_config.monero_network, + 0, + true, + daemon.clone(), + ) + .await + .context("Failed to create wallet from provided seed")? + } + SeedChoice::FromWalletPath { ref wallet_path } => { + let wallet_path = wallet_path.clone(); - // First try empty password - if verify_password(WALLET_EMPTY_PASSWORD.to_string())? { - Some(WALLET_EMPTY_PASSWORD.to_string()) - } else { - // If empty password fails, ask user for password - loop { - // Request password from user - let password = tauri_handle - .request_password(wallet_path.clone()) - .await - .inspect_err(|e| { - tracing::error!( - "Failed to get password from user: {}", - e - ); - }) - .ok(); + // Helper function to verify password + let verify_password = |password: String| -> Result { + monero_sys::WalletHandle::verify_wallet_password( + wallet_path.clone(), + password, + ) + .map_err(|e| { + anyhow::anyhow!("Failed to verify wallet password: {}", e) + }) + }; - // If the user rejects the password request (presses cancel) - // We prompt him to select a wallet again - let password = match password { - Some(password) => password, - None => break None, - }; + // Request and verify password before opening wallet + let wallet_password: Option = { + const WALLET_EMPTY_PASSWORD: &str = ""; - // Verify the password using the helper function - match verify_password(password.clone()) { - Ok(true) => { - break Some(password); - } - Ok(false) => { - // Continue loop to request password again - continue; - } - Err(e) => { - return Err(e); + // First try empty password + if verify_password(WALLET_EMPTY_PASSWORD.to_string())? { + Some(WALLET_EMPTY_PASSWORD.to_string()) + } else { + // If empty password fails, ask user for password + loop { + // Request password from user + let password = tauri_handle + .request_password(wallet_path.clone()) + .await + .inspect_err(|e| { + tracing::error!( + "Failed to get password from user: {}", + e + ); + }) + .ok(); + + // If the user rejects the password request (presses cancel) + // We prompt him to select a wallet again + let password = match password { + Some(password) => password, + None => break None, + }; + + // Verify the password using the helper function + match verify_password(password.clone()) { + Ok(true) => { + break Some(password); + } + Ok(false) => { + // Continue loop to request password again + continue; + } + Err(e) => { + return Err(e); + } } } } - } - }; + }; - let password = match wallet_password { - Some(password) => password, - // None means the user rejected the password request - // We prompt him to select a wallet again - None => { - seed_choice = - request_seed_choice(tauri_handle.clone().unwrap(), database) - .await?; - continue; - } - }; + let password = match wallet_password { + Some(password) => password, + // None means the user rejected the password request + // We prompt him to select a wallet again + None => { + seed_choice = request_seed_choice( + tauri_handle.clone().unwrap(), + database, + ) + .await?; + continue; + } + }; - // Open existing wallet with verified password - monero::Wallet::open_or_create_with_password( - wallet_path.clone(), - password, - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to open wallet from provided path")? - } - - SeedChoice::Legacy => { - let wallet = request_and_open_monero_wallet_legacy( - legacy_data_dir, - env_config, - daemon, - ) - .await?; - let seed = Seed::from_file_or_generate(legacy_data_dir) + // Open existing wallet with verified password + monero::Wallet::open_or_create_with_password( + wallet_path.clone(), + password, + daemon.clone(), + env_config.monero_network, + true, + ) .await - .context("Failed to extract seed from wallet")?; + .context("Failed to open wallet from provided path")? + } - break (wallet, seed); - } - }; + SeedChoice::Legacy => { + let wallet = request_and_open_monero_wallet_legacy( + legacy_data_dir, + env_config, + daemon, + ) + .await?; + let seed = Seed::from_file_or_generate(legacy_data_dir) + .await + .context("Failed to extract seed from wallet")?; - // Extract seed from the wallet - tracing::info!( - "Extracting seed from wallet directory: {}", - legacy_data_dir.display() - ); - let seed = Seed::from_monero_wallet(&wallet) + break (wallet, seed); + } + }; + + // Extract seed from the wallet + tracing::info!( + "Extracting seed from wallet directory: {}", + legacy_data_dir.display() + ); + let seed = Seed::from_monero_wallet(&wallet) + .await + .context("Failed to extract seed from wallet")?; + + break (wallet, seed); + } + } + + // If we don't have a seed choice, we use the legacy wallet + // This is used for the CLI to monitor the blockchain + None => { + let wallet = + request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon) + .await?; + let seed = Seed::from_file_or_generate(legacy_data_dir) .await .context("Failed to extract seed from wallet")?; - break (wallet, seed); + (wallet, seed) } - } + }; - // If we don't have a seed choice, we use the legacy wallet - // This is used for the CLI to monitor the blockchain - None => { - let wallet = - request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon).await?; - let seed = Seed::from_file_or_generate(legacy_data_dir) - .await - .context("Failed to extract seed from wallet")?; - - (wallet, seed) - } - }; - - Ok(wallet) + Ok(wallet) + } } pub mod data { @@ -989,32 +1118,6 @@ pub mod eigenwallet_data { } } -fn env_config_from(testnet: bool) -> EnvConfig { - if testnet { - Testnet::get_config() - } else { - Mainnet::get_config() - } -} - -impl Config { - pub fn for_harness(seed: Seed, env_config: EnvConfig) -> Self { - let data_dir = data::data_dir_from(None, false).expect("Could not find data directory"); - let log_dir = data_dir.join("logs"); // not used in production - - Self { - namespace: XmrBtcNamespace::from_is_testnet(false), - env_config, - seed: seed.into(), - debug: false, - json: false, - is_testnet: false, - data_dir, - log_dir, - } - } -} - impl From for MoneroNodeConfig { fn from(monero: Monero) -> Self { match monero.monero_node_address { @@ -1056,7 +1159,7 @@ pub mod api_test { let seed = Seed::from_file_or_generate(data_dir.as_path()) .await .unwrap(); - let env_config = env_config_from(is_testnet); + let env_config = config::env_config_from(is_testnet); Self { namespace: XmrBtcNamespace::from_is_testnet(is_testnet), diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 8cb5fb01..8ad5950e 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -421,7 +421,8 @@ impl Request for GetLogsArgs { type Response = GetLogsResponse; async fn request(self, ctx: Arc) -> Result { - let dir = self.logs_dir.unwrap_or(ctx.config.log_dir.clone()); + let config = ctx.try_get_config().await?; + let dir = self.logs_dir.unwrap_or(config.log_dir.clone()); let logs = get_logs(dir, self.swap_id, self.redact).await?; for msg in &logs { @@ -470,11 +471,8 @@ impl Request for GetRestoreHeightArgs { type Response = GetRestoreHeightResponse; async fn request(self, ctx: Arc) -> Result { - let wallet = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; - let wallet = wallet.main_wallet().await; + let wallet_manager = ctx.try_get_monero_manager().await?; + let wallet = wallet_manager.main_wallet().await; let height = wallet.get_restore_height().await?; Ok(GetRestoreHeightResponse { height }) @@ -496,7 +494,8 @@ impl Request for GetMoneroAddressesArgs { type Response = GetMoneroAddressesResponse; async fn request(self, ctx: Arc) -> Result { - let addresses = ctx.db.get_monero_addresses().await?; + let db = ctx.try_get_db().await?; + let addresses = db.get_monero_addresses().await?; Ok(GetMoneroAddressesResponse { addresses }) } } @@ -515,11 +514,8 @@ impl Request for GetMoneroHistoryArgs { type Response = GetMoneroHistoryResponse; async fn request(self, ctx: Arc) -> Result { - let wallet = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; - let wallet = wallet.main_wallet().await; + let wallet_manager = ctx.try_get_monero_manager().await?; + let wallet = wallet_manager.main_wallet().await; let transactions = wallet.history().await; Ok(GetMoneroHistoryResponse { transactions }) @@ -541,11 +537,8 @@ impl Request for GetMoneroMainAddressArgs { type Response = GetMoneroMainAddressResponse; async fn request(self, ctx: Arc) -> Result { - let wallet = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; - let wallet = wallet.main_wallet().await; + let wallet_manager = ctx.try_get_monero_manager().await?; + let wallet = wallet_manager.main_wallet().await; let address = wallet.main_address().await; Ok(GetMoneroMainAddressResponse { address }) } @@ -582,11 +575,8 @@ impl Request for SetRestoreHeightArgs { type Response = SetRestoreHeightResponse; async fn request(self, ctx: Arc) -> Result { - let wallet = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; - let wallet = wallet.main_wallet().await; + let wallet_manager = ctx.try_get_monero_manager().await?; + let wallet = wallet_manager.main_wallet().await; let height = match self { SetRestoreHeightArgs::Height(height) => height as u64, @@ -661,10 +651,7 @@ impl Request for GetMoneroBalanceArgs { type Response = GetMoneroBalanceResponse; async fn request(self, ctx: Arc) -> Result { - let wallet_manager = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; + let wallet_manager = ctx.try_get_monero_manager().await?; let wallet = wallet_manager.main_wallet().await; let total_balance = wallet.total_balance().await; @@ -706,10 +693,7 @@ impl Request for SendMoneroArgs { type Response = SendMoneroResponse; async fn request(self, ctx: Arc) -> Result { - let wallet_manager = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; + let wallet_manager = ctx.try_get_monero_manager().await?; let wallet = wallet_manager.main_wallet().await; // Parse the address @@ -717,7 +701,8 @@ impl Request for SendMoneroArgs { .map_err(|e| anyhow::anyhow!("Invalid Monero address: {}", e))?; let tauri_handle = ctx - .tauri_handle() + .tauri_handle + .clone() .context("Tauri needs to be available to approve transactions")?; // This is a closure that will be called by the monero-sys library to get approval for the transaction @@ -793,7 +778,8 @@ pub async fn suspend_current_swap(context: Arc) -> Result) -> Result> { - let swap_ids = context.db.all().await?; + let db = context.try_get_db().await?; + let swap_ids = db.all().await?; let mut swap_infos = Vec::new(); for (swap_id, _) in swap_ids { @@ -813,27 +799,23 @@ pub async fn get_swap_info( args: GetSwapInfoArgs, context: Arc, ) -> Result { - let bitcoin_wallet = context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; + let db = context.try_get_db().await?; - let state = context.db.get_state(args.swap_id).await?; + let state = db.get_state(args.swap_id).await?; let is_completed = state.swap_finished(); - let peer_id = context - .db + let peer_id = db .get_peer_id(args.swap_id) .await .with_context(|| "Could not get PeerID")?; - let addresses = context - .db + let addresses = db .get_addresses(peer_id) .await .with_context(|| "Could not get addressess")?; - let start_date = context.db.get_swap_start_date(args.swap_id).await?; + let start_date = db.get_swap_start_date(args.swap_id).await?; let swap_state: BobState = state.try_into()?; @@ -847,8 +829,7 @@ pub async fn get_swap_info( btc_refund_address, cancel_timelock, punish_timelock, - ) = context - .db + ) = db .get_states(args.swap_id) .await? .iter() @@ -884,7 +865,7 @@ pub async fn get_swap_info( let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?; - let monero_receive_pool = context.db.get_monero_address_pool(args.swap_id).await?; + let monero_receive_pool = db.get_monero_address_pool(args.swap_id).await?; Ok(GetSwapInfoResponse { swap_id: args.swap_id, @@ -924,15 +905,13 @@ pub async fn buy_xmr( monero_receive_pool, } = buy_xmr; - monero_receive_pool.assert_network(context.config.env_config.monero_network)?; + let config = context.try_get_config().await?; + let db = context.try_get_db().await?; + + monero_receive_pool.assert_network(config.env_config.monero_network)?; monero_receive_pool.assert_sum_to_one()?; - let bitcoin_wallet = Arc::clone( - context - .bitcoin_wallet - .as_ref() - .expect("Could not find Bitcoin wallet"), - ); + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let bitcoin_change_address = match bitcoin_change_address { Some(addr) => addr @@ -950,21 +929,15 @@ pub async fn buy_xmr( } }; - let monero_wallet = Arc::clone( - context - .monero_manager - .as_ref() - .context("Could not get Monero wallet")?, - ); + let monero_wallet = context.try_get_monero_manager().await?; - let env_config = context.config.env_config; - let seed = context.config.seed.clone().context("Could not get seed")?; + let env_config = config.env_config; + let seed = config.seed.clone().context("Could not get seed")?; // Prepare variables for the quote fetching process let identity = seed.derive_libp2p_identity(); - let namespace = context.config.namespace; - let tor_client = context.tor_client.clone(); - let db = Some(context.db.clone()); + let namespace = config.namespace; + let tor_client = context.tor_client.read().await.clone(); let tauri_handle = context.tauri_handle.clone(); // Wait for the user to approve a seller and to deposit coins @@ -973,10 +946,18 @@ pub async fn buy_xmr( let bitcoin_wallet_for_closures = Arc::clone(&bitcoin_wallet); - // Clone bitcoin_change_address before moving it in the emit call + // Clone variables before moving them into closures let bitcoin_change_address_for_spawn = bitcoin_change_address.clone(); let rendezvous_points_clone = rendezvous_points.clone(); let sellers_clone = sellers.clone(); + let db_for_fetch = db.clone(); + let tor_client_for_swarm = tor_client.clone(); + + // Clone tauri_handle for different closures + let tauri_handle_for_fetch = tauri_handle.clone(); + 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(..) @@ -989,9 +970,9 @@ pub async fn buy_xmr( let sellers = sellers_clone.clone(); let namespace = namespace; let identity = identity.clone(); - let db = db.clone(); + let db = db_for_fetch.clone(); let tor_client = tor_client.clone(); - let tauri_handle = tauri_handle.clone(); + let tauri_handle = tauri_handle_for_fetch.clone(); Box::pin(async move { fetch_quotes_task( @@ -999,7 +980,7 @@ pub async fn buy_xmr( namespace, sellers, identity, - db, + Some(db), tor_client, tauri_handle, ).await @@ -1027,10 +1008,10 @@ pub async fn buy_xmr( async move { w.sync().await } } }, - context.tauri_handle.clone(), + tauri_handle_for_determine, swap_id, |quote_with_address| { - let tauri_handle = context.tauri_handle.clone(); + let tauri_handle_clone = tauri_handle_for_selection.clone(); Box::new(async move { let details = SelectMakerDetails { swap_id, @@ -1038,7 +1019,7 @@ pub async fn buy_xmr( maker: quote_with_address, }; - tauri_handle.request_maker_selection(details, 300).await + tauri_handle_clone.request_maker_selection(details, 300).await }) as Box> + Send> }, ) => { @@ -1046,55 +1027,51 @@ pub async fn buy_xmr( } _ = context.swap_lock.listen_for_swap_force_suspension() => { context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + if let Some(handle) = tauri_handle_for_suspension { + handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + } bail!("Shutdown signal received"); }, }; // Insert the peer_id into the database - context.db.insert_peer_id(swap_id, seller_peer_id).await?; + db.insert_peer_id(swap_id, seller_peer_id).await?; - context - .db - .insert_address(seller_peer_id, seller_multiaddr.clone()) + db.insert_address(seller_peer_id, seller_multiaddr.clone()) .await?; let behaviour = cli::Behaviour::new( seller_peer_id, env_config, bitcoin_wallet.clone(), - (seed.derive_libp2p_identity(), context.config.namespace), + (seed.derive_libp2p_identity(), namespace), ); let mut swarm = swarm::cli( seed.derive_libp2p_identity(), - context.tor_client.clone(), + tor_client_for_swarm, behaviour, ) .await?; swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone()); - context - .db - .insert_monero_address_pool(swap_id, monero_receive_pool.clone()) + db.insert_monero_address_pool(swap_id, monero_receive_pool.clone()) .await?; tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized"); - context.tauri_handle.emit_swap_progress_event( + tauri_handle.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::ReceivedQuote(quote.clone()), ); // Now create the event loop we use for the swap let (event_loop, event_loop_handle) = - EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?; + EventLoop::new(swap_id, swarm, seller_peer_id, db.clone())?; let event_loop = tokio::spawn(event_loop.run().in_current_span()); - context - .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote)); context.tasks.clone().spawn(async move { tokio::select! { @@ -1103,7 +1080,7 @@ pub async fn buy_xmr( tracing::debug!("Shutdown signal received, exiting"); context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); bail!("Shutdown signal received"); }, @@ -1120,9 +1097,9 @@ pub async fn buy_xmr( }, swap_result = async { let swap = Swap::new( - Arc::clone(&context.db), + db.clone(), swap_id, - Arc::clone(&bitcoin_wallet), + bitcoin_wallet.clone(), monero_wallet, env_config, event_loop_handle, @@ -1130,7 +1107,7 @@ pub async fn buy_xmr( bitcoin_change_address_for_spawn, tx_lock_amount, tx_lock_fee - ).with_event_emitter(context.tauri_handle.clone()); + ).with_event_emitter(tauri_handle.clone()); bob::run(swap).await } => { @@ -1152,7 +1129,7 @@ pub async fn buy_xmr( .await .expect("Could not release swap lock"); - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); Ok::<_, anyhow::Error>(()) }.in_current_span()).await; @@ -1167,11 +1144,16 @@ pub async fn resume_swap( ) -> Result { let ResumeSwapArgs { swap_id } = resume; - let seller_peer_id = context.db.get_peer_id(swap_id).await?; - let seller_addresses = context.db.get_addresses(seller_peer_id).await?; + 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 tor_client = context.tor_client.read().await.clone(); - let seed = context - .config + let seller_peer_id = db.get_peer_id(swap_id).await?; + let seller_addresses = db.get_addresses(seller_peer_id).await?; + + let seed = config .seed .as_ref() .context("Could not get seed")? @@ -1179,16 +1161,11 @@ pub async fn resume_swap( let behaviour = cli::Behaviour::new( seller_peer_id, - context.config.env_config, - Arc::clone( - context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?, - ), - (seed.clone(), context.config.namespace), + config.env_config, + bitcoin_wallet.clone(), + (seed.clone(), config.namespace), ); - let mut swarm = swarm::cli(seed.clone(), context.tor_client.clone(), behaviour).await?; + let mut swarm = swarm::cli(seed.clone(), tor_client, behaviour).await?; let our_peer_id = swarm.local_peer_id(); tracing::debug!(peer_id = %our_peer_id, "Network layer initialized"); @@ -1199,36 +1176,27 @@ pub async fn resume_swap( } let (event_loop, event_loop_handle) = - EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?; + EventLoop::new(swap_id, swarm, seller_peer_id, db.clone())?; - let monero_receive_pool = context.db.get_monero_address_pool(swap_id).await?; + let monero_receive_pool = db.get_monero_address_pool(swap_id).await?; + + let tauri_handle = context.tauri_handle.clone(); let swap = Swap::from_db( - Arc::clone(&context.db), + db.clone(), swap_id, - Arc::clone( - context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?, - ), - context - .monero_manager - .as_ref() - .context("Could not get Monero wallet manager")? - .clone(), - context.config.env_config, + bitcoin_wallet, + monero_manager, + config.env_config, event_loop_handle, monero_receive_pool, ) .await? - .with_event_emitter(context.tauri_handle.clone()); + .with_event_emitter(tauri_handle.clone()); context.swap_lock.acquire_swap_lock(swap_id).await?; - context - .tauri_handle - .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming); context.tasks.clone().spawn( async move { @@ -1239,7 +1207,7 @@ pub async fn resume_swap( tracing::debug!("Shutdown signal received, exiting"); context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active."); - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); bail!("Shutdown signal received"); }, @@ -1272,7 +1240,7 @@ pub async fn resume_swap( .await .expect("Could not release swap lock"); - context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); + tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released); Ok::<(), anyhow::Error>(()) } @@ -1290,15 +1258,12 @@ pub async fn cancel_and_refund( context: Arc, ) -> Result { let CancelAndRefundArgs { swap_id } = cancel_and_refund; - let bitcoin_wallet = context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; + let db = context.try_get_db().await?; context.swap_lock.acquire_swap_lock(swap_id).await?; - let state = - cli::cancel_and_refund(swap_id, Arc::clone(bitcoin_wallet), Arc::clone(&context.db)).await; + let state = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await; context .swap_lock @@ -1319,7 +1284,8 @@ pub async fn cancel_and_refund( #[tracing::instrument(fields(method = "get_history"), skip(context))] pub async fn get_history(context: Arc) -> Result { - let swaps = context.db.all().await?; + let db = context.try_get_db().await?; + let swaps = db.all().await?; let mut vec: Vec = Vec::new(); for (swap_id, state) in swaps { let state: BobState = state.try_into()?; @@ -1334,7 +1300,8 @@ pub async fn get_history(context: Arc) -> Result { #[tracing::instrument(fields(method = "get_config"), skip(context))] pub async fn get_config(context: Arc) -> Result { - let data_dir_display = context.config.data_dir.display(); + let config = context.try_get_config().await?; + let data_dir_display = config.data_dir.display(); tracing::info!(path=%data_dir_display, "Data directory"); tracing::info!(path=%format!("{}/logs", data_dir_display), "Log files directory"); tracing::info!(path=%format!("{}/sqlite", data_dir_display), "Sqlite file location"); @@ -1357,10 +1324,7 @@ pub async fn withdraw_btc( context: Arc, ) -> Result { let WithdrawBtcArgs { address, amount } = withdraw_btc; - let bitcoin_wallet = context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let (withdraw_tx_unsigned, amount) = match amount { Some(amount) => { @@ -1402,10 +1366,7 @@ pub async fn withdraw_btc( #[tracing::instrument(fields(method = "get_balance"), skip(context))] pub async fn get_balance(balance: BalanceArgs, context: Arc) -> Result { let BalanceArgs { force_refresh } = balance; - let bitcoin_wallet = context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; if force_refresh { bitcoin_wallet.sync().await?; @@ -1441,8 +1402,12 @@ pub async fn list_sellers( .filter_map(|rendezvous_point| rendezvous_point.split_peer_id()) .collect(); - let identity = context - .config + let config = context.try_get_config().await?; + let db = context.try_get_db().await?; + let tor_client = context.tor_client.read().await.clone(); + let tauri_handle = context.tauri_handle.clone(); + + let identity = config .seed .as_ref() .context("Cannot extract seed")? @@ -1450,11 +1415,11 @@ pub async fn list_sellers( let sellers = list_sellers_impl( rendezvous_nodes, - context.config.namespace, - context.tor_client.clone(), + config.namespace, + tor_client, identity, - Some(context.db.clone()), - context.tauri_handle(), + Some(db.clone()), + tauri_handle, ) .await?; @@ -1480,10 +1445,7 @@ pub async fn list_sellers( // Add the peer as known to the database // This'll allow us to later request a quote again // without having to re-discover the peer at the rendezvous point - context - .db - .insert_address(*peer_id, multiaddr.clone()) - .await?; + db.insert_address(*peer_id, multiaddr.clone()).await?; } SellerStatus::Unreachable(UnreachableSeller { peer_id }) => { tracing::trace!( @@ -1500,10 +1462,7 @@ pub async fn list_sellers( #[tracing::instrument(fields(method = "export_bitcoin_wallet"), skip(context))] pub async fn export_bitcoin_wallet(context: Arc) -> Result { - let bitcoin_wallet = context - .bitcoin_wallet - .as_ref() - .context("Could not get Bitcoin wallet")?; + let bitcoin_wallet = context.try_get_bitcoin_wallet().await?; let wallet_export = bitcoin_wallet.wallet_export("cli").await?; tracing::info!(descriptor=%wallet_export.to_string(), "Exported bitcoin wallet"); @@ -1518,14 +1477,17 @@ pub async fn monero_recovery( context: Arc, ) -> Result { let MoneroRecoveryArgs { swap_id } = monero_recovery; - let swap_state: BobState = context.db.get_state(swap_id).await?.try_into()?; + let db = context.try_get_db().await?; + let config = context.try_get_config().await?; + + let swap_state: BobState = db.get_state(swap_id).await?.try_into()?; if let BobState::BtcRedeemed(state5) = swap_state { let (spend_key, view_key) = state5.xmr_keys(); let restore_height = state5.monero_wallet_restore_blockheight.height; let address = monero::Address::standard( - context.config.env_config.monero_network, + config.env_config.monero_network, monero::PublicKey::from_private_key(&spend_key), monero::PublicKey::from(view_key.public()), ); @@ -1978,10 +1940,7 @@ impl Request for GetMoneroSyncProgressArgs { type Response = GetMoneroSyncProgressResponse; async fn request(self, ctx: Arc) -> Result { - let wallet_manager = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; + let wallet_manager = ctx.try_get_monero_manager().await?; let wallet = wallet_manager.main_wallet().await; let sync_progress = wallet.call(|wallet| wallet.sync_progress()).await; @@ -2008,10 +1967,7 @@ impl Request for GetMoneroSeedArgs { type Response = GetMoneroSeedResponse; async fn request(self, ctx: Arc) -> Result { - let wallet_manager = ctx - .monero_manager - .as_ref() - .context("Monero wallet manager not available")?; + let wallet_manager = ctx.try_get_monero_manager().await?; let wallet = wallet_manager.main_wallet().await; let seed = wallet.seed().await?; diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 6de1f3a7..feb5af5c 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -21,12 +21,13 @@ use tokio::sync::oneshot; use typeshare::typeshare; use uuid::Uuid; +const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event"; + #[typeshare] #[derive(Clone, Serialize)] #[serde(tag = "channelName", content = "event")] pub enum TauriEvent { SwapProgress(TauriSwapProgressEventWrapper), - ContextInitProgress(TauriContextStatusEvent), CliLog(TauriLogEvent), BalanceChange(BalanceResponse), SwapDatabaseStateUpdate(TauriDatabaseStateEvent), @@ -46,7 +47,14 @@ pub enum MoneroWalletUpdate { HistoryUpdate(GetMoneroHistoryResponse), } -const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event"; +#[typeshare] +#[derive(Clone, Debug, Serialize)] +pub struct ContextStatus { + pub bitcoin_wallet_available: bool, + pub monero_wallet_available: bool, + pub database_available: bool, + pub tor_available: bool, +} #[typeshare] #[derive(Clone, Debug, Serialize, Deserialize)] @@ -456,10 +464,6 @@ pub trait TauriEmitter { })); } - fn emit_context_init_progress_event(&self, event: TauriContextStatusEvent) { - self.emit_unified_event(TauriEvent::ContextInitProgress(event)); - } - fn emit_cli_log_event(&self, event: TauriLogEvent) { self.emit_unified_event(TauriEvent::CliLog(event)); } diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index dff06716..7b385309 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -79,17 +79,16 @@ where .transpose()? .map(|address| address.into_unchecked()); - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) - .with_bitcoin(bitcoin) - .with_monero(monero) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_tor(tor.enable_tor) + .with_bitcoin(bitcoin) + .with_monero(monero) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; BuyXmrArgs { rendezvous_points: vec![], @@ -103,14 +102,13 @@ where Ok(context) } CliCommand::History => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; GetHistoryArgs {}.request(context.clone()).await?; @@ -121,14 +119,13 @@ where redact, swap_id, } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; GetLogsArgs { logs_dir, @@ -141,29 +138,27 @@ where Ok(context) } CliCommand::Config => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; GetConfigArgs {}.request(context.clone()).await?; Ok(context) } CliCommand::Balance { bitcoin } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_bitcoin(bitcoin) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_bitcoin(bitcoin) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; BalanceArgs { force_refresh: true, @@ -180,15 +175,14 @@ where } => { let address = bitcoin_address::validate(address, is_testnet)?; - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_bitcoin(bitcoin) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_bitcoin(bitcoin) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; WithdrawBtcArgs { amount, address } .request(context.clone()) @@ -202,17 +196,16 @@ where monero, tor, } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) - .with_bitcoin(bitcoin) - .with_monero(monero) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_tor(tor.enable_tor) + .with_bitcoin(bitcoin) + .with_monero(monero) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; ResumeSwapArgs { swap_id }.request(context.clone()).await?; @@ -222,15 +215,14 @@ where swap_id: SwapId { swap_id }, bitcoin, } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_bitcoin(bitcoin) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_bitcoin(bitcoin) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; CancelAndRefundArgs { swap_id } .request(context.clone()) @@ -242,15 +234,14 @@ where rendezvous_point, tor, } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_tor(tor.enable_tor) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_tor(tor.enable_tor) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; ListSellersArgs { rendezvous_points: vec![rendezvous_point], @@ -261,15 +252,14 @@ where Ok(context) } CliCommand::ExportBitcoinWallet { bitcoin } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_bitcoin(bitcoin) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_bitcoin(bitcoin) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; ExportBitcoinWalletArgs {}.request(context.clone()).await?; @@ -278,14 +268,13 @@ where CliCommand::MoneroRecovery { swap_id: SwapId { swap_id }, } => { - let context = Arc::new( - ContextBuilder::new(is_testnet) - .with_data_dir(data) - .with_debug(debug) - .with_json(json) - .build() - .await?, - ); + let context = Arc::new(Context::new_without_tauri_handle()); + ContextBuilder::new(is_testnet) + .with_data_dir(data) + .with_debug(debug) + .with_json(json) + .build(context.clone()) + .await?; MoneroRecoveryArgs { swap_id } .request(context.clone()) diff --git a/swap/src/common/tracing_util.rs b/swap/src/common/tracing_util.rs index 3b925785..83e98671 100644 --- a/swap/src/common/tracing_util.rs +++ b/swap/src/common/tracing_util.rs @@ -33,17 +33,55 @@ pub fn init( tauri_handle: Option, trace_stdout: bool, ) -> Result<()> { - let TOR_CRATES: Vec<&str> = vec!["arti"]; + let TOR_CRATES: Vec<&str> = vec![ + "arti", + "arti-client", + "arti-fork", + "tor-api2", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-chanmgr", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-consdiff", + "tor-dirclient", + "tor-dirmgr", + "tor-error", + "tor-general-addr", + "tor-guardmgr", + "tor-hsclient", + "tor-hscrypto", + "tor-hsservice", + "tor-key-forge", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-rtmock", + "tor-socksproto", + "tor-units", + ]; let LIBP2P_CRATES: Vec<&str> = vec![ - // Main libp2p crates "libp2p", "libp2p_swarm", "libp2p_core", "libp2p_tcp", "libp2p_noise", "libp2p_tor", - // Specific libp2p module targets that appear in logs "libp2p_core::transport", "libp2p_core::transport::choice", "libp2p_core::transport::dummy", @@ -67,6 +105,7 @@ pub fn init( "libp2p_dcutr", "monero_cpp", ]; + let OUR_CRATES: Vec<&str> = vec![ "swap", "asb", @@ -79,8 +118,6 @@ pub fn init( "monero_rpc_pool", ]; - let INFO_LEVEL_CRATES: Vec<&str> = vec![]; - // General log file for non-verbose logs let file_appender: RollingFileAppender = tracing_appender::rolling::never(&dir, "swap-all.log"); @@ -104,11 +141,10 @@ pub fn init( .with_file(true) .with_line_number(true) .json() - .with_filter(env_filter_with_info_crates( - level_filter, + .with_filter(env_filter_with_all_crates(vec![( OUR_CRATES.clone(), - INFO_LEVEL_CRATES.clone(), - )?); + level_filter, + )])?); // Layer for writing to the verbose log file // Crates: All crates with different levels (libp2p at INFO+, others at TRACE) @@ -121,13 +157,11 @@ pub fn init( .with_file(true) .with_line_number(true) .json() - .with_filter(env_filter_with_all_crates( - LevelFilter::TRACE, - OUR_CRATES.clone(), - LIBP2P_CRATES.clone(), - TOR_CRATES.clone(), - INFO_LEVEL_CRATES.clone(), - )?); + .with_filter(env_filter_with_all_crates(vec![ + (OUR_CRATES.clone(), LevelFilter::TRACE), + (LIBP2P_CRATES.clone(), LevelFilter::TRACE), + (TOR_CRATES.clone(), LevelFilter::TRACE), + ])?); // Layer for writing to the terminal // Crates: swap, asb @@ -152,29 +186,21 @@ pub fn init( .with_file(true) .with_line_number(true) .json() - .with_filter(env_filter_with_all_crates( - level_filter, - OUR_CRATES.clone(), - LIBP2P_CRATES.clone(), - TOR_CRATES.clone(), - INFO_LEVEL_CRATES.clone(), - )?); + .with_filter(env_filter_with_all_crates(vec![ + (OUR_CRATES.clone(), LevelFilter::DEBUG), + (LIBP2P_CRATES.clone(), LevelFilter::INFO), + (TOR_CRATES.clone(), LevelFilter::INFO), + ])?); // If trace_stdout is true, we log all messages to the terminal // Otherwise, we only log the bare minimum let terminal_layer_env_filter = match trace_stdout { - true => env_filter_with_all_crates( - LevelFilter::TRACE, - OUR_CRATES.clone(), - LIBP2P_CRATES.clone(), - TOR_CRATES.clone(), - INFO_LEVEL_CRATES.clone(), - )?, - false => env_filter_with_info_crates( - level_filter, - OUR_CRATES.clone(), - INFO_LEVEL_CRATES.clone(), - )?, + true => env_filter_with_all_crates(vec![ + (OUR_CRATES.clone(), level_filter), + (TOR_CRATES.clone(), level_filter), + (LIBP2P_CRATES.clone(), LevelFilter::INFO), + ])?, + false => env_filter_with_all_crates(vec![(OUR_CRATES.clone(), level_filter)])?, }; let final_terminal_layer = match format { @@ -201,60 +227,18 @@ pub fn init( Ok(()) } -/// This function controls which crate's logs actually get logged and from which level, with info-level crates at INFO level or higher. -fn env_filter_with_info_crates( - level_filter: LevelFilter, - our_crates: Vec<&str>, - info_level_crates: Vec<&str>, -) -> Result { - let mut filter = EnvFilter::from_default_env(); - - // Add directives for each crate in the provided list - for crate_name in our_crates { - filter = filter.add_directive(Directive::from_str(&format!( - "{}={}", - crate_name, &level_filter - ))?); - } - - for crate_name in info_level_crates { - filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?); - } - - Ok(filter) -} - /// This function controls which crate's logs actually get logged and from which level, including all crate categories. -fn env_filter_with_all_crates( - level_filter: LevelFilter, - our_crates: Vec<&str>, - libp2p_crates: Vec<&str>, - tor_crates: Vec<&str>, - info_level_crates: Vec<&str>, -) -> Result { +fn env_filter_with_all_crates(crates: Vec<(Vec<&str>, LevelFilter)>) -> Result { let mut filter = EnvFilter::from_default_env(); - // Add directives for each crate in the provided list - for crate_name in our_crates { - filter = filter.add_directive(Directive::from_str(&format!( - "{}={}", - crate_name, &level_filter - ))?); - } - - for crate_name in libp2p_crates { - filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?); - } - - for crate_name in tor_crates { - filter = filter.add_directive(Directive::from_str(&format!( - "{}={}", - crate_name, &level_filter - ))?); - } - - for crate_name in info_level_crates { - filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?); + // Add directives for each group of crates with their specified level filter + for (crate_names, level_filter) in crates { + for crate_name in crate_names { + filter = filter.add_directive(Directive::from_str(&format!( + "{}={}", + crate_name, &level_filter + ))?); + } } Ok(filter)