From 8f33fe5b4123dafa52ef4e62ec8ee6431f332e0d Mon Sep 17 00:00:00 2001 From: binarybaron <86064887+binarybaron@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:06:57 +0600 Subject: [PATCH] feat(tauri, gui): Send event on changes to details, timelocks and tx_lock confirmations (#100) - Send event when new swap state is inserated into database. The event only has the `swap_id` attached. The frontend then sends a request to the `get_swap_info` command to retrieve the updated version - Send event when the Bitcoin lock transaction gets a new confirmation - A new `watcher` daemon runs contineously and sends an event when the timelock updated. The event has the the `swap_id` and the timelock attached - Display logs on `ProcessExitedPage` (if swap was stopped prematurely) - Rename `CliLogEmittedEvent` to `TauriLogEvent` - Apply env_filter to tracing terminal writer to silence logging from other crates - Add `.env.*` files in `src-gui` to `.gitingore` Closes #93 and #12 --- src-gui/.gitignore | 3 + src-gui/src/models/cliModel.ts | 19 ++++ .../modal/swap/SwapStateStepper.tsx | 6 +- .../components/modal/swap/pages/DebugPage.tsx | 13 ++- .../modal/swap/pages/SwapStatePage.tsx | 12 +- .../exited/ProcessExitedAndNotDonePage.tsx | 71 ------------ .../swap/pages/exited/ProcessExitedPage.tsx | 28 +++-- .../components/other/RenderedCliLog.tsx | 4 +- .../other/ScrollablePaperTextBox.tsx | 2 +- .../src/renderer/components/other/Units.tsx | 14 +-- src-gui/src/renderer/rpc.ts | 38 ++++++- src-gui/src/store/features/rpcSlice.ts | 18 ++- src-gui/src/store/features/swapSlice.ts | 4 +- src-gui/src/store/hooks.ts | 19 +++- src-tauri/src/lib.rs | 8 +- swap/src/bin/asb.rs | 14 +-- swap/src/bitcoin/wallet.rs | 2 +- swap/src/cli.rs | 1 + swap/src/cli/api.rs | 46 ++++---- swap/src/cli/api/request.rs | 70 +++++------- swap/src/cli/api/tauri_bindings.rs | 39 ++++++- swap/src/cli/watcher.rs | 105 ++++++++++++++++++ swap/src/common/mod.rs | 1 - swap/src/common/tracing_util.rs | 8 +- swap/src/database.rs | 14 ++- swap/src/database/sqlite.rs | 21 +++- swap/src/protocol/bob/state.rs | 32 +++++- swap/src/protocol/bob/swap.rs | 25 ++++- 28 files changed, 429 insertions(+), 208 deletions(-) delete mode 100644 src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedAndNotDonePage.tsx create mode 100644 swap/src/cli/watcher.rs diff --git a/src-gui/.gitignore b/src-gui/.gitignore index 12df96313..a1ee1b0c0 100644 --- a/src-gui/.gitignore +++ b/src-gui/.gitignore @@ -26,3 +26,6 @@ dist-ssr # Autogenerated bindings src/models/tauriModel.ts + +# Env files +.env.* diff --git a/src-gui/src/models/cliModel.ts b/src-gui/src/models/cliModel.ts index aff0c66cc..d0c3f88d3 100644 --- a/src-gui/src/models/cliModel.ts +++ b/src-gui/src/models/cliModel.ts @@ -29,6 +29,25 @@ function isCliLog(log: unknown): log is CliLog { ); } +export function isCliLogRelatedToSwap( + log: CliLog | string, + swapId: string, +): boolean { + // If we only have a string, simply check if the string contains the swap id + // This provides reasonable backwards compatability + if (typeof log === "string") { + return log.includes(swapId); + } + + // If we have a parsed object as the log, check if + // - 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 || + (log.spans?.some((span) => span["swap_id"] === swapId) ?? false) + ); +} + export function parseCliLogString(log: string): CliLog | string { try { const parsed = JSON.parse(log); diff --git a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx index ac77d51e4..2424a6ab2 100644 --- a/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx +++ b/src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx @@ -61,7 +61,11 @@ function getActiveStep(state: SwapState | null): PathStep { // Step 1: Waiting for Bitcoin lock confirmation // Bitcoin has been locked, waiting for the counterparty to lock their XMR case "BtcLockTxInMempool": - return [PathType.HAPPY_PATH, 1, isReleased]; + // We only display the first step as completed if the Bitcoin lock has been confirmed + if(latestState.content.btc_lock_confirmations > 0) { + return [PathType.HAPPY_PATH, 1, isReleased]; + } + return [PathType.HAPPY_PATH, 0, isReleased]; // Still Step 1: Both Bitcoin and XMR have been locked, waiting for Monero lock to be confirmed case "XmrLockTxInMempool": diff --git a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx index 00d068e2c..49bbdbe15 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/DebugPage.tsx @@ -1,11 +1,15 @@ import { Box, DialogContentText } from "@material-ui/core"; -import { useActiveSwapInfo, useAppSelector } from "store/hooks"; +import { + useActiveSwapInfo, + useActiveSwapLogs, + useAppSelector, +} from "store/hooks"; import JsonTreeView from "../../../other/JSONViewTree"; import CliLogsBox from "../../../other/RenderedCliLog"; export default function DebugPage() { const torStdOut = useAppSelector((s) => s.tor.stdOut); - const logs = useAppSelector((s) => s.swap.logs); + const logs = useActiveSwapLogs(); const guiState = useAppSelector((s) => s); const cliState = useActiveSwapInfo(); @@ -19,7 +23,10 @@ export default function DebugPage() { gap: "8px", }} > - + state.rpc.state.moneroWallet.isSyncing, - ); - - if (isSyncingMoneroWallet) { - return ; - } - */ - if (state === null) { return ; } + switch (state.curr.type) { case "Initiated": return ; diff --git a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedAndNotDonePage.tsx b/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedAndNotDonePage.tsx deleted file mode 100644 index 62423d348..000000000 --- a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedAndNotDonePage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Box, DialogContentText } from "@material-ui/core"; -import { SwapSpawnType } from "models/cliModel"; -import { SwapStateProcessExited } from "models/storeModel"; -import { useActiveSwapInfo, useAppSelector } from "store/hooks"; -import CliLogsBox from "../../../../other/RenderedCliLog"; - -export default function ProcessExitedAndNotDonePage({ - state, -}: { - state: SwapStateProcessExited; -}) { - const swap = useActiveSwapInfo(); - const logs = useAppSelector((s) => s.swap.logs); - const spawnType = useAppSelector((s) => s.swap.spawnType); - - function getText() { - const isCancelRefund = spawnType === SwapSpawnType.CANCEL_REFUND; - const hasRpcError = state.rpcError != null; - const hasSwap = swap != null; - - const messages = []; - - messages.push( - isCancelRefund - ? "The manual cancel and refund was unsuccessful." - : "The swap exited unexpectedly without completing.", - ); - - if (!hasSwap && !isCancelRefund) { - messages.push("No funds were locked."); - } - - messages.push( - hasRpcError - ? "Check the error and the logs below for more information." - : "Check the logs below for more information.", - ); - - if (hasSwap) { - messages.push(`The swap is in the "${swap.state_name}" state.`); - if (!isCancelRefund) { - messages.push( - "Try resuming the swap or attempt to initiate a manual cancel and refund.", - ); - } - } - - return messages.join(" "); - } - - return ( - - {getText()} - - {state.rpcError && ( - - )} - - - - ); -} diff --git a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx index ca37986e1..8d7d052bc 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/exited/ProcessExitedPage.tsx @@ -1,4 +1,7 @@ +import { Box, DialogContentText } from "@material-ui/core"; import { TauriSwapProgressEvent } from "models/tauriModel"; +import CliLogsBox from "renderer/components/other/RenderedCliLog"; +import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks"; import SwapStatePage from "../SwapStatePage"; export default function ProcessExitedPage({ @@ -8,8 +11,11 @@ export default function ProcessExitedPage({ prevState: TauriSwapProgressEvent | null; swapId: string; }) { + const swap = useActiveSwapInfo(); + const logs = useActiveSwapLogs(); + // If we have a previous state, we can show the user the last state of the swap - // We only show the last state if its a final state (XmrRedeemInMempool, BtcRefunded, BtcPunished) + // We only show the last state if its a final state (XmrRedeemInMempool, BtcRefunded, BtcPunished, CooperativeRedeemRejected) if ( prevState != null && (prevState.type === "XmrRedeemInMempool" || @@ -28,15 +34,17 @@ export default function ProcessExitedPage({ ); } - // TODO: Display something useful here return ( - <> - If the swap is not a "done" state (or we don't have a db state because the - swap did complete the SwapSetup yet) we should tell the user and show logs - Not implemented yet - + + + The swap was stopped but it has not been completed yet. Check the logs + below for more information. The current GUI state is{" "} + {prevState?.type ?? "unknown"}. The current database state is{" "} + {swap?.state_name ?? "unknown"}. + + + + + ); - - // If the swap is not a "done" state (or we don't have a db state because the swap did complete the SwapSetup yet) we should tell the user and show logs - // return ; } diff --git a/src-gui/src/renderer/components/other/RenderedCliLog.tsx b/src-gui/src/renderer/components/other/RenderedCliLog.tsx index 50e4bf9f5..52517396a 100644 --- a/src-gui/src/renderer/components/other/RenderedCliLog.tsx +++ b/src-gui/src/renderer/components/other/RenderedCliLog.tsx @@ -81,7 +81,9 @@ export default function CliLogsBox({ setSearchQuery={setSearchQuery} rows={memoizedLogs.map((log) => typeof log === "string" ? ( - {log} + + {log} + ) : ( ), diff --git a/src-gui/src/renderer/components/other/ScrollablePaperTextBox.tsx b/src-gui/src/renderer/components/other/ScrollablePaperTextBox.tsx index b828b7c81..ae1ddd3c8 100644 --- a/src-gui/src/renderer/components/other/ScrollablePaperTextBox.tsx +++ b/src-gui/src/renderer/components/other/ScrollablePaperTextBox.tsx @@ -21,7 +21,7 @@ export default function ScrollablePaperTextBox({ copyValue: string; searchQuery: string | null; setSearchQuery?: ((query: string) => void) | null; - minHeight: string; + minHeight?: string; }) { const virtuaEl = useRef(null); diff --git a/src-gui/src/renderer/components/other/Units.tsx b/src-gui/src/renderer/components/other/Units.tsx index b14b554b9..41c787135 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -15,15 +15,13 @@ export function AmountWithUnit({ fixedPrecision: number; dollarRate?: Amount; }) { + const title = + dollarRate != null && amount != null + ? `≈ $${(dollarRate * amount).toFixed(2)}` + : ""; + return ( - + {amount != null ? Number.parseFloat(amount.toFixed(fixedPrecision)) diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index ad484fc67..ae1a3264a 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -5,7 +5,7 @@ import { BalanceResponse, BuyXmrArgs, BuyXmrResponse, - CliLogEmittedEvent, + TauriLogEvent, GetLogsArgs, GetLogsResponse, GetSwapInfoResponse, @@ -18,14 +18,18 @@ import { TauriSwapProgressEventWrapper, WithdrawBtcArgs, WithdrawBtcResponse, + TauriDatabaseStateEvent, + TauriTimelockChangeEvent, + GetSwapInfoArgs, } from "models/tauriModel"; import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, rpcSetSwapInfo, + timelockChangeEventReceived, } from "store/features/rpcSlice"; -import { swapTauriEventReceived } from "store/features/swapSlice"; +import { swapProgressEventReceived } from "store/features/swapSlice"; import { store } from "./store/storeRenderer"; import { Provider } from "models/apiModel"; import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils"; @@ -49,7 +53,7 @@ export async function initEventListeners() { listen("swap-progress-update", (event) => { console.log("Received swap progress event", event.payload); - store.dispatch(swapTauriEventReceived(event.payload)); + store.dispatch(swapProgressEventReceived(event.payload)); }); listen("context-init-progress-update", (event) => { @@ -57,10 +61,25 @@ export async function initEventListeners() { store.dispatch(contextStatusEventReceived(event.payload)); }); - listen("cli-log-emitted", (event) => { + listen("cli-log-emitted", (event) => { console.log("Received cli log event", event.payload); store.dispatch(receivedCliLog(event.payload)); }); + + listen("swap-database-state-update", (event) => { + console.log("Received swap database state update event", event.payload); + getSwapInfo(event.payload.swap_id); + + // This is ugly but it's the best we can do for now + // Sometimes we are too quick to fetch the swap info and the new state is not yet reflected + // in the database. So we wait a bit before fetching the new state + setTimeout(() => getSwapInfo(event.payload.swap_id), 3000); + }); + + listen('timelock-change', (event) => { + console.log('Received timelock change event', event.payload); + store.dispatch(timelockChangeEventReceived(event.payload)); + }) } async function invoke( @@ -93,6 +112,17 @@ export async function getAllSwapInfos() { }); } +export async function getSwapInfo(swapId: string) { + const response = await invoke( + "get_swap_info", + { + swap_id: swapId, + }, + ); + + store.dispatch(rpcSetSwapInfo(response)); +} + export async function withdrawBtc(address: string): Promise { const response = await invoke( "withdraw_btc", diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index 085545a0d..b5d3fd503 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -1,14 +1,17 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel"; import { - CliLogEmittedEvent, + TauriLogEvent, GetSwapInfoResponse, TauriContextStatusEvent, + TauriDatabaseStateEvent, + TauriTimelockChangeEvent, } from "models/tauriModel"; import { MoneroRecoveryResponse } from "../../models/rpcModel"; import { GetSwapInfoResponseExt } from "models/tauriModelExt"; import { getLogsAndStringsFromRawFileString } from "utils/parseUtils"; import { CliLog } from "models/cliModel"; +import logger from "utils/logger"; interface State { balance: number | null; @@ -52,7 +55,7 @@ export const rpcSlice = createSlice({ name: "rpc", initialState, reducers: { - receivedCliLog(slice, action: PayloadAction) { + receivedCliLog(slice, action: PayloadAction) { const buffer = action.payload.buffer; const logs = getLogsAndStringsFromRawFileString(buffer); slice.logs = slice.logs.concat(logs); @@ -63,6 +66,16 @@ export const rpcSlice = createSlice({ ) { slice.status = action.payload; }, + timelockChangeEventReceived( + slice, + action: PayloadAction + ) { + if (slice.state.swapInfos[action.payload.swap_id]) { + slice.state.swapInfos[action.payload.swap_id].timelock = action.payload.timelock; + } else { + logger.warn(`Received timelock change event for unknown swap ${action.payload.swap_id}`); + } + }, rpcSetBalance(slice, action: PayloadAction) { slice.state.balance = action.payload; }, @@ -110,6 +123,7 @@ export const { rpcSetSwapInfo, rpcSetMoneroRecoveryKeys, rpcResetMoneroRecoveryKeys, + timelockChangeEventReceived } = rpcSlice.actions; export default rpcSlice.reducer; diff --git a/src-gui/src/store/features/swapSlice.ts b/src-gui/src/store/features/swapSlice.ts index 14e66617c..cd45130b9 100644 --- a/src-gui/src/store/features/swapSlice.ts +++ b/src-gui/src/store/features/swapSlice.ts @@ -14,7 +14,7 @@ export const swapSlice = createSlice({ name: "swap", initialState, reducers: { - swapTauriEventReceived( + swapProgressEventReceived( swap, action: PayloadAction, ) { @@ -42,6 +42,6 @@ export const swapSlice = createSlice({ }, }); -export const { swapReset, swapTauriEventReceived } = swapSlice.actions; +export const { swapReset, swapProgressEventReceived } = swapSlice.actions; export default swapSlice.reducer; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index cedd3e39c..9843d8557 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -1,7 +1,10 @@ import { sortBy } from "lodash"; +import { GetSwapInfoResponseExt } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import { parseDateString } from "utils/parseUtils"; +import { useMemo } from "react"; +import { isCliLogRelatedToSwap } from "models/cliModel"; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; @@ -26,7 +29,9 @@ export function useIsContextAvailable() { return useAppSelector((state) => state.rpc.status?.type === "Available"); } -export function useSwapInfo(swapId: string | null) { +export function useSwapInfo( + swapId: string | null, +): GetSwapInfoResponseExt | null { return useAppSelector((state) => swapId ? state.rpc.state.swapInfos[swapId] ?? null : null, ); @@ -36,11 +41,21 @@ export function useActiveSwapId() { return useAppSelector((s) => s.swap.state?.swapId ?? null); } -export function useActiveSwapInfo() { +export function useActiveSwapInfo(): GetSwapInfoResponseExt | null { const swapId = useActiveSwapId(); return useSwapInfo(swapId); } +export function useActiveSwapLogs() { + const swapId = useActiveSwapId(); + const logs = useAppSelector((s) => s.rpc.logs); + + return useMemo( + () => logs.filter((log) => isCliLogRelatedToSwap(log, swapId)), + [logs, swapId], + ); +} + export function useAllProviders() { return useAppSelector((state) => { const registryProviders = state.providers.registry.providers || []; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0aa45343f..732bc7e48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,8 +5,8 @@ use swap::cli::{ api::{ request::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, GetHistoryArgs, GetLogsArgs, - GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, - SuspendCurrentSwapArgs, WithdrawBtcArgs, + GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, + ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, Context, ContextBuilder, @@ -40,7 +40,7 @@ impl ToStringResult for Result { /// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result { /// args.handle(context.inner().clone()).await.to_string_result() /// } -/// +/// ``` /// # Example 2 /// ```ignored /// tauri_command!(get_balance, BalanceArgs, no_args); @@ -130,6 +130,7 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ get_balance, + get_swap_info, get_swap_infos_all, withdraw_btc, buy_xmr, @@ -185,6 +186,7 @@ tauri_command!(cancel_and_refund, CancelAndRefundArgs); // These commands require no arguments 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); diff --git a/swap/src/bin/asb.rs b/swap/src/bin/asb.rs index 7d021b357..7a7e5f234 100644 --- a/swap/src/bin/asb.rs +++ b/swap/src/bin/asb.rs @@ -103,7 +103,7 @@ pub async fn main() -> Result<()> { match cmd { Command::Start { resume_only } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; // check and warn for duplicate rendezvous points let mut rendezvous_addrs = config.network.rendezvous_point.clone(); @@ -233,7 +233,7 @@ pub async fn main() -> Result<()> { event_loop.run().await; } Command::History => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadOnly).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadOnly, None).await?; let mut table = Table::new(); @@ -293,7 +293,7 @@ pub async fn main() -> Result<()> { tracing::info!(%bitcoin_balance, %monero_balance, "Current balance"); } Command::Cancel { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; @@ -302,7 +302,7 @@ pub async fn main() -> Result<()> { tracing::info!("Cancel transaction successfully published with id {}", txid); } Command::Refund { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; let monero_wallet = init_monero_wallet(&config, env_config).await?; @@ -318,7 +318,7 @@ pub async fn main() -> Result<()> { tracing::info!("Monero successfully refunded"); } Command::Punish { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; @@ -327,7 +327,7 @@ pub async fn main() -> Result<()> { tracing::info!("Punish transaction successfully published with id {}", txid); } Command::SafelyAbort { swap_id } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; safely_abort(swap_id, db).await?; @@ -337,7 +337,7 @@ pub async fn main() -> Result<()> { swap_id, do_not_await_finality, } => { - let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite).await?; + let db = open_db(config.data.dir.join("sqlite"), AccessMode::ReadWrite, None).await?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?; diff --git a/swap/src/bitcoin/wallet.rs b/swap/src/bitcoin/wallet.rs index cb4663b05..af113d38d 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -280,7 +280,7 @@ impl Subscription { .await } - async fn wait_until(&self, mut predicate: impl FnMut(&ScriptStatus) -> bool) -> Result<()> { + pub async fn wait_until(&self, mut predicate: impl FnMut(&ScriptStatus) -> bool) -> Result<()> { let mut receiver = self.receiver.clone(); while !predicate(&receiver.borrow()) { diff --git a/swap/src/cli.rs b/swap/src/cli.rs index 571c92fea..f881737c4 100644 --- a/swap/src/cli.rs +++ b/swap/src/cli.rs @@ -5,6 +5,7 @@ pub mod command; mod event_loop; mod list_sellers; pub mod transport; +pub mod watcher; pub use behaviour::{Behaviour, OutEvent}; pub use cancel_and_refund::{cancel, cancel_and_refund, refund}; diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 7c70d24d9..15b0565e5 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -27,6 +27,8 @@ use tracing::Level; use url::Url; use uuid::Uuid; +use super::watcher::Watcher; + static START: Once = Once::new(); #[derive(Clone, PartialEq, Debug)] @@ -306,25 +308,31 @@ impl ContextBuilder { TauriContextInitializationProgress::OpeningBitcoinWallet, )); - let bitcoin_wallet = { - if let Some(bitcoin) = self.bitcoin { - let (bitcoin_electrum_rpc_url, bitcoin_target_block) = - bitcoin.apply_defaults(self.is_testnet)?; - Some(Arc::new( - init_bitcoin_wallet( - bitcoin_electrum_rpc_url, - &seed, - data_dir.clone(), - env_config, - bitcoin_target_block, - ) - .await?, - )) - } else { - None - } + let bitcoin_wallet = if let Some(bitcoin) = self.bitcoin { + let (url, target_block) = bitcoin.apply_defaults(self.is_testnet)?; + Some(Arc::new( + init_bitcoin_wallet(url, &seed, data_dir.clone(), env_config, target_block).await?, + )) + } else { + None }; + let db = open_db( + data_dir.join("sqlite"), + AccessMode::ReadWrite, + self.tauri_handle.clone(), + ) + .await?; + + // If we are connected to the Bitcoin blockchain and if there is a handle to Tauri present, + // we start a background task to watch for timelock changes. + if let Some(wallet) = bitcoin_wallet.clone() { + if self.tauri_handle.is_some() { + let watcher = Watcher::new(wallet, db.clone(), self.tauri_handle.clone()); + tokio::spawn(watcher.run()); + } + } + // We initialize the Monero wallet below // To display the progress to the user, we emit events to the Tauri frontend self.tauri_handle @@ -353,7 +361,7 @@ impl ContextBuilder { let tor_socks5_port = self.tor.map_or(9050, |tor| tor.tor_socks5_port); let context = Context { - db: open_db(data_dir.join("sqlite"), AccessMode::ReadWrite).await?, + db, bitcoin_wallet, monero_wallet, monero_rpc_process, @@ -396,7 +404,7 @@ impl Context { bitcoin_wallet: Some(bob_bitcoin_wallet), monero_wallet: Some(bob_monero_wallet), config, - db: open_db(db_path, AccessMode::ReadWrite) + db: open_db(db_path, AccessMode::ReadWrite, None) .await .expect("Could not open sqlite database"), monero_rpc_process: None, diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 32cf1d2ae..e5dba3102 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -478,52 +478,36 @@ pub async fn get_swap_info( .await? .iter() .find_map(|state| { - if let State::Bob(BobState::SwapSetupCompleted(state2)) = state { - let xmr_amount = state2.xmr; - let btc_amount = state2.tx_lock.lock_amount(); - let tx_cancel_fee = state2.tx_cancel_fee; - let tx_refund_fee = state2.tx_refund_fee; - let tx_lock_id = state2.tx_lock.txid(); - let btc_refund_address = state2.refund_address.to_string(); - - if let Ok(tx_lock_fee) = state2.tx_lock.fee() { - Some(( - xmr_amount, - btc_amount, - tx_lock_id, - tx_cancel_fee, - tx_refund_fee, - tx_lock_fee, - btc_refund_address, - state2.cancel_timelock, - state2.punish_timelock, - )) - } else { - None - } - } else { - None - } + let State::Bob(BobState::SwapSetupCompleted(state2)) = state else { + return None; + }; + + let xmr_amount = state2.xmr; + let btc_amount = state2.tx_lock.lock_amount(); + let tx_cancel_fee = state2.tx_cancel_fee; + let tx_refund_fee = state2.tx_refund_fee; + let tx_lock_id = state2.tx_lock.txid(); + let btc_refund_address = state2.refund_address.to_string(); + + let Ok(tx_lock_fee) = state2.tx_lock.fee() else { + return None; + }; + + Some(( + xmr_amount, + btc_amount, + tx_lock_id, + tx_cancel_fee, + tx_refund_fee, + tx_lock_fee, + btc_refund_address, + state2.cancel_timelock, + state2.punish_timelock, + )) }) .with_context(|| "Did not find SwapSetupCompleted state for swap")?; - let timelock = match swap_state.clone() { - BobState::Started { .. } | BobState::SafelyAborted | BobState::SwapSetupCompleted(_) => { - None - } - BobState::BtcLocked { state3: state, .. } - | BobState::XmrLockProofReceived { state, .. } => { - Some(state.expired_timelock(bitcoin_wallet).await?) - } - BobState::XmrLocked(state) | BobState::EncSigSent(state) => { - Some(state.expired_timelock(bitcoin_wallet).await?) - } - BobState::CancelTimelockExpired(state) | BobState::BtcCancelled(state) => { - Some(state.expired_timelock(bitcoin_wallet).await?) - } - BobState::BtcPunished { .. } => Some(ExpiredTimelocks::Punish), - BobState::BtcRefunded(_) | BobState::BtcRedeemed(_) | BobState::XmrRedeemed { .. } => None, - }; + let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?; Ok(GetSwapInfoResponse { swap_id: args.swap_id, diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 79111c837..82194f172 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1,4 +1,4 @@ -use crate::{monero, network::quote::BidQuote}; +use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; use anyhow::Result; use bitcoin::Txid; use serde::{Deserialize, Serialize}; @@ -7,9 +7,11 @@ use typeshare::typeshare; use url::Url; use uuid::Uuid; +const CLI_LOG_EMITTED_EVENT_NAME: &str = "cli-log-emitted"; const SWAP_PROGRESS_EVENT_NAME: &str = "swap-progress-update"; +const SWAP_STATE_CHANGE_EVENT_NAME: &str = "swap-database-state-update"; +const TIMELOCK_CHANGE_EVENT_NAME: &str = "timelock-change"; const CONTEXT_INIT_PROGRESS_EVENT_NAME: &str = "context-init-progress-update"; -const CLI_LOG_EMITTED_EVENT_NAME: &str = "cli-log-emitted"; #[derive(Debug, Clone)] pub struct TauriHandle( @@ -50,11 +52,25 @@ pub trait TauriEmitter { let _ = self.emit_tauri_event(CONTEXT_INIT_PROGRESS_EVENT_NAME, event); } - fn emit_cli_log_event(&self, event: CliLogEmittedEvent) { + fn emit_cli_log_event(&self, event: TauriLogEvent) { let _ = self .emit_tauri_event(CLI_LOG_EMITTED_EVENT_NAME, event) .ok(); } + + fn emit_swap_state_change_event(&self, swap_id: Uuid) { + let _ = self.emit_tauri_event( + SWAP_STATE_CHANGE_EVENT_NAME, + TauriDatabaseStateEvent { swap_id }, + ); + } + + fn emit_timelock_change_event(&self, swap_id: Uuid, timelock: Option) { + let _ = self.emit_tauri_event( + TIMELOCK_CHANGE_EVENT_NAME, + TauriTimelockChangeEvent { swap_id, timelock }, + ); + } } impl TauriEmitter for TauriHandle { @@ -174,11 +190,26 @@ pub enum TauriSwapProgressEvent { #[typeshare] #[derive(Debug, Serialize, Clone)] #[typeshare] -pub struct CliLogEmittedEvent { +pub struct TauriLogEvent { /// The serialized object containing the log message and metadata. pub buffer: String, } +#[derive(Serialize, Clone)] +#[typeshare] +pub struct TauriDatabaseStateEvent { + #[typeshare(serialized_as = "string")] + swap_id: Uuid, +} + +#[derive(Serialize, Clone)] +#[typeshare] +pub struct TauriTimelockChangeEvent { + #[typeshare(serialized_as = "string")] + swap_id: Uuid, + timelock: Option, +} + /// This struct contains the settings for the Context #[typeshare] #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/swap/src/cli/watcher.rs b/swap/src/cli/watcher.rs new file mode 100644 index 000000000..9edd67d64 --- /dev/null +++ b/swap/src/cli/watcher.rs @@ -0,0 +1,105 @@ +use super::api::tauri_bindings::TauriEmitter; +use crate::bitcoin::{ExpiredTimelocks, Wallet}; +use crate::cli::api::tauri_bindings::TauriHandle; +use crate::protocol::bob::BobState; +use crate::protocol::{Database, State}; +use anyhow::Result; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use uuid::Uuid; + +/// A long running task which watches for changes to timelocks +#[derive(Clone)] +pub struct Watcher { + wallet: Arc, + database: Arc, + tauri: Option, + /// This saves for every running swap the last known timelock status + cached_timelocks: HashMap>, +} + +impl Watcher { + /// How often to check for changes (in seconds) + const CHECK_INTERVAL: u64 = 30; + + /// Create a new Watcher + pub fn new( + wallet: Arc, + database: Arc, + tauri: Option, + ) -> Self { + Self { + wallet, + database, + cached_timelocks: HashMap::new(), + tauri, + } + } + + /// Start running the watcher event loop. + /// Should be done in a new task using [`tokio::spawn`]. + pub async fn run(mut self) { + // Note: since this is de-facto a daemon, we have to gracefully handle errors + // (which in our case means logging the error message and trying again later) + loop { + // Fetch current transactions and timelocks + let current_swaps = match self.get_current_swaps().await { + Ok(val) => val, + Err(e) => { + tracing::error!(error=%e, "Failed to fetch current transactions, retrying later"); + continue; + } + }; + + // Check for changes for every current swap + for (swap_id, state) in current_swaps { + // Determine if the timelock has expired for the current swap. + // We intentionally do not skip swaps with a None timelock status, as this represents a valid state. + // When a swap reaches its final state, the timelock becomes irrelevant, but it is still important to explicitly send None + // This indicates that the timelock no longer needs to be displayed in the GUI + let new_timelock_status = match state.expired_timelocks(self.wallet.clone()).await { + Ok(val) => val, + Err(e) => { + tracing::error!(error=%e, swap_id=%swap_id, "Failed to check timelock status, retrying later"); + continue; + } + }; + + // Check if the status changed + if let Some(old_status) = self.cached_timelocks.get(&swap_id) { + // And send a tauri event if it did + if *old_status != new_timelock_status { + self.tauri + .emit_timelock_change_event(swap_id, new_timelock_status); + } + } else { + // If this is the first time we see this swap, send a tauri event, too + self.tauri + .emit_timelock_change_event(swap_id, new_timelock_status); + } + + // Insert new status + self.cached_timelocks.insert(swap_id, new_timelock_status); + } + + // Sleep and check again later + tokio::time::sleep(Duration::from_secs(Watcher::CHECK_INTERVAL)).await; + } + } + + /// Helper function for fetching the current list of swaps + async fn get_current_swaps(&self) -> Result> { + Ok(self + .database + .all() + .await? + .into_iter() + // Filter for BobState + .filter_map(|(uuid, state)| match state { + State::Bob(bob_state) => Some((uuid, bob_state)), + _ => None, + }) + .collect()) + } +} diff --git a/swap/src/common/mod.rs b/swap/src/common/mod.rs index bae1c0f46..48d05aab9 100644 --- a/swap/src/common/mod.rs +++ b/swap/src/common/mod.rs @@ -52,7 +52,6 @@ macro_rules! regex_find_placeholders { ($pattern:expr, $create_placeholder:expr, $replacements:expr, $input:expr) => {{ // compile the regex pattern static REGEX: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - tracing::debug!("initializing regex"); regex::Regex::new($pattern).expect("invalid regex pattern") }); diff --git a/swap/src/common/tracing_util.rs b/swap/src/common/tracing_util.rs index b8b505e42..7719df57f 100644 --- a/swap/src/common/tracing_util.rs +++ b/swap/src/common/tracing_util.rs @@ -10,7 +10,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{fmt, EnvFilter, Layer}; -use crate::cli::api::tauri_bindings::{CliLogEmittedEvent, TauriEmitter, TauriHandle}; +use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriLogEvent}; /// Output formats for logging messages. pub enum Format { @@ -63,13 +63,13 @@ pub fn init( tracing_subscriber::registry() .with(file_layer) .with(tauri_layer) - .with(terminal_layer.json().with_filter(level_filter)) + .with(terminal_layer.json().with_filter(env_filter(level_filter)?)) .try_init()?; } else { tracing_subscriber::registry() .with(file_layer) .with(tauri_layer) - .with(terminal_layer.with_filter(level_filter)) + .with(terminal_layer.with_filter(env_filter(level_filter)?)) .try_init()?; } @@ -121,7 +121,7 @@ impl std::io::Write for TauriWriter { .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; // Then send to tauri - self.tauri_handle.emit_cli_log_event(CliLogEmittedEvent { + self.tauri_handle.emit_cli_log_event(TauriLogEvent { buffer: utf8_string, }); diff --git a/swap/src/database.rs b/swap/src/database.rs index 7c6185f7f..fabc4ffc8 100644 --- a/swap/src/database.rs +++ b/swap/src/database.rs @@ -2,6 +2,7 @@ pub use alice::Alice; pub use bob::Bob; pub use sqlite::SqliteDatabase; +use crate::cli::api::tauri_bindings::TauriHandle; use crate::fs::ensure_directory_exists; use crate::protocol::{Database, State}; use anyhow::{bail, Result}; @@ -92,16 +93,25 @@ pub enum AccessMode { pub async fn open_db( sqlite_path: impl AsRef, access_mode: AccessMode, + tauri_handle: impl Into>, ) -> Result> { if sqlite_path.as_ref().exists() { tracing::debug!("Using existing sqlite database."); - let sqlite = SqliteDatabase::open(sqlite_path, access_mode).await?; + + let sqlite = SqliteDatabase::open(sqlite_path, access_mode) + .await? + .with_tauri_handle(tauri_handle.into()); + Ok(Arc::new(sqlite)) } else { tracing::debug!("Creating and using new sqlite database."); + ensure_directory_exists(sqlite_path.as_ref())?; tokio::fs::File::create(&sqlite_path).await?; - let sqlite = SqliteDatabase::open(sqlite_path, access_mode).await?; + let sqlite = SqliteDatabase::open(sqlite_path, access_mode) + .await? + .with_tauri_handle(tauri_handle.into()); + Ok(Arc::new(sqlite)) } } diff --git a/swap/src/database/sqlite.rs b/swap/src/database/sqlite.rs index 5800bd837..ba99ab0c7 100644 --- a/swap/src/database/sqlite.rs +++ b/swap/src/database/sqlite.rs @@ -1,3 +1,5 @@ +use crate::cli::api::tauri_bindings::TauriEmitter; +use crate::cli::api::tauri_bindings::TauriHandle; use crate::database::Swap; use crate::monero::{Address, TransferProof}; use crate::protocol::{Database, State}; @@ -15,6 +17,7 @@ use super::AccessMode; pub struct SqliteDatabase { pool: Pool, + tauri_handle: Option, } impl SqliteDatabase { @@ -30,7 +33,10 @@ impl SqliteDatabase { let options = options.disable_statement_logging(); let pool = SqlitePool::connect_with(options.to_owned()).await?; - let mut sqlite = Self { pool }; + let mut sqlite = Self { + pool, + tauri_handle: None, + }; if !read_only { sqlite.run_migrations().await?; @@ -39,6 +45,11 @@ impl SqliteDatabase { Ok(sqlite) } + pub fn with_tauri_handle(mut self, tauri_handle: impl Into>) -> Self { + self.tauri_handle = tauri_handle.into(); + self + } + async fn run_migrations(&mut self) -> anyhow::Result<()> { sqlx::migrate!("./migrations").run(&self.pool).await?; Ok(()) @@ -204,9 +215,9 @@ impl Database for SqliteDatabase { let mut conn = self.pool.acquire().await?; let entered_at = OffsetDateTime::now_utc(); - let swap_id = swap_id.to_string(); let swap = serde_json::to_string(&Swap::from(state))?; let entered_at = entered_at.to_string(); + let swap_id_str = swap_id.to_string(); sqlx::query!( r#" @@ -216,13 +227,17 @@ impl Database for SqliteDatabase { state ) values (?, ?, ?); "#, - swap_id, + swap_id_str, entered_at, swap ) .execute(&mut conn) .await?; + // Emit event to Tauri, the frontend will then send another request to get the latest state + // This is why we don't send the state here + self.tauri_handle.emit_swap_state_change_event(swap_id); + Ok(()) } diff --git a/swap/src/protocol/bob/state.rs b/swap/src/protocol/bob/state.rs index 873be156a..1e6425a5e 100644 --- a/swap/src/protocol/bob/state.rs +++ b/swap/src/protocol/bob/state.rs @@ -1,7 +1,7 @@ use crate::bitcoin::wallet::{EstimateFeeRate, Subscription}; use crate::bitcoin::{ self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, - TxLock, Txid, + TxLock, Txid, Wallet, }; use crate::monero::wallet::WatchRequest; use crate::monero::{self, TxHash}; @@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize}; use sha2::Sha256; use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof; use std::fmt; +use std::sync::Arc; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Serialize)] @@ -76,6 +77,35 @@ impl fmt::Display for BobState { } } +impl BobState { + /// Fetch the expired timelocks for the swap. + /// Depending on the State, there are no locks to expire. + pub async fn expired_timelocks( + &self, + bitcoin_wallet: Arc, + ) -> Result> { + Ok(match self.clone() { + BobState::Started { .. } + | BobState::SafelyAborted + | BobState::SwapSetupCompleted(_) => None, + BobState::BtcLocked { state3: state, .. } + | BobState::XmrLockProofReceived { state, .. } => { + Some(state.expired_timelock(&bitcoin_wallet).await?) + } + BobState::XmrLocked(state) | BobState::EncSigSent(state) => { + Some(state.expired_timelock(&bitcoin_wallet).await?) + } + BobState::CancelTimelockExpired(state) | BobState::BtcCancelled(state) => { + Some(state.expired_timelock(&bitcoin_wallet).await?) + } + BobState::BtcPunished { .. } => Some(ExpiredTimelocks::Punish), + BobState::BtcRefunded(_) | BobState::BtcRedeemed(_) | BobState::XmrRedeemed { .. } => { + None + } + }) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct State0 { swap_id: Uuid, diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 165ffda17..04ad357d6 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -1,3 +1,4 @@ +use crate::bitcoin::wallet::ScriptStatus; use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund}; use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent}; use crate::cli::EventLoopHandle; @@ -119,12 +120,17 @@ async fn next_state( let monero_wallet_restore_blockheight = monero_wallet.block_height().await?; // Alice and Bob have exchanged info + // Sign the Bitcoin lock transaction let (state3, tx_lock) = state2.lock_btc().await?; let signed_tx = bitcoin_wallet .sign_and_finalize(tx_lock.clone().into()) .await .context("Failed to sign Bitcoin lock transaction")?; + // Publish the signed Bitcoin lock transaction + let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?; + + // Emit an event to tauri that the the swap started event_emitter.emit_swap_progress_event( swap_id, TauriSwapProgressEvent::Started { @@ -134,8 +140,6 @@ async fn next_state( }, ); - let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?; - BobState::BtcLocked { state3, monero_wallet_restore_blockheight, @@ -187,8 +191,21 @@ async fn next_state( // Wait for either Alice to send the XMR transfer proof or until we can cancel the swap let transfer_proof_watcher = event_loop_handle.recv_transfer_proof(); - let cancel_timelock_expires = - tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock); + let cancel_timelock_expires = tx_lock_status.wait_until(|status| { + // Emit a tauri event on new confirmations + if let ScriptStatus::Confirmed(confirmed) = status { + event_emitter.emit_swap_progress_event( + swap_id, + TauriSwapProgressEvent::BtcLockTxInMempool { + btc_lock_txid: state3.tx_lock_id(), + btc_lock_confirmations: u64::from(confirmed.confirmations()), + }, + ); + } + + // Stop when the cancel timelock expires + status.is_confirmed_with(state3.cancel_timelock) + }); select! { // Alice sent us the transfer proof for the Monero she locked