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")
}
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);
self.balance().await?;
let addresses: Vec<Option<Address>> = 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()

View file

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

View file

@ -436,16 +436,21 @@ 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<monero::Address>],
percentages: &[f64],
) -> anyhow::Result<Vec<TxReceipt>> {
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
}

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;
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 <span>{truncatedText}</span>;
}

View file

@ -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
/>
<CancelButton />
</>
);
}

View file

@ -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,
}}
>
<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
label="Monero redeem address"
label="External Monero redeem address"
address={redeemAddress}
onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid}
helperText="The monero will be sent to this address if the swap is successful."
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={{}}>
<Tabs
@ -80,7 +104,7 @@ export default function InitPage() {
<PromiseInvokeButton
disabled={
(!refundAddressValid && useExternalRefundAddress) ||
!redeemAddressValid
(!redeemAddressValid && useExternalRedeemAddress)
}
variant="contained"
color="primary"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,9 @@
"parameters": {
"Right": 1
},
"nullable": [false]
"nullable": [
false
]
},
"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,
version,
}) => {
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"

View file

@ -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<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 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::<Result<Vec<_>, _>>()?;
@ -176,14 +180,14 @@ impl Database for SqliteDatabase {
}
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)
.await?;
let addresses = rows
.iter()
.map(|row| row.address.parse())
.collect::<Result<Vec<_>, _>>()?;
.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::<Vec<_>>();
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
];

View file

@ -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<monero::Address>,
#[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<Option<monero::Address>>,
percentage: Decimal,
label: String,
) -> Result<Self, String> {
) -> Result<Self> {
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> {
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.
pub fn address(&self) -> monero::Address {
self.address
pub fn address(&self) -> Option<monero::Address> {
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<monero::Address> {
pub fn addresses(&self) -> Vec<Option<monero::Address>> {
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::<monero::Address>().unwrap();
// Valid percentages should work (0-1 range)
assert!(LabeledMoneroAddress::new(address, Decimal::ZERO, "test".to_string()).is_ok());