feat(gui): Partially available global state (#593)

* feat(gui): Partially availiable global state

* move tauri command into own module

* move commands list into src-tauri/src/commands.rs

* cleanup swap/src/cli/api.rs

* add contextRequirement attribute to PromiseInvokeButton

* amend

* allow wallet operation on partially availiable context

* improvements

* fix some linter errors

* limit amount of logs to 5k

* keep behaviour from before

* make sure if swapId is null useActiveSwapLogs, return no logs

* remove unused variable

* create ContextStatusType enum
This commit is contained in:
Mohan 2025-10-02 21:28:12 +02:00 committed by GitHub
parent 3b701fe1c5
commit 7d019bfb30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2361 additions and 2080 deletions

View file

@ -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)
);
}

View file

@ -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;
}

View file

@ -227,9 +227,8 @@ export async function fetchAllConversations(): Promise<void> {
store.dispatch(setConversation({ feedbackId, messages }));
} catch (error) {
logger.error(
error,
"Error fetching messages for feedback id",
feedbackId,
{ error, feedbackId },
"Error fetching messages for feedback",
);
}
}

View file

@ -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,28 +80,32 @@ export async function setupBackgroundTasks(): Promise<void> {
// 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");
store.dispatch(contextInitializationFailed(String(e)));
});
}, 2000);
});
}
}
// Listen for the unified event
listen<TauriEvent>(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => {
// Listen for the unified event
listen<TauriEvent>(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => {
const { channelName, event: eventData } = event.payload;
switch (channelName) {
@ -105,10 +113,6 @@ export async function setupBackgroundTasks(): Promise<void> {
store.dispatch(swapProgressEventReceived(eventData));
break;
case "ContextInitProgress":
store.dispatch(contextStatusEventReceived(eventData));
break;
case "CliLog":
store.dispatch(receivedCliLog(eventData));
break;
@ -158,5 +162,4 @@ export async function setupBackgroundTasks(): Promise<void> {
default:
exhaustiveGuard(channelName);
}
});
}
});

View file

@ -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() {
<IntroductionModal />
<SeedSelectionDialog />
<PasswordEntryDialog />
<ContextErrorDialog />
<Router>
<Navigation />
<InnerContent />

View file

@ -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<T> {
onSuccess?: (data: T) => void | null;
@ -23,7 +26,10 @@ interface PromiseInvokeButtonProps<T> {
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<T>({
@ -39,13 +45,11 @@ export default function PromiseInvokeButton<T>({
isChipButton = false,
displayErrorSnackbar = false,
onPendingChange = null,
requiresContext = true,
contextRequirement = true,
tooltipTitle = null,
...rest
}: PromiseInvokeButtonProps<T> & ButtonProps) {
const { enqueueSnackbar } = useSnackbar();
const isContextAvailable = useIsContextAvailable();
const [isPending, setIsPending] = useState(false);
const isLoading = isPending || isLoadingOverride;
@ -73,7 +77,23 @@ export default function PromiseInvokeButton<T>({
}
}
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 =

View file

@ -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 <Alert severity="success">The daemon is running</Alert>;
case TauriContextStatusEvent.Failed:
return (
<Alert
severity="error"
action={
<Button
size="small"
variant="outlined"
onClick={() => navigate("/settings#daemon-control-box")}
>
View Logs
</Button>
}
>
The daemon has stopped unexpectedly
</Alert>
);
default:
return exhaustiveGuard(contextStatus);
}
}
export function BackgroundProgressAlerts() {
const backgroundProgress = usePendingBackgroundProcesses();

View file

@ -72,6 +72,7 @@ export default function SwapSuspendAlert({
color="primary"
onSuccess={onClose}
onInvoke={suspendCurrentSwap}
contextRequirement={false}
>
Suspend
</PromiseInvokeButton>

View file

@ -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 (
<Dialog open={true} maxWidth="md" fullWidth disableEscapeKeyDown>
<DialogTitle>Failed to start</DialogTitle>
<DialogContent>
<DialogContentText>
Check the logs below for details. Try restarting the GUI. Reach out to
the developers and the community if this continues.
</DialogContentText>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<Box sx={{ alignSelf: "center" }}>
<ContactInfoBox />
</Box>
<ActionableMonospaceTextBox
content={errorMessage}
displayCopyIcon={true}
enableQrCode={false}
/>
<CliLogsBox label="Logs" logs={logs} minHeight="30vh" />
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => relaunch()}>
Restart GUI
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -227,7 +227,7 @@ export default function FeedbackDialog({
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<PromiseInvokeButton
requiresContext={false}
contextRequirement={false}
color="primary"
variant="contained"
onInvoke={submitFeedback}

View file

@ -9,13 +9,13 @@ import {
Switch,
Typography,
} from "@mui/material";
import { CliLog } from "models/cliModel";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { HashedLog } from "store/features/logsSlice";
interface LogViewerProps {
open: boolean;
setOpen: (_: boolean) => void;
logs: (string | CliLog)[] | null;
logs: HashedLog[];
setIsRedacted: (_: boolean) => void;
isRedacted: boolean;
}

View file

@ -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)),
});
}

View file

@ -116,7 +116,7 @@ export default function PasswordEntryDialog() {
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
requiresContext={false}
contextRequirement={false}
>
Unlock
</PromiseInvokeButton>

View file

@ -364,7 +364,7 @@ export default function SeedSelectionDialog() {
<PromiseInvokeButton
variant="text"
onInvoke={Legacy}
requiresContext={false}
contextRequirement={false}
color="inherit"
>
No wallet (Legacy)
@ -373,7 +373,7 @@ export default function SeedSelectionDialog() {
onInvoke={accept}
variant="contained"
disabled={isDisabled}
requiresContext={false}
contextRequirement={false}
>
Continue
</PromiseInvokeButton>

View file

@ -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 (
<Box sx={{ padding: 2, display: "flex", flexDirection: "column", gap: 2 }}>

View file

@ -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
</PromiseInvokeButton>

View file

@ -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() {
<FundsLeftInWalletAlert />
<UnfinishedSwapsAlert />
<BackgroundRefundAlert />
<DaemonStatusAlert />
<BackgroundProgressAlerts />
<Box
sx={{
display: "flex",
justifyContent: "space-evenly",
}}
>
<Tooltip title="Check out the GitHub repository">
<span>
<LinkIconButton url="https://github.com/eigenwallet/core">
<GitHubIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Matrix room">
<span>
<LinkIconButton url="https://matrix.to/#/#unstoppableswap-space:matrix.org">
<MatrixIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Read our official documentation">
<span>
<LinkIconButton url="https://docs.unstoppableswap.net">
<MenuBook />
</LinkIconButton>
</span>
</Tooltip>
</Box>
<ContactInfoBox />
</Box>
);
}

View file

@ -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 (
<Box
sx={{
display: "flex",
justifyContent: "space-evenly",
}}
>
<Tooltip title="Check out the GitHub repository">
<span>
<LinkIconButton url="https://github.com/eigenwallet/core">
<GitHubIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Matrix room">
<span>
<LinkIconButton url="https://eigenwallet.org/matrix">
<MatrixIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Discord server">
<span>
<LinkIconButton url="https://eigenwallet.org/discord">
<DiscordIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Read our official documentation">
<span>
<LinkIconButton url="https://docs.unstoppableswap.net">
<MenuBook />
</LinkIconButton>
</span>
</Tooltip>
</Box>
);
}

View file

@ -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<string>("");
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" ? (
<Typography key={hash} component="pre">
{log}
</Typography>
) : (
<RenderedCliLog log={log} key={hash} />
),
);
}, [filteredLogs]);
const rawStrings = useMemo(
() => filteredLogs.map(({ log }) => log),
[filteredLogs],
);
return (
<ScrollablePaperTextBox
minHeight={minHeight}
title={label}
copyValue={logsToRawString(logs)}
copyValue={logsToRawString(rawStrings)}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
topRightButton={topRightButton}
autoScroll={autoScroll}
rows={memoizedLogs.map((log) =>
typeof log === "string" ? (
<Typography key={log} component="pre">
{log}
</Typography>
) : (
<RenderedCliLog log={log} key={JSON.stringify(log)} />
),
)}
rows={rows}
/>
);
}

View file

@ -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",
});

