From 7b67dce140ee02320af6799a41cdd27f3a06bef2 Mon Sep 17 00:00:00 2001 From: Mohan <86064887+binarybaron@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:50:23 +0200 Subject: [PATCH] feat(gui): Redeem to internal Monero wallet (#448) * fmt * remove old stuff * refactor --- monero-harness/src/lib.rs | 9 ++- monero-sys/build.rs | 2 +- monero-sys/src/lib.rs | 13 ++-- .../components/modal/swap/SwapDialog.tsx | 65 ------------------- .../components/other/TruncatedText.tsx | 12 ++-- .../swap/in_progress/XmrLockInMempoolPage.tsx | 3 - .../pages/swap/swap/init/InitPage.tsx | 44 ++++++++++--- ...c946bc4d37d50f40670b4f51d2efcce595aa6.json | 4 +- ...3cad023c30705d5b41a1399ef79d8d2571d7c.json | 4 +- ...d82ff1cdc2690eb464ccc607d3a613fa76a1.json} | 8 ++- ...d9431ee68360941f341386cbacf44ea598de8.json | 5 +- ...741f5cf23b5237fb676faed009106627a4ca6.json | 5 +- ...b1c0a27b2a45656549b23076d7542cfa21ecf.json | 4 +- ...aa665aae3c6f1e312ee444e65df1c95afe8b2.json | 4 +- ...58564fa346b038866699210a63f8a33b03f0b.json | 6 +- ...03323a8cf258cbebb2834e3f7cf8f812fa646.json | 4 +- ...35821da948bd86cc57798b4d8375baf0b51ae.json | 4 +- ...4115958_receive_xmr_to_internal_wallet.sql | 26 ++++++++ swap/src/cli/api/request.rs | 4 +- swap/src/database/sqlite.rs | 24 ++++--- swap/src/monero.rs | 37 +++++++---- 21 files changed, 160 insertions(+), 127 deletions(-) delete mode 100644 src-gui/src/renderer/components/modal/swap/SwapDialog.tsx rename swap/.sqlx/{query-98a8b7f4971e0eb4ab8f5aa688aa22e7fdc6b925de211f7784782f051c2dcd8c.json => query-1f332be08a5426f3fbcadea4e755d82ff1cdc2690eb464ccc607d3a613fa76a1.json} (51%) create mode 100644 swap/migrations/20250704115958_receive_xmr_to_internal_wallet.sql diff --git a/monero-harness/src/lib.rs b/monero-harness/src/lib.rs index 598f9f6c..b0b8bbef 100644 --- a/monero-harness/src/lib.rs +++ b/monero-harness/src/lib.rs @@ -500,12 +500,17 @@ impl MoneroWallet { .context("No transaction receipts returned from sweep") } - pub async fn sweep_multi(&self, addresses: &[Address], ratios: &[f64]) -> Result { + /// Sweep multiple addresses with different ratios + /// If the address is `None`, the address will be set to the primary address of the + /// main wallet. + pub async fn sweep_multi(&self, addresses: &[impl Into> + Clone], ratios: &[f64]) -> Result { tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios); self.balance().await?; + let addresses: Vec> = addresses.iter().map(|a| a.clone().into()).collect(); + self.wallet - .sweep_multi(addresses, ratios) + .sweep_multi(&addresses, ratios) .await .context("Failed to perform sweep")? .into_iter() diff --git a/monero-sys/build.rs b/monero-sys/build.rs index b50ff465..4a7498a5 100644 --- a/monero-sys/build.rs +++ b/monero-sys/build.rs @@ -79,7 +79,7 @@ fn main() { .build_arg(match (is_github_actions, is_docker_build) { (true, _) => "-j1", (_, true) => "-j1", - (_, _) => "-j", + (_, _) => "-j4", }) .build(); diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index 9d4df588..cb862100 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -436,15 +436,20 @@ impl WalletHandle { } /// Sweep all funds to a set of addresses. + /// If the address is `None`, the address will be set to the primary address of the + /// wallet pub async fn sweep_multi( &self, - addresses: &[monero::Address], + addresses: &[Option], percentages: &[f64], ) -> anyhow::Result> { - let addresses = addresses.to_vec(); - let percentages = percentages.to_vec(); - tracing::debug!(addresses=?addresses, percentages=?percentages, "Sweeping multi"); + + let primary_address = self.main_address().await; + let addresses = addresses.iter().map(|address| address.unwrap_or(primary_address)); + let addresses: Vec<_> = addresses.collect(); + + let percentages = percentages.to_vec(); self.call(move |wallet| wallet.sweep_multi(&addresses, &percentages)) .await diff --git a/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx b/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx deleted file mode 100644 index 64e74ef8..00000000 --- a/src-gui/src/renderer/components/modal/swap/SwapDialog.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Box, Dialog, DialogActions, DialogContent } from "@mui/material"; -import { useState } from "react"; -import { useAppSelector } from "store/hooks"; -import DebugPage from "./pages/DebugPage"; -import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage"; -import SwapDialogTitle from "./SwapDialogTitle"; -import SwapStateStepper from "./SwapStateStepper"; -import CancelButton from "renderer/components/pages/swap/swap/CancelButton"; - -export default function SwapDialog({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - const swap = useAppSelector((state) => state.swap); - const [debug, setDebug] = useState(false); - - // This prevents an issue where the Dialog is shown for a split second without a present swap state - if (!open) return null; - - return ( - - - - - {debug ? ( - - ) : ( - - - - - )} - - - - - - - ); -} diff --git a/src-gui/src/renderer/components/other/TruncatedText.tsx b/src-gui/src/renderer/components/other/TruncatedText.tsx index 877c7454..0099f6c9 100644 --- a/src-gui/src/renderer/components/other/TruncatedText.tsx +++ b/src-gui/src/renderer/components/other/TruncatedText.tsx @@ -9,14 +9,16 @@ export default function TruncatedText({ ellipsis?: string; truncateMiddle?: boolean; }) { + let finalChildren = children ?? ""; + const truncatedText = - children.length > limit + finalChildren.length > limit ? truncateMiddle - ? children.slice(0, Math.floor(limit / 2)) + + ? finalChildren.slice(0, Math.floor(limit / 2)) + ellipsis + - children.slice(children.length - Math.floor(limit / 2)) - : children.slice(0, limit) + ellipsis - : children; + finalChildren.slice(finalChildren.length - Math.floor(limit / 2)) + : finalChildren.slice(0, limit) + ellipsis + : finalChildren; return {truncatedText}; } diff --git a/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx index 293817c7..8af7bfb3 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/in_progress/XmrLockInMempoolPage.tsx @@ -2,7 +2,6 @@ import { Box, DialogContentText } from "@mui/material"; import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { formatConfirmations } from "utils/formatUtils"; import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox"; -import CancelButton from "../CancelButton"; export default function XmrLockTxInMempoolPage({ xmr_lock_tx_confirmations, @@ -24,8 +23,6 @@ export default function XmrLockTxInMempoolPage({ additionalContent={additionalContent} loading /> - - ); } diff --git a/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx b/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx index 30b8481d..937f384c 100644 --- a/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx +++ b/src-gui/src/renderer/components/pages/swap/swap/init/InitPage.tsx @@ -12,6 +12,8 @@ export default function InitPage() { const [refundAddress, setRefundAddress] = useState(""); const [useExternalRefundAddress, setUseExternalRefundAddress] = useState(false); + const [useExternalRedeemAddress, setUseExternalRedeemAddress] = + useState(false); const [redeemAddressValid, setRedeemAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false); @@ -21,7 +23,7 @@ export default function InitPage() { async function init() { await buyXmr( useExternalRefundAddress ? refundAddress : null, - redeemAddress, + useExternalRedeemAddress ? redeemAddress : null, donationRatio, ); } @@ -35,14 +37,36 @@ export default function InitPage() { gap: 1.5, }} > - + + + setUseExternalRedeemAddress(newValue === 1) + } + > + + + + + {useExternalRedeemAddress ? ( + + ) : ( + + The Monero will be sent to the internal Monero wallet of the GUI. + You can then withdraw them from there or use them for another swap directly. + + )} + + { - tracing::debug!( + tracing::trace!( status = "Online", price = %quote.price.to_string(), min_quantity = %quote.min_quantity.to_string(), @@ -1182,7 +1182,7 @@ pub async fn list_sellers( .await?; } SellerStatus::Unreachable(UnreachableSeller { peer_id }) => { - tracing::debug!( + tracing::trace!( status = "Unreachable", peer_id = %peer_id.to_string(), "Fetched peer status" diff --git a/swap/src/database/sqlite.rs b/swap/src/database/sqlite.rs index 1028695b..ad224477 100644 --- a/swap/src/database/sqlite.rs +++ b/swap/src/database/sqlite.rs @@ -111,7 +111,7 @@ impl Database for SqliteDatabase { let swap_id = swap_id.to_string(); for labeled_address in address.iter() { - let address_str = labeled_address.address().to_string(); + let address_str = labeled_address.address().map(|address| address.to_string()); let percentage_f64 = labeled_address .percentage() .to_f64() @@ -163,12 +163,16 @@ impl Database for SqliteDatabase { let addresses = row .iter() .map(|row| -> Result { - let address = row.address.parse()?; + let address: Option = row.address.clone().map(|address| address.parse()).transpose()?; let percentage = Decimal::from_f64(row.percentage).expect("Invalid percentage"); let label = row.label.clone(); - LabeledMoneroAddress::new(address, percentage, label) - .map_err(|e| anyhow::anyhow!("Invalid percentage in database: {}", e)) + match address { + Some(address) => LabeledMoneroAddress::with_address(address, percentage, label) + .map_err(|e| anyhow::anyhow!("Invalid percentage in database: {}", e)), + None => LabeledMoneroAddress::with_internal_address(percentage, label) + .map_err(|e| anyhow::anyhow!("Invalid percentage in database: {}", e)), + } }) .collect::, _>>()?; @@ -176,14 +180,14 @@ impl Database for SqliteDatabase { } async fn get_monero_addresses(&self) -> Result> { - let rows = sqlx::query!("SELECT DISTINCT address FROM monero_addresses") + let rows = sqlx::query!("SELECT DISTINCT address FROM monero_addresses WHERE address IS NOT NULL") .fetch_all(&self.pool) .await?; let addresses = rows .iter() - .map(|row| row.address.parse()) - .collect::, _>>()?; + .filter_map(|row| row.address.as_ref().and_then(|address| address.parse().inspect_err(|e| tracing::error!(%address, error = ?e, "Failed to parse monero address")).ok())) + .collect::>(); Ok(addresses) } @@ -538,11 +542,11 @@ mod tests { let address3 = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?; // Same as address1 for simplicity let labeled_addresses = vec![ - LabeledMoneroAddress::new(address1, Decimal::new(5, 1), "Primary".to_string()) + LabeledMoneroAddress::with_address(address1, Decimal::new(5, 1), "Primary".to_string()) .map_err(|e| anyhow!(e))?, // 0.5 - LabeledMoneroAddress::new(address2, Decimal::new(3, 1), "Secondary".to_string()) + LabeledMoneroAddress::with_address(address2, Decimal::new(3, 1), "Secondary".to_string()) .map_err(|e| anyhow!(e))?, // 0.3 - LabeledMoneroAddress::new(address3, Decimal::new(2, 1), "Tertiary".to_string()) + LabeledMoneroAddress::with_address(address3, Decimal::new(2, 1), "Tertiary".to_string()) .map_err(|e| anyhow!(e))?, // 0.2 ]; diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 2932f068..4eed6ff6 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -227,8 +227,9 @@ impl Amount { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[typeshare] pub struct LabeledMoneroAddress { + // If this is None, we will use an address of the internal Monero wallet #[typeshare(serialized_as = "string")] - address: monero::Address, + address: Option, #[typeshare(serialized_as = "number")] percentage: Decimal, label: String, @@ -246,28 +247,36 @@ impl LabeledMoneroAddress { /// # Errors /// /// Returns an error if the percentage is not between 0.0 and 1.0 inclusive. - pub fn new( - address: monero::Address, + fn new( + address: impl Into>, percentage: Decimal, label: String, - ) -> Result { + ) -> Result { if percentage < Decimal::ZERO || percentage > Decimal::ONE { - return Err(format!( + bail!( "Percentage must be between 0 and 1 inclusive, got: {}", percentage - )); + ); } Ok(Self { - address, + address: address.into(), percentage, label, }) } + pub fn with_address(address: monero::Address, percentage: Decimal, label: String) -> Result { + Self::new(address, percentage, label) + } + + pub fn with_internal_address(percentage: Decimal, label: String) -> Result { + Self::new(None, percentage, label) + } + /// Returns the Monero address. - pub fn address(&self) -> monero::Address { - self.address + pub fn address(&self) -> Option { + self.address.clone() } /// Returns the percentage as a decimal. @@ -303,7 +312,7 @@ impl MoneroAddressPool { } /// Returns a vector of all Monero addresses in the pool. - pub fn addresses(&self) -> Vec { + pub fn addresses(&self) -> Vec> { self.0.iter().map(|address| address.address()).collect() } @@ -336,8 +345,10 @@ impl MoneroAddressPool { /// Returns an error if any address is on a different network than expected. pub fn assert_network(&self, network: Network) -> Result<()> { for address in self.0.iter() { - if address.address().network != network { - bail!("Address pool contains addresses on the wrong network (address {} is on {:?}, expected {:?})", address.address(), address.address().network, network); + if let Some(address) = address.address { + if address.network != network { + bail!("Address pool contains addresses on the wrong network (address {} is on {:?}, expected {:?})", address, address.network, network); + } } } @@ -918,7 +929,7 @@ mod tests { fn labeled_monero_address_percentage_validation() { use rust_decimal::Decimal; - let address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse().unwrap(); + let address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse::().unwrap(); // Valid percentages should work (0-1 range) assert!(LabeledMoneroAddress::new(address, Decimal::ZERO, "test".to_string()).is_ok());