feat(cli, gui, tauri): Emit events on Monero transaction confirmation update and redeem transaction publication (#57)

We now,
- emit a Tauri event when the Monero lock transaction receives a new confirmation
- emit a Tauri event with a list of transaction hashes once we have published the Monero redeem transaction 
- gui: display the confirmations and txids

This PR closes #12.
This commit is contained in:
Einliterflasche 2024-09-18 17:53:13 +02:00 committed by GitHub
parent a1fd147850
commit 9d1151c3d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 266 additions and 153 deletions

10
node_modules/.yarn-integrity generated vendored Normal file
View file

@ -0,0 +1,10 @@
{
"systemParams": "win32-x64-127",
"modulesFolders": [],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [],
"lockfileEntries": {},
"files": [],
"artifacts": {}
}

View file

@ -1,23 +1,18 @@
import { ReactNode } from "react";
import BitcoinIcon from "renderer/components/icons/BitcoinIcon";
import { isTestnet } from "store/config";
import { getBitcoinTxExplorerUrl } from "utils/conversionUtils";
import TransactionInfoBox from "./TransactionInfoBox";
type Props = {
title: string;
txId: string;
additionalContent: ReactNode;
loading: boolean;
};
export default function BitcoinTransactionInfoBox({ txId, ...props }: Props) {
const explorerUrl = getBitcoinTxExplorerUrl(txId, isTestnet());
import TransactionInfoBox, {
TransactionInfoBoxProps,
} from "./TransactionInfoBox";
export default function BitcoinTransactionInfoBox({
txId,
...props
}: Omit<TransactionInfoBoxProps, "icon" | "explorerUrlCreator">) {
return (
<TransactionInfoBox
txId={txId}
explorerUrl={explorerUrl}
explorerUrlCreator={(txId) => getBitcoinTxExplorerUrl(txId, isTestnet())}
icon={<BitcoinIcon />}
{...props}
/>

View file

@ -1,23 +1,18 @@
import { ReactNode } from "react";
import MoneroIcon from "renderer/components/icons/MoneroIcon";
import { isTestnet } from "store/config";
import { getMoneroTxExplorerUrl } from "utils/conversionUtils";
import TransactionInfoBox from "./TransactionInfoBox";
type Props = {
title: string;
txId: string;
additionalContent: ReactNode;
loading: boolean;
};
export default function MoneroTransactionInfoBox({ txId, ...props }: Props) {
const explorerUrl = getMoneroTxExplorerUrl(txId, isTestnet());
import TransactionInfoBox, {
TransactionInfoBoxProps,
} from "./TransactionInfoBox";
export default function MoneroTransactionInfoBox({
txId,
...props
}: Omit<TransactionInfoBoxProps, "icon" | "explorerUrlCreator">) {
return (
<TransactionInfoBox
txId={txId}
explorerUrl={explorerUrl}
explorerUrlCreator={(txid) => getMoneroTxExplorerUrl(txid, isTestnet())}
icon={<MoneroIcon />}
{...props}
/>

View file

@ -2,10 +2,10 @@ import { Link, Typography } from "@material-ui/core";
import { ReactNode } from "react";
import InfoBox from "./InfoBox";
type TransactionInfoBoxProps = {
export type TransactionInfoBoxProps = {
title: string;
txId: string;
explorerUrl: string;
txId: string | null;
explorerUrlCreator: ((txId: string) => string) | null;
additionalContent: ReactNode;
loading: boolean;
icon: JSX.Element;
@ -14,24 +14,31 @@ type TransactionInfoBoxProps = {
export default function TransactionInfoBox({
title,
txId,
explorerUrl,
additionalContent,
icon,
loading,
explorerUrlCreator,
}: TransactionInfoBoxProps) {
return (
<InfoBox
title={title}
mainContent={<Typography variant="h5">{txId}</Typography>}
mainContent={
<Typography variant="h5">
{txId ?? "Transaction ID not available"}
</Typography>
}
loading={loading}
additionalContent={
<>
<Typography variant="subtitle2">{additionalContent}</Typography>
<Typography variant="body1">
<Link href={explorerUrl} target="_blank">
View on explorer
</Link>
</Typography>
{explorerUrlCreator != null &&
txId != null && ( // Only show the link if the txId is not null and we have a creator for the explorer URL
<Typography variant="body1">
<Link href={explorerUrlCreator(txId)} target="_blank">
View on explorer
</Link>
</Typography>
)}
</>
}
icon={icon}

View file

@ -3,16 +3,10 @@ import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
export default function XmrRedeemInMempoolPage({
xmr_redeem_address,
xmr_redeem_txid,
}: TauriSwapProgressEventContent<"XmrRedeemInMempool">) {
// TODO: Reimplement this using Tauri
//const additionalContent = swap
// ? `This transaction transfers ${getSwapXmrAmount(swap).toFixed(6)} XMR to ${
// state?.bobXmrRedeemAddress
// }`
// : null;
export default function XmrRedeemInMempoolPage(
state: TauriSwapProgressEventContent<"XmrRedeemInMempool">,
) {
const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null;
return (
<Box>
@ -30,7 +24,7 @@ export default function XmrRedeemInMempoolPage({
<MoneroTransactionInfoBox
title="Monero Redeem Transaction"
txId={xmr_redeem_txid}
additionalContent={`The funds have been sent to the address ${xmr_redeem_address}`}
additionalContent={`The funds have been sent to the address ${state.xmr_redeem_address}`}
loading={false}
/>
<FeedbackInfoBox />

View file

@ -46,3 +46,12 @@ pub enum ExpiredTimelocks {
Cancel { blocks_left: u32 },
Punish,
}
impl ExpiredTimelocks {
/// Check whether the timelock on the cancel transaction has expired.
///
/// Retuns `true` even if the swap has already been canceled or punished.
pub fn cancel_timelock_expired(&self) -> bool {
!matches!(self, ExpiredTimelocks::None { .. })
}
}

View file

@ -135,8 +135,8 @@ pub enum TauriSwapProgressEvent {
XmrLocked,
BtcRedeemed,
XmrRedeemInMempool {
#[typeshare(serialized_as = "string")]
xmr_redeem_txid: monero::TxHash,
#[typeshare(serialized_as = "Vec<string>")]
xmr_redeem_txids: Vec<monero::TxHash>,
#[typeshare(serialized_as = "string")]
xmr_redeem_address: monero::Address,
},

View file

@ -6,7 +6,9 @@ use ::monero::{Address, Network, PrivateKey, PublicKey};
use anyhow::{Context, Result};
use monero_rpc::wallet::{BlockHeight, MoneroWalletRpc as _, Refreshed};
use monero_rpc::{jsonrpc, wallet};
use std::future::Future;
use std::ops::Div;
use std::pin::Pin;
use std::str::FromStr;
use std::time::Duration;
use tokio::sync::Mutex;
@ -215,7 +217,18 @@ impl Wallet {
))
}
/// Wait until the specified transfer has been completed or failed.
pub async fn watch_for_transfer(&self, request: WatchRequest) -> Result<(), InsufficientFunds> {
self.watch_for_transfer_with(request, None).await
}
/// Wait until the specified transfer has been completed or failed and listen to each new confirmation.
#[allow(clippy::too_many_arguments)]
pub async fn watch_for_transfer_with(
&self,
request: WatchRequest,
listener: Option<ConfirmationListener>,
) -> Result<(), InsufficientFunds> {
let WatchRequest {
conf_target,
public_view_key,
@ -236,7 +249,7 @@ impl Wallet {
let check_interval = tokio::time::interval(self.sync_interval.div(10));
wait_for_confirmations(
wait_for_confirmations_with(
&self.inner,
transfer_proof,
address,
@ -244,6 +257,7 @@ impl Wallet {
conf_target,
check_interval,
self.name.clone(),
listener,
)
.await?;
@ -332,7 +346,13 @@ pub struct WatchRequest {
pub expected: Amount,
}
async fn wait_for_confirmations<C: monero_rpc::wallet::MoneroWalletRpc<reqwest::Client> + Sync>(
type ConfirmationListener =
Box<dyn Fn(u64) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> + Send + 'static>;
#[allow(clippy::too_many_arguments)]
async fn wait_for_confirmations_with<
C: monero_rpc::wallet::MoneroWalletRpc<reqwest::Client> + Sync,
>(
client: &Mutex<C>,
transfer_proof: TransferProof,
to_address: Address,
@ -340,6 +360,7 @@ async fn wait_for_confirmations<C: monero_rpc::wallet::MoneroWalletRpc<reqwest::
conf_target: u64,
mut check_interval: Interval,
wallet_name: String,
listener: Option<ConfirmationListener>,
) -> Result<(), InsufficientFunds> {
let mut seen_confirmations = 0u64;
@ -405,6 +426,11 @@ async fn wait_for_confirmations<C: monero_rpc::wallet::MoneroWalletRpc<reqwest::
needed_confirmations = %conf_target,
"Received new confirmation for Monero lock tx"
);
// notify the listener we received new confirmations
if let Some(listener) = &listener {
listener(seen_confirmations).await;
}
}
}
@ -419,6 +445,30 @@ mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use tracing::metadata::LevelFilter;
async fn wait_for_confirmations<
C: monero_rpc::wallet::MoneroWalletRpc<reqwest::Client> + Sync,
>(
client: &Mutex<C>,
transfer_proof: TransferProof,
to_address: Address,
expected: Amount,
conf_target: u64,
check_interval: Interval,
wallet_name: String,
) -> Result<(), InsufficientFunds> {
wait_for_confirmations_with(
client,
transfer_proof,
to_address,
expected,
conf_target,
check_interval,
wallet_name,
None,
)
.await
}
#[tokio::test]
async fn given_exact_confirmations_does_not_fetch_tx_again() {
let client = Mutex::new(DummyClient::new(vec![Ok(CheckTxKey {
@ -435,7 +485,7 @@ mod tests {
Amount::from_piconero(100),
10,
tokio::time::interval(Duration::from_millis(10)),
"foo-wallet".to_owned()
"foo-wallet".to_owned(),
)
.await;
@ -533,7 +583,7 @@ mod tests {
Amount::from_piconero(100),
5,
tokio::time::interval(Duration::from_millis(10)),
"foo-wallet".to_owned()
"foo-wallet".to_owned(),
)
.await
.unwrap();

View file

@ -3,8 +3,8 @@ use crate::bitcoin::{
self, current_epoch, CancelTimelock, ExpiredTimelocks, PunishTimelock, Transaction, TxCancel,
TxLock, Txid,
};
use crate::monero;
use crate::monero::wallet::WatchRequest;
use crate::monero::{self, TxHash};
use crate::monero::{monero_private_key, TransferProof};
use crate::monero_ext::ScalarExt;
use crate::protocol::{Message0, Message1, Message2, Message3, Message4, CROSS_CURVE_PROOF_SYSTEM};
@ -627,7 +627,7 @@ impl State5 {
monero_wallet: &monero::Wallet,
wallet_file_name: std::string::String,
monero_receive_address: monero::Address,
) -> Result<()> {
) -> Result<Vec<TxHash>> {
let (spend_key, view_key) = self.xmr_keys();
tracing::info!(%wallet_file_name, "Generating and opening Monero wallet from the extracted keys to redeem the Monero");
@ -652,12 +652,15 @@ impl State5 {
// Ensure that the generated wallet is synced so we have a proper balance
monero_wallet.refresh(20).await?;
// Sweep (transfer all funds) to the given address
// Sweep (transfer all funds) to the Bobs Monero redeem address
let tx_hashes = monero_wallet.sweep_all(monero_receive_address).await?;
for tx_hash in tx_hashes {
for tx_hash in &tx_hashes {
tracing::info!(%monero_receive_address, txid=%tx_hash.0, "Successfully transferred XMR to wallet");
}
Ok(())
Ok(tx_hashes)
}
}

View file

@ -141,8 +141,8 @@ async fn next_state(
monero_wallet_restore_blockheight,
}
}
// Bob has locked Btc
// Watch for Alice to Lock Xmr or for cancel timelock to elapse
// Bob has locked Bitcoin
// Watch for Alice to lock Monero or for cancel timelock to elapse
BobState::BtcLocked {
state3,
monero_wallet_restore_blockheight,
@ -151,59 +151,66 @@ async fn next_state(
swap_id,
TauriSwapProgressEvent::BtcLockTxInMempool {
btc_lock_txid: state3.tx_lock_id(),
// TODO: Replace this with the actual confirmations
btc_lock_confirmations: 0,
},
);
let tx_lock_status = bitcoin_wallet.subscribe_to(state3.tx_lock.clone()).await;
if let ExpiredTimelocks::None { .. } = state3.expired_timelock(bitcoin_wallet).await? {
tracing::info!("Waiting for Alice to lock Monero");
// Check whether we can cancel the swap, and do so if possible
if state3
.expired_timelock(bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
let state4 = state3.cancel(monero_wallet_restore_blockheight);
return Ok(BobState::CancelTimelockExpired(state4));
};
let buffered_transfer_proof = db
.get_buffered_transfer_proof(swap_id)
.await
.context("Failed to get buffered transfer proof")?;
tracing::info!("Waiting for Alice to lock Monero");
// Check if we have already buffered the XMR transfer proof
if let Some(transfer_proof) = db
.get_buffered_transfer_proof(swap_id)
.await
.context("Failed to get buffered transfer proof")?
{
tracing::debug!(txid = %transfer_proof.tx_hash(), "Found buffered transfer proof");
tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero");
return Ok(BobState::XmrLockProofReceived {
state: state3,
lock_transfer_proof: transfer_proof,
monero_wallet_restore_blockheight,
});
}
// Wait for either Alice to send the XMR transfer proof or until we can cancel the swap
let transfer_proof_watcher = event_loop_handle.recv_transfer_proof();
let cancel_timelock_expires =
tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock);
select! {
// Alice sent us the transfer proof for the Monero she locked
transfer_proof = transfer_proof_watcher => {
let transfer_proof = transfer_proof?;
if let Some(transfer_proof) = buffered_transfer_proof {
tracing::debug!(txid = %transfer_proof.tx_hash(), "Found buffered transfer proof");
tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero");
return Ok(BobState::XmrLockProofReceived {
BobState::XmrLockProofReceived {
state: state3,
lock_transfer_proof: transfer_proof,
monero_wallet_restore_blockheight,
});
}
monero_wallet_restore_blockheight
}
},
// The cancel timelock expired before Alice locked her Monero
result = cancel_timelock_expires => {
result?;
tracing::info!("Alice took too long to lock Monero, cancelling the swap");
let transfer_proof_watcher = event_loop_handle.recv_transfer_proof();
let cancel_timelock_expires =
tx_lock_status.wait_until_confirmed_with(state3.cancel_timelock);
select! {
transfer_proof = transfer_proof_watcher => {
let transfer_proof = transfer_proof?;
tracing::info!(txid = %transfer_proof.tx_hash(), "Alice locked Monero");
BobState::XmrLockProofReceived {
state: state3,
lock_transfer_proof: transfer_proof,
monero_wallet_restore_blockheight
}
},
result = cancel_timelock_expires => {
result?;
tracing::info!("Alice took too long to lock Monero, cancelling the swap");
let state4 = state3.cancel(monero_wallet_restore_blockheight);
BobState::CancelTimelockExpired(state4)
},
}
} else {
let state4 = state3.cancel(monero_wallet_restore_blockheight);
BobState::CancelTimelockExpired(state4)
let state4 = state3.cancel(monero_wallet_restore_blockheight);
BobState::CancelTimelockExpired(state4)
},
}
}
BobState::XmrLockProofReceived {
@ -221,31 +228,66 @@ async fn next_state(
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? {
let watch_request = state.lock_xmr_watch_request(lock_transfer_proof);
// Check if the cancel timelock has expired
// If it has, we have to cancel the swap
if state
.expired_timelock(bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
return Ok(BobState::CancelTimelockExpired(
state.cancel(monero_wallet_restore_blockheight),
));
};
select! {
received_xmr = monero_wallet.watch_for_transfer(watch_request) => {
match received_xmr {
Ok(()) => BobState::XmrLocked(state.xmr_locked(monero_wallet_restore_blockheight)),
Err(monero::InsufficientFunds { expected, actual }) => {
tracing::warn!(%expected, %actual, "Insufficient Monero have been locked!");
tracing::info!(timelock = %state.cancel_timelock, "Waiting for cancel timelock to expire");
// Clone these so that we can move them into the listener closure
let tauri_clone = event_emitter.clone();
let transfer_proof_clone = lock_transfer_proof.clone();
let watch_request = state.lock_xmr_watch_request(lock_transfer_proof);
tx_lock_status.wait_until_confirmed_with(state.cancel_timelock).await?;
// We pass a listener to the function that get's called everytime a new confirmation is spotted.
let watch_future = monero_wallet.watch_for_transfer_with(
watch_request,
Some(Box::new(move |confirmations| {
// Clone them again so that we can move them again
let tranfer = transfer_proof_clone.clone();
let tauri = tauri_clone.clone();
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
// Emit an event to notify about the new confirmation
Box::pin(async move {
tauri.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::XmrLockTxInMempool {
xmr_lock_txid: tranfer.tx_hash(),
xmr_lock_tx_confirmations: confirmations,
},
}
}
// TODO: Send Tauri event here everytime we receive a new confirmation
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
);
})
})),
);
select! {
received_xmr = watch_future => {
match received_xmr {
Ok(()) =>
BobState::XmrLocked(state.xmr_locked(monero_wallet_restore_blockheight)),
Err(monero::InsufficientFunds { expected, actual }) => {
// Alice locked insufficient Monero
tracing::warn!(%expected, %actual, "Insufficient Monero have been locked!");
tracing::info!(timelock = %state.cancel_timelock, "Waiting for cancel timelock to expire");
// We wait for the cancel timelock to expire before we cancel the swap
// because there's no way of recovering from this state
tx_lock_status.wait_until_confirmed_with(state.cancel_timelock).await?;
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
},
}
}
} else {
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
}
}
}
BobState::XmrLocked(state) => {
@ -260,25 +302,29 @@ async fn next_state(
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? {
// Alice has locked Xmr
// Bob sends Alice his key
// Check whether we can cancel the swap and do so if possible.
if state
.expired_timelock(bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
return Ok(BobState::CancelTimelockExpired(state.cancel()));
}
select! {
result = event_loop_handle.send_encrypted_signature(state.tx_redeem_encsig()) => {
match result {
Ok(_) => BobState::EncSigSent(state),
Err(bmrng::error::RequestError::RecvError | bmrng::error::RequestError::SendError(_)) => bail!("Failed to communicate encrypted signature through event loop channel"),
Err(bmrng::error::RequestError::RecvTimeoutError) => unreachable!("We construct the channel with no timeout"),
}
},
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel())
// Alice has locked their Monero
// Bob sends Alice the encrypted signature which allows her to sign and broadcast the Bitcoin redeem transaction
select! {
result = event_loop_handle.send_encrypted_signature(state.tx_redeem_encsig()) => {
match result {
Ok(_) => BobState::EncSigSent(state),
Err(bmrng::error::RequestError::RecvError | bmrng::error::RequestError::SendError(_)) => bail!("Failed to communicate encrypted signature through event loop channel"),
Err(bmrng::error::RequestError::RecvTimeoutError) => unreachable!("We construct the channel with no timeout"),
}
},
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel())
}
} else {
BobState::CancelTimelockExpired(state.cancel())
}
}
BobState::EncSigSent(state) => {
@ -291,32 +337,35 @@ async fn next_state(
let tx_lock_status = bitcoin_wallet.subscribe_to(state.tx_lock.clone()).await;
if let ExpiredTimelocks::None { .. } = state.expired_timelock(bitcoin_wallet).await? {
select! {
state5 = state.watch_for_redeem_btc(bitcoin_wallet) => {
BobState::BtcRedeemed(state5?)
},
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel())
}
if state
.expired_timelock(bitcoin_wallet)
.await?
.cancel_timelock_expired()
{
return Ok(BobState::CancelTimelockExpired(state.cancel()));
}
select! {
state5 = state.watch_for_redeem_btc(bitcoin_wallet) => {
BobState::BtcRedeemed(state5?)
},
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel())
}
} else {
BobState::CancelTimelockExpired(state.cancel())
}
}
BobState::BtcRedeemed(state) => {
event_emitter.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::BtcRedeemed);
state
let xmr_redeem_txids = state
.redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address)
.await?;
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::XmrRedeemInMempool {
// TODO: Replace this with the actual txid
xmr_redeem_txid: monero::TxHash("placeholder".to_string()),
xmr_redeem_txids,
xmr_redeem_address: monero_receive_address,
},
);
@ -402,11 +451,11 @@ async fn next_state(
.redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address)
.await
{
Ok(_) => {
Ok(xmr_redeem_txids) => {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::XmrRedeemInMempool {
xmr_redeem_txid: monero::TxHash("placeholder".to_string()),
xmr_redeem_txids,
xmr_redeem_address: monero_receive_address,
},
);
@ -466,11 +515,12 @@ async fn next_state(
}
BobState::SafelyAborted => BobState::SafelyAborted,
BobState::XmrRedeemed { tx_lock_id } => {
// TODO: Replace this with the actual txid
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::XmrRedeemInMempool {
xmr_redeem_txid: monero::TxHash("placeholder".to_string()),
// We don't have the txids of the redeem transaction here, so we can't emit them
// We return an empty array instead
xmr_redeem_txids: vec![],
xmr_redeem_address: monero_receive_address,
},
);