diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index e4943eb978..ca6c275a53 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/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index eb369af532..daabe78563 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -7,6 +7,7 @@ import { TauriBackgroundProgress, TauriSwapProgressEvent, SendMoneroDetails, + WithdrawBitcoinDetails, ContextStatus, QuoteWithAddress, ExportBitcoinWalletResponse, @@ -319,6 +320,10 @@ export type PendingSendMoneroApprovalRequest = PendingApprovalRequest & { request: { type: "SendMonero"; content: SendMoneroDetails }; }; +export type PendingWithdrawBitcoinApprovalRequest = PendingApprovalRequest & { + request: { type: "WithdrawBitcoin"; content: WithdrawBitcoinDetails }; +}; + export type PendingPasswordApprovalRequest = PendingApprovalRequest & { request: { type: "PasswordRequest"; content: { wallet_path: string } }; }; @@ -335,16 +340,21 @@ export function isPendingSelectMakerApprovalEvent( return event.request.type === "SelectMaker"; } -export function isPendingSendMoneroApprovalEvent( +export function isPendingSendCurrencyApprovalEvent( event: ApprovalRequest, -): event is PendingSendMoneroApprovalRequest { + currency: "monero" | "bitcoin", +): event is + | PendingSendMoneroApprovalRequest + | PendingWithdrawBitcoinApprovalRequest { // Check if the request is pending if (event.request_status.state !== "Pending") { return false; } + const type = currency === "monero" ? "SendMonero" : "WithdrawBitcoin"; + // Check if the request is a SendMonero request - return event.request.type === "SendMonero"; + return event.request.type === type; } export function isPendingPasswordApprovalEvent( diff --git a/src-gui/src/renderer/background.ts b/src-gui/src/renderer/background.ts index 40eab67623..189b72ecd9 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/PromiseInvokeButton.tsx b/src-gui/src/renderer/components/PromiseInvokeButton.tsx index 4987b7001a..5ea46cde21 100644 --- a/src-gui/src/renderer/components/PromiseInvokeButton.tsx +++ b/src-gui/src/renderer/components/PromiseInvokeButton.tsx @@ -132,11 +132,11 @@ export default function PromiseInvokeButton({ {isLoading ? resolvedLoadingIcon diff --git a/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx b/src-gui/src/renderer/components/inputs/BitcoinAddressTextField.tsx index 026dc1f34c..0694a65fa4 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 faf1788458..88ef5f8589 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 62e5317503..0000000000 --- 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 4857f7f473..0000000000 --- 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 8e2efb6146..0000000000 --- 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 cf5909de0e..0000000000 --- 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 268f8a90df..0000000000 --- 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/other/ActionableMonospaceTextBox.tsx b/src-gui/src/renderer/components/other/ActionableMonospaceTextBox.tsx index 8211ac891c..e24f39bb55 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 bce732a0e8..bab8e83ada 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 cc485acebe..2e65a7a018 100644 --- a/src-gui/src/renderer/components/other/Units.tsx +++ b/src-gui/src/renderer/components/other/Units.tsx @@ -88,19 +88,57 @@ 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, + fixedPrecision = 6, +}: { + amount: Amount; + disableTooltip?: boolean; + fixedPrecision?: number; +}) { const btcRate = useAppSelector((state) => state.rates.btcPrice); return ( ); } @@ -184,24 +222,39 @@ export function MoneroSatsExchangeRate({ return ; } -export function SatsAmount({ amount }: { amount: Amount }) { +export function SatsAmount({ + amount, + disableTooltip = false, + fixedPrecision = 6, +}: { + amount: Amount; + disableTooltip?: boolean; + fixedPrecision?: number; +}) { const btcAmount = amount == null ? null : satsToBtc(amount); - return ; + 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 ( - + ); } diff --git a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx index 1ae68367ac..0321ff56b4 100644 --- a/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx +++ b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx @@ -3,27 +3,28 @@ import SendTransactionContent from "./components/SendTransactionContent"; 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 { usePendingSendCurrencyApproval } from "store/hooks"; +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 pendingApprovals = usePendingSendCurrencyApproval(wallet); const hasPendingApproval = pendingApprovals.length > 0; - const [successResponse, setSuccessResponse] = - useState(null); + const [successResponse, setSuccessResponse] = useState< + SendMoneroResponse | WithdrawBtcResponse | null + >(null); const showSuccess = successResponse !== null; @@ -44,18 +45,23 @@ export default function SendTransactionModal({ > {!showSuccess && !hasPendingApproval && ( )} {!showSuccess && hasPendingApproval && ( - + )} {showSuccess && ( )} 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 ca23c8cd8e..8098270914 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 +198,11 @@ export default function SendAmountInput({ /> {secondaryAmount}{" "} - {isMaxSelected ? "" : currency === "XMR" ? fiatCurrency : "XMR"} + {isMaxSelected + ? "" + : currency === walletCurrency + ? fiatCurrency + : walletCurrency} )} @@ -209,9 +222,7 @@ export default function SendAmountInput({ > Available - + + + + ); +} 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 10adfdbfa1..521cffde91 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 0d3faf7c6c..657a4d6354 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,17 @@ import { import { CallMade as OutgoingIcon } from "@mui/icons-material"; import { FiatPiconeroAmount, + FiatSatsAmount, PiconeroAmount, + SatsAmount, + PiconeroAmountArgs, } from "renderer/components/other/Units"; import ConfirmationsBadge from "./ConfirmationsBadge"; -import { getMoneroTxExplorerUrl } from "utils/conversionUtils"; +import TransactionDetailsDialog from "./TransactionDetailsDialog"; +import { + getMoneroTxExplorerUrl, + getBitcoinTxExplorerUrl, +} from "utils/conversionUtils"; import { isTestnet } from "store/config"; import { open } from "@tauri-apps/plugin-shell"; import dayjs from "dayjs"; @@ -25,9 +36,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", @@ -39,6 +54,13 @@ export default function TransactionItem({ transaction }: TransactionItemProps) { const [menuAnchorEl, setMenuAnchorEl] = useState(null); const menuOpen = Boolean(menuAnchorEl); + const [showDetails, setShowDetails] = useState(false); + + const UnitAmount = currency == "monero" ? PiconeroAmount : SatsAmount; + const FiatUnitAmount = + currency == "monero" ? FiatPiconeroAmount : FiatSatsAmount; + const getExplorerUrl = + currency == "monero" ? getMoneroTxExplorerUrl : getBitcoinTxExplorerUrl; return ( + setShowDetails(false)} + transaction={transaction} + UnitAmount={UnitAmount} + /> - - + @@ -142,12 +170,20 @@ export default function TransactionItem({ transaction }: TransactionItemProps) { { - 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/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx b/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx index 610522328b..68bf6d84cc 100644 --- a/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/WalletActionButtons.tsx @@ -33,12 +33,11 @@ import DfxButton from "./DFXWidget"; import { GetMoneroSeedResponse, GetRestoreHeightResponse, + GetMoneroBalanceResponse, } from "models/tauriModel"; interface WalletActionButtonsProps { - balance: { - unlocked_balance: string; - }; + balance: GetMoneroBalanceResponse; } export default function WalletActionButtons({ @@ -75,7 +74,8 @@ export default function WalletActionButtons({ /> setSeedPhrase(null)} seed={seedPhrase} /> setSendDialogOpen(false)} /> @@ -94,6 +94,7 @@ export default function WalletActionButtons({ variant="button" clickable onClick={() => setSendDialogOpen(true)} + disabled={!balance || balance.unlocked_balance <= 0} /> navigate("/swap")} diff --git a/src-gui/src/renderer/components/pages/monero/components/WalletOverview.tsx b/src-gui/src/renderer/components/pages/monero/components/WalletOverview.tsx index 182bc21e89..6d992eb4ce 100644 --- a/src-gui/src/renderer/components/pages/monero/components/WalletOverview.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/WalletOverview.tsx @@ -100,7 +100,7 @@ export default function WalletOverview({ const timeEstimation = useSyncTimeEstimation(syncProgress); const pendingBalance = balance - ? parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance) + ? balance.total_balance - balance.unlocked_balance : null; const isSyncing = !!(syncProgress && syncProgress.progress_percentage < 100); @@ -185,14 +185,14 @@ export default function WalletOverview({ diff --git a/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx b/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx index 3b22e8d159..e449321242 100644 --- a/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx +++ b/src-gui/src/renderer/components/pages/wallet/WalletPage.tsx @@ -1,12 +1,26 @@ -import { Box } from "@mui/material"; +import { + Box, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, +} from "@mui/material"; +import { useState } from "react"; import { useAppSelector } from "store/hooks"; +import { generateBitcoinAddresses } from "renderer/rpc"; import WalletOverview from "./components/WalletOverview"; import WalletActionButtons from "./components/WalletActionButtons"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; +import { Add as AddIcon } from "@mui/icons-material"; +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, + ); + const [moreAddresses, setMoreAddresses] = useState(null); return ( - - {bitcoinAddress && ( - + setMoreAddresses(null)}> + Addresses + + {moreAddresses && + moreAddresses.map((a) => ( + + ))} + + + + {address && ( + + + + + + generateBitcoinAddresses(7)} + onSuccess={setMoreAddresses} + > + + + )} + ); } diff --git a/src-gui/src/renderer/components/pages/wallet/components/WalletActionButtons.tsx b/src-gui/src/renderer/components/pages/wallet/components/WalletActionButtons.tsx index c6ddb55131..af19abc934 100644 --- a/src-gui/src/renderer/components/pages/wallet/components/WalletActionButtons.tsx +++ b/src-gui/src/renderer/components/pages/wallet/components/WalletActionButtons.tsx @@ -2,16 +2,21 @@ import { Box, Chip } from "@mui/material"; import { Send as SendIcon } from "@mui/icons-material"; import { useState } from "react"; import { useAppSelector } from "store/hooks"; -import WithdrawDialog from "../../../modal/wallet/WithdrawDialog"; import WalletDescriptorButton from "./WalletDescriptorButton"; +import SendTransactionModal from "../../monero/SendTransactionModal"; export default function WalletActionButtons() { - const [showDialog, setShowDialog] = useState(false); + const [sendDialogOpen, setSendDialogOpen] = useState(false); const balance = useAppSelector((state) => state.bitcoinWallet.balance); return ( <> - setShowDialog(false)} /> + setSendDialogOpen(false)} + /> } - label="Sweep" + label="Send" variant="button" clickable - onClick={() => setShowDialog(true)} + onClick={() => setSendDialogOpen(true)} disabled={balance === null || balance <= 0} /> diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 01a8098efb..992db89ad0 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -16,7 +16,6 @@ import { WithdrawBtcResponse, GetSwapInfoArgs, ExportBitcoinWalletResponse, - GetBitcoinAddressResponse, CheckMoneroNodeArgs, CheckSeedArgs, CheckSeedResponse, @@ -24,6 +23,8 @@ import { TauriSettings, CheckElectrumNodeArgs, CheckElectrumNodeResponse, + GenerateBitcoinAddressesArgs, + GenerateBitcoinAddressesResponse, GetMoneroAddressesResponse, GetDataDirArgs, ResolveApprovalArgs, @@ -60,7 +61,11 @@ import { timelockChangeEventReceived, } from "store/features/rpcSlice"; import { selectAllSwapIds } from "store/selectors"; -import { setBitcoinBalance } from "store/features/bitcoinWalletSlice"; +import { + setBitcoinAddress, + setBitcoinBalance, + setBitcoinHistory, +} from "store/features/bitcoinWalletSlice"; import { setMainAddress, setBalance, @@ -161,6 +166,14 @@ export async function checkBitcoinBalance() { }); store.dispatch(setBitcoinBalance(response.balance)); + store.dispatch(setBitcoinHistory(response.transactions)); +} + +export async function generateBitcoinAddresses(amount: number) { + return await invoke< + GenerateBitcoinAddressesArgs, + GenerateBitcoinAddressesResponse + >("generate_bitcoin_addresses", amount); } export async function buyXmr() { @@ -318,14 +331,7 @@ export async function cheapCheckBitcoinBalance() { }); store.dispatch(setBitcoinBalance(response.balance)); -} - -export async function getBitcoinAddress() { - const response = await invokeNoArgs( - "get_bitcoin_address", - ); - - return response.address; + store.dispatch(setBitcoinHistory(response.transactions)); } export async function getAllSwapInfos() { @@ -378,12 +384,15 @@ export async function getAllSwapTimelocks() { ); } -export async function sweepBtc(address: string): Promise { +export async function withdrawBtc( + address: string, + amount: number | undefined, +): Promise { const response = await invoke( "withdraw_btc", { address, - amount: undefined, + amount, }, ); @@ -391,7 +400,7 @@ export async function sweepBtc(address: string): Promise { // but instead uses our local cached balance await cheapCheckBitcoinBalance(); - return response.txid; + return response; } export async function resumeSwap(swapId: string) { @@ -587,6 +596,19 @@ export async function getMoneroSeedAndRestoreHeight(): Promise< } // Wallet management functions that handle Redux dispatching +export async function initializeBitcoinWallet() { + try { + await Promise.all([ + checkBitcoinBalance(), + generateBitcoinAddresses(1).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/features/bitcoinWalletSlice.ts b/src-gui/src/store/features/bitcoinWalletSlice.ts index 1f14804f8a..372ef42090 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/hooks.ts b/src-gui/src/store/hooks.ts index 77a6b46c04..45e6e58b10 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -13,7 +13,8 @@ import { haveFundsBeenLocked, PendingSeedSelectionApprovalRequest, PendingSendMoneroApprovalRequest, - isPendingSendMoneroApprovalEvent, + PendingWithdrawBitcoinApprovalRequest, + isPendingSendCurrencyApprovalEvent, PendingPasswordApprovalRequest, isPendingPasswordApprovalEvent, isContextFullyInitialized, @@ -216,9 +217,16 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c)); } -export function usePendingSendMoneroApproval(): PendingSendMoneroApprovalRequest[] { +export function usePendingSendCurrencyApproval( + currency: "monero" | "bitcoin", +): ( + | PendingSendMoneroApprovalRequest + | PendingWithdrawBitcoinApprovalRequest +)[] { const approvals = usePendingApprovals(); - return approvals.filter((c) => isPendingSendMoneroApprovalEvent(c)); + return approvals.filter((c) => + isPendingSendCurrencyApprovalEvent(c, currency), + ); } export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] { diff --git a/src-gui/src/store/middleware/storeListener.ts b/src-gui/src/store/middleware/storeListener.ts index b0917d478b..8b278a5ed3 100644 --- a/src-gui/src/store/middleware/storeListener.ts +++ b/src-gui/src/store/middleware/storeListener.ts @@ -3,8 +3,8 @@ import { throttle, debounce } from "lodash"; import { getAllSwapInfos, getAllSwapTimelocks, + initializeBitcoinWallet, checkBitcoinBalance, - getBitcoinAddress, updateAllNodeStatuses, fetchSellersAtPresetRendezvousPoints, getSwapInfo, @@ -32,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< @@ -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 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 44f0285f94..edf6090eef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,14 +8,14 @@ use swap::cli::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse, - ExportBitcoinWalletArgs, GetBitcoinAddressArgs, GetCurrentSwapArgs, GetDataDirArgs, - GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, - GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, - GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, - GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs, - RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, - SendMoneroArgs, SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, - SuspendCurrentSwapArgs, WithdrawBtcArgs, + ExportBitcoinWalletArgs, GenerateBitcoinAddressesArgs, GetCurrentSwapArgs, + GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, + GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, + GetMoneroSeedArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, + GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, + MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse, + ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroWalletPasswordArgs, + SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, ContextBuilder, @@ -36,7 +36,6 @@ macro_rules! generate_command_handlers { () => { tauri::generate_handler![ get_balance, - get_bitcoin_address, get_monero_addresses, get_swap_info, get_swap_infos_all, @@ -72,7 +71,8 @@ macro_rules! generate_command_handlers { set_monero_wallet_password, dfx_authenticate, change_monero_node, - get_context_status + get_context_status, + generate_bitcoin_addresses, ] }; } @@ -429,6 +429,7 @@ tauri_command!(get_balance, BalanceArgs); tauri_command!(buy_xmr, BuyXmrArgs); tauri_command!(resume_swap, ResumeSwapArgs); tauri_command!(withdraw_btc, WithdrawBtcArgs); +tauri_command!(generate_bitcoin_addresses, GenerateBitcoinAddressesArgs); tauri_command!(monero_recovery, MoneroRecoveryArgs); tauri_command!(get_logs, GetLogsArgs); tauri_command!(list_sellers, ListSellersArgs); @@ -438,7 +439,6 @@ tauri_command!(send_monero, SendMoneroArgs); tauri_command!(change_monero_node, ChangeMoneroNodeArgs); // These commands require no arguments -tauri_command!(get_bitcoin_address, GetBitcoinAddressArgs, no_args); tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args); tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args); tauri_command!(get_swap_info, GetSwapInfoArgs); diff --git a/swap-core/src/bitcoin.rs b/swap-core/src/bitcoin.rs index 113195cae4..689818afb4 100644 --- a/swap-core/src/bitcoin.rs +++ b/swap-core/src/bitcoin.rs @@ -341,8 +341,7 @@ pub mod bitcoin_address { expected_network: bitcoin::Network, ) -> Result
{ address - .as_unchecked() - .clone() + .into_unchecked() .require_network(expected_network) .context("bitcoin address network mismatch") } diff --git a/swap-serde/src/bitcoin.rs b/swap-serde/src/bitcoin.rs index 4d94279b4e..9af1e030d0 100644 --- a/swap-serde/src/bitcoin.rs +++ b/swap-serde/src/bitcoin.rs @@ -33,7 +33,7 @@ pub mod address_serde { D: Deserializer<'de>, { let unchecked: Address = - Address::from_str(&String::deserialize(deserializer)?) + Address::from_str(<&str>::deserialize(deserializer)?) .map_err(serde::de::Error::custom)?; Ok(unchecked.assume_checked()) @@ -62,15 +62,49 @@ pub mod address_serde { where D: Deserializer<'de>, { - let opt: Option = Option::deserialize(deserializer)?; + let opt: Option<&str> = Option::deserialize(deserializer)?; match opt { Some(s) => { let unchecked: Address = - Address::from_str(&s).map_err(serde::de::Error::custom)?; + Address::from_str(s).map_err(serde::de::Error::custom)?; Ok(Some(unchecked.assume_checked())) } None => Ok(None), } } } + + /// This submodule supports Vec
. + pub mod vec { + use super::*; + use serde::ser::SerializeSeq; + + pub fn serialize( + addresses: &Vec>, + serializer: S, + ) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(addresses.len()))?; + for addr in addresses { + seq.serialize_element(addr)?; + } + seq.end() + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let vec: Vec<&str> = Vec::deserialize(deserializer)?; + Result::from_iter(vec.into_iter().map(|s| { + Address::from_str(s) + .map(|unchecked: Address| unchecked.assume_checked()) + .map_err(serde::de::Error::custom) + })) + } + } } diff --git a/swap/src/asb/rpc/server.rs b/swap/src/asb/rpc/server.rs index bc38dfe949..e078e7dd80 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 c1148fc578..c735a76862 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -20,13 +20,14 @@ 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; 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,38 @@ 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, + pub splits: TransactionSplits, +} + +#[typeshare] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum TransactionDirection { + // In > Out => break ties for transactions in the same block by sorting incoming transactions first + Out, + In, +} + +#[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 @@ -1290,18 +1324,99 @@ where /// Reveals the next address from the wallet. pub async fn new_address(&self) -> Result
{ + self.new_addresses(1).await.map(|mut a| a.remove(0)) + } + + pub async fn new_addresses(&self, amount: usize) -> Result> { let mut wallet = self.wallet.lock().await; - // Only reveal a new address if absolutely necessary + // Only reveal new addresses if absolutely necessary // We want to avoid revealing more and more addresses - let address = wallet.next_unused_address(KeychainKind::External).address; + let mut addresses: Vec<_> = wallet + .list_unused_addresses(KeychainKind::External) + .map(|a| a.address) + .take(amount) + .collect(); + addresses.resize_with(amount, || { + wallet.reveal_next_address(KeychainKind::External).address + }); - // Important: persist that we revealed a new address. + // Important: persist that we revealed new addresses. // Otherwise the wallet might reuse it (bad). let mut persister = self.persister.lock().await; wallet.persist(&mut persister)?; - Ok(address) + Ok(addresses) + } + + 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, + 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(); + history.sort_unstable_by(|ti1, ti2| { + (ti1.confirmations.cmp(&ti2.confirmations)) + .then_with(|| ti1.direction.cmp(&ti2.direction)) + .then_with(|| ti1.tx_hash.cmp(&ti2.tx_hash)) + }); + history } /// Builds a partially signed transaction that sends diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 7a55e9986f..ecdf74a8ea 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -2,7 +2,7 @@ use super::tauri_bindings::TauriHandle; use crate::bitcoin::wallet; use crate::cli::api::tauri_bindings::{ ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter, - TauriSwapProgressEvent, + TauriSwapProgressEvent, WithdrawBitcoinDetails, }; use crate::cli::api::Context; use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller}; @@ -174,6 +174,9 @@ pub struct WithdrawBtcResponse { #[serde(with = "::bitcoin::amount::serde::as_sat")] pub amount: bitcoin::Amount, pub txid: String, + #[typeshare(serialized_as = "string")] + #[serde(with = "swap_serde::bitcoin::address_serde")] + pub address: bitcoin::Address, } impl Request for WithdrawBtcArgs { @@ -184,6 +187,27 @@ impl Request for WithdrawBtcArgs { } } +// GenerateBitcoinAddresses +#[typeshare(serialized_as = "number")] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GenerateBitcoinAddressesArgs(pub usize); + +#[typeshare(serialized_as = "string[]")] +#[derive(Serialize, Deserialize, Debug)] +pub struct GenerateBitcoinAddressesResponse( + #[serde(with = "swap_serde::bitcoin::address_serde::vec")] pub Vec, +); + +impl Request for GenerateBitcoinAddressesArgs { + type Response = GenerateBitcoinAddressesResponse; + + async fn request(self, ctx: Arc) -> Result { + let bitcoin_wallet = ctx.try_get_bitcoin_wallet().await?; + let addresses = bitcoin_wallet.new_addresses(self.0).await?; + Ok(GenerateBitcoinAddressesResponse(addresses)) + } +} + // ListSellers #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -293,6 +317,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 { @@ -303,30 +328,6 @@ impl Request for BalanceArgs { } } -// GetBitcoinAddress -#[typeshare] -#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct GetBitcoinAddressArgs; - -#[typeshare] -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GetBitcoinAddressResponse { - #[typeshare(serialized_as = "string")] - #[serde(with = "swap_serde::bitcoin::address_serde")] - pub address: bitcoin::Address, -} - -impl Request for GetBitcoinAddressArgs { - type Response = GetBitcoinAddressResponse; - - async fn request(self, ctx: Arc) -> Result { - let bitcoin_wallet = ctx.try_get_bitcoin_wallet().await?; - let address = bitcoin_wallet.new_address().await?; - - Ok(GetBitcoinAddressResponse { address }) - } -} - // GetHistory #[typeshare] #[derive(Serialize, Deserialize, Debug)] @@ -720,9 +721,9 @@ pub struct GetMoneroBalanceArgs; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetMoneroBalanceResponse { - #[typeshare(serialized_as = "string")] + #[typeshare(serialized_as = "number")] pub total_balance: crate::monero::Amount, - #[typeshare(serialized_as = "string")] + #[typeshare(serialized_as = "number")] pub unlocked_balance: crate::monero::Amount, } @@ -764,7 +765,9 @@ pub enum SendMoneroAmount { pub struct SendMoneroResponse { pub tx_hash: String, pub address: String, + #[typeshare(serialized_as = "number")] pub amount_sent: crate::monero::Amount, + #[typeshare(serialized_as = "number")] pub fee: crate::monero::Amount, } @@ -1417,7 +1420,7 @@ pub async fn withdraw_btc( let (withdraw_tx_unsigned, amount) = match amount { Some(amount) => { let withdraw_tx_unsigned = bitcoin_wallet - .send_to_address_dynamic_fee(address, amount, None) + .send_to_address_dynamic_fee(address.clone(), amount, None) .await?; (withdraw_tx_unsigned, amount) @@ -1428,17 +1431,35 @@ pub async fn withdraw_btc( .await?; let withdraw_tx_unsigned = bitcoin_wallet - .send_to_address(address, max_giveable, spending_fee, None) + .send_to_address(address.clone(), max_giveable, spending_fee, None) .await?; (withdraw_tx_unsigned, max_giveable) } }; + let fee = withdraw_tx_unsigned.fee()?; let withdraw_tx = bitcoin_wallet .sign_and_finalize(withdraw_tx_unsigned) .await?; + if !context + .tauri_handle + .as_ref() + .context("Tauri needs to be available to approve transactions")? + .request_approval::( + ApprovalRequestType::WithdrawBitcoin(WithdrawBitcoinDetails { + address: address.to_string(), + amount, + fee, + }), + Some(60 * 5), + ) + .await? + { + bail!("Transaction rejected interactively."); + } + bitcoin_wallet .broadcast(withdraw_tx.clone(), "withdraw") .await?; @@ -1448,6 +1469,7 @@ pub async fn withdraw_btc( Ok(WithdrawBtcResponse { txid: txid.to_string(), amount, + address, }) } @@ -1460,22 +1482,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 ec57b1ea06..6256784034 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -98,6 +98,20 @@ pub struct SendMoneroDetails { pub fee: monero::Amount, } +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WithdrawBitcoinDetails { + /// Destination address for the Bitcoin transfer + #[typeshare(serialized_as = "string")] + pub address: String, + /// Amount to send + #[typeshare(serialized_as = "number")] + pub amount: bitcoin::Amount, + /// Transaction fee + #[typeshare(serialized_as = "number")] + pub fee: bitcoin::Amount, +} + #[typeshare] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PasswordRequestDetails { @@ -146,6 +160,8 @@ pub enum ApprovalRequestType { SeedSelection(SeedSelectionDetails), /// Request approval for publishing a Monero transaction. SendMonero(SendMoneroDetails), + /// Request approval for publishing a Bitcoin transaction. + WithdrawBitcoin(WithdrawBitcoinDetails), /// Request password for wallet file. /// User must provide password to unlock the selected wallet. PasswordRequest(PasswordRequestDetails), @@ -423,6 +439,7 @@ impl Display for ApprovalRequest { ApprovalRequestType::SelectMaker(..) => write!(f, "SelectMaker()"), ApprovalRequestType::SeedSelection(_) => write!(f, "SeedSelection()"), ApprovalRequestType::SendMonero(_) => write!(f, "SendMonero()"), + ApprovalRequestType::WithdrawBitcoin(_) => write!(f, "WithdrawBitcoin()"), ApprovalRequestType::PasswordRequest(_) => write!(f, "PasswordRequest()"), } } @@ -482,9 +499,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 dafbd2cede..ea9d893044 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