View file

@ -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 (
<InfoBox
@ -36,22 +32,11 @@ export default function DaemonControlBox() {
}
additionalContent={
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<PromiseInvokeButton
variant="contained"
endIcon={<PlayArrowIcon />}
onInvoke={initializeContext}
requiresContext={false}
disabled={!canContextBeManuallyStarted}
isLoadingOverride={isContextInitializing}
displayErrorSnackbar
>
Start Daemon
</PromiseInvokeButton>
<PromiseInvokeButton
variant="contained"
endIcon={<RotateLeftIcon />}
onInvoke={relaunch}
requiresContext={false}
contextRequirement={false}
displayErrorSnackbar
>
Restart GUI
@ -59,7 +44,7 @@ export default function DaemonControlBox() {
<PromiseInvokeButton
endIcon={<FolderOpenIcon />}
isIconButton
requiresContext={false}
contextRequirement={false}
size="small"
tooltipTitle="Open the data directory in your file explorer"
onInvoke={async () => {

View file

@ -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
</PromiseInvokeButton>

View file

@ -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),

View file

@ -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<HashedLog[] | null>(null);
function onLogsReceived(response: GetLogsResponse) {
setLogs(response.logs.map(parseCliLogString));
const parsedLogs = response.logs.map(parseCliLogString);
setLogs(hashLogs(parsedLogs));
}
return (

View file

@ -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",

View file

@ -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
</PromiseInvokeButton>

View file

@ -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
</PromiseInvokeButton>

View file

@ -130,7 +130,7 @@ export default function SendApprovalContent({
color="error"
startIcon={<CloseIcon />}
displayErrorSnackbar={true}
requiresContext={false}
contextRequirement={false}
>
Reject
</PromiseInvokeButton>
@ -141,7 +141,7 @@ export default function SendApprovalContent({
color="primary"
startIcon={<CheckIcon />}
displayErrorSnackbar={true}
requiresContext={false}
contextRequirement={false}
>
Send
</PromiseInvokeButton>

View file

@ -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
</PromiseInvokeButton>

View file

@ -145,7 +145,6 @@ export default function SwapSetupInflightPage({
resolveApproval(request.request_id, false as unknown as object)
}
displayErrorSnackbar
requiresContext
>
Deny
</PromiseInvokeButton>
@ -158,7 +157,6 @@ export default function SwapSetupInflightPage({
resolveApproval(request.request_id, true as unknown as object)
}
displayErrorSnackbar
requiresContext
endIcon={<CheckIcon />}
>
{`Confirm`}

View file

@ -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}
/>
);
}

View file

@ -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<boolean> {
const available = await invokeNoArgs<boolean>("is_context_available");
return available;
export async function checkContextStatus(): Promise<ContextStatus> {
return await invokeNoArgs<ContextStatus>("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<void>("initialize_context", {
settings: tauriSettings,
testnet,
});
} catch (error) {
throw new Error("Couldn't initialize context: " + error);
}
logger.info("Initialized context");
} catch (error) {
throw new Error(error);
}
}
export async function getWalletDescriptor() {

View file

@ -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<TauriLogEvent>) {
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;

View file

@ -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<TauriContextStatusEvent>,
) {
slice.status = action.payload;
contextStatusEventReceived(slice, action: PayloadAction<ContextStatus>) {
// 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<string>) {
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,

View file

@ -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<AppDispatch>();
@ -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() {

View file

@ -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:");
}
},
});

13
src-gui/src/utils/hash.ts Normal file
View file

@ -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");
}

View file

@ -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"
]
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["cli:default", "cli:allow-cli-matches"]
}

View file

@ -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"
}
}

