feat(gui): Redeem to internal Monero wallet (#448)

* fmt

* remove old stuff

* refactor
This commit is contained in:
Mohan 2025-07-04 15:50:23 +02:00 committed by GitHub
parent 293ff2cdf3
commit 7b67dce140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 160 additions and 127 deletions

View file

@ -500,12 +500,17 @@ impl MoneroWallet {
.context("No transaction receipts returned from sweep") .context("No transaction receipts returned from sweep")
} }
pub async fn sweep_multi(&self, addresses: &[Address], ratios: &[f64]) -> Result<TxReceipt> { /// 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<Option<Address>> + Clone], ratios: &[f64]) -> Result<TxReceipt> {
tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios); tracing::info!("`{}` sweeping multi ({:?})", self.name, ratios);
self.balance().await?; self.balance().await?;
let addresses: Vec<Option<Address>> = addresses.iter().map(|a| a.clone().into()).collect();
self.wallet self.wallet
.sweep_multi(addresses, ratios) .sweep_multi(&addresses, ratios)
.await .await
.context("Failed to perform sweep")? .context("Failed to perform sweep")?
.into_iter() .into_iter()

View file

@ -79,7 +79,7 @@ fn main() {
.build_arg(match (is_github_actions, is_docker_build) { .build_arg(match (is_github_actions, is_docker_build) {
(true, _) => "-j1", (true, _) => "-j1",
(_, true) => "-j1", (_, true) => "-j1",
(_, _) => "-j", (_, _) => "-j4",
}) })
.build(); .build();

View file

@ -436,16 +436,21 @@ impl WalletHandle {
} }
/// Sweep all funds to a set of addresses. /// 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( pub async fn sweep_multi(
&self, &self,
addresses: &[monero::Address], addresses: &[Option<monero::Address>],
percentages: &[f64], percentages: &[f64],
) -> anyhow::Result<Vec<TxReceipt>> { ) -> anyhow::Result<Vec<TxReceipt>> {
let addresses = addresses.to_vec();
let percentages = percentages.to_vec();
tracing::debug!(addresses=?addresses, percentages=?percentages, "Sweeping multi"); 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)) self.call(move |wallet| wallet.sweep_multi(&addresses, &percentages))
.await .await
} }

View file

@ -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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<SwapDialogTitle
debug={debug}
setDebug={setDebug}
title="Swap Bitcoin for Monero"
/>
<DialogContent
dividers
sx={{
minHeight: "25rem",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
flex: 1,
gap: "1rem",
}}
>
{debug ? (
<DebugPage />
) : (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
justifyContent: "space-between",
flex: 1,
}}
>
<SwapStatePage state={swap.state} />
<SwapStateStepper state={swap.state} />
</Box>
)}
</DialogContent>
<DialogActions>
<CancelButton />
</DialogActions>
</Dialog>
);
}

View file

@ -9,14 +9,16 @@ export default function TruncatedText({
ellipsis?: string; ellipsis?: string;
truncateMiddle?: boolean; truncateMiddle?: boolean;
}) { }) {
let finalChildren = children ?? "";
const truncatedText = const truncatedText =
children.length > limit finalChildren.length > limit
? truncateMiddle ? truncateMiddle
? children.slice(0, Math.floor(limit / 2)) + ? finalChildren.slice(0, Math.floor(limit / 2)) +
ellipsis + ellipsis +
children.slice(children.length - Math.floor(limit / 2)) finalChildren.slice(finalChildren.length - Math.floor(limit / 2))
: children.slice(0, limit) + ellipsis : finalChildren.slice(0, limit) + ellipsis
: children; : finalChildren;
return <span>{truncatedText}</span>; return <span>{truncatedText}</span>;
} }

View file

