mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-08-25 06:39:53 -04:00
feat(gui): Redeem to internal Monero wallet (#448)
* fmt * remove old stuff * refactor
This commit is contained in:
parent
293ff2cdf3
commit
7b67dce140
21 changed files with 160 additions and 127 deletions
|
@ -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()
|
||||
|
|
|
@ -79,7 +79,7 @@ fn main() {
|
|||
.build_arg(match (is_github_actions, is_docker_build) {
|
||||
(true, _) => "-j1",
|
||||
(_, true) => "-j1",
|
||||
(_, _) => "-j",
|
||||
(_, _) => "-j4",
|
||||
})
|
||||
.build();
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false]
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "081c729a0f1ad6e4ff3e13d6702c946bc4d37d50f40670b4f51d2efcce595aa6"
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [true]
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0d465a17ebbb5761421def759c73cad023c30705d5b41a1399ef79d8d2571d7c"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -17,7 +17,10 @@
|
|||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [true, true]
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5cc61dd0315571bc198401a354cd9431ee68360941f341386cbacf44ea598de8"
|
||||
}
|
||||
|
|
|
@ -17,7 +17,10 @@
|
|||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [false, false]
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6130b6cdd184181f890964eb460741f5cf23b5237fb676faed009106627a4ca6"
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false]
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "88f761a4f7a0429cad1df0b1bebb1c0a27b2a45656549b23076d7542cfa21ecf"
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false]
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d78acba5eb8563826dd190e0886aa665aae3c6f1e312ee444e65df1c95afe8b2"
|
||||
}
|
||||
|
|
|
@ -22,7 +22,11 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false, false, false]
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dff8b986c3dde27b8121775e48a58564fa346b038866699210a63f8a33b03f0b"
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false]
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e05620f420f8c1022971eeb66a803323a8cf258cbebb2834e3f7cf8f812fa646"
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [false]
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e9d422daf774d099fcbde6c4cda35821da948bd86cc57798b4d8375baf0b51ae"
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
];
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue