diff --git a/CHANGELOG.md b/CHANGELOG.md index c327a227..275940ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: The user will now be asked to approve the swap offer again before the Bitcoin lock transaction is published. Makers should take care to only assume a swap has been accepted by the taker if the Bitcoin lock transaction is detected (`Advancing state state=bitcoin lock transaction in mempool ...`). Swaps that have been safely aborted will not be displayed in the GUI anymore. + ## [1.0.0-rc.16] - 2025-04-17 - ASB: Quotes are now cached (Time-to-live of 2 minutes) to avoid overloading the maker with requests in times of high demand diff --git a/Cargo.lock b/Cargo.lock index 2aa5782d..c1732887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3309,6 +3309,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + [[package]] name = "ghash" version = "0.5.1" @@ -7126,6 +7138,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "radium" version = "0.7.0" @@ -11463,6 +11481,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tracing", + "uuid", ] [[package]] @@ -11527,11 +11546,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.11.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.3.2", "serde", ] @@ -11667,6 +11686,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -12407,6 +12435,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/src-gui/.env.development b/src-gui/.env.development index 76c862e4..9d7d3079 100644 --- a/src-gui/.env.development +++ b/src-gui/.env.development @@ -1,2 +1,2 @@ # You can configure the address of a locally running testnet asb. It'll displayed in the GUI. This is useful for testing -VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/clztcslas7hlfrprevcdo3l6bczwa3cumr2b5up5nsumsj7sqgd3p2qd:9939/p2p/12D3KooWS1DtT4JmZoAS6m4wZcxXnUB3eVFNvW8hSPrAyCtVSSYm \ No newline at end of file +VITE_TESTNET_STUB_PROVIDER_ADDRESS=/onion3/dmdrgmy27szmps3p5zqh4ujd7twoi2a5ao7mouugfg6owyj4ikd2h5yd:9939/p2p/12D3KooWCa6vLE6SFhEBs3EhsC5tCBoHKBLoLEo1riDDmcExr5BW \ No newline at end of file diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index d64813a3..ed295642 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -1,5 +1,6 @@ import { exhaustiveGuard } from "utils/typescriptUtils"; import { + ApprovalRequest, ExpiredTimelocks, GetSwapInfoResponse, TauriSwapProgressEvent, @@ -209,3 +210,23 @@ export function isGetSwapInfoResponseWithTimelock( ): response is GetSwapInfoResponseExtWithTimelock { return response.timelock !== null; } + +export type PendingApprovalRequest = Extract; + +export type PendingLockBitcoinApprovalRequest = PendingApprovalRequest & { + content: { + details: { type: "LockBitcoin" }; + }; +}; + +export function isPendingLockBitcoinApprovalEvent( + event: ApprovalRequest, +): event is PendingLockBitcoinApprovalRequest { + // Check if the request is pending + if (event.state !== "Pending") { + return false; + } + + // Check if the request is a LockBitcoin request + return event.content.details.type === "LockBitcoin"; +} diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 37819d5c..60e532f7 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -1,12 +1,11 @@ import { listen } from "@tauri-apps/api/event"; -import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, TauriTorEvent } from "models/tauriModel"; -import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState } from "store/features/rpcSlice"; +import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent, BalanceResponse, TauriDatabaseStateEvent, TauriTimelockChangeEvent, TauriBackgroundRefundEvent, ApprovalRequest } from "models/tauriModel"; +import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState, approvalEventReceived } from "store/features/rpcSlice"; import { swapProgressEventReceived } from "store/features/swapSlice"; import logger from "utils/logger"; import { updatePublicRegistry, updateRates } from "./api"; import { checkContextAvailability, getSwapInfo, initializeContext, updateAllNodeStatuses } from "./rpc"; import { store } from "./store/storeRenderer"; -import { torEventReceived } from "store/features/torSlice"; // Update the public registry every 5 minutes const PROVIDER_UPDATE_INTERVAL = 5 * 60 * 1_000; @@ -84,4 +83,9 @@ export async function setupBackgroundTasks(): Promise { logger.info('Received background refund event', event.payload); store.dispatch(rpcSetBackgroundRefundState(event.payload)); }) + + listen("approval_event", (event) => { + logger.info("Received approval_event:", event.payload); + store.dispatch(approvalEventReceived(event.payload)); + }); } \ No newline at end of file diff --git a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx b/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx index 2bf67745..f24999ef 100644 --- a/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx +++ b/src-gui/src/renderer/components/alert/RemainingFundsWillBeUsedAlert.tsx @@ -26,9 +26,7 @@ export default function RemainingFundsWillBeUsedAlert() { variant="filled" > The remaining funds of in the wallet - will be used for the next swap. If the remaining funds exceed the - minimum swap amount of the maker, a swap will be initiated - instantaneously. + will be used for the next swap ); diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx b/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx index d91ce404..104b6da7 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/in_progress/ReceivedQuotePage.tsx @@ -2,6 +2,6 @@ import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; export default function ReceivedQuotePage() { return ( - + ); } diff --git a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx index 2c0fa5bf..997a3369 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/in_progress/SwapSetupInflightPage.tsx @@ -1,18 +1,152 @@ -import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import { SatsAmount } from "renderer/components/other/Units"; -import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; +import { useState, useEffect } from 'react'; +import { resolveApproval } from 'renderer/rpc'; +import { PendingLockBitcoinApprovalRequest, TauriSwapProgressEventContent } from 'models/tauriModelExt'; +import { + SatsAmount, + PiconeroAmount, + MoneroBitcoinExchangeRateFromAmounts +} from 'renderer/components/other/Units'; +import { + Box, + Typography, + Divider, +} from '@material-ui/core'; +import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; +import { useActiveSwapId, usePendingLockBitcoinApproval } from 'store/hooks'; +import PromiseInvokeButton from 'renderer/components/PromiseInvokeButton'; +import InfoBox from 'renderer/components/modal/swap/InfoBox'; +import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle'; +import CheckIcon from '@material-ui/icons/Check'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + detailGrid: { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + rowGap: theme.spacing(1), + columnGap: theme.spacing(2), + alignItems: 'center', + marginBlock: theme.spacing(2), + }, + label: { + color: theme.palette.text.secondary, + }, + receiveValue: { + fontWeight: 'bold', + color: theme.palette.success.main, + }, + actions: { + marginTop: theme.spacing(2), + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing(2), + }, + cancelButton: { + color: theme.palette.text.secondary, + }, + }) +); + +/// A hook that returns the LockBitcoin confirmation request for the active swap +/// Returns null if no confirmation request is found +function useActiveLockBitcoinApprovalRequest(): PendingLockBitcoinApprovalRequest | null { + const approvals = usePendingLockBitcoinApproval(); + const activeSwapId = useActiveSwapId(); + + return approvals + ?.find(r => r.content.details.content.swap_id === activeSwapId) || null; +} export default function SwapSetupInflightPage({ btc_lock_amount, - btc_tx_lock_fee, -}: TauriSwapProgressEventContent<"SwapSetupInflight">) { +}: TauriSwapProgressEventContent<'SwapSetupInflight'>) { + const classes = useStyles(); + const request = useActiveLockBitcoinApprovalRequest(); + + const [timeLeft, setTimeLeft] = useState(0); + + const expiresAtMs = request?.content.expiration_ts * 1000 || 0; + + useEffect(() => { + const tick = () => { + const remainingMs = Math.max(expiresAtMs - Date.now(), 0); + setTimeLeft(Math.ceil(remainingMs / 1000)); + }; + + tick(); + const id = setInterval(tick, 250); + return () => clearInterval(id); + }, [expiresAtMs]); + + // If we do not have an approval request yet for the Bitcoin lock transaction, we haven't received the offer from Alice yet + // Display a loading spinner to the user for as long as the swap_setup request is in flight + if (!request) { + return Negotiating offer for } />; + } + + const { btc_network_fee, xmr_receive_amount } = request.content.details.content; + return ( - } + loading={false} + mainContent={ <> - Starting swap with maker to lock + + + You send + + + + + Bitcoin network fees + + + + + You receive + + + + + Exchange rate + + + + } + additionalContent={ + + resolveApproval(request.content.request_id, false)} + displayErrorSnackbar + requiresContext + > + Deny + + + resolveApproval(request.content.request_id, true)} + displayErrorSnackbar + requiresContext + endIcon={} + > + {`Confirm & lock BTC (${timeLeft}s)`} + + + } /> ); -} +} \ No newline at end of file diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx index 5cd73797..b7e0afc7 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/init/InitPage.tsx @@ -109,7 +109,7 @@ export default function InitPage() { onInvoke={init} displayErrorSnackbar > - Request quote and start swap + Begin swap diff --git a/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx b/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx index f6bc169e..08785f4a 100644 --- a/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx +++ b/src-gui/src/renderer/components/modal/swap/pages/init/WaitingForBitcoinDepositPage.tsx @@ -1,6 +1,5 @@ import { Box, makeStyles, Typography } from "@material-ui/core"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; -import { useAppSelector } from "store/hooks"; import BitcoinIcon from "../../../../icons/BitcoinIcon"; import { MoneroSatsExchangeRate, SatsAmount } from "../../../../other/Units"; import DepositAddressInfoBox from "../../DepositAddressInfoBox"; @@ -57,18 +56,17 @@ export default function WaitingForBtcDepositPage({ )}
  • - All Bitcoin sent to this this address will converted into - Monero at an exchance rate of{" "} + Bitcoin sent to this this address will be converted into + Monero at an exchange rate of{" ≈ "}
  • - The network fee of{" "} + The Network fee of{" ≈ "} will automatically be deducted from the deposited coins
  • - The swap will start automatically as soon as the minimum - amount is deposited. + After the deposit is detected, you'll get to confirm the exact details before your funds are locked
  • { is_testnet: testnet, }); } + +export async function resolveApproval(requestId: string, accept: boolean): Promise { + await invoke("resolve_approval_request", { request_id: requestId, accept }); +} diff --git a/src-gui/src/store/features/rpcSlice.ts b/src-gui/src/store/features/rpcSlice.ts index 3c547547..2cb61f69 100644 --- a/src-gui/src/store/features/rpcSlice.ts +++ b/src-gui/src/store/features/rpcSlice.ts @@ -6,6 +6,7 @@ import { TauriContextStatusEvent, TauriTimelockChangeEvent, BackgroundRefundState, + ApprovalRequest, } from "models/tauriModel"; import { MoneroRecoveryResponse } from "../../models/rpcModel"; import { GetSwapInfoResponseExt } from "models/tauriModelExt"; @@ -32,6 +33,10 @@ interface State { swapId: string; state: BackgroundRefundState; } | null; + approvalRequests: { + // Store the full event, keyed by request_id + [requestId: string]: ApprovalRequest; + }; } export interface RPCSlice { @@ -52,6 +57,7 @@ const initialState: RPCSlice = { updateState: false, }, backgroundRefund: null, + approvalRequests: {}, }, logs: [], }; @@ -138,6 +144,11 @@ export const rpcSlice = createSlice({ state: action.payload.state, }; }, + approvalEventReceived(slice, action: PayloadAction) { + const event = action.payload; + const requestId = event.content.request_id; + slice.state.approvalRequests[requestId] = event; + }, }, }); @@ -152,7 +163,8 @@ export const { rpcSetMoneroRecoveryKeys, rpcResetMoneroRecoveryKeys, rpcSetBackgroundRefundState, - timelockChangeEventReceived + timelockChangeEventReceived, + approvalEventReceived, } = rpcSlice.actions; export default rpcSlice.reducer; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index b5697807..29b1774e 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -1,5 +1,5 @@ import { sortBy } from "lodash"; -import { BobStateName, GetSwapInfoResponseExt } from "models/tauriModelExt"; +import { BobStateName, GetSwapInfoResponseExt, PendingApprovalRequest, PendingLockBitcoinApprovalRequest } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; import { parseDateString } from "utils/parseUtils"; @@ -16,22 +16,29 @@ export const useAppSelector: TypedUseSelectorHook = useSelector; export function useResumeableSwapsCount( additionalFilter?: (s: GetSwapInfoResponseExt) => boolean, ) { + const saneSwapInfos = useSaneSwapInfos(); + return useAppSelector( (state) => - Object.values(state.rpc.state.swapInfos).filter( + saneSwapInfos.filter( (swapInfo: GetSwapInfoResponseExt) => !swapInfo.completed && (additionalFilter == null || additionalFilter(swapInfo)) ).length, ); } - +/** + * Counts the number of resumeable swaps excluding: + * - Punished swaps + * - Swaps where the sanity check was not passed (e.g. they were aborted) + */ export function useResumeableSwapsCountExcludingPunished() { return useResumeableSwapsCount( - (s) => s.state_name !== BobStateName.BtcPunished, + (s) => s.state_name !== BobStateName.BtcPunished && s.state_name !== BobStateName.SwapSetupCompleted, ); } +/// Returns true if we have a swap that is running export function useIsSwapRunning() { return useAppSelector( (state) => @@ -43,6 +50,8 @@ export function useIsContextAvailable() { return useAppSelector((state) => state.rpc.status?.type === "Available"); } +/// We do not use a sanity check here, as opposed to the other useSwapInfo hooks, +/// because we are explicitly asking for a specific swap export function useSwapInfo( swapId: string | null, ): GetSwapInfoResponseExt | null { @@ -51,7 +60,7 @@ export function useSwapInfo( ); } -export function useActiveSwapId() { +export function useActiveSwapId(): string | null { return useAppSelector((s) => s.swap.state?.swapId ?? null); } @@ -80,11 +89,36 @@ export function useAllMakers() { }); } -export function useSwapInfosSortedByDate() { +/// This hook returns the all swap infos, as an array +/// Excluding those who are in a state where it's better to hide them from the user +export function useSaneSwapInfos() { const swapInfos = useAppSelector((state) => state.rpc.state.swapInfos); + return Object.values(swapInfos).filter((swap) => { + // We hide swaps that are in the SwapSetupCompleted state + // This is because they are probably ones where: + // 1. The user force stopped the swap while we were waiting for their confirmation of the offer + // 2. We where therefore unable to transition to SafelyAborted + if (swap.state_name === BobStateName.SwapSetupCompleted) { + return false; + } + + // We hide swaps that were safely aborted + // No funds were locked. Cannot be resumed. + // Wouldn't be beneficial to show them to the user + if (swap.state_name === BobStateName.SafelyAborted) { + return false; + } + + return true; + }); +} + +/// This hook returns the swap infos sorted by date +export function useSwapInfosSortedByDate() { + const swapInfos = useSaneSwapInfos(); return sortBy( - Object.values(swapInfos), + swapInfos, (swap) => -parseDateString(swap.start_date), ); } @@ -103,3 +137,13 @@ export function useNodes(selector: (nodes: NodesSlice) => T): T { const nodes = useAppSelector((state) => state.nodes); return selector(nodes); } + +export function usePendingApprovals(): PendingApprovalRequest[] { + const approvals = useAppSelector((state) => state.rpc.state.approvalRequests); + return Object.values(approvals).filter((c) => c.state === "Pending"); +} + +export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] { + const approvals = usePendingApprovals(); + return approvals.filter((c) => c.content.details.type === "LockBitcoin"); +} \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d905c605..1b681296 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-shell = "^2.0.0" tauri-plugin-store = "^2.0.0" tauri-plugin-updater = "^2.1.0" tracing = "0.1" +uuid = "1.16.0" [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-cli = "^2.0.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38521efe..bf2c6299 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,8 @@ use swap::cli::{ CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, - MoneroRecoveryArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, + MoneroRecoveryArgs, ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, + WithdrawBtcArgs, }, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, Context, ContextBuilder, @@ -17,6 +18,7 @@ use swap::cli::{ command::{Bitcoin, Monero}, }; use tauri::{async_runtime::RwLock, Manager, RunEvent}; +use uuid::Uuid; /// Trait to convert Result to Result /// Tauri commands require the error type to be a string @@ -183,7 +185,8 @@ pub fn run() { check_monero_node, check_electrum_node, get_wallet_descriptor, - get_data_dir + get_data_dir, + resolve_approval_request, ]) .setup(setup) .build(tauri::generate_context!()) @@ -224,6 +227,7 @@ tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); tauri_command!(list_sellers, ListSellersArgs); tauri_command!(cancel_and_refund, CancelAndRefundArgs); +tauri_command!(resolve_approval_request, ResolveApprovalArgs); // These commands require no arguments tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 9794b6ae..b9226a67 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -194,7 +194,6 @@ pub struct Context { } /// A conveniant builder struct for [`Context`]. -#[derive(Debug)] #[must_use = "ContextBuilder must be built to be useful"] pub struct ContextBuilder { monero: Option, @@ -512,6 +511,10 @@ impl Context { pub fn bitcoin_wallet(&self) -> Option> { self.bitcoin_wallet.clone() } + + pub fn tauri_handle(&self) -> Option { + self.tauri_handle.clone() + } } impl fmt::Debug for Context { diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index f45cd765..57cd19e0 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1375,3 +1375,32 @@ impl CheckElectrumNodeArgs { }) } } + +#[typeshare] +#[derive(Deserialize, Serialize)] +pub struct ResolveApprovalArgs { + pub request_id: String, + pub accept: bool, +} + +#[typeshare] +#[derive(Deserialize, Serialize)] +pub struct ResolveApprovalResponse { + pub success: bool, +} + +impl Request for ResolveApprovalArgs { + type Response = ResolveApprovalResponse; + + async fn request(self, ctx: Arc) -> Result { + let request_id = Uuid::parse_str(&self.request_id).context("Invalid request ID")?; + + if let Some(handle) = ctx.tauri_handle.clone() { + handle.resolve_approval(request_id, self.accept).await?; + } else { + bail!("Cannot resolve approval without a Tauri handle"); + } + + Ok(ResolveApprovalResponse { success: true }) + } +} diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 0fe88688..a36c78ab 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -1,8 +1,15 @@ +use crate::bitcoin; use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote}; -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; use bitcoin::Txid; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use strum::Display; +use tokio::sync::{oneshot, Mutex as TokioMutex}; use typeshare::typeshare; use url::Url; use uuid::Uuid; @@ -16,12 +23,70 @@ const TIMELOCK_CHANGE_EVENT_NAME: &str = "timelock-change"; const CONTEXT_INIT_PROGRESS_EVENT_NAME: &str = "context-init-progress-update"; const BALANCE_CHANGE_EVENT_NAME: &str = "balance-change"; const BACKGROUND_REFUND_EVENT_NAME: &str = "background-refund"; +const APPROVAL_EVENT_NAME: &str = "approval_event"; -#[derive(Debug, Clone)] +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LockBitcoinDetails { + #[typeshare(serialized_as = "number")] + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub btc_lock_amount: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + #[serde(with = "::bitcoin::util::amount::serde::as_sat")] + pub btc_network_fee: bitcoin::Amount, + #[typeshare(serialized_as = "number")] + pub xmr_receive_amount: monero::Amount, + #[typeshare(serialized_as = "string")] + pub swap_id: Uuid, +} + +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum ApprovalRequestDetails { + /// Request approval before locking Bitcoin. + /// Contains specific details for review. + LockBitcoin(LockBitcoinDetails), +} + +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "state", content = "content")] +pub enum ApprovalRequest { + Pending { + request_id: String, + #[typeshare(serialized_as = "number")] + expiration_ts: u64, + details: ApprovalRequestDetails, + }, + Resolved { + request_id: String, + details: ApprovalRequestDetails, + }, + Rejected { + request_id: String, + details: ApprovalRequestDetails, + }, +} + +struct PendingApproval { + responder: Option>, + details: ApprovalRequestDetails, + #[allow(dead_code)] + expiration_ts: u64, +} + +#[cfg(feature = "tauri")] +struct TauriHandleInner { + app_handle: tauri::AppHandle, + pending_approvals: TokioMutex>, +} + +#[derive(Clone)] pub struct TauriHandle( #[cfg(feature = "tauri")] #[cfg_attr(feature = "tauri", allow(unused))] - std::sync::Arc, + Arc, ); impl TauriHandle { @@ -29,20 +94,146 @@ impl TauriHandle { pub fn new(tauri_handle: tauri::AppHandle) -> Self { Self( #[cfg(feature = "tauri")] - std::sync::Arc::new(tauri_handle), + Arc::new(TauriHandleInner { + app_handle: tauri_handle, + pending_approvals: TokioMutex::new(HashMap::new()), + }), ) } #[allow(unused_variables)] pub fn emit_tauri_event(&self, event: &str, payload: S) -> Result<()> { #[cfg(feature = "tauri")] - tauri::Emitter::emit(self.0.as_ref(), event, payload).map_err(anyhow::Error::from)?; + { + let inner = self.0.as_ref(); + tauri::Emitter::emit(&inner.app_handle, event, payload).map_err(anyhow::Error::from)?; + } Ok(()) } + + /// Helper to emit a approval event via the unified event name + fn emit_approval(&self, event: ApprovalRequest) -> Result<()> { + self.emit_tauri_event(APPROVAL_EVENT_NAME, event) + } + + pub async fn request_approval( + &self, + request_type: ApprovalRequestDetails, + timeout_secs: u64, + ) -> Result { + #[cfg(not(feature = "tauri"))] + { + return Ok(true); + } + + #[cfg(feature = "tauri")] + { + // Compute absolute expiration timestamp, and UUID for the request + let request_id = Uuid::new_v4(); + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expiration_ts = now_secs + timeout_secs; + + // Build the approval event + let details = request_type.clone(); + let pending_event = ApprovalRequest::Pending { + request_id: request_id.to_string(), + expiration_ts, + details: details.clone(), + }; + + // Emit the creation of the approval request to the frontend + self.emit_approval(pending_event.clone())?; + + tracing::debug!(%request_id, request=?pending_event, "Emitted approval request event"); + + // Construct the data structure we use to internally track the approval request + let (responder, receiver) = oneshot::channel(); + let timeout_duration = Duration::from_secs(timeout_secs); + + let pending = PendingApproval { + responder: Some(responder), + details: request_type.clone(), + expiration_ts, + }; + + // Lock map and insert the pending approval + { + let mut pending_map = self.0.pending_approvals.lock().await; + pending_map.insert(request_id, pending); + } + + // Determine if the request will be accepted or rejected + // Either by being resolved by the user, or by timing out + let accepted = tokio::select! { + res = receiver => res.map_err(|_| anyhow!("Approval responder dropped"))?, + _ = tokio::time::sleep(timeout_duration) => { + tracing::debug!(%request_id, "Approval request timed out and was therefore rejected"); + false + }, + }; + + let mut map = self.0.pending_approvals.lock().await; + if let Some(pending) = map.remove(&request_id) { + let event = if accepted { + ApprovalRequest::Resolved { + request_id: request_id.to_string(), + details: pending.details, + } + } else { + ApprovalRequest::Rejected { + request_id: request_id.to_string(), + details: pending.details, + } + }; + + self.emit_approval(event)?; + tracing::debug!(%request_id, %accepted, "Resolved approval request"); + } + + Ok(accepted) + } + } + + pub async fn resolve_approval(&self, request_id: Uuid, accepted: bool) -> Result<()> { + #[cfg(not(feature = "tauri"))] + { + return Err(anyhow!( + "Cannot resolve approval: Tauri feature not enabled." + )); + } + + #[cfg(feature = "tauri")] + { + let mut pending_map = self.0.pending_approvals.lock().await; + if let Some(pending) = pending_map.get_mut(&request_id) { + let _ = pending + .responder + .take() + .context("Approval responder was already consumed")? + .send(accepted); + + Ok(()) + } else { + Err(anyhow!("Approval not found or already handled")) + } + } + } } pub trait TauriEmitter { + fn request_approval<'life0, 'async_trait>( + &'life0 self, + request_type: ApprovalRequestDetails, + timeout_secs: u64, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait; + fn emit_tauri_event(&self, event: &str, payload: S) -> Result<()>; fn emit_swap_progress_event(&self, swap_id: Uuid, event: TauriSwapProgressEvent) { @@ -94,6 +285,18 @@ pub trait TauriEmitter { } impl TauriEmitter for TauriHandle { + fn request_approval<'life0, 'async_trait>( + &'life0 self, + request_type: ApprovalRequestDetails, + timeout_secs: u64, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(self.request_approval(request_type, timeout_secs)) + } + fn emit_tauri_event(&self, event: &str, payload: S) -> Result<()> { self.emit_tauri_event(event, payload) } @@ -103,9 +306,28 @@ impl TauriEmitter for Option { fn emit_tauri_event(&self, event: &str, payload: S) -> Result<()> { match self { Some(tauri) => tauri.emit_tauri_event(event, payload), + + // If no TauriHandle is available, we just ignore the event and pretend as if it was emitted None => Ok(()), } } + + fn request_approval<'life0, 'async_trait>( + &'life0 self, + request_type: ApprovalRequestDetails, + timeout_secs: u64, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + match self { + Some(tauri) => tauri.request_approval(request_type, timeout_secs).await, + None => Ok(true), + } + }) + } } #[typeshare] diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index d8cf8b1d..5813bbf3 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -81,7 +81,7 @@ where Err(_) => { tracing::info!( minutes = %env_config.bitcoin_lock_mempool_timeout.as_secs_f64() / 60.0, - "TxLock lock was not seen in mempool in time", + "TxLock lock was not seen in mempool in time. Alice might have denied our offer.", ); AliceState::SafelyAborted } diff --git a/swap/src/protocol/bob/swap.rs b/swap/src/protocol/bob/swap.rs index 77fce91f..297f8d06 100644 --- a/swap/src/protocol/bob/swap.rs +++ b/swap/src/protocol/bob/swap.rs @@ -1,17 +1,22 @@ use crate::bitcoin::wallet::ScriptStatus; use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund}; -use crate::cli::api::tauri_bindings::{TauriEmitter, TauriHandle, TauriSwapProgressEvent}; +use crate::cli::api::tauri_bindings::ApprovalRequestDetails; +use crate::cli::api::tauri_bindings::{ + LockBitcoinDetails, TauriEmitter, TauriHandle, TauriSwapProgressEvent, +}; use crate::cli::EventLoopHandle; use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected}; use crate::network::swap_setup::bob::NewSwap; use crate::protocol::bob::state::*; use crate::protocol::{bob, Database}; use crate::{bitcoin, monero}; -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Context as AnyContext, Result}; use std::sync::Arc; use tokio::select; use uuid::Uuid; +const PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS: u64 = 120; + pub fn is_complete(state: &BobState) -> bool { matches!( state, @@ -145,6 +150,8 @@ async fn next_state( // which can lead to the wallet not detect the transaction. let monero_wallet_restore_blockheight = monero_wallet.block_height().await?; + let xmr_receive_amount = state2.xmr; + // Alice and Bob have exchanged info // Sign the Bitcoin lock transaction let (state3, tx_lock) = state2.lock_btc().await?; @@ -153,12 +160,50 @@ async fn next_state( .await .context("Failed to sign Bitcoin lock transaction")?; - // Publish the signed Bitcoin lock transaction - let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?; + let btc_network_fee = tx_lock.fee().context("Failed to get fee")?; + let btc_lock_amount = bitcoin::Amount::from_sat( + signed_tx + .output + .get(0) + .context("Failed to get lock amount")? + .value, + ); - BobState::BtcLocked { - state3, - monero_wallet_restore_blockheight, + let request = ApprovalRequestDetails::LockBitcoin(LockBitcoinDetails { + btc_lock_amount, + btc_network_fee, + xmr_receive_amount, + swap_id, + }); + + // We request approval before publishing the Bitcoin lock transaction, as the exchange rate determined at this step might be different from the + // we previously displayed to the user. + let approval_result = event_emitter + .request_approval(request, PRE_BTC_LOCK_APPROVAL_TIMEOUT_SECS) + .await; + + match approval_result { + Ok(true) => { + tracing::debug!("User approved swap offer"); + + // Publish the signed Bitcoin lock transaction + let (..) = bitcoin_wallet.broadcast(signed_tx, "lock").await?; + + BobState::BtcLocked { + state3, + monero_wallet_restore_blockheight, + } + } + Ok(false) => { + tracing::warn!("User denied or timed out on swap offer approval"); + + BobState::SafelyAborted + } + Err(err) => { + tracing::warn!(%err, "Failed to get user approval for swap offer. Assuming swap was aborted."); + + BobState::SafelyAborted + } } } // Bob has locked Bitcoin