diff --git a/monero-wallet-ng/src/rpc.rs b/monero-wallet-ng/src/rpc.rs index be11d19ee..6982c0e9b 100644 --- a/monero-wallet-ng/src/rpc.rs +++ b/monero-wallet-ng/src/rpc.rs @@ -8,6 +8,83 @@ use core::future::Future; use monero_daemon_rpc::{HttpTransport, MoneroDaemon}; use monero_interface::InterfaceError; +/// Spend status of a single key image, per the daemon's `is_key_image_spent` RPC. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyImageSpentStatus { + /// The key image has not been spent. + Unspent, + /// The key image was spent by a transaction confirmed in the blockchain. + SpentInBlockchain, + /// The key image was spent by a transaction currently in the mempool. + SpentInPool, +} + +/// Query the spend status of key images directly, without submitting a transaction. +/// +/// Unlike the `double_spend` flag from `send_raw_transaction`, this distinguishes a +/// confirmed spend (`SpentInBlockchain`) from a transient pool spend (`SpentInPool`). +pub trait IsKeyImageSpent: Sync { + /// Returns the spend status of each key image, in the same order as the input. + fn is_key_image_spent( + &self, + key_images: &[[u8; 32]], + ) -> impl Send + Future, InterfaceError>>; +} + +impl IsKeyImageSpent for MoneroDaemon { + fn is_key_image_spent( + &self, + key_images: &[[u8; 32]], + ) -> impl Send + Future, InterfaceError>> { + let key_images_hex: Vec = key_images.iter().map(hex::encode).collect(); + + async move { + #[derive(serde::Deserialize)] + struct IsKeyImageSpentResponse { + status: String, + spent_status: Vec, + } + + let params = serde_json::json!({ "key_images": key_images_hex }).to_string(); + + let response = self + .rpc_call( + "is_key_image_spent", + Some(params), + // The response is a small array of integers. + 65536, + ) + .await?; + + let response: IsKeyImageSpentResponse = + serde_json::from_str(&response).map_err(|e| { + InterfaceError::InvalidInterface(format!( + "Failed to parse is_key_image_spent response: {e}" + )) + })?; + + if response.status != "OK" { + return Err(InterfaceError::InvalidInterface(format!( + "is_key_image_spent returned status {}", + response.status + ))); + } + + response + .spent_status + .into_iter() + .map(|status| match status { + 0 => Ok(KeyImageSpentStatus::Unspent), + 1 => Ok(KeyImageSpentStatus::SpentInBlockchain), + 2 => Ok(KeyImageSpentStatus::SpentInPool), + other => Err(InterfaceError::InvalidInterface(format!( + "Unknown key image spent status {other}" + ))), + }) + .collect() + } + } +} #[derive(Debug, thiserror::Error)] pub enum TransactionStatusError { diff --git a/monero-wallet/src/wallets.rs b/monero-wallet/src/wallets.rs index 99f8669bd..745c27cec 100644 --- a/monero-wallet/src/wallets.rs +++ b/monero-wallet/src/wallets.rs @@ -322,6 +322,35 @@ impl Wallets { Ok(!matches!(status, TransactionStatus::Unknown)) } + /// Returns true if any of `tx`'s inputs has already been spent by a transaction confirmed + /// in the blockchain. Combined with `tx` itself not being present on-chain, this indicates + /// a different transaction spent our inputs (a confirmed double spend). + pub async fn has_input_confirmed_spent(&self, tx: &Transaction) -> Result { + use monero_oxide_wallet::transaction::Input; + use monero_wallet_ng::rpc::{IsKeyImageSpent, KeyImageSpentStatus}; + + let key_images: Vec<[u8; 32]> = tx + .prefix() + .inputs + .iter() + .filter_map(|input| match input { + Input::ToKey { key_image, .. } => Some(key_image.to_bytes()), + Input::Gen(_) => None, + }) + .collect(); + + let statuses = self + .rpc_client() + .await? + .is_key_image_spent(&key_images) + .await + .context("Failed to query key image spend status")?; + + Ok(statuses + .iter() + .any(|status| matches!(status, KeyImageSpentStatus::SpentInBlockchain))) + } + pub async fn direct_rpc_block_height(&self) -> Result { use monero_daemon_rpc::prelude::ProvidesBlockchainMeta; let rpc_client = self.rpc_client().await?; diff --git a/swap-env/src/config.rs b/swap-env/src/config.rs index 074cfc857..e494eea4a 100644 --- a/swap-env/src/config.rs +++ b/swap-env/src/config.rs @@ -79,6 +79,12 @@ pub struct Monero { pub finality_confirmations: Option, #[serde(with = "swap_serde::monero::network")] pub network: monero_address::Network, + /// Whether the configured Monero daemon is trusted. You should generally only + /// consider self-hosted Monero nodes on your own hardware as trusted. If an + /// attacker controls your node and you set this to true, they might be able to + /// steal your funds. + #[serde(default)] + pub trusted_daemon: bool, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -451,6 +457,7 @@ pub fn query_user_for_initial_config_with_network( daemon_url: monero_daemon_url, finality_confirmations: None, network: monero_network, + trusted_daemon: false, }, tor: TorConf { register_hidden_service, diff --git a/swap-env/src/env.rs b/swap-env/src/env.rs index 9cbcbe0ba..df727f95b 100644 --- a/swap-env/src/env.rs +++ b/swap-env/src/env.rs @@ -23,6 +23,10 @@ pub struct Config { pub monero_lock_retry_timeout: Duration, // After this many confirmations we assume that the Monero transaction is safe from double spending pub monero_double_spend_safe_confirmations: u64, + // Whether the configured Monero daemon is trusted. You should generally only consider + // self-hosted Monero nodes on your own hardware as trusted. If an attacker controls your + // node and you set this to true, they might be able to steal your funds. + pub monero_trusted_daemon: bool, #[serde(with = "swap_serde::monero::network")] pub monero_network: monero_address::Network, } @@ -70,6 +74,7 @@ impl GetConfig for Mainnet { monero_lock_retry_timeout: 10.std_minutes(), monero_finality_confirmations: 10, monero_double_spend_safe_confirmations: 10, + monero_trusted_daemon: false, monero_network: monero_address::Network::Mainnet, } } @@ -91,6 +96,7 @@ impl GetConfig for Testnet { monero_lock_retry_timeout: 10.std_minutes(), monero_finality_confirmations: 10, monero_double_spend_safe_confirmations: 10, + monero_trusted_daemon: false, monero_network: monero_address::Network::Stagenet, } } @@ -112,6 +118,7 @@ impl GetConfig for Regtest { monero_lock_retry_timeout: 1.std_minutes(), monero_finality_confirmations: 10, monero_double_spend_safe_confirmations: 10, + monero_trusted_daemon: false, monero_network: monero_address::Network::Mainnet, // yes this is strange } } @@ -138,13 +145,19 @@ pub fn new(is_testnet: bool, asb_config: &AsbConfig) -> Config { env_config }; - if let Some(monero_finality_confirmations) = asb_config.monero.finality_confirmations { - Config { - monero_finality_confirmations, - ..env_config - } - } else { - env_config + let env_config = + if let Some(monero_finality_confirmations) = asb_config.monero.finality_confirmations { + Config { + monero_finality_confirmations, + ..env_config + } + } else { + env_config + }; + + Config { + monero_trusted_daemon: asb_config.monero.trusted_daemon, + ..env_config } } diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 870453600..d9450f115 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -369,6 +369,7 @@ fn main() { network: monero_network, // This means that we will use the default set in swap-env/src/env.rs finality_confirmations: None, + trusted_daemon: false, }, tor: TorConf { register_hidden_service: tor_hidden_service, diff --git a/swap/src/protocol/alice/swap.rs b/swap/src/protocol/alice/swap.rs index d29e7b7d3..e8b581273 100644 --- a/swap/src/protocol/alice/swap.rs +++ b/swap/src/protocol/alice/swap.rs @@ -353,6 +353,28 @@ where .map_err(backoff::Error::transient)?; if !is_present { + // A trusted daemon reporting one of our lock transaction's inputs as already + // spent by a confirmed transaction (while our own transaction is absent from + // the chain) means a different transaction took our inputs. Our lock + // transaction can never confirm, so we rebuild it from scratch by returning to + // BtcLocked. We only act on a trusted daemon, as a malicious one could + // otherwise grief us into rebuilding indefinitely. + if env_config.monero_trusted_daemon + && monero_wallet + .has_input_confirmed_spent(&xmr_lock_tx) + .await + .context("Failed to check whether the Monero lock transaction inputs were already spent") + .map_err(backoff::Error::transient)? + { + tracing::warn!( + %swap_id, + %xmr_lock_tx_hash, + "Trusted Monero daemon reports the lock transaction's inputs were already spent by a confirmed transaction. Rebuilding the lock transaction." + ); + + return Ok(AliceState::BtcLocked { state3: state3.clone() }); + } + if !cancel_timelock_not_expired(&state3, &*bitcoin_wallet) .await .context("Failed to check for expired timelocks before publishing Monero lock transaction")