From e64567ed8d28b03472f9fded02e9b24deb3fac6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Tue, 18 Nov 2025 21:41:45 +0100 Subject: [PATCH 01/13] refactor(gui): lift bitcoin wallet initialisation to common function like monero --- src-gui/src/renderer/rpc.ts | 15 ++++++++++++++- src-gui/src/store/middleware/storeListener.ts | 11 ++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 01a8098ef..70679c871 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -60,7 +60,7 @@ import { timelockChangeEventReceived, } from "store/features/rpcSlice"; import { selectAllSwapIds } from "store/selectors"; -import { setBitcoinBalance } from "store/features/bitcoinWalletSlice"; +import { setBitcoinAddress, setBitcoinBalance } from "store/features/bitcoinWalletSlice"; import { setMainAddress, setBalance, @@ -587,6 +587,19 @@ export async function getMoneroSeedAndRestoreHeight(): Promise< } // Wallet management functions that handle Redux dispatching +export async function initializeBitcoinWallet() { + try { + await Promise.all([ + checkBitcoinBalance(), + getBitcoinAddress().then((address) => { + store.dispatch(setBitcoinAddress(address)); + }) + ]); + } catch (err) { + console.error("Failed to fetch Bitcoin wallet data:", err); + } +} + export async function initializeMoneroWallet() { try { await Promise.all([ diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index b0917d478..3212a8a4b 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -3,8 +3,7 @@ import { throttle, debounce } from "lodash"; import { getAllSwapInfos, getAllSwapTimelocks, - checkBitcoinBalance, - getBitcoinAddress, + initializeBitcoinWallet, updateAllNodeStatuses, fetchSellersAtPresetRendezvousPoints, getSwapInfo, @@ -102,13 +101,7 @@ export function createMainListeners() { logger.info( "Bitcoin wallet just became available, checking balance and getting address...", ); - await checkBitcoinBalance(); - try { - const address = await getBitcoinAddress(); - store.dispatch(setBitcoinAddress(address)); - } catch (error) { - logger.error("Failed to fetch Bitcoin address", error); - } + await initializeBitcoinWallet(); } // If the Monero wallet just came available, initialize the Monero wallet From f1f3506e5600b4fc616a10af9a4c6ab335d73cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Wed, 19 Nov 2025 01:20:57 +0100 Subject: [PATCH 02/13] feat(gui): transaction history for bitcoin wallet Ref: #744 --- monero-sys/src/lib.rs | 2 +- src-gui/src/renderer/background.ts | 6 +- .../src/renderer/components/other/Units.tsx | 48 +++++++++++++- .../pages/monero/MoneroWalletPage.tsx | 5 +- .../monero/components/TransactionHistory.tsx | 18 +++--- .../monero/components/TransactionItem.tsx | 33 ++++++++-- .../components/pages/wallet/WalletPage.tsx | 16 ++--- src-gui/src/renderer/rpc.ts | 10 ++- .../src/store/features/bitcoinWalletSlice.ts | 14 ++++- src-gui/src/store/middleware/storeListener.ts | 2 +- swap/src/bitcoin/wallet.rs | 63 +++++++++++++++++++ swap/src/cli/api/request.rs | 11 ++-- swap/src/cli/api/tauri_bindings.rs | 9 ++- swap/src/cli/watcher.rs | 4 +- 14 files changed, 202 insertions(+), 39 deletions(-) diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index e4943eb97..ca6c275a5 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -2395,7 +2395,7 @@ impl FfiWallet { let history_handle = TransactionHistoryHandle(history_ptr); let count = history_handle.count(); - let mut transactions = Vec::new(); + let mut transactions = Vec::with_capacity(count as _); for i in 0..count { if let Some(tx_info_handle) = history_handle.transaction(i) { if let Some(serialized_tx) = tx_info_handle.serialize() { diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 40eab6762..189b72ecd 100644 --- a/src-gui/src/renderer/background.ts +++ b/src-gui/src/renderer/background.ts @@ -7,7 +7,10 @@ import { approvalEventReceived, backgroundProgressEventReceived, } from "store/features/rpcSlice"; -import { setBitcoinBalance } from "store/features/bitcoinWalletSlice"; +import { + setBitcoinBalance, + setBitcoinHistory, +} from "store/features/bitcoinWalletSlice"; import { receivedCliLog } from "store/features/logsSlice"; import { poolStatusReceived } from "store/features/poolSlice"; import { swapProgressEventReceived } from "store/features/swapSlice"; @@ -135,6 +138,7 @@ listen(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => { case "BalanceChange": store.dispatch(setBitcoinBalance(eventData.balance)); + store.dispatch(setBitcoinHistory(eventData.transactions)); break; case "SwapDatabaseStateUpdate": diff --git a/src-gui/src/renderer/components/other/Units.tsx b/src-gui/src/renderer/components/other/Units.tsx index cc485aceb..3eed671a8 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -88,11 +88,46 @@ export function FiatPiconeroAmount({ ); } +export function FiatSatsAmount({ + amount, + fixedPrecision = 2, +}: { + amount: Amount; + fixedPrecision?: number; +}) { + const btcPrice = useAppSelector((state) => state.rates.btcPrice); + const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [ + settings.fetchFiatPrices, + settings.fiatCurrency, + ]); + + if ( + !fetchFiatPrices || + fiatCurrency == null || + amount == null || + btcPrice == null + ) { + return null; + } + + return ( + + {(satsToBtc(amount) * btcPrice).toFixed(fixedPrecision)} {fiatCurrency} + + ); +} + AmountWithUnit.defaultProps = { exchangeRate: null, }; -export function BitcoinAmount({ amount }: { amount: Amount }) { +export function BitcoinAmount({ + amount, + disableTooltip = false, +}: { + amount: Amount; + disableTooltip?: boolean; +}) { const btcRate = useAppSelector((state) => state.rates.btcPrice); return ( @@ -101,6 +136,7 @@ export function BitcoinAmount({ amount }: { amount: Amount }) { unit="BTC" fixedPrecision={6} exchangeRate={btcRate} + disableTooltip={disableTooltip} /> ); } @@ -184,9 +220,15 @@ export function MoneroSatsExchangeRate({ return ; } -export function SatsAmount({ amount }: { amount: Amount }) { +export function SatsAmount({ + amount, + disableTooltip = false, +}: { + amount: Amount; + disableTooltip?: boolean; +}) { const btcAmount = amount == null ? null : satsToBtc(amount); - return ; + return ; } export function PiconeroAmount({ diff --git a/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx index 9b1939332..bdbe2aba9 100644 --- a/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx +++ b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx @@ -43,7 +43,10 @@ export default function MoneroWalletPage() { displayCopyIcon={true} /> - + ); } diff --git a/src-gui/src/renderer/components/pages/monero/components/TransactionHistory.tsx b/src-gui/src/renderer/components/pages/monero/components/TransactionHistory.tsx index 10adfdbfa..521cffde9 100644 --- a/src-gui/src/renderer/components/pages/monero/components/TransactionHistory.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/TransactionHistory.tsx @@ -5,9 +5,8 @@ import dayjs from "dayjs"; import TransactionItem from "./TransactionItem"; interface TransactionHistoryProps { - history: { - transactions: TransactionInfo[]; - } | null; + transactions: TransactionInfo[] | null; + currency: "monero" | "bitcoin"; } interface TransactionGroup { @@ -18,14 +17,13 @@ interface TransactionGroup { // Component for displaying transaction history export default function TransactionHistory({ - history, + transactions, + currency, }: TransactionHistoryProps) { - if (!history || !history.transactions || history.transactions.length === 0) { + if (!transactions || transactions.length === 0) { return Transactions; } - const transactions = history.transactions; - // Group transactions by date using dayjs and lodash const transactionGroups: TransactionGroup[] = _(transactions) .groupBy((tx) => dayjs(tx.timestamp * 1000).format("YYYY-MM-DD")) // Convert Unix timestamp to date string @@ -50,7 +48,11 @@ export default function TransactionHistory({ {group.transactions.map((tx) => ( - + ))} diff --git a/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx b/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx index 0d3faf7c6..bd869d394 100644 --- a/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx @@ -6,7 +6,11 @@ import { MenuItem, Typography, } from "@mui/material"; -import { TransactionDirection, TransactionInfo } from "models/tauriModel"; +import { + TransactionDirection, + TransactionInfo, + Amount, +} from "models/tauriModel"; import { CallReceived as IncomingIcon, MoreVert as MoreVertIcon, @@ -14,10 +18,15 @@ import { import { CallMade as OutgoingIcon } from "@mui/icons-material"; import { FiatPiconeroAmount, + FiatSatsAmount, PiconeroAmount, + SatsAmount, } from "renderer/components/other/Units"; import ConfirmationsBadge from "./ConfirmationsBadge"; -import { getMoneroTxExplorerUrl } from "utils/conversionUtils"; +import { + getMoneroTxExplorerUrl, + getBitcoinTxExplorerUrl, +} from "utils/conversionUtils"; import { isTestnet } from "store/config"; import { open } from "@tauri-apps/plugin-shell"; import dayjs from "dayjs"; @@ -25,9 +34,13 @@ import { useState } from "react"; interface TransactionItemProps { transaction: TransactionInfo; + currency: "monero" | "bitcoin"; } -export default function TransactionItem({ transaction }: TransactionItemProps) { +export default function TransactionItem({ + transaction, + currency, +}: TransactionItemProps) { const isIncoming = transaction.direction === TransactionDirection.In; const displayDate = dayjs(transaction.timestamp * 1000).format( "MMM DD YYYY, HH:mm", @@ -40,6 +53,16 @@ export default function TransactionItem({ transaction }: TransactionItemProps) { const [menuAnchorEl, setMenuAnchorEl] = useState(null); const menuOpen = Boolean(menuAnchorEl); + const UnitAmount = + currency == "monero" + ? PiconeroAmount + : (args: { amount: Amount }) => + SatsAmount({ disableTooltip: true, ...args }); + const FiatUnitAmount = + currency == "monero" ? FiatPiconeroAmount : FiatSatsAmount; + const getExplorerUrl = + currency == "monero" ? getMoneroTxExplorerUrl : getBitcoinTxExplorerUrl; + return ( - - + diff --git a/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx b/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx index 3b22e8d15..f7d367cc0 100644 --- a/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx @@ -3,10 +3,12 @@ import { useAppSelector } from "store/hooks"; import WalletOverview from "./components/WalletOverview"; import WalletActionButtons from "./components/WalletActionButtons"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import { TransactionHistory } from "renderer/components/pages/monero/components"; export default function WalletPage() { - const walletBalance = useAppSelector((state) => state.bitcoinWallet.balance); - const bitcoinAddress = useAppSelector((state) => state.bitcoinWallet.address); + const { balance, address, history } = useAppSelector( + (state) => state.bitcoinWallet, + ); return ( - - {bitcoinAddress && ( - + + {address && ( + )} + ); } diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 70679c871..45f52f038 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -60,7 +60,11 @@ import { timelockChangeEventReceived, } from "store/features/rpcSlice"; import { selectAllSwapIds } from "store/selectors"; -import { setBitcoinAddress, setBitcoinBalance } from "store/features/bitcoinWalletSlice"; +import { + setBitcoinAddress, + setBitcoinBalance, + setBitcoinHistory, +} from "store/features/bitcoinWalletSlice"; import { setMainAddress, setBalance, @@ -161,6 +165,7 @@ export async function checkBitcoinBalance() { }); store.dispatch(setBitcoinBalance(response.balance)); + store.dispatch(setBitcoinHistory(response.transactions)); } export async function buyXmr() { @@ -318,6 +323,7 @@ export async function cheapCheckBitcoinBalance() { }); store.dispatch(setBitcoinBalance(response.balance)); + store.dispatch(setBitcoinHistory(response.transactions)); } export async function getBitcoinAddress() { @@ -593,7 +599,7 @@ export async function initializeBitcoinWallet() { checkBitcoinBalance(), getBitcoinAddress().then((address) => { store.dispatch(setBitcoinAddress(address)); - }) + }), ]); } catch (err) { console.error("Failed to fetch Bitcoin wallet data:", err); diff --git a/src-gui/src/store/features/bitcoinWalletSlice.ts b/src-gui/src/store/features/bitcoinWalletSlice.ts index 1f14804f8..372ef4209 100644 --- a/src-gui/src/store/features/bitcoinWalletSlice.ts +++ b/src-gui/src/store/features/bitcoinWalletSlice.ts @@ -1,13 +1,16 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { TransactionInfo } from "models/tauriModel"; interface BitcoinWalletState { address: string | null; balance: number | null; + history: TransactionInfo[] | null; } const initialState: BitcoinWalletState = { address: null, balance: null, + history: null, }; export const bitcoinWalletSlice = createSlice({ @@ -20,13 +23,20 @@ export const bitcoinWalletSlice = createSlice({ setBitcoinBalance(state, action: PayloadAction) { state.balance = action.payload; }, + setBitcoinHistory(state, action: PayloadAction) { + state.history = action.payload; + }, resetBitcoinWalletState(state) { return initialState; }, }, }); -export const { setBitcoinAddress, setBitcoinBalance, resetBitcoinWalletState } = - bitcoinWalletSlice.actions; +export const { + setBitcoinAddress, + setBitcoinBalance, + setBitcoinHistory, + resetBitcoinWalletState, +} = bitcoinWalletSlice.actions; export default bitcoinWalletSlice.reducer; diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index 3212a8a4b..8b278a5ed 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -4,6 +4,7 @@ import { getAllSwapInfos, getAllSwapTimelocks, initializeBitcoinWallet, + checkBitcoinBalance, updateAllNodeStatuses, fetchSellersAtPresetRendezvousPoints, getSwapInfo, @@ -31,7 +32,6 @@ import { addFeedbackId, setConversation, } from "store/features/conversationsSlice"; -import { setBitcoinAddress } from "store/features/bitcoinWalletSlice"; // Create a Map to store throttled functions per swap_id const throttledGetSwapInfoFunctions = new Map< diff --git a/swap/src/bitcoin/wallet.rs b/swap/src/bitcoin/wallet.rs index c1148fc57..6401f14af 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -27,6 +27,7 @@ use moka; use rust_decimal::prelude::*; use rust_decimal::Decimal; use rust_decimal_macros::dec; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt::Debug; @@ -42,6 +43,7 @@ use sync_ext::{CumulativeProgressHandle, InnerSyncCallback, SyncCallbackExt}; use tokio::sync::watch; use tokio::sync::Mutex as TokioMutex; use tracing::{debug_span, Instrument}; +use typeshare::typeshare; /// We allow transaction fees of up to 20% of the transferred amount to ensure /// that lock transactions can always be published, even when fees are high. @@ -50,6 +52,27 @@ const MAX_ABSOLUTE_TX_FEE: Amount = Amount::from_sat(100_000); const MIN_ABSOLUTE_TX_FEE: Amount = Amount::from_sat(1000); const DUST_AMOUNT: Amount = Amount::from_sat(546); +/// Serialisation matches [`monero_sys::TransactionInfo`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct TransactionInfo { + pub fee: Amount, + pub amount: Amount, + #[typeshare(serialized_as = "number")] + pub confirmations: u32, + pub tx_hash: String, + pub direction: TransactionDirection, + #[typeshare(serialized_as = "number")] + pub timestamp: u64, +} + +#[typeshare] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TransactionDirection { + In, + Out, +} + /// This is our wrapper around a bdk wallet and a corresponding /// bdk electrum client. /// It unifies all the functionality we need when interacting @@ -1304,6 +1327,46 @@ where Ok(address) } + /// Get list + pub async fn history(&self) -> Vec { + let wallet = self.wallet.lock().await; + let current_height = wallet.latest_checkpoint().height(); + + let mut history: Vec<_> = wallet + .transactions() + .flat_map(|tx| wallet.tx_details(tx.tx_node.txid)) + .map(|txd| { + let (timestamp, confirmations) = match txd.chain_position { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => ( + anchor.confirmation_time, + current_height + 1 - anchor.block_id.height, + ), + bdk_chain::ChainPosition::Unconfirmed { + first_seen, + last_seen, + } => (last_seen.or(first_seen).unwrap_or(0), 0), + }; + TransactionInfo { + fee: txd.fee.unwrap_or(Amount::ZERO), + amount: txd.balance_delta.unsigned_abs(), + confirmations, + tx_hash: txd.txid.to_string(), + direction: match txd.balance_delta.is_positive() { + true => TransactionDirection::In, + false => TransactionDirection::Out, + }, + timestamp, + } + }) + .collect(); + history.sort_unstable_by(|ti1, ti2| { + ti1.confirmations + .cmp(&ti2.confirmations) + .then_with(|| ti1.tx_hash.cmp(&ti2.tx_hash)) + }); + history + } + /// Builds a partially signed transaction that sends /// the given amount to the given address. /// The fee is calculated based on the weight of the transaction diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 7a55e9986..96aa769e6 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -293,6 +293,7 @@ pub struct BalanceResponse { #[typeshare(serialized_as = "number")] #[serde(with = "::bitcoin::amount::serde::as_sat")] pub balance: bitcoin::Amount, + pub transactions: Vec, } impl Request for BalanceArgs { @@ -1460,22 +1461,24 @@ pub async fn get_balance(balance: BalanceArgs, context: Arc) -> Result< bitcoin_wallet.sync().await?; } - let bitcoin_balance = bitcoin_wallet.balance().await?; + let balance = bitcoin_wallet.balance().await?; + let transactions = bitcoin_wallet.history().await; if force_refresh { tracing::info!( - balance = %bitcoin_balance, + %balance, "Checked Bitcoin balance", ); } else { tracing::debug!( - balance = %bitcoin_balance, + %balance, "Current Bitcoin balance as of last sync", ); } Ok(BalanceResponse { - balance: bitcoin_balance, + balance, + transactions, }) } diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index ec57b1ea0..be2cf4276 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -482,9 +482,14 @@ pub trait TauriEmitter { })); } - fn emit_balance_update_event(&self, new_balance: bitcoin::Amount) { + fn emit_balance_update_event( + &self, + balance: bitcoin::Amount, + transactions: Vec, + ) { self.emit_unified_event(TauriEvent::BalanceChange(BalanceResponse { - balance: new_balance, + balance, + transactions, })); } diff --git a/swap/src/cli/watcher.rs b/swap/src/cli/watcher.rs index dafbd2ced..ea9d89304 100644 --- a/swap/src/cli/watcher.rs +++ b/swap/src/cli/watcher.rs @@ -69,9 +69,11 @@ impl Watcher { .balance() .await .context("Failed to fetch Bitcoin balance, retrying later")?; + let new_history = self.wallet.history().await; // Emit a balance update event - self.tauri.emit_balance_update_event(new_balance); + self.tauri + .emit_balance_update_event(new_balance, new_history); // Fetch current transactions and timelocks let current_swaps = self From 9d3e4d1190fa56c443328f662f78a87683273dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Wed, 19 Nov 2025 21:45:19 +0100 Subject: [PATCH 03/13] feat(gui): add Details to transaction view, show fee and splits (if available) --- .../other/ActionableMonospaceTextBox.tsx | 3 + .../components/other/MonospaceTextBox.tsx | 3 + .../src/renderer/components/other/Units.tsx | 15 ++- .../components/TransactionDetailsDialog.tsx | 126 ++++++++++++++++++ .../monero/components/TransactionItem.tsx | 25 +++- swap/src/asb/rpc/server.rs | 4 +- swap/src/bitcoin/wallet.rs | 44 +++++- 7 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 src-gui/src/renderer/components/pages/monero/components/TransactionDetailsDialog.tsx diff --git a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx index 8211ac891..e24f39bb5 100644 --- a/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx @@ -18,6 +18,7 @@ type Props = { enableQrCode?: boolean; light?: boolean; spoilerText?: string; + centered?: boolean; }; function QRCodeModal({ open, onClose, content }: ModalProps) { @@ -65,6 +66,7 @@ export default function ActionableMonospaceTextBox({ enableQrCode = true, light = false, spoilerText, + centered = false, }: Props) { const [copied, setCopied] = useState(false); const [qrCodeOpen, setQrCodeOpen] = useState(false); @@ -101,6 +103,7 @@ export default function ActionableMonospaceTextBox({ > {displayCopyIcon && ( diff --git a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx index bce732a0e..bab8e83ad 100644 --- a/src-gui/src/renderer/components/other/MonospaceTextBox.tsx +++ b/src-gui/src/renderer/components/other/MonospaceTextBox.tsx @@ -3,12 +3,14 @@ import { Box, Typography } from "@mui/material"; type Props = { children: React.ReactNode; light?: boolean; + centered?: boolean; actions?: React.ReactNode; }; export default function MonospaceTextBox({ children, light = false, + centered = false, actions, }: Props) { return ( @@ -33,6 +35,7 @@ export default function MonospaceTextBox({ fontFamily: "monospace", lineHeight: 1.5, flex: 1, + ...(centered ? { textAlign: "center" } : {}), }} > {children} diff --git a/src-gui/src/renderer/components/other/Units.tsx b/src-gui/src/renderer/components/other/Units.tsx index 3eed671a8..335662510 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -231,19 +231,20 @@ export function SatsAmount({ return ; } +export interface PiconeroAmountArgs { + amount: Amount; + fixedPrecision?: number; + labelStyles?: SxProps; + amountStyles?: SxProps; + disableTooltip?: boolean; +} export function PiconeroAmount({ amount, fixedPrecision = 8, labelStyles, amountStyles, disableTooltip = false, -}: { - amount: Amount; - fixedPrecision?: number; - labelStyles?: SxProps; - amountStyles?: SxProps; - disableTooltip?: boolean; -}) { +}: PiconeroAmountArgs) { return ( (a: A[], b: B[]) { + return Array(Math.max(b.length, a.length)) + .fill(undefined) + .map((_, i) => [a[i], b[i]]); +} + +export default function TransactionDetailsDialog({ + open, + onClose, + transaction, + UnitAmount, +}: { + open: boolean; + onClose: () => void; + transaction: TransactionInfo; + UnitAmount: React.FC; +}) { + const rowKey = (input: [string, number], output: [string, number]) => + `${input && input[0]}${output && output[0]}`; + const rowPair = (split: [string, number]) => { + if (!split) return ; + + const [id, amount] = split; + return ( + <> + + + + + + + + ); + }; + const rows = + transaction.splits && + zip(transaction.splits.inputs, transaction.splits.outputs).map( + ([input, output]) => { + return ( + + {rowPair(input)} + {rowPair(output)} + + ); + }, + ); + + return ( + + + + + + + {transaction.splits && ( + + Input + Amount + Output + Amount + + )} + + + {rows} + + Fee + + + + + +
+
+ + + + +
+ ); +} diff --git a/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx b/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx index bd869d394..657a4d635 100644 --- a/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/TransactionItem.tsx @@ -21,8 +21,10 @@ import { FiatSatsAmount, PiconeroAmount, SatsAmount, + PiconeroAmountArgs, } from "renderer/components/other/Units"; import ConfirmationsBadge from "./ConfirmationsBadge"; +import TransactionDetailsDialog from "./TransactionDetailsDialog"; import { getMoneroTxExplorerUrl, getBitcoinTxExplorerUrl, @@ -52,12 +54,9 @@ export default function TransactionItem({ const [menuAnchorEl, setMenuAnchorEl] = useState(null); const menuOpen = Boolean(menuAnchorEl); + const [showDetails, setShowDetails] = useState(false); - const UnitAmount = - currency == "monero" - ? PiconeroAmount - : (args: { amount: Amount }) => - SatsAmount({ disableTooltip: true, ...args }); + const UnitAmount = currency == "monero" ? PiconeroAmount : SatsAmount; const FiatUnitAmount = currency == "monero" ? FiatPiconeroAmount : FiatSatsAmount; const getExplorerUrl = @@ -72,6 +71,12 @@ export default function TransactionItem({ justifyContent: "space-between", }} > + setShowDetails(false)} + transaction={transaction} + UnitAmount={UnitAmount} + /> { - open(getMoneroTxExplorerUrl(transaction.tx_hash, isTestnet())); + open(getExplorerUrl(transaction.tx_hash, isTestnet())); setMenuAnchorEl(null); }} > View on Explorer + { + setShowDetails(true); + setMenuAnchorEl(null); + }} + > + Details + diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index bc38dfe94..e078e7dd8 100644 --- a/swap/src/asb/rpc/server.rs +++ b/swap/src/asb/rpc/server.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use swap_controller_api::{ ActiveConnectionsResponse, AsbApiServer, BitcoinBalanceResponse, BitcoinSeedResponse, MoneroAddressResponse, MoneroBalanceResponse, MoneroSeedResponse, MultiaddressesResponse, - PeerIdResponse, RegistrationStatusItem, RegistrationStatusResponse, - RendezvousConnectionStatus, RendezvousRegistrationStatus, Swap, + PeerIdResponse, RegistrationStatusItem, RegistrationStatusResponse, RendezvousConnectionStatus, + RendezvousRegistrationStatus, Swap, }; use tokio_util::task::AbortOnDropHandle; diff --git a/swap/src/bitcoin/wallet.rs b/swap/src/bitcoin/wallet.rs index 6401f14af..716a7cc88 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -20,7 +20,7 @@ use bdk_wallet::WalletPersister; use bdk_wallet::{Balance, PersistedWallet}; use bitcoin::bip32::Xpriv; use bitcoin::{psbt::Psbt as PartiallySignedTransaction, Txid}; -use bitcoin::{Psbt, ScriptBuf, Weight}; +use bitcoin::{OutPoint, Psbt, ScriptBuf, Weight}; use derive_builder::Builder; use electrum_pool::ElectrumBalancer; use moka; @@ -64,6 +64,7 @@ pub struct TransactionInfo { pub direction: TransactionDirection, #[typeshare(serialized_as = "number")] pub timestamp: u64, + pub splits: TransactionSplits, } #[typeshare] @@ -73,6 +74,15 @@ pub enum TransactionDirection { Out, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare] +pub struct TransactionSplits { + #[typeshare(serialized_as = "[string, number][]")] + inputs: Vec<(String, Amount)>, + #[typeshare(serialized_as = "[string, number][]")] + outputs: Vec<(String, Amount)>, +} + /// This is our wrapper around a bdk wallet and a corresponding /// bdk electrum client. /// It unifies all the functionality we need when interacting @@ -1327,7 +1337,6 @@ where Ok(address) } - /// Get list pub async fn history(&self) -> Vec { let wallet = self.wallet.lock().await; let current_height = wallet.latest_checkpoint().height(); @@ -1356,6 +1365,37 @@ where false => TransactionDirection::Out, }, timestamp, + splits: TransactionSplits { + inputs: txd + .tx + .input + .iter() + .map(|i| { + ( + i.previous_output.to_string(), + wallet + .get_tx(i.previous_output.txid) + .and_then(|tx| { + tx.tx_node + .tx + .output + .get(i.previous_output.vout as usize) + .map(|txo| txo.value) + }) + .unwrap_or_default(), + ) + }) + .collect(), + outputs: txd + .tx + .output + .iter() + .enumerate() + .map(|(vout, o)| { + (OutPoint::new(txd.txid, vout as _).to_string(), o.value) + }) + .collect(), + }, } }) .collect(); From 359855d9507903824cb5cf51855d5f799aaa0cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Thu, 20 Nov 2025 02:44:29 +0100 Subject: [PATCH 04/13] feat(gui): use fully-featured send dialog for bitcoin. Disable monero send button if no balance Ref: #744, pt. 3 --- .../inputs/BitcoinAddressTextField.tsx | 8 +- .../inputs/MoneroAddressTextField.tsx | 2 +- .../modal/wallet/WithdrawDialog.tsx | 73 ----------- .../modal/wallet/WithdrawDialogContent.tsx | 29 ----- .../modal/wallet/WithdrawStepper.tsx | 30 ----- .../modal/wallet/pages/AddressInputPage.tsx | 30 ----- .../pages/BitcoinWithdrawTxInMempoolPage.tsx | 23 ---- .../pages/monero/SendTransactionModal.tsx | 19 +-- .../monero/components/SendAmountInput.tsx | 89 ++++++++------ .../monero/components/SendSuccessContent.tsx | 40 ++++-- .../components/SendTransactionContent.tsx | 115 +++++++++++------- .../monero/components/WalletActionButtons.tsx | 9 +- .../monero/components/WalletOverview.tsx | 6 +- .../wallet/components/WalletActionButtons.tsx | 15 ++- src-gui/src/renderer/rpc.ts | 9 +- swap-core/src/bitcoin.rs | 3 +- swap/src/cli/api/request.rs | 14 ++- 17 files changed, 202 insertions(+), 312 deletions(-) delete mode 100644 src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx delete mode 100644 src-gui/src/renderer/components/modal/wallet/WithdrawDialogContent.tsx delete mode 100644 src-gui/src/renderer/components/modal/wallet/WithdrawStepper.tsx delete mode 100644 src-gui/src/renderer/components/modal/wallet/pages/AddressInputPage.tsx delete mode 100644 src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx diff --git a/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx b/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx index 026dc1f34..0694a65fa 100644 --- a/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx +++ b/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx @@ -3,22 +3,22 @@ import { useEffect } from "react"; import { isTestnet } from "store/config"; import { isBtcAddressValid } from "utils/conversionUtils"; -type BitcoinAddressTextFieldProps = { +type BitcoinAddressTextFieldProps = TextFieldProps & { address: string; onAddressChange: (address: string) => void; - helperText: string; onAddressValidityChange?: (valid: boolean) => void; + helperText?: string; allowEmpty?: boolean; }; export default function BitcoinAddressTextField({ address, onAddressChange, + onAddressValidityChange, helperText, allowEmpty = true, - onAddressValidityChange, ...props -}: BitcoinAddressTextFieldProps & TextFieldProps) { +}: BitcoinAddressTextFieldProps) { const placeholder = isTestnet() ? "tb1q4aelwalu..." : "bc18ociqZ9mZ..."; function errorText() { diff --git a/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx b/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx index faf178845..88ef5f858 100644 --- a/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx +++ b/src-gui/src/renderer/components/inputs/MoneroAddressTextField.tsx @@ -8,8 +8,8 @@ import { List, ListItemText, TextField, + TextFieldProps, } from "@mui/material"; -import { TextFieldProps } from "@mui/material"; import { useEffect, useState } from "react"; import { getMoneroAddresses } from "renderer/rpc"; import { isTestnet } from "store/config"; diff --git a/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx b/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx deleted file mode 100644 index 62e531750..000000000 --- a/src-gui/src/renderer/components/modal/wallet/WithdrawDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Button, Dialog, DialogActions } from "@mui/material"; -import { useState } from "react"; -import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { sweepBtc } from "renderer/rpc"; -import DialogHeader from "../DialogHeader"; -import AddressInputPage from "./pages/AddressInputPage"; -import BtcTxInMempoolPageContent from "./pages/BitcoinWithdrawTxInMempoolPage"; -import WithdrawDialogContent from "./WithdrawDialogContent"; -import { isContextWithBitcoinWallet } from "models/tauriModelExt"; - -export default function WithdrawDialog({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - const [pending, setPending] = useState(false); - const [withdrawTxId, setWithdrawTxId] = useState(null); - const [withdrawAddressValid, setWithdrawAddressValid] = useState(false); - const [withdrawAddress, setWithdrawAddress] = useState(""); - - const haveFundsBeenWithdrawn = withdrawTxId !== null; - - function onCancel() { - if (!pending) { - setWithdrawTxId(null); - setWithdrawAddress(""); - onClose(); - } - } - - return ( - - - - {haveFundsBeenWithdrawn ? ( - - ) : ( - - )} - - - - {!haveFundsBeenWithdrawn && ( - sweepBtc(withdrawAddress)} - onPendingChange={setPending} - onSuccess={setWithdrawTxId} - contextRequirement={isContextWithBitcoinWallet} - > - Withdraw - - )} - - - ); -} diff --git a/src-gui/src/renderer/components/modal/wallet/WithdrawDialogContent.tsx b/src-gui/src/renderer/components/modal/wallet/WithdrawDialogContent.tsx deleted file mode 100644 index 4857f7f47..000000000 --- a/src-gui/src/renderer/components/modal/wallet/WithdrawDialogContent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box, DialogContent } from "@mui/material"; -import { ReactNode } from "react"; -import WithdrawStepper from "./WithdrawStepper"; - -export default function WithdrawDialogContent({ - children, - isPending, - withdrawTxId, -}: { - children: ReactNode; - isPending: boolean; - withdrawTxId: string | null; -}) { - return ( - - {children} - - - ); -} diff --git a/src-gui/src/renderer/components/modal/wallet/WithdrawStepper.tsx b/src-gui/src/renderer/components/modal/wallet/WithdrawStepper.tsx deleted file mode 100644 index 8e2efb614..000000000 --- a/src-gui/src/renderer/components/modal/wallet/WithdrawStepper.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Step, StepLabel, Stepper } from "@mui/material"; - -function getActiveStep(isPending: boolean, withdrawTxId: string | null) { - if (isPending) { - return 1; - } - if (withdrawTxId !== null) { - return 2; - } - return 0; -} - -export default function WithdrawStepper({ - isPending, - withdrawTxId, -}: { - isPending: boolean; - withdrawTxId: string | null; -}) { - return ( - - - Enter withdraw address - - - Transfer funds to wallet - - - ); -} diff --git a/src-gui/src/renderer/components/modal/wallet/pages/AddressInputPage.tsx b/src-gui/src/renderer/components/modal/wallet/pages/AddressInputPage.tsx deleted file mode 100644 index cf5909de0..000000000 --- a/src-gui/src/renderer/components/modal/wallet/pages/AddressInputPage.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { DialogContentText } from "@mui/material"; -import BitcoinAddressTextField from "../../../inputs/BitcoinAddressTextField"; - -export default function AddressInputPage({ - withdrawAddress, - setWithdrawAddress, - setWithdrawAddressValid, -}: { - withdrawAddress: string; - setWithdrawAddress: (address: string) => void; - setWithdrawAddressValid: (valid: boolean) => void; -}) { - return ( - <> - - To withdraw the Bitcoin inside the internal wallet, please enter an - address. All funds (the entire balance) will be sent to that address. - - - - - ); -} diff --git a/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx b/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx deleted file mode 100644 index 268f8a90d..000000000 --- a/src-gui/src/renderer/components/modal/wallet/pages/BitcoinWithdrawTxInMempoolPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { DialogContentText } from "@mui/material"; -import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox"; - -export default function BtcTxInMempoolPageContent({ - withdrawTxId, -}: { - withdrawTxId: string; -}) { - return ( - <> - - All funds of the internal Bitcoin wallet have been transferred to your - withdraw address. - - - - ); -} diff --git a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx index 1ae68367a..bb4c745fa 100644 --- a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx +++ b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx @@ -4,26 +4,27 @@ import SendApprovalContent from "./components/SendApprovalContent"; import { useState } from "react"; import SendSuccessContent from "./components/SendSuccessContent"; import { usePendingSendMoneroApproval } from "store/hooks"; -import { SendMoneroResponse } from "models/tauriModel"; +import { SendMoneroResponse, WithdrawBtcResponse } from "models/tauriModel"; interface SendTransactionModalProps { open: boolean; onClose: () => void; - balance: { - unlocked_balance: string; - }; + unlocked_balance: number; + wallet: "monero" | "bitcoin"; } export default function SendTransactionModal({ - balance, open, onClose, + unlocked_balance, + wallet, }: SendTransactionModalProps) { const pendingApprovals = usePendingSendMoneroApproval(); const hasPendingApproval = pendingApprovals.length > 0; - const [successResponse, setSuccessResponse] = - useState(null); + const [successResponse, setSuccessResponse] = useState< + SendMoneroResponse | WithdrawBtcResponse | null + >(null); const showSuccess = successResponse !== null; @@ -44,7 +45,8 @@ export default function SendTransactionModal({ > {!showSuccess && !hasPendingApproval && ( @@ -56,6 +58,7 @@ export default function SendTransactionModal({ )} diff --git a/src-gui/src/renderer/components/pages/monero/components/SendAmountInput.tsx b/src-gui/src/renderer/components/pages/monero/components/SendAmountInput.tsx index ca23c8cd8..aa9c52db8 100644 --- a/src-gui/src/renderer/components/pages/monero/components/SendAmountInput.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/SendAmountInput.tsx @@ -2,39 +2,52 @@ import { Box, Button, Card, Grow, Typography } from "@mui/material"; import NumberInput from "renderer/components/inputs/NumberInput"; import SwapVertIcon from "@mui/icons-material/SwapVert"; import { useTheme } from "@mui/material/styles"; -import { piconerosToXmr } from "../../../../../utils/conversionUtils"; -import { MoneroAmount } from "renderer/components/other/Units"; +import { + piconerosToXmr, + satsToBtc, +} from "../../../../../utils/conversionUtils"; +import { MoneroAmount, BitcoinAmount } from "renderer/components/other/Units"; interface SendAmountInputProps { - balance: { - unlocked_balance: string; - }; + unlocked_balance: number; amount: string; onAmountChange: (amount: string) => void; onMaxClicked?: () => void; onMaxToggled?: () => void; currency: string; onCurrencyChange: (currency: string) => void; + wallet: "monero" | "bitcoin"; + walletCurrency: string; + walletPrecision: number; fiatCurrency: string; - xmrPrice: number | null; + fiatPrice: number | null; showFiatRate: boolean; disabled?: boolean; } export default function SendAmountInput({ - balance, + unlocked_balance, amount, currency, + wallet, + walletCurrency, + walletPrecision, onCurrencyChange, onAmountChange, onMaxClicked, onMaxToggled, fiatCurrency, - xmrPrice, + fiatPrice, showFiatRate, disabled = false, }: SendAmountInputProps) { const theme = useTheme(); + const baseunitsToFraction = wallet === "monero" ? piconerosToXmr : satsToBtc; + const estFee = + wallet === "monero" ? 10000000000 /* 0.01 XMR */ : 10000; /* 0.0001 BTC */ + const walletStep = wallet === "monero" ? 0.001 : 0.00001; + const walletLargeStep = wallet === "monero" ? 0.1 : 0.001; + const WalletAmount = wallet === "monero" ? MoneroAmount : BitcoinAmount; const isMaxSelected = amount === ""; @@ -48,17 +61,17 @@ export default function SendAmountInput({ return "0.00"; } - if (xmrPrice === null) { + if (fiatPrice === null) { return "?"; } const primaryValue = parseFloat(amount); - if (currency === "XMR") { + if (currency === walletCurrency) { // Primary is XMR, secondary is USD - return (primaryValue * xmrPrice).toFixed(2); + return (primaryValue * fiatPrice).toFixed(2); } else { // Primary is USD, secondary is XMR - return (primaryValue / xmrPrice).toFixed(3); + return (primaryValue / fiatPrice).toFixed(walletPrecision); } })(); @@ -70,21 +83,15 @@ export default function SendAmountInput({ onMaxClicked(); } else { // Fallback to old behavior if no callback provided - if ( - balance?.unlocked_balance !== undefined && - balance?.unlocked_balance !== null - ) { - // TODO: We need to use a real fee here and call sweep(...) instead of just subtracting a fixed amount - const unlocked = parseFloat(balance.unlocked_balance); - const maxAmountXmr = piconerosToXmr(unlocked - 10000000000); // Subtract ~0.01 XMR for fees + // TODO: We need to use a real fee here and call sweep(...) instead of just subtracting a fixed amount + const maxWalletAmount = baseunitsToFraction(unlocked_balance - estFee); // Subtract ~0.01 XMR/~0.0001 BTC for fees - if (currency === "XMR") { - onAmountChange(Math.max(0, maxAmountXmr).toString()); - } else if (xmrPrice !== null) { - // Convert to USD for display - const maxAmountUsd = maxAmountXmr * xmrPrice; - onAmountChange(Math.max(0, maxAmountUsd).toString()); - } + if (currency === walletCurrency) { + onAmountChange(Math.max(0, maxWalletAmount).toString()); + } else if (fiatPrice !== null) { + // Convert to USD for display + const maxAmountUsd = maxWalletAmount * fiatPrice; + onAmountChange(Math.max(0, maxAmountUsd).toString()); } } }; @@ -98,18 +105,18 @@ export default function SendAmountInput({ const handleCurrencySwap = () => { if (!isMaxSelected && !disabled) { - onCurrencyChange(currency === "XMR" ? fiatCurrency : "XMR"); + onCurrencyChange( + currency === walletCurrency ? fiatCurrency : walletCurrency, + ); } }; const isAmountTooHigh = !isMaxSelected && - (currency === "XMR" - ? parseFloat(amount) > - piconerosToXmr(parseFloat(balance.unlocked_balance)) - : xmrPrice !== null && - parseFloat(amount) / xmrPrice > - piconerosToXmr(parseFloat(balance.unlocked_balance))); + (currency === walletCurrency + ? parseFloat(amount) > baseunitsToFraction(unlocked_balance) + : fiatPrice !== null && + parseFloat(amount) / fiatPrice > baseunitsToFraction(unlocked_balance)); return ( {} : onAmountChange} - placeholder={currency === "XMR" ? "0.000" : "0.00"} + placeholder={(0).toFixed(currency === walletCurrency ? walletPrecision : 2)} fontSize="3em" fontWeight={600} minWidth={60} - step={currency === "XMR" ? 0.001 : 0.01} - largeStep={currency === "XMR" ? 0.1 : 10} + step={currency === walletCurrency ? walletStep : 0.01} + largeStep={currency === walletCurrency ? walletLargeStep : 10} /> {currency} @@ -189,7 +196,11 @@ export default function SendAmountInput({ /> {secondaryAmount}{" "} - {isMaxSelected ? "" : currency === "XMR" ? fiatCurrency : "XMR"} + {isMaxSelected + ? "" + : currency === walletCurrency + ? fiatCurrency + : walletCurrency} )} @@ -209,9 +220,7 @@ export default function SendAmountInput({ > Available - +