diff --git a/Cargo.lock b/Cargo.lock index 23a424174..80f193e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -977,6 +977,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bdk_bitcoind_rpc" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99a9a7a93a0c377d1858263122afe0565e6b38800fe39b2025f217e01f146294" +dependencies = [ + "bdk_core", + "bitcoin 0.32.7", + "bitcoincore-rpc", +] + [[package]] name = "bdk_chain" version = "0.23.2" @@ -1228,6 +1239,19 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoincore-rpc" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee" +dependencies = [ + "bitcoincore-rpc-json", + "jsonrpc", + "log", + "serde", + "serde_json", +] + [[package]] name = "bitcoincore-rpc-json" version = "0.19.0" @@ -5361,6 +5385,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq", + "serde", + "serde_json", +] + [[package]] name = "jsonrpc_client" version = "0.7.1" @@ -6521,6 +6557,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "minreq" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "mio" version = "1.1.0" @@ -11055,6 +11101,7 @@ dependencies = [ "backoff", "base64 0.22.1", "bdk", + "bdk_bitcoind_rpc", "bdk_chain", "bdk_core", "bdk_electrum", diff --git a/Cargo.toml b/Cargo.toml index 222a11a29..0e49169db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ [workspace.dependencies] # Bitcoin Dev Kit bdk = "0.28" +bdk_bitcoind_rpc = "0.22" bdk_chain = "0.23.0" bdk_core = "0.6.0" bdk_electrum = { version = "0.23.0", default-features = false } diff --git a/dev-docs/cli/README.md b/dev-docs/cli/README.md index 24c430535..97b40e746 100644 --- a/dev-docs/cli/README.md +++ b/dev-docs/cli/README.md @@ -67,7 +67,8 @@ OPTIONS: --receive-address The monero address where you would like to receive monero --seller The seller's address. Must include a peer ID part, i.e. `/p2p/` - --electrum-rpc Provide the Bitcoin Electrum RPC URL + --electrum-rpc Provide the Bitcoin Electrum RPC URL. Supersedes --bitcoind-rpc + --bitcoind-rpc Provide the bitcoind RPC URL. Superseded by --electrum-rpc --bitcoin-target-block Estimate Bitcoin fees such that transactions are confirmed within the specified number of blocks --monero-daemon-address Specify to connect to a monero daemon of your choice: : --tor-socks5-port Your local Tor socks5 proxy port [default: 9050] diff --git a/docs/pages/becoming_a_maker/overview.mdx b/docs/pages/becoming_a_maker/overview.mdx index 6c9d196c7..5f9182585 100644 --- a/docs/pages/becoming_a_maker/overview.mdx +++ b/docs/pages/becoming_a_maker/overview.mdx @@ -145,7 +145,7 @@ Below an explanation of what each option does: ### Bitcoin Section The `bitcoin` section specifies a few details about the asb's interaction with the Bitcoin blockchain. -We do not recommend changing these settings, however we document them for completeness sake. +We do not recommend changing these settings, however we document them for completeness' sake. ```toml filename="config_mainnet.toml" # ... @@ -153,6 +153,7 @@ We do not recommend changing these settings, however we document them for comple [bitcoin] target_block = 1 electrum_rpc_urls = ["tcp://mainnet_electrs:50001"] +# bitcoind_rpc_urls = ["http://user:pass@127.0.0.1:8332"] use_mempool_space_fee_estimation = true network = "Mainnet" 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/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index eb369af53..daabe7856 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 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/PromiseInvokeButton.tsx b/src-gui/src/renderer/components/PromiseInvokeButton.tsx index 4987b7001..5ea46cde2 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 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/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 cc485aceb..2e65a7a01 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 ( + @@ -246,6 +249,47 @@ function isValidUrl(url: string, allowedProtocols: string[]): boolean { return urlPattern.test(url); } +/* + * A setting that allows entering the bitcoind RPC URL. + */ +function BitcoindRpcUrlSetting() { + const network = getNetwork(); + const bitcoindNode = useSettings((s) => s.bitcoindNode); + const dispatch = useAppDispatch(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const currentNodes = useSettings((s) => s.nodes[network][Blockchain.Monero]); + + const isValid = (url: string) => + url.trim() === "" || isValidUrl(url.trim(), ["http", "https"]); + + return ( + + + + + + + + dispatch(setBitcoindNode(value.trim())) + } + placeholder={PLACEHOLDER_BITCOIND_RPC_URL} + fullWidth + isValid={isValid} + variant="outlined" + noErrorWhenEmpty + /> + + + + ); +} + /** * A setting that allows you to select the Electrum RPC URL to use. */ @@ -253,6 +297,7 @@ function ElectrumRpcUrlSetting() { const [tableVisible, setTableVisible] = useState(false); const network = getNetwork(); + const bitcoindNode = useSettings((s) => s.bitcoindNode); const isValid = (url: string) => isValidUrl(url, ["ssl", "tcp"]); return ( @@ -264,7 +309,11 @@ function ElectrumRpcUrlSetting() { /> - setTableVisible(true)} size="large"> + setTableVisible(true)} + disabled={bitcoindNode !== undefined} + size="large" + > {} {tableVisible ? ( 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/SendTransactionModal.tsx b/src-gui/src/renderer/components/pages/monero/SendTransactionModal.tsx index 1ae68367a..0321ff56b 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 ca23c8cd8..809827091 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 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..657a4d635 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 610522328..68bf6d84c 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 182bc21e8..6d992eb4c 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 3b22e8d15..e44932124 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 c6ddb5513..af19abc93 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 01a8098ef..0bef69361 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() { @@ -246,6 +259,7 @@ export async function initializeContext() { // Get all Bitcoin nodes without checking availability // The backend ElectrumBalancer will handle load balancing and failover + const bitcoindRpc = store.getState().settings.bitcoindNode; const bitcoinNodes = store.getState().settings.nodes[network][Blockchain.Bitcoin]; @@ -272,6 +286,7 @@ export async function initializeContext() { // Initialize Tauri settings const tauriSettings: TauriSettings = { + bitcoind_rpc_url: bitcoindRpc, electrum_rpc_urls: bitcoinNodes, monero_node_config: moneroNodeConfig, use_tor: useTor, @@ -318,14 +333,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 +386,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 +402,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 +598,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 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/features/settingsSlice.ts b/src-gui/src/store/features/settingsSlice.ts index 45059613f..04859db0b 100644 --- a/src-gui/src/store/features/settingsSlice.ts +++ b/src-gui/src/store/features/settingsSlice.ts @@ -8,6 +8,8 @@ export type DonateToDevelopmentTip = false | 0.0005 | 0.0075; const MIN_TIME_BETWEEN_DEFAULT_NODES_APPLY = 14 * 24 * 60 * 60 * 1000; // 14 days export interface SettingsState { + /// This is an URL to a bitcoind node. If present it overrules `nodes`. + bitcoindNode?: string; /// This is an ordered list of node urls for each network and blockchain nodes: Record>; /// Which theme to use @@ -124,6 +126,9 @@ const alertsSlice = createSlice({ name: "settings", initialState, reducers: { + setBitcoindNode(slice, action: PayloadAction) { + slice.bitcoindNode = action.payload.length ? action.payload : undefined; + }, moveUpNode( slice, action: PayloadAction<{ @@ -288,6 +293,7 @@ const alertsSlice = createSlice({ }); export const { + setBitcoindNode, moveUpNode, setTheme, addNode, diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 77a6b46c0..45e6e58b1 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 b0917d478..8b278a5ed 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 44f0285f9..06f208293 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, ] }; } @@ -171,7 +171,8 @@ pub async fn initialize_context( // Now populate the context in the background let context_result = ContextBuilder::new(testnet) .with_bitcoin(Bitcoin { - bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(), + bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls, + bitcoind_rpc_url: settings.bitcoind_rpc_url, bitcoin_target_block: None, }) .with_monero(settings.monero_node_config) @@ -429,6 +430,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 +440,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-asb/src/main.rs b/swap-asb/src/main.rs index f1ff2e588..ca0a05a78 100644 --- a/swap-asb/src/main.rs +++ b/swap-asb/src/main.rs @@ -28,6 +28,7 @@ mod command; use command::{parse_args, Arguments, Command}; use swap::asb::rpc::RpcServer; use swap::asb::{cancel, punish, redeem, refund, safely_abort, EventLoop, Finality, KrakenRate}; +use swap::cli::command::BitcoinRemotes; use swap::common::tor::{bootstrap_tor_client, create_tor_client}; use swap::common::tracing_util::Format; use swap::common::{self, get_logs, warn_if_outdated}; @@ -570,13 +571,24 @@ async fn init_bitcoin_wallet( let wallet = bitcoin::wallet::WalletBuilder::default() .seed(seed.clone()) .network(env_config.bitcoin_network) - .electrum_rpc_urls( + .remotes( config .bitcoin - .electrum_rpc_urls - .iter() - .map(|url| url.as_str().to_string()) - .collect::>(), + .bitcoind_rpc_url + .as_ref() + .map(|bitcoind_rpc_url| { + BitcoinRemotes::BitcoindRpc(bitcoind_rpc_url.as_str().to_string()) + }) + .unwrap_or_else(|| { + BitcoinRemotes::Electrum( + config + .bitcoin + .electrum_rpc_urls + .iter() + .map(|url| url.as_str().to_string()) + .collect(), + ) + }), ) .persister(bitcoin::wallet::PersisterConfig::SqliteFile { data_dir: config.data.dir.clone(), diff --git a/swap-core/src/bitcoin.rs b/swap-core/src/bitcoin.rs index 113195cae..689818afb 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-core/src/bitcoin/timelocks.rs b/swap-core/src/bitcoin/timelocks.rs index dce3bfa0f..9e04f6706 100644 --- a/swap-core/src/bitcoin/timelocks.rs +++ b/swap-core/src/bitcoin/timelocks.rs @@ -28,6 +28,16 @@ impl From for BlockHeight { } } +impl TryFrom for BlockHeight { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + Ok(Self( + value.try_into().context("Failed to fit u64 into u32")?, + )) + } +} + impl TryFrom for BlockHeight { type Error = anyhow::Error; diff --git a/swap-env/src/config.rs b/swap-env/src/config.rs index 60538eb54..6da6f4e3f 100644 --- a/swap-env/src/config.rs +++ b/swap-env/src/config.rs @@ -49,6 +49,7 @@ pub struct Network { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct Bitcoin { + pub bitcoind_rpc_url: Option, #[serde(deserialize_with = "swap_serde::electrum::urls::deserialize")] pub electrum_rpc_urls: Vec, pub target_block: u16, @@ -205,6 +206,7 @@ pub fn query_user_for_initial_config_with_network( external_addresses: vec![], }, bitcoin: Bitcoin { + bitcoind_rpc_url: None, electrum_rpc_urls, target_block, finality_confirmations: None, diff --git a/swap-env/src/defaults.rs b/swap-env/src/defaults.rs index 86465e624..0785492d5 100644 --- a/swap-env/src/defaults.rs +++ b/swap-env/src/defaults.rs @@ -53,51 +53,44 @@ pub fn default_rendezvous_points() -> Vec { ] } +const DEFAULT_ELECTRUM_SERVERS_MAINNET: &[&str] = &[ + "ssl://electrum.blockstream.info:50002", + "ssl://bitcoin.stackwallet.com:50002", + "ssl://b.1209k.com:50002", + "ssl://mainnet.foundationdevices.com:50002", + "tcp://bitcoin.lu.ke:50001", + "ssl://electrum.coinfinity.co:50002", + "tcp://electrum1.bluewallet.io:50001", + "tcp://electrum2.bluewallet.io:50001", + "tcp://electrum3.bluewallet.io:50001", + "ssl://btc-electrum.cakewallet.com:50002", + "tcp://bitcoin.aranguren.org:50001", +]; + +const DEFAULT_ELECTRUM_SERVERS_TESTNET: &[&str] = &[ + "ssl://blackie.c3-soft.com:57006", + "ssl://v22019051929289916.bestsrv.de:50002", + "tcp://v22019051929289916.bestsrv.de:50001", + "ssl://electrum.blockstream.info:60002", + "ssl://blockstream.info:993", + "tcp://testnet.aranguren.org:51001", + "ssl://testnet.aranguren.org:51002", + "ssl://bitcoin.devmole.eu:5010", + "tcp://bitcoin.devmole.eu:5000", +]; + pub fn default_electrum_servers_mainnet() -> Vec { - vec![ - Url::parse("ssl://electrum.blockstream.info:50002") - .expect("default electrum server url to be valid"), - Url::parse("ssl://bitcoin.stackwallet.com:50002") - .expect("default electrum server url to be valid"), - Url::parse("ssl://b.1209k.com:50002").expect("default electrum server url to be valid"), - Url::parse("ssl://mainnet.foundationdevices.com:50002") - .expect("default electrum server url to be valid"), - Url::parse("tcp://bitcoin.lu.ke:50001").expect("default electrum server url to be valid"), - Url::parse("ssl://electrum.coinfinity.co:50002") - .expect("default electrum server url to be valid"), - Url::parse("tcp://electrum1.bluewallet.io:50001") - .expect("default electrum server url to be valid"), - Url::parse("tcp://electrum2.bluewallet.io:50001") - .expect("default electrum server url to be valid"), - Url::parse("tcp://electrum3.bluewallet.io:50001") - .expect("default electrum server url to be valid"), - Url::parse("ssl://btc-electrum.cakewallet.com:50002") - .expect("default electrum server url to be valid"), - Url::parse("tcp://bitcoin.aranguren.org:50001") - .expect("default electrum server url to be valid"), - ] + DEFAULT_ELECTRUM_SERVERS_MAINNET + .into_iter() + .map(|u| Url::parse(u).expect("default electrum server url to be valid")) + .collect() } pub fn default_electrum_servers_testnet() -> Vec { - vec![ - Url::parse("ssl://blackie.c3-soft.com:57006") - .expect("default electrum server url to be valid"), - Url::parse("ssl://v22019051929289916.bestsrv.de:50002") - .expect("default electrum server url to be valid"), - Url::parse("tcp://v22019051929289916.bestsrv.de:50001") - .expect("default electrum server url to be valid"), - Url::parse("ssl://electrum.blockstream.info:60002") - .expect("default electrum server url to be valid"), - Url::parse("ssl://blockstream.info:993").expect("default electrum server url to be valid"), - Url::parse("tcp://testnet.aranguren.org:51001") - .expect("default electrum server url to be valid"), - Url::parse("ssl://testnet.aranguren.org:51002") - .expect("default electrum server url to be valid"), - Url::parse("ssl://bitcoin.devmole.eu:5010") - .expect("default electrum server url to be valid"), - Url::parse("tcp://bitcoin.devmole.eu:5000") - .expect("default electrum server url to be valid"), - ] + DEFAULT_ELECTRUM_SERVERS_TESTNET + .into_iter() + .map(|u| Url::parse(u).expect("default electrum server url to be valid")) + .collect() } pub trait GetDefaults { diff --git a/swap-serde/src/bitcoin.rs b/swap-serde/src/bitcoin.rs index 4d94279b4..9af1e030d 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/Cargo.toml b/swap/Cargo.toml index 04e44a337..3c2e35d26 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -14,6 +14,7 @@ tauri = ["dep:tauri", "dep:dfx-swiss-sdk"] [dependencies] # Bitcoin Dev Kit bdk = { workspace = true } +bdk_bitcoind_rpc = { workspace = true } bdk_chain = { workspace = true } bdk_core = { workspace = true } bdk_electrum = { workspace = true, features = ["use-rustls-ring"] } 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 c1148fc57..b050e106e 100644 --- a/swap/src/bitcoin/wallet.rs +++ b/swap/src/bitcoin/wallet.rs @@ -3,12 +3,14 @@ use crate::cli::api::tauri_bindings::{ TauriBackgroundProgress, TauriBitcoinFullScanProgress, TauriBitcoinSyncProgress, TauriEmitter, TauriHandle, }; +use crate::cli::command::BitcoinRemotes; use crate::seed::Seed; use anyhow::{anyhow, bail, Context, Result}; use bdk_chain::spk_client::{SyncRequest, SyncRequestBuilder}; use bdk_chain::CheckPoint; use bdk_electrum::electrum_client::{ElectrumApi, GetHistoryRes}; +use bdk_bitcoind_rpc::bitcoincore_rpc; use bdk_wallet::bitcoin::FeeRate; use bdk_wallet::bitcoin::Network; use bdk_wallet::export::FullyNodedExport; @@ -20,13 +22,16 @@ 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 bitcoincore_rpc::{jsonrpc, RpcApi}; use derive_builder::Builder; use electrum_pool::ElectrumBalancer; +use futures::StreamExt; 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 +47,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 +56,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 @@ -87,9 +125,11 @@ pub struct Wallet { #[derive(Clone)] pub struct Client { /// The underlying electrum balancer for load balancing across multiple servers. - inner: Arc, + inner: ClientBackend, /// The history of transactions for each script. - script_history: BTreeMap>, + script_history: BTreeMap>, + /// Transaction-only variant of `script_history` used by some backends. + tx_heights: BTreeMap>, /// The subscriptions to the status of transactions. subscriptions: HashMap<(Txid, ScriptBuf), Subscription>, /// The time of the last sync. @@ -100,6 +140,20 @@ pub struct Client { latest_block_height: BlockHeight, } +#[derive(Clone)] +struct ScriptHistory { + height: i32, + tx_hash: Txid, +} +impl From for ScriptHistory { + fn from(ghr: GetHistoryRes) -> Self { + Self { + height: ghr.height, + tx_hash: ghr.tx_hash, + } + } +} + /// Holds the configuration parameters for creating a Bitcoin wallet. /// The actual Wallet will be constructed from this configuration. #[derive(Builder, Clone)] @@ -117,7 +171,7 @@ pub struct Client { pub struct WalletConfig { seed: Seed, network: Network, - electrum_rpc_urls: Vec, + remotes: BitcoinRemotes, persister: PersisterConfig, finality_confirmations: u32, target_block: u32, @@ -137,7 +191,7 @@ impl WalletBuilder { .validate_config() .map_err(|e| anyhow!("Builder validation failed: {e}"))?; - let client = Client::new(&config.electrum_rpc_urls, config.sync_interval) + let client = Client::new(config.remotes, config.sync_interval) .await .context("Failed to create Electrum client")?; @@ -375,102 +429,6 @@ impl Wallet { } } - /// Create a new wallet, persisted to a sqlite database. - /// This is a private API so we allow too many arguments. - #[allow(clippy::too_many_arguments)] - pub async fn with_sqlite( - seed: &Seed, - network: Network, - electrum_rpc_urls: &[String], - data_dir: impl AsRef, - finality_confirmations: u32, - target_block: u32, - sync_interval: Duration, - env_config: swap_env::env::Config, - tauri_handle: Option, - ) -> Result> { - // Construct the private key, directory and wallet file for the new (>= 1.0.0) bdk wallet - let xprivkey = seed.derive_extended_private_key(env_config.bitcoin_network)?; - let wallet_dir = data_dir - .as_ref() - .join(Self::WALLET_PARENT_DIR_NAME) - .join(Self::WALLET_DIR_NAME); - let wallet_path = wallet_dir.join(Self::WALLET_FILE_NAME); - let wallet_exists = wallet_path.exists(); - - // Connect to the electrum server. - let client = Client::new(electrum_rpc_urls, sync_interval).await?; - - // Make sure the wallet directory exists. - tokio::fs::create_dir_all(&wallet_dir).await?; - - let connection = - || Connection::open(&wallet_path).context("Failed to open SQLite database"); - - // If the new Bitcoin wallet (> 1.0.0 bdk) already exists, we open it - if wallet_exists { - Self::create_existing( - xprivkey, - network, - client, - connection()?, - finality_confirmations, - target_block, - tauri_handle, - true, // default to true for mempool space fee estimation - ) - .await - } else { - // If the new Bitcoin wallet (> 1.0.0 bdk) does not yet exist: - // We check if we have an old (< 1.0.0 bdk) wallet. If so, we migrate. - let export = Self::get_pre_1_0_bdk_wallet_export(data_dir, network, seed).await?; - - Self::create_new( - xprivkey, - network, - client, - connection, - finality_confirmations, - target_block, - export, - tauri_handle, - true, // default to true for mempool space fee estimation - ) - .await - } - } - - /// Create a new wallet, persisted to an in-memory sqlite database. - /// Should only be used for testing. - #[cfg(test)] - pub async fn with_sqlite_in_memory( - seed: &Seed, - network: Network, - electrum_rpc_urls: &[String], - finality_confirmations: u32, - target_block: u32, - sync_interval: Duration, - tauri_handle: Option, - ) -> Result> { - Self::create_new( - seed.derive_extended_private_key(network)?, - network, - Client::new(electrum_rpc_urls, sync_interval) - .await - .expect("Failed to create electrum client"), - || { - bdk_wallet::rusqlite::Connection::open_in_memory() - .context("Failed to open in-memory SQLite database") - }, - finality_confirmations, - target_block, - None, - tauri_handle, - true, // default to true for mempool space fee estimation - ) - .await - } - /// Create a new wallet in the database and perform a full scan. /// This is a private API so we allow too many arguments. #[allow(clippy::too_many_arguments)] @@ -528,7 +486,7 @@ impl Wallet { let progress_handle_clone = progress_handle.clone(); - let callback = sync_ext::InnerSyncCallback::new(move |consumed, total| { + let mut callback = sync_ext::InnerSyncCallback::new(move |consumed, total| { progress_handle_clone.update(TauriBitcoinFullScanProgress::Known { current_index: consumed, assumed_total: total, @@ -539,16 +497,35 @@ impl Wallet { consumed, total ); - }).throttle_callback(10.0)).to_full_scan_callback(Self::SCAN_STOP_GAP, 100); - - let full_scan = wallet.start_full_scan().inspect(callback); - - let full_scan_response = client.inner.get_any_client().await?.full_scan( - full_scan, - Self::SCAN_STOP_GAP as usize, - Self::SCAN_BATCH_SIZE as usize, - true, - )?; + }).throttle_callback(10.0)); + + let (full_scan_response, bitcoind_scan_response) = match &client.inner { + ClientBackend::BitcoindRpc(rpc) => ( + None, + Some( + rpc.run_sync( + network, + wallet.latest_checkpoint(), + bdk_bitcoind_rpc::NO_EXPECTED_MEMPOOL_TXS, + move |cur, tot| callback.call(cur, tot), + ) + .await?, + ), + ), + ClientBackend::Electrum(inner) => { + let full_scan = wallet + .start_full_scan() + .inspect(callback.to_full_scan_callback(Self::SCAN_STOP_GAP, 100)); + let full_scan_response = inner.get_any_client().await?.full_scan( + full_scan, + Self::SCAN_STOP_GAP as usize, + Self::SCAN_BATCH_SIZE as usize, + true, + )?; + + (Some(full_scan_response), None) + } + }; // Only create the persister once we have the full scan result let mut persister = persister_constructor()?; @@ -560,7 +537,19 @@ impl Wallet { .context("Failed to create wallet with persister")?; // Apply the full scan result to the wallet - wallet.apply_update(full_scan_response)?; + if let Some(full_scan_response) = full_scan_response { + wallet.apply_update(full_scan_response)?; + } + if let Some((block_events_scan_response, mempool_scan_response)) = bitcoind_scan_response { + for block_event in block_events_scan_response { + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + wallet.apply_unconfirmed_txs(mempool_scan_response.update); + } wallet.persist(&mut persister)?; progress_handle.finish(); @@ -677,7 +666,7 @@ impl Wallet { let client = self.electrum_client.lock().await; let broadcast_results = client - .transaction_broadcast_all(&transaction) + .transaction_broadcast_all(transaction.clone()) .await .with_context(|| { format!( @@ -983,36 +972,78 @@ impl Wallet { pub async fn sync_with_custom_callback( &self, sync_request_factory: SyncRequestBuilderFactory, - callback: InnerSyncCallback, + mut callback: InnerSyncCallback, ) -> Result<()> { - let callback = Arc::new(SyncMutex::new(callback)); - - let sync_response = self - .electrum_client - .lock() - .await - .inner - .call_async("sync_wallet", move |client| { - let sync_request_factory = sync_request_factory.clone(); - let callback = callback.clone(); + let (sync_response, bitcoind_sync_response) = match &self.electrum_client.lock().await.inner + { + ClientBackend::BitcoindRpc(rpc) => { + let (wallet_tip, expected_mempool_txs) = { + let wallet = self.wallet.lock().await; + ( + wallet.latest_checkpoint(), + wallet + .transactions() + .filter(|wtx| wtx.chain_position.is_unconfirmed()) + .map(|wtx| wtx.tx_node.tx) + .collect::>(), + ) + }; - // Build the sync request - let sync_request = sync_request_factory - .build() - .inspect(move |_, progress| { - if let Ok(mut guard) = callback.lock() { - guard.call(progress.consumed() as u64, progress.total() as u64); - } + ( + None, + Some( + // rpc.run_sync(self.network, wallet_tip, expected_mempool_txs, move |cur, tot| if let Ok(mut guard) = callback.lock() {guard.call(cur,tot);}) + rpc.run_sync( + self.network, + wallet_tip, + expected_mempool_txs, + move |cur, tot| callback.call(cur, tot), + ) + .await?, + ), + ) + } + ClientBackend::Electrum(inner) => { + let callback = Arc::new(SyncMutex::new(callback)); + inner + .call_async("sync_wallet", move |client| { + let callback = callback.clone(); + let sync_request_factory = sync_request_factory.clone(); + + // Build the sync request + let sync_request = sync_request_factory + .build() + .inspect(move |_, progress| { + if let Ok(mut guard) = callback.lock() { + guard.call(progress.consumed() as u64, progress.total() as u64); + } + }) + .build(); + + let sync_response = + client.sync(sync_request, Self::SCAN_BATCH_SIZE as usize, true)?; + Ok((Some(sync_response), None)) }) - .build(); - - client.sync(sync_request, Self::SCAN_BATCH_SIZE as usize, true) - }) - .await?; + .await? + } + }; // We only acquire the lock after the long running .sync(...) call has finished let mut wallet = self.wallet.lock().await; - wallet.apply_update(sync_response)?; // Use the full sync_response, not just chain_update + if let Some(sync_response) = sync_response { + wallet.apply_update(sync_response)?; // Use the full sync_response, not just chain_update + } + if let Some((block_events_sync_response, mempool_sync_response)) = bitcoind_sync_response { + for block_event in block_events_sync_response { + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + wallet.apply_unconfirmed_txs(mempool_sync_response.update); + wallet.apply_evicted_txs(mempool_sync_response.evicted); + } let mut persister = self.persister.lock().await; wallet.persist(&mut persister)?; @@ -1290,18 +1321,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 @@ -1576,9 +1688,7 @@ where /// /// This uses different techniques to estimate the fee under the hood: /// 1. `estimate_fee_rate` from Electrum which calls `estimatesmartfee` from Bitcoin Core - /// 2. `estimate_fee_rate_from_histogram` which calls `mempool.get_fee_histogram` from Electrum. It calculates the distance to the tip of the mempool. - /// it can adapt faster to sudden spikes in the mempool. - /// 3. `MempoolClient::estimate_feerate` which uses the mempool.space API for fee estimation + /// 2. `MempoolClient::estimate_feerate` which uses the mempool.space API for fee estimation /// /// To compute the min relay fee we fetch from both the Electrum server and the MempoolClient. /// @@ -1588,21 +1698,156 @@ where weight: Weight, transfer_amount: Option, ) -> Result { - let fee_rate = self.combined_fee_rate().await?; - let min_relay_fee = self.combined_min_relay_fee().await?; + let (fee_rate, min_relay_fee) = + tokio::join!(self.combined_fee_rate(), self.combined_min_relay_fee()); + + estimate_fee(weight, transfer_amount, fee_rate?, min_relay_fee?) + } +} + +#[derive(Clone)] +enum ClientBackend { + BitcoindRpc(AsyncBitcoindRpcClient), + Electrum(Arc), +} - estimate_fee(weight, transfer_amount, fee_rate, min_relay_fee) +impl ClientBackend { + async fn new(remotes: BitcoinRemotes) -> Result { + match remotes { + BitcoinRemotes::BitcoindRpc(mut bitcoind_rpc_url) => { + let mut auth = bitcoincore_rpc::Auth::None; + if let Ok(mut url) = url::Url::parse(&bitcoind_rpc_url) { + let username = url.username(); + let password = url.password().unwrap_or(""); + if username != "" || password != "" { + auth = bitcoincore_rpc::Auth::UserPass(username.into(), password.into()); + let _ = url.set_username(""); + let _ = url.set_password(None); + bitcoind_rpc_url = url.into(); + } + } + Ok(Self::BitcoindRpc(AsyncBitcoindRpcClient(Arc::new( + bitcoincore_rpc::Client::new(&bitcoind_rpc_url, auth)?, + )))) + } + BitcoinRemotes::Electrum(electrum_rpc_urls) => Ok(Self::Electrum(Arc::new( + ElectrumBalancer::new(electrum_rpc_urls).await?, + ))), + } + } +} + +#[derive(Clone)] +struct AsyncBitcoindRpcClient(pub Arc); +impl AsyncBitcoindRpcClient { + async fn call_async( + &self, + f: impl FnOnce(&bitcoincore_rpc::Client) -> Result + Send + 'static, + ) -> Result { + let client = self.0.clone(); + tokio::task::spawn_blocking(move || f(&*client)).await? + } + + async fn run_sync( + &self, + network: Network, + wallet_tip: bdk_core::CheckPoint, + expected_mempool_txs: impl IntoIterator>> + Send + 'static, + mut callback: impl FnMut(u64, u64) + Send + 'static, + ) -> Result<( + Vec>, + bdk_bitcoind_rpc::MempoolEvent, + )> { + self.call_async(move |client| { + // https://bookofbdk.com/cookbook/syncing/rpc/ + let info = client.get_blockchain_info()?; + + let rpc_network = info.chain; + if rpc_network != network { + bail!("bitcoind connected to {}, we want {}", rpc_network, network); + } + + let warnings = match info.warnings { + bitcoincore_rpc::json::StringOrStringArray::String(s) => { + if !s.is_empty() { + vec![s] + } else { + vec![] + } + } + bitcoincore_rpc::json::StringOrStringArray::StringArray(sa) => sa, + }; + if !warnings.is_empty() { + tracing::warn!(?warnings, "bitcoind warnings"); + } + + let est_to_sync = info + .headers + .checked_sub(wallet_tip.height() as u64) + .unwrap_or(0); + + let mut emitter = bdk_bitcoind_rpc::Emitter::new( + client, + wallet_tip.clone(), + wallet_tip.height(), + expected_mempool_txs, + ); + + let mut block_events = vec![]; + while let Some(block) = emitter.next_block()? { + callback( + block + .block_height() + .checked_sub(wallet_tip.height()) + .unwrap_or(0) as _, + est_to_sync, + ); + block_events.push(block); + } + + Ok((block_events, emitter.mempool()?)) + }) + .await + } + + /// <=0: unconfirmed; >0 <=> confirmed in block # + async fn tx_height( + &self, + txid: Txid, + latest_block_height: BlockHeight, + ) -> Result<(Txid, Option)> { + self.call_async(move |client| { + Ok(match client.get_raw_transaction_info(&txid, None) { + Ok(rtxr) => { + // confirmations=1 <=> in latest block + let confirmations = rtxr.confirmations.unwrap_or(0); + ( + txid, + Some(if confirmations <= 0 { + confirmations + } else { + (latest_block_height + (confirmations - 1)).into() + } as i32), + ) + } + // -5 is ENOENT; error string "No such mempool or blockchain transaction." + Err(bitcoincore_rpc::Error::JsonRpc(jsonrpc::error::Error::Rpc( + jsonrpc::error::RpcError { code: -5, .. }, + ))) => (txid, None), + Err(e) => Err(e)?, + }) + }) + .await } } impl Client { /// Create a new client with multiple electrum servers for load balancing. - pub async fn new(electrum_rpc_urls: &[String], sync_interval: Duration) -> Result { - let balancer = ElectrumBalancer::new(electrum_rpc_urls.to_vec()).await?; - + pub async fn new(remotes: BitcoinRemotes, sync_interval: Duration) -> Result { Ok(Self { - inner: Arc::new(balancer), + inner: ClientBackend::new(remotes).await?, script_history: Default::default(), + tx_heights: Default::default(), last_sync: Instant::now() .checked_sub(sync_interval) .ok_or(anyhow!("failed to set last sync time"))?, @@ -1623,8 +1868,8 @@ impl Client { } self.last_sync = now; - self.update_script_histories().await?; self.update_block_height().await?; + self.update_script_histories().await?; Ok(()) } @@ -1635,22 +1880,31 @@ impl Client { /// check the time since the last update before refreshing /// It therefore also does not take a [`force`] parameter pub async fn update_state_single(&mut self, script: &dyn Watchable) -> Result<()> { - self.update_script_history(script).await?; self.update_block_height().await?; + self.update_script_history(script).await?; Ok(()) } /// Update the block height. async fn update_block_height(&mut self) -> Result<()> { - let latest_block = self - .inner - .call_async("block_headers_subscribe", |client| { - client.inner.block_headers_subscribe() - }) - .await - .context("Failed to subscribe to header notifications")?; - let latest_block_height = BlockHeight::try_from(latest_block)?; + let latest_block_height: BlockHeight = match &self.inner { + ClientBackend::BitcoindRpc(rpc) => rpc + .call_async(move |client| { + client + .get_block_count() + .context("Failed to get block height") + }) + .await? + .try_into()?, + ClientBackend::Electrum(inner) => inner + .call_async("block_headers_subscribe", |client| { + client.inner.block_headers_subscribe() + }) + .await + .context("Failed to subscribe to header notifications")? + .try_into()?, + }; if latest_block_height > self.latest_block_height { tracing::trace!( @@ -1665,27 +1919,65 @@ impl Client { /// Update the script histories. async fn update_script_histories(&mut self) -> Result<()> { - let scripts: Vec<_> = self.script_history.keys().cloned().collect(); - // No need to do any network request if we have nothing to fetch - if scripts.is_empty() { + if self.script_history.is_empty() { return Ok(()); } // Concurrently fetch the script histories from ALL electrum servers - let results = self - .inner + let inner = match &self.inner { + ClientBackend::BitcoindRpc(rpc) => { + let mut poll: futures::stream::FuturesUnordered<_> = self + .tx_heights + .keys() + .map(|txid| rpc.tx_height(txid.clone(), self.latest_block_height)) + .collect(); + let mut error = None; + let mut anyok = false; + while let Some(height) = poll.next().await { + match height { + Ok((txid, height)) => { + anyok = true; + self.tx_heights.insert(txid, height); + } + Err(err) if error.is_none() => error = Some(err), + Err(_) => {} + } + } + if error.is_some() && !anyok { + return Err(error.unwrap()); + } + return Ok(()); + } + ClientBackend::Electrum(inner) => inner, + }; + + let scripts: Arc> = Arc::new(self.script_history.keys().cloned().collect()); + let results = inner .join_all("batch_script_get_history", { let scripts = scripts.clone(); move |client| { - let script_refs: Vec<_> = scripts.iter().map(|s| s.as_script()).collect(); - client.inner.batch_script_get_history(script_refs) + let script_refs = scripts.iter().map(|s| s.as_script()); + client + .inner + .batch_script_get_history(script_refs) + .map(|vvghr| { + // Vec> -> Vec> + vvghr + .into_iter() + .map(|vghr| { + vghr.into_iter() + .map(ScriptHistory::from) + .collect::>() + }) + .collect::>() + }) } }) .await?; - let successful_results: Vec>> = results + let successful_results: Vec>> = results .iter() .filter_map(|r| r.as_ref().ok()) .cloned() @@ -1701,14 +1993,12 @@ impl Client { // Iterate through each script we fetched and find the highest // returned entry at any Electrum node for (script_index, script) in scripts.iter().enumerate() { - let all_history_for_script: Vec = successful_results + let all_history_for_script = successful_results .iter() .filter_map(|server_result| server_result.get(script_index)) - .flatten() - .cloned() - .collect(); + .flatten(); - let mut best_history: BTreeMap = BTreeMap::new(); + let mut best_history: BTreeMap = BTreeMap::new(); for item in all_history_for_script { best_history .entry(item.tx_hash) @@ -1717,10 +2007,10 @@ impl Client { *current = item.clone(); } }) - .or_insert(item); + .or_insert(item.clone()); } - let final_history: Vec = best_history.into_values().collect(); + let final_history = best_history.into_values().collect(); self.script_history.insert(script.clone(), final_history); } @@ -1729,19 +2019,32 @@ impl Client { /// Update the script history of a single script. pub async fn update_script_history(&mut self, script: &dyn Watchable) -> Result<()> { - let (script_buf, _) = script.script_and_txid(); - let script_clone = script_buf.clone(); + let (script_buf, txid) = script.script_and_txid(); // Call all electrum servers in parallel to get script history. - let results = self - .inner - .join_all("script_get_history", move |client| { - client.inner.script_get_history(script_clone.as_script()) - }) - .await?; + let results = match &self.inner { + ClientBackend::BitcoindRpc(rpc) => { + let (_, height) = rpc.tx_height(txid, self.latest_block_height).await?; + self.tx_heights.insert(txid, height); + return Ok(()); + } + ClientBackend::Electrum(inner) => { + let script_buf = script_buf.clone(); + inner + .join_all("script_get_history", move |client| { + Ok(client + .inner + .script_get_history(script_buf.as_script())? + .into_iter() + .map(ScriptHistory::from) + .collect::>()) + }) + .await? + } + }; // Collect all successful history entries from all servers. - let mut all_history_items: Vec = Vec::new(); + let mut all_history_items = Vec::new(); let mut first_error = None; for result in results { @@ -1764,7 +2067,7 @@ impl Client { } // Use a map to find the best (highest confirmation) entry for each transaction. - let mut best_history: BTreeMap = BTreeMap::new(); + let mut best_history: BTreeMap = BTreeMap::new(); for item in all_history_items { best_history .entry(item.tx_hash) @@ -1776,7 +2079,7 @@ impl Client { .or_insert(item); } - let final_history: Vec = best_history.into_values().collect(); + let final_history: Vec = best_history.into_values().collect(); self.script_history.insert(script_buf, final_history); @@ -1787,19 +2090,31 @@ impl Client { /// Returns the results from all servers - at least one success indicates successful broadcast. pub async fn transaction_broadcast_all( &self, - transaction: &Transaction, + transaction: Transaction, ) -> Result>> { - // Broadcast to all electrum servers in parallel - let results = self.inner.broadcast_all(transaction.clone()).await?; + match &self.inner { + // https://developer.bitcoin.org/reference/rpc/sendrawtransaction.html + ClientBackend::BitcoindRpc(rpc) => { + rpc.call_async(move |client| { + client.send_raw_transaction(&transaction)?; + Ok(vec![Ok(transaction.compute_txid())]) + }) + .await + } + ClientBackend::Electrum(inner) => { + // Broadcast to all electrum servers in parallel + let results = inner.broadcast_all(transaction.clone()).await?; + + // Add the transaction to the cache if at least one broadcast succeeded + if results.iter().any(|r| r.is_ok()) { + // Note: Perhaps it is better to only populate caches of the Electrum nodes + // that accepted our transaction? + inner.populate_tx_cache([transaction]); + } - // Add the transaction to the cache if at least one broadcast succeeded - if results.iter().any(|r| r.is_ok()) { - // Note: Perhaps it is better to only populate caches of the Electrum nodes - // that accepted our transaction? - self.inner.populate_tx_cache(vec![transaction.clone()]); + Ok(results) + } } - - Ok(results) } /// Get the status of a script. @@ -1812,6 +2127,7 @@ impl Client { if !self.script_history.contains_key(&script_buf) { self.script_history.insert(script_buf.clone(), vec![]); + self.tx_heights.insert(txid.clone(), None); // Immediately refetch the status of the script // when we first subscribe to it. @@ -1825,12 +2141,17 @@ impl Client { self.update_state(false).await?; } - let history = self.script_history.entry(script_buf).or_default(); - - let history_of_tx: Vec<&GetHistoryRes> = history - .iter() - .filter(|entry| entry.tx_hash == txid) - .collect(); + let history_of_tx = match self.tx_heights.get(&txid) { + Some(Some(height)) => vec![*height], + _ => self + .script_history + .entry(script_buf) + .or_default() + .iter() + .filter(|entry| entry.tx_hash == txid) + .map(|entry| entry.height) + .collect(), + }; // Destructure history_of_tx into the last entry and the rest. let [rest @ .., last] = history_of_tx.as_slice() else { @@ -1843,7 +2164,7 @@ impl Client { tracing::warn!(%txid, "Found multiple history entries for the same txid. Ignoring all but the last one."); } - match last.height { + match *last { // If the height is 0 or less, the transaction is still in the mempool. ..=0 => Ok(ScriptStatus::InMempool), // Otherwise, the transaction has been included in a block. @@ -1859,62 +2180,84 @@ impl Client { /// Get a transaction from the Electrum server. /// Fails if the transaction is not found. pub async fn get_tx(&self, txid: Txid) -> Result>> { - match self - .inner - .call_async_with_multi_error("get_raw_transaction", move |client| { - use bitcoin::consensus::Decodable; - client.inner.transaction_get_raw(&txid).and_then(|raw| { - let mut cursor = std::io::Cursor::new(&raw); - bitcoin::Transaction::consensus_decode(&mut cursor).map_err(|e| { - bdk_electrum::electrum_client::Error::Protocol( - format!("Failed to deserialize transaction: {}", e).into(), - ) + match &self.inner { + // https://developer.bitcoin.org/reference/rpc/gettransaction.html + // Get detailed information about in-wallet transaction + // + // https://developer.bitcoin.org/reference/rpc/getrawtransaction.html + // getrawtransaction will return the transaction if it is in the mempool, + // or if -txindex is enabled and the transaction is in a block in the blockchain. + ClientBackend::BitcoindRpc(rpc) => { + rpc.call_async(move |client| { + Ok(match client.get_raw_transaction(&txid, None) { + Ok(tx) => Some(Arc::new(tx)), + // -5 is ENOENT; error string "No such mempool or blockchain transaction." + Err(bitcoincore_rpc::Error::JsonRpc(jsonrpc::error::Error::Rpc( + jsonrpc::error::RpcError { code: -5, .. }, + ))) => None, + Err(e) => Err(e)?, }) }) - }) - .await - { - Ok(tx) => { - let tx = Arc::new(tx); - // Note: Perhaps it is better to only populate caches of the Electrum nodes - // that accepted our transaction? - self.inner.populate_tx_cache(vec![(*tx).clone()]); - Ok(Some(tx)) + .await } - Err(multi_error) => { - // Check if any error indicates the transaction doesn't exist - let has_not_found = multi_error.any(|error| { - let error_str = error.to_string(); - - // Check for specific error patterns that indicate "not found" - if error_str.contains("\"code\": Number(-5)") - || error_str.contains("No such mempool or blockchain transaction") - || error_str.contains("missing transaction") - { - return true; + ClientBackend::Electrum(inner) => { + match inner + .call_async_with_multi_error("get_raw_transaction", move |client| { + use bitcoin::consensus::Decodable; + client.inner.transaction_get_raw(&txid).and_then(|raw| { + let mut cursor = std::io::Cursor::new(&raw); + bitcoin::Transaction::consensus_decode(&mut cursor).map_err(|e| { + bdk_electrum::electrum_client::Error::Protocol( + format!("Failed to deserialize transaction: {}", e).into(), + ) + }) + }) + }) + .await + { + Ok(tx) => { + let tx = Arc::new(tx); + // Note: Perhaps it is better to only populate caches of the Electrum nodes + // that accepted our transaction? + inner.populate_tx_cache([tx.clone()]); + Ok(Some(tx)) } + Err(multi_error) => { + // Check if any error indicates the transaction doesn't exist + let has_not_found = multi_error.any(|error| { + let error_str = error.to_string(); + + // Check for specific error patterns that indicate "not found" + if error_str.contains("\"code\": Number(-5)") + || error_str.contains("No such mempool or blockchain transaction") + || error_str.contains("missing transaction") + { + return true; + } + + // Also try to parse the RPC error code if possible + let err_anyhow = anyhow::anyhow!(error_str); + if let Ok(error_code) = parse_rpc_error_code(&err_anyhow) { + if error_code == i64::from(RpcErrorCode::RpcInvalidAddressOrKey) { + return true; + } + } - // Also try to parse the RPC error code if possible - let err_anyhow = anyhow::anyhow!(error_str); - if let Ok(error_code) = parse_rpc_error_code(&err_anyhow) { - if error_code == i64::from(RpcErrorCode::RpcInvalidAddressOrKey) { - return true; + false + }); + + if has_not_found { + tracing::trace!( + txid = %txid, + error_count = multi_error.len(), + "Transaction not found indicated by one or more Electrum servers" + ); + Ok(None) + } else { + let err = anyhow::anyhow!(multi_error); + Err(err.context("Failed to get transaction from the Electrum server")) } } - - false - }); - - if has_not_found { - tracing::trace!( - txid = %txid, - error_count = multi_error.len(), - "Transaction not found indicated by one or more Electrum servers" - ); - Ok(None) - } else { - let err = anyhow::anyhow!(multi_error); - Err(err.context("Failed to get transaction from the Electrum server")) } } } @@ -1927,130 +2270,85 @@ impl Client { /// This uses estimatesmartfee of bitcoind pub async fn estimate_fee_rate(&self, target_block: u32) -> Result { // Get the fee rate in Bitcoin per kilobyte - let btc_per_kvb = self - .inner - .call_async("estimate_fee", move |client| { - client.inner.estimate_fee(target_block as usize) - }) - .await?; - - // If the fee rate is less than 0, return an error - // The Electrum server returns a value <= 0 if it cannot estimate the fee rate. - // See: https://github.com/romanz/electrs/blob/ed0ef2ee22efb45fcf0c7f3876fd746913008de3/src/electrum.rs#L239-L245 - // https://github.com/romanz/electrs/blob/ed0ef2ee22efb45fcf0c7f3876fd746913008de3/src/electrum.rs#L31 - if btc_per_kvb <= 0.0 { - return Err(anyhow!( - "Fee rate returned by Electrum server is less than 0" - )); - } - - // Convert to sat / kB without ever constructing an Amount from the float - // Simply by multiplying the float with the satoshi value of 1 BTC. - // Truncation is allowed here because we are converting to sats and rounding down sats will - // not lose us any precision (because there is no fractional satoshi). - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss - )] - let sats_per_kvb = (btc_per_kvb * Amount::ONE_BTC.to_sat() as f64).ceil() as u64; - - // Convert to sat / kwu (kwu = kB × 4) - let sat_per_kwu = sats_per_kvb / 4; - - // Construct the fee rate - let fee_rate = FeeRate::from_sat_per_kwu(sat_per_kwu); - - Ok(fee_rate) - } - - /// Calculates the fee_rate needed to be included in a block at the given offset. - /// We calculate how many vMB we are away from the tip of the mempool. - /// This method adapts faster to sudden spikes in the mempool. - async fn estimate_fee_rate_from_histogram(&self, target_block: u32) -> Result { - // Assume we want to get into the next block: - // We want to be 80% of the block size away from the tip of the mempool. - const HISTOGRAM_SAFETY_MARGIN: f32 = 0.8; - - // First we fetch the fee histogram from the Electrum server - let fee_histogram = self - .inner - .call_async("get_fee_histogram", move |client| { - client.inner.raw_call("mempool.get_fee_histogram", vec![]) - }) - .await?; - - // Parse the histogram as array of [fee, vsize] pairs - let histogram: Vec<(f64, u64)> = serde_json::from_value(fee_histogram)?; - - // If the histogram is empty, we return an error - if histogram.is_empty() { - return Err(anyhow!( - "The mempool seems to be empty therefore we cannot estimate the fee rate from the histogram" - )); - } - - // Sort the histogram by fee rate - let mut histogram = histogram; - histogram.sort_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - - // Estimate block size (typically ~1MB = 1,000,000 vbytes) - let estimated_block_size = 1_000_000u64; - #[allow(clippy::cast_precision_loss)] - let target_distance_from_tip = - (estimated_block_size * target_block as u64) as f32 * HISTOGRAM_SAFETY_MARGIN; - - // Find cumulative vsize and corresponding fee rate - let mut cumulative_vsize = 0u64; - for (fee_rate, vsize) in histogram.clone() { - cumulative_vsize += vsize; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - if cumulative_vsize >= target_distance_from_tip as u64 { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let sat_per_vb = fee_rate.ceil() as u64; - return FeeRate::from_sat_per_vb(sat_per_vb) - .context("Failed to create fee rate from histogram"); + match &self.inner { + // https://developer.bitcoin.org/reference/rpc/estimatesmartfee.html + ClientBackend::BitcoindRpc(rpc) => { + rpc.call_async(move |client| { + let estimate = client.estimate_smart_fee( + target_block + .try_into() + .context("block too far in the future")?, + None, + )?; + estimate + .fee_rate + .map(feerate_bitcoind_rpc) + .ok_or_else(|| anyhow!("{:?}", estimate.errors)) + }) + .await } + ClientBackend::Electrum(inner) => feerate_electrum( + inner + .call_async("estimate_fee", move |client| { + client.inner.estimate_fee(target_block as usize) + }) + .await?, + ), } - - // If we get here, the entire mempool is less than the target distance from the tip. - // We return the lowest fee rate in the histogram. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let sat_per_vb = histogram - .first() - .expect("The histogram should not be empty") - .0 - .ceil() as u64; - FeeRate::from_sat_per_vb(sat_per_vb) - .context("Failed to create fee rate from histogram (all mempool is less than the target distance from the tip)") } /// Get the minimum relay fee rate from the Electrum server. async fn min_relay_fee(&self) -> Result { - let min_relay_btc_per_kvb = self - .inner - .call_async("relay_fee", |client| client.inner.relay_fee()) - .await?; + match &self.inner { + // https://developer.bitcoin.org/reference/rpc/getnetworkinfo.html + ClientBackend::BitcoindRpc(rpc) => { + rpc.call_async(|client| { + Ok(feerate_bitcoind_rpc(client.get_network_info()?.relay_fee)) + }) + .await + } + ClientBackend::Electrum(inner) => feerate_electrum( + inner + .call_async("relay_fee", |client| client.inner.relay_fee()) + .await?, + ), + } + } +} - // Convert to sat / kB without ever constructing an Amount from the float - // Simply by multiplying the float with the satoshi value of 1 BTC. - // Truncation is allowed here because we are converting to sats and rounding down sats will - // not lose us any precision (because there is no fractional satoshi). - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss - )] - let sats_per_kvb = (min_relay_btc_per_kvb * Amount::ONE_BTC.to_sat() as f64).ceil() as u64; +fn feerate_bitcoind_rpc(btc_per_kvb: Amount) -> FeeRate { + let sats_per_kvb = btc_per_kvb.to_sat(); + feerate_from_sats_per_kvb(sats_per_kvb) +} - // Convert to sat / kwu (kwu = kB × 4) - let sat_per_kwu = sats_per_kvb / 4; +fn feerate_electrum(btc_per_kvb: f64) -> Result { + // If the fee rate is less than 0, return an error + // The Electrum server returns a value <= 0 if it cannot estimate the fee rate. + // See: https://github.com/romanz/electrs/blob/ed0ef2ee22efb45fcf0c7f3876fd746913008de3/src/electrum.rs#L239-L245 + // https://github.com/romanz/electrs/blob/ed0ef2ee22efb45fcf0c7f3876fd746913008de3/src/electrum.rs#L31 + if btc_per_kvb <= 0.0 { + bail!("Fee rate returned by Electrum server is less than 0"); + } + + // Convert to sat / kB without ever constructing an Amount from the float + // Simply by multiplying the float with the satoshi value of 1 BTC. + // Truncation is allowed here because we are converting to sats and rounding down sats will + // not lose us any precision (because there is no fractional satoshi). + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + let sats_per_kvb = (btc_per_kvb * Amount::ONE_BTC.to_sat() as f64).ceil() as u64; + + Ok(feerate_from_sats_per_kvb(sats_per_kvb)) +} - // Construct the fee rate - let fee_rate = FeeRate::from_sat_per_kwu(sat_per_kwu); +fn feerate_from_sats_per_kvb(sats_per_kvb: u64) -> FeeRate { + // Convert to sat / kwu (kwu = kB × 4) + let sat_per_kwu = sats_per_kvb / 4; - Ok(fee_rate) - } + FeeRate::from_sat_per_kwu(sat_per_kwu) } #[derive(Clone)] @@ -2169,49 +2467,17 @@ impl bitcoin_wallet::BitcoinWallet for Wallet { impl EstimateFeeRate for Client { async fn estimate_feerate(&self, target_block: u32) -> Result { // Now that the Electrum client methods are async, we can parallelize the calls - let (electrum_conservative_fee_rate, electrum_histogram_fee_rate) = tokio::join!( - self.estimate_fee_rate(target_block), - self.estimate_fee_rate_from_histogram(target_block) - ); - - match (electrum_conservative_fee_rate, electrum_histogram_fee_rate) { - // If both the histogram and conservative fee rate are successful, we use the higher one - (Ok(electrum_conservative_fee_rate), Ok(electrum_histogram_fee_rate)) => { - tracing::debug!( - electrum_conservative_fee_rate_sat_vb = - electrum_conservative_fee_rate.to_sat_per_vb_ceil(), - electrum_histogram_fee_rate_sat_vb = - electrum_histogram_fee_rate.to_sat_per_vb_ceil(), - "Successfully fetched fee rates from both sources. We will use the higher one" - ); + let electrum_conservative_fee_rate = self + .estimate_fee_rate(target_block) + .await + .context("Failed to fetch the conservative fee rates from Electrum")?; - Ok(electrum_conservative_fee_rate.max(electrum_histogram_fee_rate)) - } - // If the conservative fee rate fails, we use the histogram fee rate - (Err(electrum_conservative_fee_rate_error), Ok(electrum_histogram_fee_rate)) => { - tracing::warn!( - electrum_conservative_fee_rate_error = ?electrum_conservative_fee_rate_error, - electrum_histogram_fee_rate_sat_vb = electrum_histogram_fee_rate.to_sat_per_vb_ceil(), - "Failed to fetch conservative fee rate, using histogram fee rate" - ); - Ok(electrum_histogram_fee_rate) - } - // If the histogram fee rate fails, we use the conservative fee rate - (Ok(electrum_conservative_fee_rate), Err(electrum_histogram_fee_rate_error)) => { - tracing::warn!( - electrum_histogram_fee_rate_error = ?electrum_histogram_fee_rate_error, - electrum_conservative_fee_rate_sat_vb = electrum_conservative_fee_rate.to_sat_per_vb_ceil(), - "Failed to fetch histogram fee rate, using conservative fee rate" - ); - Ok(electrum_conservative_fee_rate) - } - // If both the histogram and conservative fee rate fail, we return an error - (Err(electrum_conservative_fee_rate_error), Err(electrum_histogram_fee_rate_error)) => { - Err(electrum_conservative_fee_rate_error - .context(electrum_histogram_fee_rate_error) - .context("Failed to fetch both the conservative and histogram fee rates from Electrum")) - } - } + tracing::debug!( + electrum_conservative_fee_rate_sat_vb = + electrum_conservative_fee_rate.to_sat_per_vb_ceil(), + "Successfully fetched fee rate" + ); + Ok(electrum_conservative_fee_rate) } async fn min_relay_fee(&self) -> Result { diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 135eb9dc8..048e67ec5 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -2,7 +2,7 @@ pub mod request; pub mod tauri_bindings; use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice}; -use crate::cli::command::{Bitcoin, Monero}; +use crate::cli::command::{Bitcoin, BitcoinRemotes, Monero}; use crate::common::tor::{bootstrap_tor_client, create_tor_client}; use crate::common::tracing_util::Format; use crate::database::{open_db, AccessMode}; @@ -792,7 +792,7 @@ mod wallet { use super::*; pub(super) async fn init_bitcoin_wallet( - electrum_rpc_urls: Vec, + remotes: BitcoinRemotes, seed: &Seed, data_dir: &Path, env_config: EnvConfig, @@ -802,7 +802,7 @@ mod wallet { let mut builder = bitcoin::wallet::WalletBuilder::default() .seed(seed.clone()) .network(env_config.bitcoin_network) - .electrum_rpc_urls(electrum_rpc_urls) + .remotes(remotes) .persister(bitcoin::wallet::PersisterConfig::SqliteFile { data_dir: data_dir.to_path_buf(), }) diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 7a55e9986..b18c6bd16 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, }) } @@ -1951,7 +1975,11 @@ impl CheckElectrumNodeArgs { }; // Check if the node is available - let res = wallet::Client::new(&[url.as_str().to_string()], Duration::from_secs(60)).await; + let res = wallet::Client::new( + cli::command::BitcoinRemotes::Electrum(vec![url.as_str().to_string()]), + Duration::from_secs(60), + ) + .await; Ok(CheckElectrumNodeResponse { available: res.is_ok(), diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index ec57b1ea0..969e79c10 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, })); } @@ -1008,7 +1030,9 @@ pub enum MoneroNodeConfig { pub struct TauriSettings { /// Configuration for Monero node connection pub monero_node_config: MoneroNodeConfig, - /// The URLs of the Electrum RPC servers e.g `["ssl://bitcoin.com:50001", "ssl://backup.com:50001"]` + /// The URL of a bitcoind RPC server, e.g. `["http://127.0.0.1:8332"]`. If `Some`, supersedes `electrum_rpc_urls` + pub bitcoind_rpc_url: Option, + /// The URLs of the Electrum RPC servers, e.g. `["ssl://bitcoin.com:50001", "ssl://backup.com:50001"]` pub electrum_rpc_urls: Vec, /// Whether to initialize and use a tor client. pub use_tor: bool, diff --git a/swap/src/cli/command.rs b/swap/src/cli/command.rs index 74eb1492c..31f188d3b 100644 --- a/swap/src/cli/command.rs +++ b/swap/src/cli/command.rs @@ -427,9 +427,18 @@ pub struct Monero { #[derive(structopt::StructOpt, Debug, PartialEq, Default)] pub struct Bitcoin { - #[structopt(long = "electrum-rpc", help = "Provide the Bitcoin Electrum RPC URLs")] + #[structopt( + long = "electrum-rpc", + help = "Provide the Bitcoin Electrum RPC URLs. Supersedes --bitcoind-rpc" + )] pub bitcoin_electrum_rpc_urls: Vec, + #[structopt( + long = "bitcoind-rpc", + help = "Provide the bitcoind RPC URL. Superseded by --electrum-rpc" + )] + pub bitcoind_rpc_url: Option, + #[structopt( long = "bitcoin-target-block", help = "Estimate Bitcoin fees such that transactions are confirmed within the specified number of blocks" @@ -437,14 +446,24 @@ pub struct Bitcoin { pub bitcoin_target_block: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BitcoinRemotes { + BitcoindRpc(String), + Electrum(Vec), +} + impl Bitcoin { - pub fn apply_defaults(self, testnet: bool) -> Result<(Vec, u16)> { - let bitcoin_electrum_rpc_urls = if !self.bitcoin_electrum_rpc_urls.is_empty() { - self.bitcoin_electrum_rpc_urls - } else if testnet { - vec![DEFAULT_ELECTRUM_RPC_URL_TESTNET.to_string()] + pub fn apply_defaults(self, testnet: bool) -> Result<(BitcoinRemotes, u16)> { + let remotes = if let Some(bitcoind_rpc_url) = self.bitcoind_rpc_url { + BitcoinRemotes::BitcoindRpc(bitcoind_rpc_url) } else { - vec![DEFAULT_ELECTRUM_RPC_URL.to_string()] + BitcoinRemotes::Electrum(if !self.bitcoin_electrum_rpc_urls.is_empty() { + self.bitcoin_electrum_rpc_urls + } else if testnet { + vec![DEFAULT_ELECTRUM_RPC_URL_TESTNET.to_string()] + } else { + vec![DEFAULT_ELECTRUM_RPC_URL.to_string()] + }) }; let bitcoin_target_block = if let Some(target_block) = self.bitcoin_target_block { @@ -455,7 +474,7 @@ impl Bitcoin { DEFAULT_BITCOIN_CONFIRMATION_TARGET }; - Ok((bitcoin_electrum_rpc_urls, bitcoin_target_block)) + Ok((remotes, bitcoin_target_block)) } } 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 diff --git a/swap/tests/harness/mod.rs b/swap/tests/harness/mod.rs index dd387e9f1..f372a2378 100644 --- a/swap/tests/harness/mod.rs +++ b/swap/tests/harness/mod.rs @@ -21,6 +21,7 @@ use std::time::Duration; use swap::asb::FixedRate; use swap::bitcoin::{CancelTimelock, PunishTimelock}; use swap::cli::api; +use swap::cli::command::BitcoinRemotes; use swap::database::{AccessMode, SqliteDatabase}; use swap::monero::wallet::no_listener; use swap::monero::Wallets; @@ -414,7 +415,9 @@ async fn init_test_wallets( let btc_wallet = swap::bitcoin::wallet::WalletBuilder::default() .seed(seed.clone()) .network(env_config.bitcoin_network) - .electrum_rpc_urls(vec![electrum_rpc_url.as_str().to_string()]) + .remotes(BitcoinRemotes::Electrum(vec![electrum_rpc_url + .as_str() + .to_string()])) .persister(swap::bitcoin::wallet::PersisterConfig::InMemorySqlite) .finality_confirmations(1_u32) .target_block(1_u32)