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
This commit is contained in:
binarybaron 2024-10-09 19:06:57 +06:00 committed by GitHub
parent e6dc7ddcef
commit 8f33fe5b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 429 additions and 208 deletions

3
src-gui/.gitignore vendored
View file

@ -26,3 +26,6 @@ dist-ssr
# Autogenerated bindings
src/models/tauriModel.ts
# Env files
.env.*

View file

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

View file

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

View file

@ -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",
}}
>
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
<CliLogsBox
logs={logs}
label="Logs relevant to the swap (only current session)"
/>
<JsonTreeView
data={guiState}
label="Internal GUI State (inferred from Logs)"

View file

@ -19,20 +19,10 @@ import InitPage from "./init/InitPage";
import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage";
export default function SwapStatePage({ state }: { state: SwapState | null }) {
// TODO: Reimplement this using tauri events
/*
const isSyncingMoneroWallet = useAppSelector(
(state) => state.rpc.state.moneroWallet.isSyncing,
);
if (isSyncingMoneroWallet) {
return <SyncingMoneroWalletPage />;
}
*/
if (state === null) {
return <InitPage />;
}
switch (state.curr.type) {
case "Initiated":
return <InitiatedPage />;

View file

@ -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 (
<Box>
<DialogContentText>{getText()}</DialogContentText>
<Box
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{state.rpcError && (
<CliLogsBox
logs={[state.rpcError]}
label="Error returned by the Swap Daemon"
/>
)}
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
</Box>
</Box>
);
}

View file

@ -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
</>
<Box>
<DialogContentText>
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"}.
</DialogContentText>
<Box>
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
</Box>
</Box>
);
// 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 <ProcessExitedAndNotDonePage state={state} />;
}

View file

@ -81,7 +81,9 @@ export default function CliLogsBox({
setSearchQuery={setSearchQuery}
rows={memoizedLogs.map((log) =>
typeof log === "string" ? (
<Typography component="pre">{log}</Typography>
<Typography key={log} component="pre">
{log}
</Typography>
) : (
<RenderedCliLog log={log} key={JSON.stringify(log)} />
),

View file

@ -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<VListHandle | null>(null);

View file

@ -15,15 +15,13 @@ export function AmountWithUnit({
fixedPrecision: number;
dollarRate?: Amount;
}) {
const title =
dollarRate != null && amount != null
? `$${(dollarRate * amount).toFixed(2)}`
: "";
return (
<Tooltip
arrow
title={
dollarRate != null && amount != null
? `$${(dollarRate * amount).toFixed(2)}`
: ""
}
>
<Tooltip arrow title={title}>
<span>
{amount != null
? Number.parseFloat(amount.toFixed(fixedPrecision))

View file

@ -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<TauriSwapProgressEventWrapper>("swap-progress-update", (event) => {
console.log("Received swap progress event", event.payload);
store.dispatch(swapTauriEventReceived(event.payload));
store.dispatch(swapProgressEventReceived(event.payload));
});
listen<TauriContextStatusEvent>("context-init-progress-update", (event) => {
@ -57,10 +61,25 @@ export async function initEventListeners() {
store.dispatch(contextStatusEventReceived(event.payload));
});
listen<CliLogEmittedEvent>("cli-log-emitted", (event) => {
listen<TauriLogEvent>("cli-log-emitted", (event) => {
console.log("Received cli log event", event.payload);
store.dispatch(receivedCliLog(event.payload));
});
listen<TauriDatabaseStateEvent>("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<TauriTimelockChangeEvent>('timelock-change', (event) => {
console.log('Received timelock change event', event.payload);
store.dispatch(timelockChangeEventReceived(event.payload));
})
}
async function invoke<ARGS, RESPONSE>(
@ -93,6 +112,17 @@ export async function getAllSwapInfos() {
});
}
export async function getSwapInfo(swapId: string) {
const response = await invoke<GetSwapInfoArgs, GetSwapInfoResponse>(
"get_swap_info",
{
swap_id: swapId,
},
);
store.dispatch(rpcSetSwapInfo(response));
}
export async function withdrawBtc(address: string): Promise<string> {
const response = await invoke<WithdrawBtcArgs, WithdrawBtcResponse>(
"withdraw_btc",

View file

@ -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<CliLogEmittedEvent>) {
receivedCliLog(slice, action: PayloadAction<TauriLogEvent>) {
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<TauriTimelockChangeEvent>
) {
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<number>) {
slice.state.balance = action.payload;
},
@ -110,6 +123,7 @@ export const {
rpcSetSwapInfo,
rpcSetMoneroRecoveryKeys,
rpcResetMoneroRecoveryKeys,
timelockChangeEventReceived
} = rpcSlice.actions;
export default rpcSlice.reducer;

View file

@ -14,7 +14,7 @@ export const swapSlice = createSlice({
name: "swap",
initialState,
reducers: {
swapTauriEventReceived(
swapProgressEventReceived(
swap,
action: PayloadAction<TauriSwapProgressEventWrapper>,
) {
@ -42,6 +42,6 @@ export const swapSlice = createSlice({
},
});
export const { swapReset, swapTauriEventReceived } = swapSlice.actions;
export const { swapReset, swapProgressEventReceived } = swapSlice.actions;
export default swapSlice.reducer;

View file

@ -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<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = 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 || [];

View file

@ -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<T, E: ToString> ToStringResult<T> for Result<T, E> {
/// 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);
@ -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);

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
let State::Bob(BobState::SwapSetupCompleted(state2)) = state else {
return None;
};
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 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,

View file

@ -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 SWAP_PROGRESS_EVENT_NAME: &str = "swap-progress-update";
const CONTEXT_INIT_PROGRESS_EVENT_NAME: &str = "context-init-progress-update";
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";
#[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<ExpiredTimelocks>) {
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<ExpiredTimelocks>,
}
/// This struct contains the settings for the Context
#[typeshare]
#[derive(Debug, Serialize, Deserialize, Clone)]

105
swap/src/cli/watcher.rs Normal file
View file

@ -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<Wallet>,
database: Arc<dyn Database + Send + Sync>,
tauri: Option<TauriHandle>,
/// This saves for every running swap the last known timelock status
cached_timelocks: HashMap<Uuid, Option<ExpiredTimelocks>>,
}
impl Watcher {
/// How often to check for changes (in seconds)
const CHECK_INTERVAL: u64 = 30;
/// Create a new Watcher
pub fn new(
wallet: Arc<Wallet>,
database: Arc<dyn Database + Send + Sync>,
tauri: Option<TauriHandle>,
) -> 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<Vec<(Uuid, BobState)>> {
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())
}
}

View file

@ -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<regex::Regex> = once_cell::sync::Lazy::new(|| {
tracing::debug!("initializing regex");
regex::Regex::new($pattern).expect("invalid regex pattern")
});

View file

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

View file

@ -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<Path>,
access_mode: AccessMode,
tauri_handle: impl Into<Option<TauriHandle>>,
) -> Result<Arc<dyn Database + Send + Sync>> {
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))
}
}

View file

@ -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<Sqlite>,
tauri_handle: Option<TauriHandle>,
}
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<Option<TauriHandle>>) -> 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(())
}

View file

@ -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<Wallet>,
) -> Result<Option<ExpiredTimelocks>> {
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,

View file

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