@ -2,7 +2,6 @@ import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { formatConfirmations } from "utils/formatUtils"; import { formatConfirmations } from "utils/formatUtils";
import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox"; import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox";
import CancelButton from "../CancelButton";
export default function XmrLockTxInMempoolPage({ export default function XmrLockTxInMempoolPage({
xmr_lock_tx_confirmations, xmr_lock_tx_confirmations,
@ -24,8 +23,6 @@ export default function XmrLockTxInMempoolPage({
additionalContent={additionalContent} additionalContent={additionalContent}
loading loading
/> />
<CancelButton />
</> </>
); );
} }

View file

@ -12,6 +12,8 @@ export default function InitPage() {
const [refundAddress, setRefundAddress] = useState(""); const [refundAddress, setRefundAddress] = useState("");
const [useExternalRefundAddress, setUseExternalRefundAddress] = const [useExternalRefundAddress, setUseExternalRefundAddress] =
useState(false); useState(false);
const [useExternalRedeemAddress, setUseExternalRedeemAddress] =
useState(false);
const [redeemAddressValid, setRedeemAddressValid] = useState(false); const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false);
@ -21,7 +23,7 @@ export default function InitPage() {
async function init() { async function init() {
await buyXmr( await buyXmr(
useExternalRefundAddress ? refundAddress : null, useExternalRefundAddress ? refundAddress : null,
redeemAddress, useExternalRedeemAddress ? redeemAddress : null,
donationRatio, donationRatio,
); );
} }
@ -35,14 +37,36 @@ export default function InitPage() {
gap: 1.5, gap: 1.5,
}} }}
> >
<Paper variant="outlined" style={{}}>
<Tabs
value={useExternalRedeemAddress ? 1 : 0}
indicatorColor="primary"
variant="fullWidth"
onChange={(_, newValue) =>
setUseExternalRedeemAddress(newValue === 1)
}
>
<Tab label="Redeem to internal Monero wallet" value={0} />
<Tab label="Redeem to external Monero address" value={1} />
</Tabs>
<Box style={{ padding: "16px" }}>
{useExternalRedeemAddress ? (
<MoneroAddressTextField <MoneroAddressTextField
label="Monero redeem address" label="External Monero redeem address"
address={redeemAddress} address={redeemAddress}
onAddressChange={setRedeemAddress} onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid} onAddressValidityChange={setRedeemAddressValid}
helperText="The monero will be sent to this address if the swap is successful." helperText="The monero will be sent to this address if the swap is successful."
fullWidth fullWidth
/> />
) : (
<Typography variant="caption">
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.
</Typography>
)}
</Box>
</Paper>
<Paper variant="outlined" style={{}}> <Paper variant="outlined" style={{}}>
<Tabs <Tabs
@ -80,7 +104,7 @@ export default function InitPage() {
<PromiseInvokeButton <PromiseInvokeButton
disabled={ disabled={
(!refundAddressValid && useExternalRefundAddress) || (!refundAddressValid && useExternalRefundAddress) ||
!redeemAddressValid (!redeemAddressValid && useExternalRedeemAddress)
} }
variant="contained" variant="contained"
color="primary" color="primary"

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false] "nullable": [
false
]
}, },
"hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6" "hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6"
} }

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [true] "nullable": [
true
]
}, },
"hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c" "hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT DISTINCT address FROM monero_addresses", "query": "SELECT DISTINCT address FROM monero_addresses WHERE address IS NOT NULL",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false] "nullable": [
true
]
}, },
"hash": "98a8b7f4971e0eb4ab8f5aa688aa22e7fdc6b925de211f7784782f051c2dcd8c" "hash": "1f332be08a5426f3fbcadea4e755d82ff1cdc2690eb464ccc607d3a613fa76a1"
} }

View file

@ -17,7 +17,10 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [true, true] "nullable": [
true,
true
]
}, },
"hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8" "hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8"
} }

View file

@ -17,7 +17,10 @@
"parameters": { "parameters": {
"Right": 0 "Right": 0
}, },
"nullable": [false, false] "nullable": [
false,
false
]
}, },
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6" "hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
} }

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false] "nullable": [
false
]
}, },
"hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf" "hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf"
} }

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false] "nullable": [
false
]
}, },
"hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2" "hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2"
} }

