Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions monero-wallet-ng/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Output = Result<Vec<KeyImageSpentStatus>, InterfaceError>>;
}

impl<T: HttpTransport> IsKeyImageSpent for MoneroDaemon<T> {
fn is_key_image_spent(
&self,
key_images: &[[u8; 32]],
) -> impl Send + Future<Output = Result<Vec<KeyImageSpentStatus>, InterfaceError>> {
let key_images_hex: Vec<String> = key_images.iter().map(hex::encode).collect();

async move {
#[derive(serde::Deserialize)]
struct IsKeyImageSpentResponse {
status: String,
spent_status: Vec<u8>,
}

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 {
Expand Down
29 changes: 29 additions & 0 deletions monero-wallet/src/wallets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotPruned>) -> Result<bool> {
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<u64> {
use monero_daemon_rpc::prelude::ProvidesBlockchainMeta;
let rpc_client = self.rpc_client().await?;
Expand Down
7 changes: 7 additions & 0 deletions swap-env/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ pub struct Monero {
pub finality_confirmations: Option<u64>,
#[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)]
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 20 additions & 7 deletions swap-env/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}

Expand Down
1 change: 1 addition & 0 deletions swap-orchestrator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions swap/src/protocol/alice/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading