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 # Autogenerated bindings
src/models/tauriModel.ts 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 { export function parseCliLogString(log: string): CliLog | string {
try { try {
const parsed = JSON.parse(log); const parsed = JSON.parse(log);

View file

@ -61,7 +61,11 @@ function getActiveStep(state: SwapState | null): PathStep {
// Step 1: Waiting for Bitcoin lock confirmation // Step 1: Waiting for Bitcoin lock confirmation
// Bitcoin has been locked, waiting for the counterparty to lock their XMR // Bitcoin has been locked, waiting for the counterparty to lock their XMR
case "BtcLockTxInMempool": 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 // Still Step 1: Both Bitcoin and XMR have been locked, waiting for Monero lock to be confirmed
case "XmrLockTxInMempool": case "XmrLockTxInMempool":

View file

@ -1,11 +1,15 @@
import { Box, DialogContentText } from "@material-ui/core"; 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 JsonTreeView from "../../../other/JSONViewTree";
import CliLogsBox from "../../../other/RenderedCliLog"; import CliLogsBox from "../../../other/RenderedCliLog";
export default function DebugPage() { export default function DebugPage() {
const torStdOut = useAppSelector((s) => s.tor.stdOut); const torStdOut = useAppSelector((s) => s.tor.stdOut);
const logs = useAppSelector((s) => s.swap.logs); const logs = useActiveSwapLogs();
const guiState = useAppSelector((s) => s); const guiState = useAppSelector((s) => s);
const cliState = useActiveSwapInfo(); const cliState = useActiveSwapInfo();
@ -19,7 +23,10 @@ export default function DebugPage() {
gap: "8px", gap: "8px",
}} }}
> >
<CliLogsBox logs={logs} label="Logs relevant to the swap" /> <CliLogsBox
logs={logs}
label="Logs relevant to the swap (only current session)"
/>
<JsonTreeView <JsonTreeView
data={guiState} data={guiState}
label="Internal GUI State (inferred from Logs)" label="Internal GUI State (inferred from Logs)"

View file

@ -19,20 +19,10 @@ import InitPage from "./init/InitPage";
import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage"; import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage";
export default function SwapStatePage({ state }: { state: SwapState | null }) { 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) { if (state === null) {
return <InitPage />; return <InitPage />;
} }
switch (state.curr.type) { switch (state.curr.type) {
case "Initiated": case "Initiated":
return <InitiatedPage />; 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 { TauriSwapProgressEvent } from "models/tauriModel";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks";
import SwapStatePage from "../SwapStatePage"; import SwapStatePage from "../SwapStatePage";
export default function ProcessExitedPage({ export default function ProcessExitedPage({
@ -8,8 +11,11 @@ export default function ProcessExitedPage({
prevState: TauriSwapProgressEvent | null; prevState: TauriSwapProgressEvent | null;
swapId: string; 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 // 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 ( if (
prevState != null && prevState != null &&
(prevState.type === "XmrRedeemInMempool" || (prevState.type === "XmrRedeemInMempool" ||
@ -28,15 +34,17 @@ export default function ProcessExitedPage({
); );
} }
// TODO: Display something useful here
return ( return (
<> <Box>
If the swap is not a "done" state (or we don't have a db state because the <DialogContentText>
swap did complete the SwapSetup yet) we should tell the user and show logs The swap was stopped but it has not been completed yet. Check the logs
Not implemented yet 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} setSearchQuery={setSearchQuery}
rows={memoizedLogs.map((log) => rows={memoizedLogs.map((log) =>
typeof log === "string" ? ( typeof log === "string" ? (
<Typography component="pre">{log}</Typography> <Typography key={log} component="pre">
{log}
</Typography>
) : ( ) : (
<RenderedCliLog log={log} key={JSON.stringify(log)} /> <RenderedCliLog log={log} key={JSON.stringify(log)} />
), ),

View file

@ -21,7 +21,7 @@ export default function ScrollablePaperTextBox({
copyValue: string; copyValue: string;
searchQuery: string | null; searchQuery: string | null;
setSearchQuery?: ((query: string) => void) | null; setSearchQuery?: ((query: string) => void) | null;
minHeight: string; minHeight?: string;
}) { }) {
const virtuaEl = useRef<VListHandle | null>(null); const virtuaEl = useRef<VListHandle | null>(null);

View file

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

View file

@ -5,7 +5,7 @@ import {
BalanceResponse, BalanceResponse,
BuyXmrArgs, BuyXmrArgs,
BuyXmrResponse, BuyXmrResponse,
CliLogEmittedEvent, TauriLogEvent,
GetLogsArgs, GetLogsArgs,
GetLogsResponse, GetLogsResponse,
GetSwapInfoResponse, GetSwapInfoResponse,
@ -18,14 +18,18 @@ import {
TauriSwapProgressEventWrapper, TauriSwapProgressEventWrapper,
WithdrawBtcArgs, WithdrawBtcArgs,
WithdrawBtcResponse, WithdrawBtcResponse,
TauriDatabaseStateEvent,
TauriTimelockChangeEvent,
GetSwapInfoArgs,
} from "models/tauriModel"; } from "models/tauriModel";
import { import {
contextStatusEventReceived, contextStatusEventReceived,
receivedCliLog, receivedCliLog,
rpcSetBalance, rpcSetBalance,
rpcSetSwapInfo, rpcSetSwapInfo,
timelockChangeEventReceived,
} from "store/features/rpcSlice"; } from "store/features/rpcSlice";
import { swapTauriEventReceived } from "store/features/swapSlice"; import { swapProgressEventReceived } from "store/features/swapSlice";
import { store } from "./store/storeRenderer"; import { store } from "./store/storeRenderer";
import { Provider } from "models/apiModel"; import { Provider } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils"; import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
@ -49,7 +53,7 @@ export async function initEventListeners() {
listen<TauriSwapProgressEventWrapper>("swap-progress-update", (event) => { listen<TauriSwapProgressEventWrapper>("swap-progress-update", (event) => {
console.log("Received swap progress event", event.payload); 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) => { listen<TauriContextStatusEvent>("context-init-progress-update", (event) => {
@ -57,10 +61,25 @@ export async function initEventListeners() {
store.dispatch(contextStatusEventReceived(event.payload)); 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); console.log("Received cli log event", event.payload);
store.dispatch(receivedCliLog(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>( 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> { export async function withdrawBtc(address: string): Promise<string> {
const response = await invoke<WithdrawBtcArgs, WithdrawBtcResponse>( const response = await invoke<WithdrawBtcArgs, WithdrawBtcResponse>(
"withdraw_btc", "withdraw_btc",

View file

@ -1,14 +1,17 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel"; import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel";
import { import {
CliLogEmittedEvent, TauriLogEvent,
GetSwapInfoResponse, GetSwapInfoResponse,
TauriContextStatusEvent, TauriContextStatusEvent,
TauriDatabaseStateEvent,
TauriTimelockChangeEvent,
} from "models/tauriModel"; } from "models/tauriModel";
import { MoneroRecoveryResponse } from "../../models/rpcModel"; import { MoneroRecoveryResponse } from "../../models/rpcModel";
import { GetSwapInfoResponseExt } from "models/tauriModelExt"; import { GetSwapInfoResponseExt } from "models/tauriModelExt";
import { getLogsAndStringsFromRawFileString } from "utils/parseUtils"; import { getLogsAndStringsFromRawFileString } from "utils/parseUtils";
import { CliLog } from "models/cliModel"; import { CliLog } from "models/cliModel";
import logger from "utils/logger";
interface State { interface State {
balance: number | null; balance: number | null;
@ -52,7 +55,7 @@ export const rpcSlice = createSlice({
name: "rpc", name: "rpc",
initialState, initialState,
reducers: { reducers: {
receivedCliLog(slice, action: PayloadAction<CliLogEmittedEvent>) { receivedCliLog(slice, action: PayloadAction<TauriLogEvent>) {
const buffer = action.payload.buffer; const buffer = action.payload.buffer;
const logs = getLogsAndStringsFromRawFileString(buffer); const logs = getLogsAndStringsFromRawFileString(buffer);
slice.logs = slice.logs.concat(logs); slice.logs = slice.logs.concat(logs);
@ -63,6 +66,16 @@ export const rpcSlice = createSlice({
) { ) {
slice.status = action.payload; 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>) { rpcSetBalance(slice, action: PayloadAction<number>) {
slice.state.balance = action.payload; slice.state.balance = action.payload;
}, },
@ -110,6 +123,7 @@ export const {
rpcSetSwapInfo, rpcSetSwapInfo,
rpcSetMoneroRecoveryKeys, rpcSetMoneroRecoveryKeys,
rpcResetMoneroRecoveryKeys, rpcResetMoneroRecoveryKeys,
timelockChangeEventReceived
} = rpcSlice.actions; } = rpcSlice.actions;
export default rpcSlice.reducer; export default rpcSlice.reducer;

View file

@ -14,7 +14,7 @@ export const swapSlice = createSlice({
name: "swap", name: "swap",
initialState, initialState,
reducers: { reducers: {
swapTauriEventReceived( swapProgressEventReceived(
swap, swap,
action: PayloadAction<TauriSwapProgressEventWrapper>, 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; export default swapSlice.reducer;

View file

@ -1,7 +1,10 @@
import { sortBy } from "lodash"; import { sortBy } from "lodash";
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
import { parseDateString } from "utils/parseUtils"; import { parseDateString } from "utils/parseUtils";
import { useMemo } from "react";
import { isCliLogRelatedToSwap } from "models/cliModel";
export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
@ -26,7 +29,9 @@ export function useIsContextAvailable() {
return useAppSelector((state) => state.rpc.status?.type === "Available"); 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) => return useAppSelector((state) =>
swapId ? state.rpc.state.swapInfos[swapId] ?? null : null, swapId ? state.rpc.state.swapInfos[swapId] ?? null : null,
); );
@ -36,11 +41,21 @@ export function useActiveSwapId() {
return useAppSelector((s) => s.swap.state?.swapId ?? null); return useAppSelector((s) => s.swap.state?.swapId ?? null);
} }
export function useActiveSwapInfo() { export function useActiveSwapInfo(): GetSwapInfoResponseExt | null {
const swapId = useActiveSwapId(); const swapId = useActiveSwapId();
return useSwapInfo(swapId); 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() { export function useAllProviders() {
return useAppSelector((state) => { return useAppSelector((state) => {
const registryProviders = state.providers.registry.providers || []; const registryProviders = state.providers.registry.providers || [];

View file

@ -5,8 +5,8 @@ use swap::cli::{
api::{ api::{
request::{ request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, GetHistoryArgs, GetLogsArgs, BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, GetHistoryArgs, GetLogsArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, ResumeSwapArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs,
SuspendCurrentSwapArgs, WithdrawBtcArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
}, },
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder, 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> { /// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result<BalanceArgs::Response, String> {
/// args.handle(context.inner().clone()).await.to_string_result() /// args.handle(context.inner().clone()).await.to_string_result()
/// } /// }
/// /// ```
/// # Example 2 /// # Example 2
/// ```ignored /// ```ignored
/// tauri_command!(get_balance, BalanceArgs, no_args); /// tauri_command!(get_balance, BalanceArgs, no_args);
@ -130,6 +130,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_balance, get_balance,
get_swap_info,
get_swap_infos_all, get_swap_infos_all,
withdraw_btc, withdraw_btc,
buy_xmr, buy_xmr,
@ -185,6 +186,7 @@ tauri_command!(cancel_and_refund, CancelAndRefundArgs);
// These commands require no arguments // These commands require no arguments
tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args);
tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args); tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args); tauri_command!(get_history, GetHistoryArgs, no_args);

View file

@ -103,7 +103,7 @@ pub async fn main() -> Result<()> {
match cmd { match cmd {
Command::Start { resume_only } => { 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 // check and warn for duplicate rendezvous points
let mut rendezvous_addrs = config.network.rendezvous_point.clone(); let mut rendezvous_addrs = config.network.rendezvous_point.clone();
@ -233,7 +233,7 @@ pub async fn main() -> Result<()> {
event_loop.run().await; event_loop.run().await;
} }
Command::History => { 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(); let mut table = Table::new();
@ -293,7 +293,7 @@ pub async fn main() -> Result<()> {
tracing::info!(%bitcoin_balance, %monero_balance, "Current balance"); tracing::info!(%bitcoin_balance, %monero_balance, "Current balance");
} }
Command::Cancel { swap_id } => { 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?; 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); tracing::info!("Cancel transaction successfully published with id {}", txid);
} }
Command::Refund { swap_id } => { 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 bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?;
let monero_wallet = init_monero_wallet(&config, 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"); tracing::info!("Monero successfully refunded");
} }
Command::Punish { swap_id } => { 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?; 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); tracing::info!("Punish transaction successfully published with id {}", txid);
} }
Command::SafelyAbort { swap_id } => { 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?; safely_abort(swap_id, db).await?;
@ -337,7 +337,7 @@ pub async fn main() -> Result<()> {
swap_id, swap_id,
do_not_await_finality, 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?; let bitcoin_wallet = init_bitcoin_wallet(&config, &seed, env_config).await?;

View file

@ -280,7 +280,7 @@ impl Subscription {
.await .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(); let mut receiver = self.receiver.clone();
while !predicate(&receiver.borrow()) { while !predicate(&receiver.borrow()) {

View file

@ -5,6 +5,7 @@ pub mod command;
mod event_loop; mod event_loop;
mod list_sellers; mod list_sellers;
pub mod transport; pub mod transport;
pub mod watcher;
pub use behaviour::{Behaviour, OutEvent}; pub use behaviour::{Behaviour, OutEvent};
pub use cancel_and_refund::{cancel, cancel_and_refund, refund}; pub use cancel_and_refund::{cancel, cancel_and_refund, refund};

View file

@ -27,6 +27,8 @@ use tracing::Level;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
use super::watcher::Watcher;
static START: Once = Once::new(); static START: Once = Once::new();
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
@ -306,25 +308,31 @@ impl ContextBuilder {
TauriContextInitializationProgress::OpeningBitcoinWallet, TauriContextInitializationProgress::OpeningBitcoinWallet,
)); ));
let bitcoin_wallet = { let bitcoin_wallet = if let Some(bitcoin) = self.bitcoin {
if let Some(bitcoin) = self.bitcoin { let (url, target_block) = bitcoin.apply_defaults(self.is_testnet)?;
let (bitcoin_electrum_rpc_url, bitcoin_target_block) = Some(Arc::new(
bitcoin.apply_defaults(self.is_testnet)?; init_bitcoin_wallet(url, &seed, data_dir.clone(), env_config, target_block).await?,
Some(Arc::new( ))
init_bitcoin_wallet( } else {
bitcoin_electrum_rpc_url, None
&seed,
data_dir.clone(),
env_config,
bitcoin_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 // We initialize the Monero wallet below
// To display the progress to the user, we emit events to the Tauri frontend // To display the progress to the user, we emit events to the Tauri frontend
self.tauri_handle self.tauri_handle
@ -353,7 +361,7 @@ impl ContextBuilder {
let tor_socks5_port = self.tor.map_or(9050, |tor| tor.tor_socks5_port); let tor_socks5_port = self.tor.map_or(9050, |tor| tor.tor_socks5_port);
let context = Context { let context = Context {
db: open_db(data_dir.join("sqlite"), AccessMode::ReadWrite).await?, db,
bitcoin_wallet, bitcoin_wallet,
monero_wallet, monero_wallet,
monero_rpc_process, monero_rpc_process,
@ -396,7 +404,7 @@ impl Context {
bitcoin_wallet: Some(bob_bitcoin_wallet), bitcoin_wallet: Some(bob_bitcoin_wallet),
monero_wallet: Some(bob_monero_wallet), monero_wallet: Some(bob_monero_wallet),
config, config,
db: open_db(db_path, AccessMode::ReadWrite) db: open_db(db_path, AccessMode::ReadWrite, None)
.await .await
.expect("Could not open sqlite database"), .expect("Could not open sqlite database"),
monero_rpc_process: None, monero_rpc_process: None,

View file

@ -478,52 +478,36 @@ pub async fn get_swap_info(
.await? .await?
.iter() .iter()
.find_map(|state| { .find_map(|state| {
if let State::Bob(BobState::SwapSetupCompleted(state2)) = state { let State::Bob(BobState::SwapSetupCompleted(state2)) = state else {
let xmr_amount = state2.xmr; return None;
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() { let xmr_amount = state2.xmr;
Some(( let btc_amount = state2.tx_lock.lock_amount();
xmr_amount, let tx_cancel_fee = state2.tx_cancel_fee;
btc_amount, let tx_refund_fee = state2.tx_refund_fee;
tx_lock_id, let tx_lock_id = state2.tx_lock.txid();
tx_cancel_fee, let btc_refund_address = state2.refund_address.to_string();
tx_refund_fee,
tx_lock_fee, let Ok(tx_lock_fee) = state2.tx_lock.fee() else {
btc_refund_address, return None;
state2.cancel_timelock, };
state2.punish_timelock,
)) Some((
} else { xmr_amount,
None btc_amount,
} tx_lock_id,
} else { tx_cancel_fee,
None tx_refund_fee,
} tx_lock_fee,
btc_refund_address,
state2.cancel_timelock,
state2.punish_timelock,
))
}) })
.with_context(|| "Did not find SwapSetupCompleted state for swap")?; .with_context(|| "Did not find SwapSetupCompleted state for swap")?;
let timelock = match swap_state.clone() { let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?;
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,
};
Ok(GetSwapInfoResponse { Ok(GetSwapInfoResponse {
swap_id: args.swap_id, 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 anyhow::Result;
use bitcoin::Txid; use bitcoin::Txid;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -7,9 +7,11 @@ use typeshare::typeshare;
use url::Url; use url::Url;
use uuid::Uuid; 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 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)] #[derive(Debug, Clone)]
pub struct TauriHandle( pub struct TauriHandle(
@ -50,11 +52,25 @@ pub trait TauriEmitter {
let _ = self.emit_tauri_event(CONTEXT_INIT_PROGRESS_EVENT_NAME, event); 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 let _ = self
.emit_tauri_event(CLI_LOG_EMITTED_EVENT_NAME, event) .emit_tauri_event(CLI_LOG_EMITTED_EVENT_NAME, event)
.ok(); .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 { impl TauriEmitter for TauriHandle {
@ -174,11 +190,26 @@ pub enum TauriSwapProgressEvent {
#[typeshare] #[typeshare]
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
#[typeshare] #[typeshare]
pub struct CliLogEmittedEvent { pub struct TauriLogEvent {
/// The serialized object containing the log message and metadata. /// The serialized object containing the log message and metadata.
pub buffer: String, 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 /// This struct contains the settings for the Context
#[typeshare] #[typeshare]
#[derive(Debug, Serialize, Deserialize, Clone)] #[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) => {{ ($pattern:expr, $create_placeholder:expr, $replacements:expr, $input:expr) => {{
// compile the regex pattern // compile the regex pattern
static REGEX: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| { 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") 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::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter, Layer}; 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. /// Output formats for logging messages.
pub enum Format { pub enum Format {
@ -63,13 +63,13 @@ pub fn init(
tracing_subscriber::registry() tracing_subscriber::registry()
.with(file_layer) .with(file_layer)
.with(tauri_layer) .with(tauri_layer)
.with(terminal_layer.json().with_filter(level_filter)) .with(terminal_layer.json().with_filter(env_filter(level_filter)?))
.try_init()?; .try_init()?;
} else { } else {
tracing_subscriber::registry() tracing_subscriber::registry()
.with(file_layer) .with(file_layer)
.with(tauri_layer) .with(tauri_layer)
.with(terminal_layer.with_filter(level_filter)) .with(terminal_layer.with_filter(env_filter(level_filter)?))
.try_init()?; .try_init()?;
} }
@ -121,7 +121,7 @@ impl std::io::Write for TauriWriter {
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
// Then send to tauri // Then send to tauri
self.tauri_handle.emit_cli_log_event(CliLogEmittedEvent { self.tauri_handle.emit_cli_log_event(TauriLogEvent {
buffer: utf8_string, buffer: utf8_string,
}); });

View file

@ -2,6 +2,7 @@ pub use alice::Alice;
pub use bob::Bob; pub use bob::Bob;
pub use sqlite::SqliteDatabase; pub use sqlite::SqliteDatabase;
use crate::cli::api::tauri_bindings::TauriHandle;
use crate::fs::ensure_directory_exists; use crate::fs::ensure_directory_exists;
use crate::protocol::{Database, State}; use crate::protocol::{Database, State};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
@ -92,16 +93,25 @@ pub enum AccessMode {
pub async fn open_db( pub async fn open_db(
sqlite_path: impl AsRef<Path>, sqlite_path: impl AsRef<Path>,
access_mode: AccessMode, access_mode: AccessMode,
tauri_handle: impl Into<Option<TauriHandle>>,
) -> Result<Arc<dyn Database + Send + Sync>> { ) -> Result<Arc<dyn Database + Send + Sync>> {
if sqlite_path.as_ref().exists() { if sqlite_path.as_ref().exists() {
tracing::debug!("Using existing sqlite database."); 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)) Ok(Arc::new(sqlite))
} else { } else {
tracing::debug!("Creating and using new sqlite database."); tracing::debug!("Creating and using new sqlite database.");
ensure_directory_exists(sqlite_path.as_ref())?; ensure_directory_exists(sqlite_path.as_ref())?;
tokio::fs::File::create(&sqlite_path).await?; 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)) 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::database::Swap;
use crate::monero::{Address, TransferProof}; use crate::monero::{Address, TransferProof};
use crate::protocol::{Database, State}; use crate::protocol::{Database, State};
@ -15,6 +17,7 @@ use super::AccessMode;
pub struct SqliteDatabase { pub struct SqliteDatabase {
pool: Pool<Sqlite>, pool: Pool<Sqlite>,
tauri_handle: Option<TauriHandle>,
} }
impl SqliteDatabase { impl SqliteDatabase {
@ -30,7 +33,10 @@ impl SqliteDatabase {
let options = options.disable_statement_logging(); let options = options.disable_statement_logging();
let pool = SqlitePool::connect_with(options.to_owned()).await?; 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 { if !read_only {
sqlite.run_migrations().await?; sqlite.run_migrations().await?;
@ -39,6 +45,11 @@ impl SqliteDatabase {
Ok(sqlite) 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<()> { async fn run_migrations(&mut self) -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?; sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(()) Ok(())
@ -204,9 +215,9 @@ impl Database for SqliteDatabase {
let mut conn = self.pool.acquire().await?; let mut conn = self.pool.acquire().await?;
let entered_at = OffsetDateTime::now_utc(); let entered_at = OffsetDateTime::now_utc();
let swap_id = swap_id.to_string();
let swap = serde_json::to_string(&Swap::from(state))?; let swap = serde_json::to_string(&Swap::from(state))?;
let entered_at = entered_at.to_string(); let entered_at = entered_at.to_string();
let swap_id_str = swap_id.to_string();
sqlx::query!( sqlx::query!(
r#" r#"
@ -216,13 +227,17 @@ impl Database for SqliteDatabase {
state state
) values (?, ?, ?); ) values (?, ?, ?);
"#, "#,
swap_id, swap_id_str,
entered_at, entered_at,
swap swap
) )
.execute(&mut conn) .execute(&mut conn)
.await?; .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(()) Ok(())
} }

View file

@ -1,7 +1,7 @@
use crate::bitcoin::wallet::{EstimateFeeRate, Subscription}; use crate::bitcoin::wallet::{EstimateFeeRate, Subscription};
use crate::bitcoin::{ use crate::bitcoin::{
self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel, self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel,
TxLock, Txid, TxLock, Txid, Wallet,
}; };
use crate::monero::wallet::WatchRequest; use crate::monero::wallet::WatchRequest;
use crate::monero::{self, TxHash}; use crate::monero::{self, TxHash};
@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize};
use sha2::Sha256; use sha2::Sha256;
use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof; use sigma_fun::ext::dl_secp256k1_ed25519_eq::CrossCurveDLEQProof;
use std::fmt; use std::fmt;
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Serialize)] #[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)] #[derive(Clone, Debug, PartialEq)]
pub struct State0 { pub struct State0 {
swap_id: Uuid, swap_id: Uuid,

View file

@ -1,3 +1,4 @@
use crate::bitcoin::wallet::ScriptStatus;
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund}; use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent}; use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent};
use crate::cli::EventLoopHandle; use crate::cli::EventLoopHandle;
@ -119,12 +120,17 @@ async fn next_state(
let monero_wallet_restore_blockheight = monero_wallet.block_height().await?; let monero_wallet_restore_blockheight = monero_wallet.block_height().await?;
// Alice and Bob have exchanged info // Alice and Bob have exchanged info
// Sign the Bitcoin lock transaction
let (state3, tx_lock) = state2.lock_btc().await?; let (state3, tx_lock) = state2.lock_btc().await?;
let signed_tx = bitcoin_wallet let signed_tx = bitcoin_wallet
.sign_and_finalize(tx_lock.clone().into()) .sign_and_finalize(tx_lock.clone().into())
.await .await
.context("Failed to sign Bitcoin lock transaction")?; .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( event_emitter.emit_swap_progress_event(
swap_id, swap_id,
TauriSwapProgressEvent::Started { TauriSwapProgressEvent::Started {
@ -134,8 +140,6 @@ async fn next_state(
}, },
); );
let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?;
BobState::BtcLocked { BobState::BtcLocked {
state3, state3,
monero_wallet_restore_blockheight, 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 // 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 transfer_proof_watcher = event_loop_handle.recv_transfer_proof();
let cancel_timelock_expires = let cancel_timelock_expires = tx_lock_status.wait_until(|status| {
tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock); // 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! { select! {
// Alice sent us the transfer proof for the Monero she locked // Alice sent us the transfer proof for the Monero she locked