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