From 53b8c0e2cba0adee2b0c002ce599a88ac0325b1c Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Fri, 19 Jun 2026 14:39:35 +0200 Subject: [PATCH 1/2] feat: monero-sys tx -> hex -> moner_oxide tx --- monero-sys/build.rs | 5 ++ ..._0005_pending_transaction_raw_tx_hex.patch | 53 ++++++++++++++ monero-sys/src/bridge.h | 5 ++ monero-sys/src/bridge.rs | 5 ++ monero-sys/src/lib.rs | 73 ++++++++++++++++++- monero-wallet-ng/src/util.rs | 17 +++++ monero-wallet/src/wallets.rs | 27 ++++++- 7 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 monero-sys/patches/eigenwallet_0005_pending_transaction_raw_tx_hex.patch diff --git a/monero-sys/build.rs b/monero-sys/build.rs index 8f9f92d6a0..12ca0153e7 100644 --- a/monero-sys/build.rs +++ b/monero-sys/build.rs @@ -80,6 +80,11 @@ const EMBEDDED_PATCHES: &[EmbeddedPatch] = &[ "Adds balancePerSubaddress() and unlockedBalancePerSubaddress() to wallet::WalletImpl in api/wallet.h", "patches/eigenwallet_0004_wallet_impl_balance_per_subaddress.patch" ), + embedded_patch!( + "eigenwallet_0005_pending_transaction_raw_tx_hex", + "Adds rawTxHex() to PendingTransaction in wallet2_api.h", + "patches/eigenwallet_0005_pending_transaction_raw_tx_hex.patch" + ), ]; /// Find the workspace target directory from OUT_DIR diff --git a/monero-sys/patches/eigenwallet_0005_pending_transaction_raw_tx_hex.patch b/monero-sys/patches/eigenwallet_0005_pending_transaction_raw_tx_hex.patch new file mode 100644 index 0000000000..a6b65da5d2 --- /dev/null +++ b/monero-sys/patches/eigenwallet_0005_pending_transaction_raw_tx_hex.patch @@ -0,0 +1,53 @@ +diff --git a/src/wallet/api/pending_transaction.cpp b/src/wallet/api/pending_transaction.cpp +index 84cc8c585..60e242b3f 100644 +--- a/src/wallet/api/pending_transaction.cpp ++++ b/src/wallet/api/pending_transaction.cpp +@@ -175,6 +175,24 @@ std::vector> PendingTransactio + return keys; + } + ++std::string PendingTransactionImpl::rawTxHex(const std::string &tx_hash) const ++{ ++ if (m_pending_tx.size() != 1) ++ { ++ throw std::runtime_error("Expected exactly one pending transaction, found " + std::to_string(m_pending_tx.size())); ++ } ++ ++ const auto &ptx = m_pending_tx.front(); ++ const std::string current_tx_hash = epee::string_tools::pod_to_hex(cryptonote::get_transaction_hash(ptx.tx)); ++ ++ if (current_tx_hash != tx_hash) ++ { ++ throw std::runtime_error("Requested transaction hash " + tx_hash + " does not match pending transaction hash " + current_tx_hash); ++ } ++ ++ return epee::string_tools::buff_to_hex_nodelimer(cryptonote::tx_to_blob(ptx.tx)); ++} ++ + bool PendingTransactionImpl::commit(const std::string &filename, bool overwrite) + { + +diff --git a/src/wallet/api/pending_transaction.h b/src/wallet/api/pending_transaction.h +index 0377eb79c..2c54f53d7 100644 +--- a/src/wallet/api/pending_transaction.h ++++ b/src/wallet/api/pending_transaction.h +@@ -52,6 +52,7 @@ public: + uint64_t fee() const override; + std::vector txid() const override; + std::vector> txKeys(const std::string &tx_hash) const override; ++ std::string rawTxHex(const std::string &tx_hash) const override; + uint64_t txCount() const override; + std::vector subaddrAccount() const override; + std::vector> subaddrIndices() const override; +diff --git a/src/wallet/api/wallet2_api.h b/src/wallet/api/wallet2_api.h +index 073799c9e..aca4e91d6 100644 +--- a/src/wallet/api/wallet2_api.h ++++ b/src/wallet/api/wallet2_api.h +@@ -132,6 +132,7 @@ struct PendingTransaction + virtual uint64_t fee() const = 0; + virtual std::vector txid() const = 0; + virtual std::vector> txKeys(const std::string &tx_hash) const = 0; ++ virtual std::string rawTxHex(const std::string &tx_hash) const = 0; + /*! + * \brief txCount - number of transactions current transaction will be splitted to + * \return diff --git a/monero-sys/src/bridge.h b/monero-sys/src/bridge.h index 06f8df2560..09c629c335 100644 --- a/monero-sys/src/bridge.h +++ b/monero-sys/src/bridge.h @@ -114,6 +114,11 @@ namespace Monero return std::make_unique(err); } + inline std::unique_ptr pendingTransactionRawTxHex(const PendingTransaction &tx, const std::string &tx_hash) + { + return std::make_unique(tx.rawTxHex(tx_hash)); + } + /** * Wrapper for Wallet::checkTxKey to accommodate passing std::string by reference. * The original API takes the tx_key parameter by value which is not compatible diff --git a/monero-sys/src/bridge.rs b/monero-sys/src/bridge.rs index 3c249db75f..186a65c83b 100644 --- a/monero-sys/src/bridge.rs +++ b/monero-sys/src/bridge.rs @@ -300,6 +300,11 @@ pub mod ffi { tx_hash: &CxxString, ) -> Result>>; + fn pendingTransactionRawTxHex( + tx: &PendingTransaction, + tx_hash: &CxxString, + ) -> Result>; + /// Get the fee of a pending transaction. fn pendingTransactionFee(tx: &PendingTransaction) -> Result; diff --git a/monero-sys/src/lib.rs b/monero-sys/src/lib.rs index 7723f946d0..2a06a6e7be 100644 --- a/monero-sys/src/lib.rs +++ b/monero-sys/src/lib.rs @@ -255,6 +255,8 @@ pub enum TransactionDirection { /// A wrapper around a pending transaction. /// /// Safety: do _not_ implement copy, send, sync, ... +/// +/// Must be manually dropped via FfiWallet::dispose_pending_transaction. pub struct PendingTransactionHandle(*mut ffi::PendingTransaction); /// Struct containing a raw pointer to a transaction history. @@ -622,6 +624,25 @@ impl WalletHandle { .map_err(|e| anyhow!("Failed to transfer funds after multiple attempts: {e:?}")) } + pub async fn construct_multi_destination_tx( + &self, + destinations: &[(monero_address::MoneroAddress, monero_oxide_ext::Amount)], + ) -> anyhow::Result<(TxReceipt, String)> { + let destinations = destinations.to_vec(); + + retry_notify(backoff(None, None), || async { + let destinations = destinations.clone(); + + self.call(move |wallet| wallet.construct_multi_destination_tx(&destinations)) + .await + .map_err(backoff::Error::transient) + }, |error, duration: Duration| { + tracing::error!(error=?error, "Failed to construct transaction, retrying in {} secs", duration.as_secs()); + }) + .await? + .map_err(|e| anyhow!("Failed to construct transaction after multiple attempts: {e:?}")) + } + /// Sweep all funds to an address. pub async fn sweep( &self, @@ -2428,6 +2449,39 @@ impl FfiWallet { result } + pub fn construct_multi_destination_tx( + &mut self, + destinations: &[(monero_address::MoneroAddress, monero_oxide_ext::Amount)], + ) -> anyhow::Result<(TxReceipt, String)> { + self.ensure_synchronized_blocking() + .context("Cannot construct transaction when wallet is not synchronized")?; + + let output_addresses = destinations + .iter() + .map(|(address, _)| *address) + .collect::>(); + + let mut pending_tx = self.create_pending_transaction_multi_dest(destinations, false)?; + + let built = (|| -> anyhow::Result<(TxReceipt, String)> { + let (txid, tx_keys) = pending_tx.validate_single_txid(&output_addresses).context( + "Failed to ensure transaction has one txid and at least one tx key before constructing", + )?; + + let height = self.blockchain_height(); + + let tx_hex = pending_tx.raw_tx_hex(&txid)?; + + Ok((TxReceipt { txid, tx_keys, height }, tx_hex)) + })(); + + if let Err(e) = self.dispose_pending_transaction(pending_tx) { + tracing::error!(error=%e, "Failed to dispose pending transaction after constructing"); + } + + built + } + /// Create a pending transaction without publishing it. /// Returns the pending transaction that can be inspected before publishing. fn create_pending_transaction_single_dest( @@ -2670,10 +2724,7 @@ impl FfiWallet { /// Returns an error if the underlying FFI call fails. Callers decide whether /// to propagate it: where it would mask a more important result (e.g. after a /// publish attempt) it should be logged instead. - fn dispose_pending_transaction( - &mut self, - tx: PendingTransactionHandle, - ) -> anyhow::Result<()> { + fn dispose_pending_transaction(&mut self, tx: PendingTransactionHandle) -> anyhow::Result<()> { // Safety: we pass a valid pointer and we verified it's not used again since PendingTransaction is moved into this function unsafe { self.inner @@ -3011,6 +3062,20 @@ impl PendingTransactionHandle { Ok((txid, keys_map)) } + + fn raw_tx_hex(&self, txid: &str) -> anyhow::Result { + self.check_error() + .context("Pending transaction is in an error state")?; + + let_cxx_string!(txid_cxx = txid); + let hex = ffi::pendingTransactionRawTxHex(self, &txid_cxx) + .context( + "Failed to get raw transaction hex from pending transaction: FFI call failed with exception", + )? + .to_string(); + + Ok(hex) + } } impl SyncProgress { diff --git a/monero-wallet-ng/src/util.rs b/monero-wallet-ng/src/util.rs index 9568ccba77..cae11b07ac 100644 --- a/monero-wallet-ng/src/util.rs +++ b/monero-wallet-ng/src/util.rs @@ -1,6 +1,23 @@ use monero_oxide::ed25519::{Point, Scalar}; +use monero_oxide_wallet::transaction::{NotPruned, Transaction}; /// Derive the Ed25519 public key for a private scalar. pub fn public_key(private_key: &Scalar) -> Point { Point::from(curve25519_dalek::constants::ED25519_BASEPOINT_POINT * (*private_key).into()) } + +#[derive(Debug, thiserror::Error)] +pub enum TransactionFromHexError { + #[error("Transaction blob was not valid hex")] + Hex(#[from] hex::FromHexError), + #[error("Failed to deserialize transaction blob")] + Read(#[from] std::io::Error), +} + +pub fn transaction_from_hex( + blob_hex: &str, +) -> Result, TransactionFromHexError> { + let bytes = hex::decode(blob_hex)?; + let tx = Transaction::read(&mut bytes.as_slice())?; + Ok(tx) +} diff --git a/monero-wallet/src/wallets.rs b/monero-wallet/src/wallets.rs index 3540af39c5..9d0205ccd5 100644 --- a/monero-wallet/src/wallets.rs +++ b/monero-wallet/src/wallets.rs @@ -4,7 +4,7 @@ //! Mostly we do two things: //! - wait for transactions to be confirmed //! - send money from one wallet to another. -pub use monero_sys::{Daemon, WalletHandle as Wallet, WalletHandleListener}; +pub use monero_sys::{Daemon, TxReceipt, WalletHandle as Wallet, WalletHandleListener}; use anyhow::{Context, Result}; use monero_address::Network; @@ -398,6 +398,31 @@ impl Wallets { .context("Failed to construct sweep transaction to destination") } + pub async fn construct_multi_destination_tx( + &self, + destinations: &[(monero_address::MoneroAddress, monero_oxide_ext::Amount)], + ) -> Result<(Transaction, TxReceipt)> { + let (receipt, tx_hex) = self + .main_wallet() + .await + .construct_multi_destination_tx(destinations) + .await + .context("Failed to construct Monero transaction")?; + + let tx = monero_wallet_ng::util::transaction_from_hex(&tx_hex) + .context("Failed to parse constructed Monero transaction")?; + + let tx_hash = hex::encode(tx.hash()); + anyhow::ensure!( + tx_hash == receipt.txid, + "Parsed Monero transaction hash {} does not match wallet-reported txid {}", + tx_hash, + receipt.txid + ); + + Ok((tx, receipt)) + } + /// Verify a transfer using the new monero-wallet-ng implementation. /// /// This verifies that a transaction sends the expected amount to the given view pair From 50ad96c8daf28e33017d7641b347b5c79d70f039 Mon Sep 17 00:00:00 2001 From: einliterflasche Date: Fri, 19 Jun 2026 17:08:50 +0200 Subject: [PATCH 2/2] feat(state-machine, alice): separate XmrLockTransactionConstructed state --- swap-db/src/alice.rs | 32 +++++++ swap-machine/src/alice/mod.rs | 13 ++- swap/src/asb/recovery/cancel.rs | 3 +- swap/src/asb/recovery/punish.rs | 3 +- swap/src/asb/recovery/redeem.rs | 1 + swap/src/asb/recovery/refund.rs | 3 +- swap/src/asb/recovery/safely_abort.rs | 3 +- swap/src/protocol/alice/swap.rs | 116 +++++++++++++++++++++----- 8 files changed, 147 insertions(+), 27 deletions(-) diff --git a/swap-db/src/alice.rs b/swap-db/src/alice.rs index 3f461e4457..afd4708e6e 100644 --- a/swap-db/src/alice.rs +++ b/swap-db/src/alice.rs @@ -20,6 +20,13 @@ pub enum Alice { BtcLocked { state3: alice::State3, }, + XmrLockTransactionConstructed { + monero_wallet_restore_blockheight: BlockHeight, + #[serde(with = "swap_serde::monero::transaction")] + xmr_lock_tx: monero_oxide_wallet::transaction::Transaction, + transfer_proof: TransferProof, + state3: alice::State3, + }, XmrLockTransactionSent { monero_wallet_restore_blockheight: BlockHeight, transfer_proof: TransferProof, @@ -169,6 +176,17 @@ impl From for Alice { Alice::BtcLockTransactionSeen { state3: *state3 } } AliceState::BtcLocked { state3 } => Alice::BtcLocked { state3: *state3 }, + AliceState::XmrLockTransactionConstructed { + monero_wallet_restore_blockheight, + xmr_lock_tx, + transfer_proof, + state3, + } => Alice::XmrLockTransactionConstructed { + monero_wallet_restore_blockheight, + xmr_lock_tx, + transfer_proof, + state3: *state3, + }, AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, @@ -344,6 +362,17 @@ impl From for AliceState { Alice::BtcLocked { state3 } => AliceState::BtcLocked { state3: Box::new(state3), }, + Alice::XmrLockTransactionConstructed { + monero_wallet_restore_blockheight, + xmr_lock_tx, + transfer_proof, + state3, + } => AliceState::XmrLockTransactionConstructed { + monero_wallet_restore_blockheight, + xmr_lock_tx, + transfer_proof, + state3: Box::new(state3), + }, Alice::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, @@ -519,6 +548,9 @@ impl fmt::Display for Alice { write!(f, "Bitcoin lock transaction in mempool") } Alice::BtcLocked { .. } => f.write_str("Bitcoin locked"), + Alice::XmrLockTransactionConstructed { .. } => { + f.write_str("Monero lock transaction constructed") + } Alice::XmrLockTransactionSent { .. } => f.write_str("Monero lock transaction sent"), Alice::XmrLocked { .. } => f.write_str("Monero locked"), Alice::XmrLockTransferProofSent { .. } => { diff --git a/swap-machine/src/alice/mod.rs b/swap-machine/src/alice/mod.rs index 613a9ff96d..62fb45bf94 100644 --- a/swap-machine/src/alice/mod.rs +++ b/swap-machine/src/alice/mod.rs @@ -33,6 +33,12 @@ pub enum AliceState { BtcEarlyRefundable { state3: Box, }, + XmrLockTransactionConstructed { + monero_wallet_restore_blockheight: BlockHeight, + xmr_lock_tx: monero_oxide_wallet::transaction::Transaction, + transfer_proof: TransferProof, + state3: Box, + }, XmrLockTransactionSent { monero_wallet_restore_blockheight: BlockHeight, transfer_proof: TransferProof, @@ -184,6 +190,9 @@ impl fmt::Display for AliceState { write!(f, "bitcoin lock transaction in mempool") } AliceState::BtcLocked { .. } => write!(f, "btc is locked"), + AliceState::XmrLockTransactionConstructed { .. } => { + write!(f, "xmr lock transaction constructed") + } AliceState::XmrLockTransactionSent { .. } => write!(f, "xmr lock transaction sent"), AliceState::XmrLocked { .. } => write!(f, "xmr is locked"), AliceState::XmrLockTransferProofSent { .. } => { @@ -1165,7 +1174,9 @@ impl ReservesMonero for AliceState { AliceState::Started { .. } => monero::Amount::ZERO, // These are the only states where we have to assume we will have to lock // our Monero, and we haven't done so yet. - AliceState::BtcLockTransactionSeen { state3 } | AliceState::BtcLocked { state3 } => { + AliceState::BtcLockTransactionSeen { state3 } + | AliceState::BtcLocked { state3 } + | AliceState::XmrLockTransactionConstructed { state3, .. } => { // We reserve as much Monero as we need for the output of the lock transaction // and as we need for the network fee state3.xmr.min_conservative_balance_to_spend() diff --git a/swap/src/asb/recovery/cancel.rs b/swap/src/asb/recovery/cancel.rs index 5b7a310a0b..af86795a11 100644 --- a/swap/src/asb/recovery/cancel.rs +++ b/swap/src/asb/recovery/cancel.rs @@ -20,7 +20,8 @@ pub async fn cancel( | AliceState::BtcLockTransactionSeen { .. } | AliceState::BtcLocked { .. } => bail!("Cannot cancel swap {} because it is in state {} where no XMR was locked.", swap_id, state), - AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, state3, } + AliceState::XmrLockTransactionConstructed { monero_wallet_restore_blockheight, transfer_proof, state3, .. } + | AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, state3, } | AliceState::XmrLocked { monero_wallet_restore_blockheight, transfer_proof, state3 } | AliceState::XmrLockTransferProofSent { monero_wallet_restore_blockheight, transfer_proof, state3 } diff --git a/swap/src/asb/recovery/punish.rs b/swap/src/asb/recovery/punish.rs index 5ed5fdfbcb..c97b4e66f4 100644 --- a/swap/src/asb/recovery/punish.rs +++ b/swap/src/asb/recovery/punish.rs @@ -22,7 +22,8 @@ pub async fn punish( let (state3, transfer_proof) = match state { // Punish potentially possible (no knowledge of cancel transaction) - AliceState::XmrLockTransactionSent {state3, transfer_proof, ..} + AliceState::XmrLockTransactionConstructed {state3, transfer_proof, ..} + | AliceState::XmrLockTransactionSent {state3, transfer_proof, ..} | AliceState::XmrLocked {state3, transfer_proof, ..} | AliceState::XmrLockTransferProofSent {state3, transfer_proof, ..} | AliceState::EncSigLearned {state3, transfer_proof, ..} diff --git a/swap/src/asb/recovery/redeem.rs b/swap/src/asb/recovery/redeem.rs index d9c65dfa5c..9c2cd0079a 100644 --- a/swap/src/asb/recovery/redeem.rs +++ b/swap/src/asb/recovery/redeem.rs @@ -82,6 +82,7 @@ pub async fn redeem( AliceState::Started { .. } | AliceState::BtcLockTransactionSeen { .. } | AliceState::BtcLocked { .. } + | AliceState::XmrLockTransactionConstructed { .. } | AliceState::XmrLockTransactionSent { .. } | AliceState::XmrLocked { .. } | AliceState::XmrLockTransferProofSent { .. } diff --git a/swap/src/asb/recovery/refund.rs b/swap/src/asb/recovery/refund.rs index ba32635bd4..e8a2738989 100644 --- a/swap/src/asb/recovery/refund.rs +++ b/swap/src/asb/recovery/refund.rs @@ -42,7 +42,8 @@ pub async fn refund( | AliceState::BtcLocked { .. } => bail!(Error::NoXmrLocked(state)), // Refund potentially possible (no knowledge of cancel transaction) - AliceState::XmrLockTransactionSent { transfer_proof, state3, .. } + AliceState::XmrLockTransactionConstructed { transfer_proof, state3, .. } + | AliceState::XmrLockTransactionSent { transfer_proof, state3, .. } | AliceState::XmrLocked { transfer_proof, state3, .. } | AliceState::XmrLockTransferProofSent { transfer_proof, state3, .. } | AliceState::EncSigLearned { transfer_proof, state3, .. } diff --git a/swap/src/asb/recovery/safely_abort.rs b/swap/src/asb/recovery/safely_abort.rs index 5d5a6033c7..48c165e082 100644 --- a/swap/src/asb/recovery/safely_abort.rs +++ b/swap/src/asb/recovery/safely_abort.rs @@ -20,7 +20,8 @@ pub async fn safely_abort(swap_id: Uuid, db: Arc) -> Result { - AliceState::XmrLockTransactionSent { + match constructed { + // If the construction was successful, we transition to the next state + Ok(Some((monero_wallet_restore_blockheight, transfer_proof, xmr_lock_tx))) => { + AliceState::XmrLockTransactionConstructed { monero_wallet_restore_blockheight: BlockHeight { height: monero_wallet_restore_blockheight, }, + xmr_lock_tx, transfer_proof, state3, } @@ -313,6 +309,72 @@ where AliceState::SafelyAborted } } + AliceState::XmrLockTransactionConstructed { + monero_wallet_restore_blockheight, + xmr_lock_tx, + transfer_proof, + state3, + } => { + let xmr_lock_tx_hash = monero::TxHash::from_tx(&xmr_lock_tx); + + retry::( + "Publishing Monero lock transaction", + || async { + let is_present = monero_wallet + .is_transaction_present(&xmr_lock_tx_hash) + .await + .context("Failed to check whether Monero lock transaction is already present on chain") + .map_err(backoff::Error::transient)?; + + if !is_present { + if !cancel_timelock_not_expired(&state3, &*bitcoin_wallet) + .await + .context("Failed to check for expired timelocks before publishing Monero lock transaction") + .map_err(backoff::Error::transient)? + { + tracing::warn!( + %swap_id, + "Cancel timelock expired before we confirmed the Monero lock transaction was published. Publishing is not atomic, so the Monero may already be locked; waiting for the cancel timelock to recover safely instead of risking an early Bitcoin refund while the Monero is locked." + ); + return Ok(AliceState::WaitingForCancelTimelockExpiration { + monero_wallet_restore_blockheight, + transfer_proof: transfer_proof.clone(), + state3: state3.clone(), + }); + } + + monero_wallet + .rpc_client() + .await + .map_err(backoff::Error::transient)? + .publish_transaction(&xmr_lock_tx) + .await + .context("Failed to publish Monero lock transaction") + .map_err(backoff::Error::transient)?; + + tracing::info!(%swap_id, %xmr_lock_tx_hash, "Published Monero lock transaction"); + } + + monero_wallet + .main_wallet() + .await + .scan_transaction(xmr_lock_tx_hash.0.clone()) + .await + .context("Failed to scan Monero lock transaction into the wallet") + .map_err(backoff::Error::transient)?; + + Ok(AliceState::XmrLockTransactionSent { + monero_wallet_restore_blockheight, + transfer_proof: transfer_proof.clone(), + state3: state3.clone(), + }) + }, + None, + None, + ) + .await + .context("Failed to publish Monero lock transaction")? + } AliceState::XmrLockTransactionSent { monero_wallet_restore_blockheight, transfer_proof, @@ -1113,6 +1175,16 @@ pub(crate) fn has_already_processed_enc_sig(state: &AliceState) -> bool { ) } +async fn cancel_timelock_not_expired( + state3: &State3, + bitcoin_wallet: &dyn BitcoinWallet, +) -> Result { + Ok(matches!( + state3.expired_timelocks(bitcoin_wallet).await?, + ExpiredTimelocks::None { .. } + )) +} + #[cfg(test)] mod tests { use super::build_transfer_destinations;