View file

@ -22,7 +22,11 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false, false, false] "nullable": [
true,
false,
false
]
}, },
"hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b" "hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b"
} }

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false] "nullable": [
false
]
}, },
"hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646" "hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646"
} }

View file

@ -12,7 +12,9 @@
"parameters": { "parameters": {
"Right": 1 "Right": 1
}, },
"nullable": [false] "nullable": [
false
]
}, },
"hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae" "hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae"
} }

View file

@ -0,0 +1,26 @@
-- Users don't have to specify a receive address for the swap anymore, if none is present
-- we will use the internal wallet address instead.
-- Now, the monero_addresses.address column can be NULL.
-- SQLite doesn't support MODIFY COLUMN directly
-- We need to recreate the table with the desired schema
CREATE TABLE monero_addresses_temp
(
swap_id TEXT NOT NULL,
address TEXT NULL,
percentage REAL NOT NULL DEFAULT 1.0,
label TEXT NOT NULL DEFAULT 'user address'
);
-- Copy data from the original table
INSERT INTO monero_addresses_temp (swap_id, address, percentage, label)
SELECT swap_id, address, percentage, label FROM monero_addresses;
-- Drop the original table
DROP TABLE monero_addresses;
-- Rename the temporary table
ALTER TABLE monero_addresses_temp RENAME TO monero_addresses;
-- Create an index on swap_id for performance
CREATE INDEX idx_monero_addresses_swap_id ON monero_addresses(swap_id);

View file