View file

@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"version": 1,
"author": "xcode"
}
}

452
src-tauri/src/commands.rs Normal file
View file

@ -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<T, E> to Result<T, String>
/// Tauri commands require the error type to be a string
pub(crate) trait ToStringResult<T> {
fn to_string_result(self) -> Result<T, String>;
}
impl<T, E: ToString> ToStringResult<T> for Result<T, E> {
fn to_string_result(self) -> Result<T, String> {
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<BalanceArgs::Response, String> {
/// 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::Response, String> {
/// 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<ContextStatus, String> {
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<RejectApprovalResponse, String> {
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<GetPendingApprovalsResponse, String> {
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<CheckMoneroNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
pub async fn check_electrum_node(
args: CheckElectrumNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckElectrumNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
pub async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, State>,
) -> Result<CheckSeedResponse, String> {
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<String, String> {
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<String, String>,
) -> 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<PathBuf>
.ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result<PathBuf, String> 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<DfxAuthenticateResponse, String> {
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<String>)>(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);

View file

@ -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<T, E> to Result<T, String>
/// Tauri commands require the error type to be a string
trait ToStringResult<T> {
fn to_string_result(self) -> Result<T, String>;
}
mod commands;
impl<T, E: ToString> ToStringResult<T> for Result<T, E> {
fn to_string_result(self) -> Result<T, String> {
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<BalanceArgs::Response, String> {
/// 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::Response, String> {
/// 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<Option<Arc<Context>>>,
pub context: Arc<Context>,
/// 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<Arc<Context>, 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<Context> {
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::<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<bool, String> {
// 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<CheckMoneroNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
async fn check_electrum_node(
args: CheckElectrumNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckElectrumNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, State>,
) -> Result<CheckSeedResponse, String> {
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<String, String> {
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<String, String>,
) -> 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<PathBuf>
.ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result<PathBuf, String> 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<RejectApprovalResponse, String> {
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<GetPendingApprovalsResponse, String> {
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<DfxAuthenticateResponse, String> {
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<String>)>(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,
})
}

View file

@ -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,22 +32,58 @@ use super::watcher::Watcher;
static START: Once = Once::new();
#[derive(Clone, PartialEq, Debug)]
pub struct Config {
namespace: XmrBtcNamespace,
mod config {
use super::*;
#[derive(Clone, PartialEq, Debug)]
pub struct Config {
pub(super) namespace: XmrBtcNamespace,
pub env_config: EnvConfig,
seed: Option<Seed>,
debug: bool,
json: bool,
log_dir: PathBuf,
data_dir: PathBuf,
is_testnet: bool,
pub(super) seed: Option<Seed>,
pub(super) debug: bool,
pub(super) json: bool,
pub(super) log_dir: PathBuf,
pub(super) data_dir: PathBuf,
pub(super) is_testnet: bool,
}
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
Self {
namespace: XmrBtcNamespace::from_is_testnet(false),
env_config,
seed: seed.into(),
debug: false,
json: false,
is_testnet: false,
data_dir,
log_dir,
}
}
}
pub(super) fn env_config_from(testnet: bool) -> EnvConfig {
if testnet {
Testnet::get_config()
} else {
Mainnet::get_config()
}
}
}
#[derive(Default)]
pub struct PendingTaskList(TokioMutex<Vec<JoinHandle<()>>>);
pub use config::Config;
impl PendingTaskList {
mod swap_lock {
use super::*;
#[derive(Default)]
pub struct PendingTaskList(TokioMutex<Vec<JoinHandle<()>>>);
impl PendingTaskList {
pub async fn spawn<F, T>(&self, future: F)
where
F: Future<Output = T> + Send + 'static,
@ -73,21 +107,21 @@ impl PendingTaskList {
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 {
/// 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<Option<Uuid>>,
suspension_trigger: Sender<()>,
}
}
impl SwapLock {
impl SwapLock {
pub fn new() -> Self {
let (suspension_trigger, _) = broadcast::channel(10);
SwapLock {
@ -167,37 +201,218 @@ impl SwapLock {
bail!("There is no current swap lock to release");
}
}
}
}
impl Default for SwapLock {
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<dyn Database + Send + Sync>,
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<RwLock> to allow independent initialization and cloning.
#[derive(Clone)]
pub struct Context {
pub db: Arc<RwLock<Option<Arc<dyn Database + Send + Sync>>>>,
pub swap_lock: Arc<SwapLock>,
pub config: Config,
pub config: Arc<RwLock<Option<Config>>>,
pub tasks: Arc<PendingTaskList>,
tauri_handle: Option<TauriHandle>,
bitcoin_wallet: Option<Arc<bitcoin::Wallet>>,
pub monero_manager: Option<Arc<monero::Wallets>>,
tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
pub tauri_handle: Option<TauriHandle>,
pub(super) bitcoin_wallet: Arc<RwLock<Option<Arc<bitcoin::Wallet>>>>,
pub monero_manager: Arc<RwLock<Option<Arc<monero::Wallets>>>>,
pub(super) tor_client: Arc<RwLock<Option<Arc<TorClient<TokioRustlsRuntime>>>>>,
#[allow(dead_code)]
monero_rpc_pool_handle: Option<Arc<monero_rpc_pool::PoolHandle>>,
pub(super) monero_rpc_pool_handle: Arc<RwLock<Option<Arc<monero_rpc_pool::PoolHandle>>>>,
}
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<TauriHandle>) -> 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<Arc<bitcoin::Wallet>> {
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<Arc<monero::Wallets>> {
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<Arc<dyn Database + Send + Sync>> {
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<Config> {
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<Arc<TorClient<TokioRustlsRuntime>>> {
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<bitcoin::Wallet>,
bob_monero_wallet: Arc<monero::Wallets>,
) -> 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<Arc<bitcoin::Wallet>> {
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
.clone()
.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, "")
}
}
}
/// A conveniant builder struct for [`Context`].
#[must_use = "ContextBuilder must be built to be useful"]
pub struct ContextBuilder {
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<MoneroNodeConfig>,
bitcoin: Option<Bitcoin>,
data: Option<PathBuf>,
@ -207,9 +422,9 @@ pub struct ContextBuilder {
tor: bool,
enable_monero_tor: bool,
tauri_handle: Option<TauriHandle>,
}
}
impl ContextBuilder {
impl ContextBuilder {
/// Start building a context
pub fn new(is_testnet: bool) -> Self {
if is_testnet {
@ -289,14 +504,14 @@ impl ContextBuilder {
self
}
/// Takes the builder, initializes the context by initializing the wallets and other components and returns the Context.
pub async fn build(self) -> Result<Context> {
// This is the data directory for the eigenwallet (wallet files)
/// 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<Context>) -> 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 = env_config_from(self.is_testnet);
let env_config = config::env_config_from(self.is_testnet);
// Initialize logging
let format = if self.json { Format::Json } else { Format::Raw };
@ -323,19 +538,18 @@ impl ContextBuilder {
);
});
// Prepare parallel initialization tasks
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?)
Some(wallet::request_seed_choice(tauri_handle, &wallet_database).await?)
}
None => None,
};
@ -344,7 +558,6 @@ impl ContextBuilder {
}
};
// Create unbootstrapped Tor client early if enabled
let future_unbootstrapped_tor_client_rpc_pool = {
let tauri_handle = self.tauri_handle.clone();
async move {
@ -360,7 +573,7 @@ impl ContextBuilder {
None
};
// Start the rpc pool for the monero wallet with optional Tor client based on enable_monero_tor setting
// 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(
@ -378,6 +591,7 @@ impl ContextBuilder {
)
.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();
@ -418,7 +632,9 @@ impl ContextBuilder {
future_unbootstrapped_tor_client_rpc_pool
)?;
// Listen for pool status updates and forward them to frontend
*context.tor_client.write().await = unbootstrapped_tor_client.clone();
// 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 {
@ -426,7 +642,7 @@ impl ContextBuilder {
}
});
// Determine the monero node address to use
// 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();
@ -434,17 +650,17 @@ impl ContextBuilder {
}
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)))
}
};
// Create a daemon struct for the monero wallet based on the node address
*context.monero_rpc_pool_handle.write().await = monero_rpc_pool_handle.clone();
let daemon = monero_sys::Daemon::try_from(monero_node_address)?;
// Prompt the user to open/create a Monero wallet
let (wallet, seed) = open_monero_wallet(
// Open or create Monero wallet
let (wallet, seed) = wallet::open_monero_wallet(
self.tauri_handle.clone(),
eigenwallet_data_dir,
base_data_dir,
@ -457,12 +673,11 @@ impl ContextBuilder {
let primary_address = wallet.main_address().await;
// Derive data directory from primary address
// Derive wallet-specific data directory
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")?;
@ -474,8 +689,9 @@ impl ContextBuilder {
let wallet_database = Some(Arc::new(wallet_database));
// Create the monero wallet manager
let monero_manager = Some(Arc::new(
// Initialize Monero wallet manager
async {
let manager = Arc::new(
monero::Wallets::new_with_existing_wallet(
eigenwallet_data_dir.to_path_buf(),
daemon.clone(),
@ -487,13 +703,28 @@ impl ContextBuilder {
)
.await
.context("Failed to initialize Monero wallets with existing wallet")?,
));
);
// Create the data structure we use to manage the swap lock
let swap_lock = Arc::new(SwapLock::new());
let tasks = PendingTaskList::default().into();
*context.monero_manager.write().await = Some(manager);
// Initialize the database
Ok::<_, Error>(())
}
.await?;
// Initialize config
*context.config.write().await = Some(Config {
namespace: XmrBtcNamespace::from_is_testnet(self.is_testnet),
env_config,
seed: seed.clone().into(),
debug: self.debug,
json: self.json,
is_testnet: self.is_testnet,
data_dir: data_dir.clone(),
log_dir: log_dir.clone(),
});
// Initialize swap database
let db = async {
let database_progress_handle = self
.tauri_handle
.new_background_process_with_initial_progress(
@ -510,10 +741,17 @@ impl ContextBuilder {
database_progress_handle.finish();
*context.db.write().await = Some(db.clone());
Ok::<_, Error>(db)
}
.await?;
let tauri_handle = &self.tauri_handle.clone();
let initialize_bitcoin_wallet = async {
match self.bitcoin {
// Initialize Bitcoin wallet
let bitcoin_wallet = async {
let wallet = match self.bitcoin {
Some(bitcoin) => {
let (urls, target_block) = bitcoin.apply_defaults(self.is_testnet)?;
@ -523,7 +761,7 @@ impl ContextBuilder {
(),
);
let wallet = init_bitcoin_wallet(
let wallet = wallet::init_bitcoin_wallet(
urls,
&seed,
&data_dir,
@ -535,15 +773,16 @@ impl ContextBuilder {
bitcoin_progress_handle.finish();
Ok::<std::option::Option<Arc<bitcoin::wallet::Wallet>>, Error>(Some(Arc::new(
wallet,
)))
}
None => Ok(None),
Some(Arc::new(wallet))
}
None => None,
};
let bitcoin_wallet = initialize_bitcoin_wallet.await?;
*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() {
@ -552,151 +791,33 @@ impl ContextBuilder {
wallet,
db.clone(),
self.tauri_handle.clone(),
swap_lock.clone(),
context.swap_lock.clone(),
);
tokio::spawn(watcher.run());
}
}
// Wait for Tor client to fully bootstrap
bootstrap_tor_client_task.await?;
let context = Context {
db,
bitcoin_wallet,
monero_manager,
config: Config {
namespace: XmrBtcNamespace::from_is_testnet(self.is_testnet),
env_config,
seed: seed.clone().into(),
debug: self.debug,
json: self.json,
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<Option<TauriHandle>>) -> 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<bitcoin::Wallet>,
bob_monero_wallet: Arc<monero::Wallets>,
) -> 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<Arc<bitcoin::Wallet>> {
self.bitcoin_wallet.clone()
}
pub fn tauri_handle(&self) -> Option<TauriHandle> {
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, "")
}
}
pub use builder::ContextBuilder;
async fn init_bitcoin_wallet(
mod wallet {
use super::*;
pub(super) async fn init_bitcoin_wallet(
electrum_rpc_urls: Vec<String>,
seed: &Seed,
data_dir: &Path,
env_config: EnvConfig,
bitcoin_target_block: u16,
tauri_handle_option: Option<TauriHandle>,
) -> Result<bitcoin::Wallet<bdk_wallet::rusqlite::Connection, bitcoin::wallet::Client>> {
) -> Result<bitcoin::Wallet<bdk_wallet::rusqlite::Connection, bitcoin::wallet::Client>> {
let mut builder = bitcoin::wallet::WalletBuilder::default()
.seed(seed.clone())
.network(env_config.bitcoin_network)
@ -718,13 +839,13 @@ async fn init_bitcoin_wallet(
.context("Failed to initialize Bitcoin wallet")?;
Ok(wallet)
}
}
async fn request_and_open_monero_wallet_legacy(
pub(super) async fn request_and_open_monero_wallet_legacy(
data_dir: &PathBuf,
env_config: EnvConfig,
daemon: &monero_sys::Daemon,
) -> Result<monero_sys::WalletHandle, Error> {
) -> Result<monero_sys::WalletHandle, Error> {
let wallet_path = data_dir.join("swap-tool-blockchain-monitoring-wallet");
let wallet = monero::Wallet::open_or_create(
@ -737,33 +858,34 @@ async fn request_and_open_monero_wallet_legacy(
.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(
/// 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<SeedChoice> {
) -> Result<SeedChoice> {
let recent_wallets = database.get_recent_wallets(5).await?;
let recent_wallets: Vec<String> = recent_wallets.into_iter().map(|w| w.wallet_path).collect();
let recent_wallets: Vec<String> =
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(
/// 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<TauriHandle>,
eigenwallet_data_dir: &PathBuf,
legacy_data_dir: &PathBuf,
@ -771,7 +893,7 @@ async fn open_monero_wallet(
daemon: &monero_sys::Daemon,
seed_choice: Option<SeedChoice>,
database: &monero_sys::Database,
) -> Result<(monero_sys::WalletHandle, Seed), Error> {
) -> Result<(monero_sys::WalletHandle, Seed), Error> {
let eigenwallet_wallets_dir = eigenwallet_data_dir.join("wallets");
let wallet = match seed_choice {
@ -793,7 +915,8 @@ async fn open_monero_wallet(
.unwrap()
.as_secs();
let wallet_path = eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp));
let wallet_path =
eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp));
if let Some(parent) = wallet_path.parent() {
swap_fs::ensure_directory_exists(parent)
@ -843,7 +966,9 @@ async fn open_monero_wallet(
wallet_path.clone(),
password,
)
.map_err(|e| anyhow::anyhow!("Failed to verify wallet password: {}", e))
.map_err(|e| {
anyhow::anyhow!("Failed to verify wallet password: {}", e)
})
};
// Request and verify password before opening wallet
@ -897,8 +1022,10 @@ async fn open_monero_wallet(
// 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)
seed_choice = request_seed_choice(
tauri_handle.clone().unwrap(),
database,
)
.await?;
continue;
}
@ -948,7 +1075,8 @@ async fn open_monero_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?;
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")?;
@ -958,6 +1086,7 @@ async fn open_monero_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<Monero> 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),

View file

@ -421,7 +421,8 @@ impl Request for GetLogsArgs {
type Response = GetLogsResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<SuspendCurren
#[tracing::instrument(fields(method = "get_swap_infos_all"), skip(context))]
pub async fn get_swap_infos_all(context: Arc<Context>) -> Result<Vec<GetSwapInfoResponse>> {
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<Context>,
) -> Result<GetSwapInfoResponse> {
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<dyn Future<Output = Result<bool>> + 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<ResumeSwapResponse> {
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<Context>,
) -> Result<serde_json::Value> {
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<Context>) -> Result<GetHistoryResponse> {
let swaps = context.db.all().await?;
let db = context.try_get_db().await?;
let swaps = db.all().await?;
let mut vec: Vec<GetHistoryEntry> = 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<Context>) -> Result<GetHistoryResponse> {
#[tracing::instrument(fields(method = "get_config"), skip(context))]
pub async fn get_config(context: Arc<Context>) -> Result<serde_json::Value> {
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<Context>,
) -> Result<WithdrawBtcResponse> {
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<Context>) -> Result<BalanceResponse> {
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<Context>) -> Result<serde_json::Value> {
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<Context>,
) -> Result<serde_json::Value> {
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<Context>) -> Result<Self::Response> {
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<Context>) -> Result<Self::Response> {
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?;

View file

@ -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));
}

View file

@ -79,7 +79,7 @@ where
.transpose()?
.map(|address| address.into_unchecked());
let context = Arc::new(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
@ -87,9 +87,8 @@ where
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.build(context.clone())
.await?;
BuyXmrArgs {
rendezvous_points: vec![],
@ -103,14 +102,13 @@ where
Ok(context)
}
CliCommand::History => {
let context = Arc::new(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.build(context.clone())
.await?;
GetHistoryArgs {}.request(context.clone()).await?;
@ -121,14 +119,13 @@ where
redact,
swap_id,
} => {
let context = Arc::new(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.build(context.clone())
.await?;
GetLogsArgs {
logs_dir,
@ -141,29 +138,27 @@ where
Ok(context)
}
CliCommand::Config => {
let context = Arc::new(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.build(context.clone())
.await?;
GetConfigArgs {}.request(context.clone()).await?;
Ok(context)
}
CliCommand::Balance { bitcoin } => {
let context = Arc::new(
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()
.await?,
);
.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(
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()
.await?,
);
.build(context.clone())
.await?;
WithdrawBtcArgs { amount, address }
.request(context.clone())
@ -202,7 +196,7 @@ where
monero,
tor,
} => {
let context = Arc::new(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
@ -210,9 +204,8 @@ where
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.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(
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()
.await?,
);
.build(context.clone())
.await?;
CancelAndRefundArgs { swap_id }
.request(context.clone())
@ -242,15 +234,14 @@ where
rendezvous_point,
tor,
} => {
let context = Arc::new(
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()
.await?,
);
.build(context.clone())
.await?;
ListSellersArgs {
rendezvous_points: vec![rendezvous_point],
@ -261,15 +252,14 @@ where
Ok(context)
}
CliCommand::ExportBitcoinWallet { bitcoin } => {
let context = Arc::new(
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()
.await?,
);
.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(
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
.build(context.clone())
.await?;
MoneroRecoveryArgs { swap_id }
.request(context.clone())

View file

@ -33,17 +33,55 @@ pub fn init(
tauri_handle: Option<TauriHandle>,
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<EnvFilter> {
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<EnvFilter> {
fn env_filter_with_all_crates(crates: Vec<(Vec<&str>, LevelFilter)>) -> Result<EnvFilter> {
let mut filter = EnvFilter::from_default_env();
// Add directives for each crate in the provided list
for crate_name in our_crates {
// 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
))?);
}
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))?);
}
Ok(filter)