@ -1162,7 +1162,7 @@ pub async fn list_sellers(
peer_id, peer_id,
version, version,
}) => { }) => {
tracing::debug!( tracing::trace!(
status = "Online", status = "Online",
price = %quote.price.to_string(), price = %quote.price.to_string(),
min_quantity = %quote.min_quantity.to_string(), min_quantity = %quote.min_quantity.to_string(),
@ -1182,7 +1182,7 @@ pub async fn list_sellers(
.await?; .await?;
} }
SellerStatus::Unreachable(UnreachableSeller { peer_id }) => { SellerStatus::Unreachable(UnreachableSeller { peer_id }) => {
tracing::debug!( tracing::trace!(
status = "Unreachable", status = "Unreachable",
peer_id = %peer_id.to_string(), peer_id = %peer_id.to_string(),
"Fetched peer status" "Fetched peer status"

View file

@ -111,7 +111,7 @@ impl Database for SqliteDatabase {
let swap_id = swap_id.to_string(); let swap_id = swap_id.to_string();
for labeled_address in address.iter() { 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 let percentage_f64 = labeled_address
.percentage() .percentage()
.to_f64() .to_f64()
@ -163,12 +163,16 @@ impl Database for SqliteDatabase {
let addresses = row let addresses = row
.iter() .iter()
.map(|row| -> Result<LabeledMoneroAddress> { .map(|row| -> Result<LabeledMoneroAddress> {
let address = row.address.parse()?; let address: Option<monero::Address> = row.address.clone().map(|address| address.parse()).transpose()?;
let percentage = Decimal::from_f64(row.percentage).expect("Invalid percentage"); let percentage = Decimal::from_f64(row.percentage).expect("Invalid percentage");
let label = row.label.clone(); let label = row.label.clone();
LabeledMoneroAddress::new(address, percentage, label) match address {
.map_err(|e| anyhow::anyhow!("Invalid percentage in database: {}", e)) 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::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
@ -176,14 +180,14 @@ impl Database for SqliteDatabase {
} }
async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>> { async fn get_monero_addresses(&self) -> Result<Vec<monero::Address>> {
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) .fetch_all(&self.pool)
.await?; .await?;
let addresses = rows let addresses = rows
.iter() .iter()
.map(|row| row.address.parse()) .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::<Result<Vec<_>, _>>()?; .collect::<Vec<_>>();
Ok(addresses) Ok(addresses)
} }
@ -538,11 +542,11 @@ mod tests {
let address3 = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?; // Same as address1 for simplicity let address3 = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse()?; // Same as address1 for simplicity
let labeled_addresses = vec![ 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 .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 .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 .map_err(|e| anyhow!(e))?, // 0.2
]; ];

View file

@ -227,8 +227,9 @@ impl Amount {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[typeshare] #[typeshare]
pub struct LabeledMoneroAddress { pub struct LabeledMoneroAddress {
// If this is None, we will use an address of the internal Monero wallet
#[typeshare(serialized_as = "string")] #[typeshare(serialized_as = "string")]
address: monero::Address, address: Option<monero::Address>,
#[typeshare(serialized_as = "number")] #[typeshare(serialized_as = "number")]
percentage: Decimal, percentage: Decimal,
label: String, label: String,
@ -246,28 +247,36 @@ impl LabeledMoneroAddress {
/// # Errors /// # Errors
/// ///
/// Returns an error if the percentage is not between 0.0 and 1.0 inclusive. /// Returns an error if the percentage is not between 0.0 and 1.0 inclusive.
pub fn new( fn new(
address: monero::Address, address: impl Into<Option<monero::Address>>,
percentage: Decimal, percentage: Decimal,
label: String, label: String,
) -> Result<Self, String> { ) -> Result<Self> {
if percentage < Decimal::ZERO || percentage > Decimal::ONE { if percentage < Decimal::ZERO || percentage > Decimal::ONE {
return Err(format!( bail!(
"Percentage must be between 0 and 1 inclusive, got: {}", "Percentage must be between 0 and 1 inclusive, got: {}",
percentage percentage
)); );
} }
Ok(Self { Ok(Self {
address, address: address.into(),
percentage, percentage,
label, label,
}) })
} }
pub fn with_address(address: monero::Address, percentage: Decimal, label: String) -> Result<Self> {
Self::new(address, percentage, label)
}
pub fn with_internal_address(percentage: Decimal, label: String) -> Result<Self> {
Self::new(None, percentage, label)
}
/// Returns the Monero address. /// Returns the Monero address.
pub fn address(&self) -> monero::Address { pub fn address(&self) -> Option<monero::Address> {
self.address self.address.clone()
} }
/// Returns the percentage as a decimal. /// Returns the percentage as a decimal.
@ -303,7 +312,7 @@ impl MoneroAddressPool {
} }
/// Returns a vector of all Monero addresses in the pool. /// Returns a vector of all Monero addresses in the pool.
pub fn addresses(&self) -> Vec<monero::Address> { pub fn addresses(&self) -> Vec<Option<monero::Address>> {
self.0.iter().map(|address| address.address()).collect() 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. /// Returns an error if any address is on a different network than expected.
pub fn assert_network(&self, network: Network) -> Result<()> { pub fn assert_network(&self, network: Network) -> Result<()> {
for address in self.0.iter() { for address in self.0.iter() {
if address.address().network != network { if let Some(address) = address.address {
bail!("Address pool contains addresses on the wrong network (address {} is on {:?}, expected {:?})", address.address(), address.address().network, network); 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() { fn labeled_monero_address_percentage_validation() {
use rust_decimal::Decimal; use rust_decimal::Decimal;
let address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse().unwrap(); let address = "53gEuGZUhP9JMEBZoGaFNzhwEgiG7hwQdMCqFxiyiTeFPmkbt1mAoNybEUvYBKHcnrSgxnVWgZsTvRBaHBNXPa8tHiCU51a".parse::<monero::Address>().unwrap();
// Valid percentages should work (0-1 range) // Valid percentages should work (0-1 range)
assert!(LabeledMoneroAddress::new(address, Decimal::ZERO, "test".to_string()).is_ok()); assert!(LabeledMoneroAddress::new(address, Decimal::ZERO, "test".to_string()).is_ok());